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

от автора

Привет, Хабр! Статья в первую очередь была прежде всего написана для самого себя с целью запоминания интересного опыта по реализации кастомных костылей авторизации с помощью 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. На мой взгляд, такой подход гораздо более безопасный, чем хранить эти токены в локальном хранилище, которые явно не предназначено для хранения конфендициальной информации

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


ссылка на оригинал статьи https://habr.com/ru/articles/873918/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *