Перенос JWT-токенов в куки: Django REST + React

в 15:15, , рубрики: django, jwt, jwt token, React, web-разработка, авторизация пользователя, безопасность веб-приложений, веб-приложения, джанго

Привет! Статья в первую очередь была прежде всего написана для самого себя с целью запоминания интересного опыта по реализации кастомных костылей авторизации с помощью JWT-токенов, находящихся в куки.

В качестве бекенда был выбран горячо любимый Django Rest Framework, в качестве фронтовой части в моем случае использовался React. Начну с реализации серверной стороны. Я пропущу шаги по настройке Django REST Framework в связке с React. В Django в моем случае в качестве приложения для аутентификации пользователей было создано приложение user.

В качестве базы JWT-токенов взял библиотеку Simple JWT.

Мои настройки:

SIMPLE_JWT = {
    'ROTATE_REFRESH_TOKENS': True,  # Обновление refresh токена при замене access токена
    'BLACKLIST_AFTER_ROTATION': True,

    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), 
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),

    'REFRESH_COOKIE': 'refresh_token',  # Название ключа в куки, в котором хранится refresh токен
    'AUTH_COOKIE': 'access_token',  # Название ключа в куки, в котором хранится access токен
    'AUTH_COOKIE_SECURE': False,  # Куки должны передаваться только по HTTPS (True для production)
    'AUTH_COOKIE_HTTP_ONLY': True,  # Запрет доступа к куки через JavaScript
    'AUTH_COOKIE_SAMESITE': 'Strict',  # Ограничение передачи куки при кросс-сайтовых запросах.
}

Предварительно разметил сами API пути:

from django.urls import path
from .views import CookieTokenObtainPairView, CookieTokenRefreshView, get_csrf

urlpatterns = [
   path('token/', CookieTokenObtainPairView.as_view(), name='token_obtain_pair'),
   path('token/refresh/', CookieTokenRefreshView.as_view(), name='token_refresh'),
   path('csrf/', get_csrf, name='get_csrf'),
]

Реализация логики входа

Далее начнем с класса CookieTokenObtainPairView, который отвечает за логику входа и генерацию первичной пары jwt токенов:

from django.conf import settings
from django.http import JsonResponse
from django.middleware.csrf import get_token
from django.utils.decorators import method_decorator

from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework import status
from rest_framework.permissions import AllowAny

from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError

from .utils import set_jwt_cookies, enforce_csrf
from .serializer import CookieTokenObtainPairSerializer

class CookieTokenObtainPairView(TokenObtainPairView):

    """
    Представление для получения JWT-токенов (access и refresh) и их сохранения в куки.

    Это представление расширяет стандартный `TokenObtainPairView` из Django REST Framework Simple JWT.
    После успешной аутентификации access и refresh токены сохраняются в HTTP-only куки и удаляются
    из тела ответа.

    Примечание:
    - Для работы с куками на клиенте необходимо настроить CORS с поддержкой credentials.
    """

    serializer_class = CookieTokenObtainPairSerializer
    authentication_classes = ()
    permission_classes = (AllowAny,)

    @method_decorator(enforce_csrf)
    def post(self, request: Request, *args, **kwargs) -> Response:
        response = super().post(request, *args, **kwargs)

        if response.status_code == 200:
            access_token = response.data.get('access')
            refresh_token = response.data.get('refresh')

            if access_token and refresh_token:
                response = set_jwt_cookies(response, access_token, refresh_token)
                
                del response.data['access']
                del response.data['refresh']

        return response    

Пробегусь по коду:

CookieTokenObtainPairSerializer - написал свой сериалайзер, так как лично мне нужно было помимо токенов добавить имя пользователя, который авторизовался и статус-заглушку

Скрытый текст
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

class CookieTokenObtainPairSerializer(TokenObtainPairSerializer):
    
    def validate(self, attrs):
        data = super().validate(attrs)
        data['user'] = str(self.user)
        data['user_status'] = "active"
        return data

Также решил перестраховать свои фобии быть взломанным перуанскими хакерами и внедрил проверку csrf-токена. Для этого был создан отдельный модуль utils.py и добавлен декоратор enforce_csrf

from functools import wraps
from rest_framework.authentication import CSRFCheck
from rest_framework import exceptions, request, response

