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