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