def enforce_csrf(func):
    """
    Декоратор для принудительной проверки CSRF.
    """
    @wraps(func)
    def wrapped_view(request, *args, **kwargs):
        check = CSRFCheck(dummy_get_response)
        check.process_request(request)
        reason = check.process_view(request, None, (), {})
        if reason:
            raise exceptions.PermissionDenied('CSRF Failed: %s' % reason) 
        return func(request, *args, **kwargs)
    return wrapped_view
Скрытый текст

Также в файл с нашим CookieTokenObtainPairView добавил API функцию генерации csrf-токена

def get_csrf(request: Request) -> Response:
    response = JsonResponse({'detail': 'CSRF cookie set'})
    response['X-CSRFToken'] = get_token(request)
    return response

После успешной валидации и обновления токенов - удаляю их из тела запроса и добавляю в куки с помощью функции set_jwt_cookies

def set_jwt_cookies(response: response.Response, access_token: str, refresh_token: str) -> response.Response:
    response.set_cookie(
        'access_token',
        access_token,
        max_age=5 * 60,  # 4 минуты
        httponly=True,    # Защита от XSS
        # secure=True,      # Включить для продакшн режима
        samesite='Strict' # Защита от CSRF
    )
    response.set_cookie(
        'refresh_token',
        refresh_token,
        max_age=24 * 60 * 60,  # 1 день
        httponly=True,
        # secure=True,
        samesite='Strict'
    )
    return response

Переходим на React

На стороне React написал простенькую функцию с логином:

import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";

//Принудительное получение crsf-токена для включения его в куки
//и заголовки POST-запросов
async function getCSRF() {
  return axios.get('/api/user/csrf/', { withCredentials: true })
      .then((res) => {
          return res.headers['x-csrftoken'];
      })
      .catch((err) => {
          console.error('Ошибка при получении CSRF-токена:', err);
          throw err;
      });
}

//Мои внутренние приколы с получением имени пользователя и его псевдо-статуса
const [username, setUserName] = useState(() =>
    localStorage.getItem("username")
    ? JSON.parse(localStorage.getItem("username"))
    : null
);
const [userStatus, setUserStatus] = useState(() =>
    localStorage.getItem("userStatus")
    ? JSON.parse(localStorage.getItem("userStatus"))
    : null
);

const loginUser = async (e) => {
    e.preventDefault();
    let csrfToken = await getCSRF() //Получили от джанго csrf токен и вставили в куки
    const response = await fetch("/api/user/token/", {
        method: "POST",
        credentials: 'include',
        headers: {
            "Content-Type": "application/json",
            'X-CSRFToken': csrfToken, // Добавляем CSRF-токен в заголовок

        },
        body: JSON.stringify({
            username: e.target.username.value,
            password: e.target.password.value,
        }),
    });

    if (response.ok) {
        const data = await response.json();
        //Делаем то, что нам надо после успешного логина
        setUserName(data["user"])
        setUserStatus(data["user_status"])
        localStorage.setItem("username", JSON.stringify(data["user"]));
        localStorage.setItem("userStatus", JSON.stringify(data["user_status"]));
        navigate("/");
    } else {
        alert("Неправильный логин или пароль");
    }
};

Функция дергает джанго, получает обновленные куки с csrf-токеном и затем отправляет данные пользователя на аутентификацию нашему CookieTokenObtainPairView . После успешного входа добавляю в локальное хранилище нужные мне имя пользователя и его статус-заглушку

Далее при API запросах на сервер нам надо включать куки в запросы, в React сделал это с помощью указания withCredentials: true

Пример клиентской функции с GET запросом:

function refreshObjectDetail(setObjectDetail, apiPathDetail) {
    axios
        .get(`${apiPathDetail}`, {
            headers: {
                'Content-Type': 'application/json',
                
            },
            withCredentials: true,
        })
        .then((res) => {
            setObjectDetail(res.data);
            if (res.data.name){
                document.title = res.data.name;
            }
        })
        .catch((err) => console.log(err));
}

И более не нужно волноваться о том, что кто-то ваши токены может украсть из открытого локального хранилища браузера, ляпота!

Аутентификация с помощью cookie

Для аутентификации пользователя на стороне сервера с помощью токенов, спрятанных в куки потребовалось написать кастомный класс CookieAuthentication и также с декоратором enforce_csrf

from django.conf import settings
from django.utils.decorators import method_decorator
from rest_framework_simplejwt.authentication import JWTAuthentication
from .utils import enforce_csrf

class CookieJWTAuthentication(JWTAuthentication):
    
    @method_decorator(enforce_csrf)
    def authenticate(self, request):
        header = self.get_header(request)
        
        if header is None:
            raw_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None
        else:
            raw_token = self.get_raw_token(header)
        if raw_token is None:
            return None

        validated_token = self.get_validated_token(raw_token)
        return self.get_user(validated_token), validated_token

Далее установил этот класс аутентификации как единственный и неповторимый в конфигах REST_FRAMEWORK

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'user.authenticate.CookieJWTAuthentication',
    ],
  }

Но мы же помним, что срок нашего access-токена всего-то 5 минут, так что пора бы приступить к обнулению к стадии обновления.

Обновление JWT-токенов

Вся серверная логика спряталась в классе CookieTokenRefreshView

class CookieTokenRefreshView(JWTAuthentication, TokenRefreshView):
    """
    Представление для обновления JWT-токенов (access и refresh) с использованием кук.

    Это представление расширяет стандартный `TokenObtainPairView` из Django REST Framework Simple JWT.
    После успешного обновления токенов -  access и refresh токены сохраняются в HTTP-only куки и удаляются
    из тела ответа.

    Примечание:
    - Для работы с куками на клиенте необходимо настроить CORS с поддержкой credentials.
    - Куки должны быть защищены флагами `HttpOnly`, `Secure` и `SameSite`.
    """
    @method_decorator(enforce_csrf)
    def post(self, request: Request, *args, **kwargs) -> Response:
        raw_refresh_token = request.COOKIES.get(settings.SIMPLE_JWT['REFRESH_COOKIE']) or None
        raw_acces_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None
        data = {'access': raw_acces_token, 'refresh': raw_refresh_token}

        serializer = self.get_serializer(data=data)
        try:
            serializer.is_valid(raise_exception=True)
        except TokenError as e:
            raise InvalidToken(e.args[0])

        response = Response(serializer.validated_data, status=status.HTTP_200_OK)
        
        access_token = response.data.get('access')
        refresh_token = response.data.get('refresh')

        if access_token and refresh_token:
            response = set_jwt_cookies(response, access_token, refresh_token)
            
            del response.data['access']
            del response.data['refresh']

        return response

Предварительно проверяю csrf-токены, далее получаю из куки свои токены. После успешной валидации и обновления токенов - удаляю их из тела запроса и добавляю в куки с помощью ранее указанной функции set_jwt_cookies

На стороне React функция для обновления токенов выглядит так:

const logoutUser = () => {
    setUserName(null);
    localStorage.removeItem("username");
    localStorage.removeItem("userStatus");
    navigate("/login");
};

const refreshToken = async () => {
    let csrfToken = await getCSRF()
    try {
        await fetch("/api/user/token/refresh/", {
            method: 'POST',
            credentials: 'include', // Включаем куки
            headers: {
                "Content-Type": "application/json",
                'X-CSRFToken': csrfToken, // Добавляем CSRF-токен в заголовок
            },
        });
    } catch (error) {
        console.error('Error refreshing token:', error);
        logoutUser();
    }
};

Для того, чтобы токен обновлялся каждые 5 минут и React дергал бы джанго в рамках этих интервалов - создал единый файл AuthContext.js на стороне фронта и добавил хук useEffect для периодического вызова

import { useState, useEffect } from "react";

useEffect(()=>{

    const REFRESH_INTERVAL = 1000 * 60 * 4.9// Почти 5 минут, как и время жизни access токена
        let interval = setInterval(()=>{
            refreshToken()
        }, REFRESH_INTERVAL)
        return () => clearInterval(interval)

    },[])

Выводы

Вот такая получилась кастомная реализация аутентификации пользователя через JWT токены, спрятанных в cookie. На мой взгляд, такой подход гораздо более безопасный, чем хранить эти токены в локальном хранилище, которые явно не предназначено для хранения конфендициальной информации

Буду рад критике и предложениям!

Автор: bunday

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js