Как создать черный список для токенов JWT в Django

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

В этой статье мы разберем генерацию JWT токена с возможностью занесения его в черный список на примере пустого проекта, а также протестируем, полученный результат с помощью Postman.

Читать на Habr

Немного о JWT

JSON Web Token (JWT) — это JSON объект, который определен в открытом стандарте RFC 7519. Он считается одним из безопасных способов передачи информации между двумя участниками. Основной его особенностью является, то что все необходимые аутентификационные данные хранятся в самом токене. Он состоит из 3-х основных частей: заголовок (header), нагрузка (payload) и подписи (signature).

Header – это JSON объект, который содержит в себе информацию о типе токена и способе шифрования:
header = { "alg": "HS256", "typ": "JWT"}
Payload – это полезная нагрузка токена, обычно там хранится идентификатор пользователя, время жизни токена или любая другая информация, на усмотрение издателя. Однако существуют зарезервированные названия полей, назначение, которых менять не рекомендуется:
  • iss: строка с уникальным идентификатором стороны, генерирующей токен.
  • sub: строка, которая является уникальным идентификатором стороны, о которой содержится информация в данном токене (subject).
  • aud: массив чувствительных к регистру строк или URI, являющийся списком получателей данного токена.
  • exp: время в формате Unix Time, определяющее момент, когда токен станет невалидным (expiration).
  • nbf: в противоположность ключу exp, это время в формате Unix Time, определяющее момент, когда токен станет валидным (not before).
  • jti: строка, определяющая уникальный идентификатор данного токена (JWT ID).
  • iat: время в формате Unix Time, определяющее момент, когда токен был создан.
Signature – подпись, которая формируется следующим образом:
1. Header и Payload приводятся к формату base64.2. Далее они соединяются в одну строку через точку.
3. По алгоритму, указанному в header, полученная строка хешируется на основе секретного ключа.
Результатом работы данного алгоритма и является подпись. Чтобы получить сам JWT необходимо соединить через точку header, payload и signature.

Аутентификация при помощи JWT

Обычно пользователь получает JWT при регистрации или первом логине. Он сохраняет его у себя на устройстве и при последующих обращения к API передает этот токен со всеми запросами. Как правило токен кладется в заголовок запроса. Получив токен, приложение сперва проверяет его подпись. Убедившись, что подпись действительна, приложение извлекает из части полезной нагрузки сведения о пользователе и на их основе авторизует его.

Время жизни токена

Очень важным вопросом при использовании JWT является время жизни токена. На этот вопрос нет универсального ответа, все зависит от сервиса. Однако нужно учитывать 2 момента:
  1. Если время жизни токена будет слишком большим, это может привести к проблемам безопасности. Например, если злоумышленнику удалось скомпрометировать токен пользователя, он может использовать его до тех пор, пока не истечет его время жизни.
  2. Малое время жизни токена может привести к излишней нагрузке на сервер, так как пользователю придется постоянно рефрешить старый токен (запрашивать новый).
Отсюда вытекает необходимость дать пользователю возможность самому сбросить все свои токены. Например, в случае компрометации токена злоумышленником, для смены пароля или выхода со всех устройств. Существует несколько способов отозвать существующие токены, например выписывать токены на основе уникального идентификатора пользователя или создать черный список для выписанных токенов.
Разберем на примере Django c использование django rest framework и библиотеки Simple JWT как заносить токены в черный список. Сразу стоит отметит, что библиотеке Simple JWT сразу предоставляет нам удобное приложения "Черного списка", которое мы и будем использовать.

Первоначальная настройка проекта

Создадим пустой проект командой django-admin startproject jwt_auth_project. Сразу же создадим приложение для работы с пользователями командой python manage.py startapp users и зарегистрируем его в INSTALLED_APPS в файле settings.py:
INSTALLED_APPS = [
…
'users.apps.UsersConfig',
]
Создадим виртуальное окружение, установим библиотеки djangorestframework и djangorestframework-simplejwt и пропишем:
from datetime import datetime
from pydantic import BaseModel
class Author(BaseModel):
first_name: str
last_name: str
date_birth: datetime
biography: str
class Book(BaseModel):
title: str
annotation: str
date_publishing: datetime
author: Author
Далее реализуем класс для работы с БД, для этого в пакете repositories также создадим файл books и наберем следующий код:
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
В настройках REST_FRAMEWORK по умолчанию прописываем разрешения только для аутентифицированных пользователей и в качестве бэкенда аутентификации указываем класс, который предоставляет нам библиотека simplejwt.
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=2),
    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY,
    'VERIFYING_KEY': None,
    'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True
}
Настройки для simplejwt прописываются также в файле settings.py. В данной статье мы не будем подробно останавливаться на каждой из них, т.к. все они подробно описаны в документации. Отметим, что время жизни токена мы выбрали 5 минут, а время жизни рефреш токена 2 дня.
После этого необходимо обновить INSTALLED_APPS:
INSTALLED_APPS = [
     …
    'users.apps.UsersConfig',
    'rest_framework',
    'rest_framework_simplejwt',
    'rest_framework_simplejwt.token_blacklist',
]
В приложении users создадим файл urls.py и в файле jwt_auth_project/urls.py зарегистрируем его:
from django.contrib import admin
from django.urls.conf import include, path
urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/users/', include('users.urls'))
]
Далее нам необходимо написать кастомный менеджер для будущей модели пользователя. В приложении users создадим файл managers.py и наберем следующий код:
from typing import Any, Type, Union
from django.contrib.auth.base_user import BaseUserManager


class UserManager(BaseUserManager):

    """
    Менеджер для переопределенной модели юзера.
    """

    use_in_migrations = True

    def create_user(self, email: str, password: str, **kwargs: Union[str, Any]) -> Type[BaseUserManager]:

        """
        Метод менеджера для создания обычного пользователя.
        """

        if not email:
            raise ValueError("Please, input email address")

        email = self.normalize_email(email)
        user = self.model(email=email, **kwargs)
        user.set_password(password)
        user.save(using=self._db)

        return user

    def create_superuser(self, email: str, password: str, **params: Union[str, Any]) -> Type[BaseUserManager]:

        """
        Метод менеджера для создания суперюзера.
        """

        params.setdefault("is_staff", True)
        params.setdefault("is_superuser", True)
        params.setdefault("is_active", True)

        if params.get("is_staff") is not True:
            raise ValueError("superuser must have a is_staff=True")
        if params.get("is_superuser") is not True:
            raise ValueError("superuser must have a is_superuser=True")

        return self.create_user(email, password, **params)
Теперь мы можем создать собственную модель пользователя в файле users/models.py:
from typing import List
from django.db import models
from django.contrib.auth.models import AbstractUser
from users.managers import UserManager
from rest_framework_simplejwt.tokens import RefreshToken


class User(AbstractUser):
    """
    [User]

    Переопределенный класс пользователя. Использует кастомный менеджер.
    """
    username = None
    # Поле email будет использоваться для идентификации пользователя в системе
    email = models.EmailField(unique=True)

    # Указывает какое поле используется для входа в систему
    USERNAME_FIELD = "email"
    REQUIRED_FIELDS: List = []
    # Указывает, какой менеджер использовать для данной модели
    objects = UserManager()

    class Meta:
        verbose_name = "Пользователь"
        verbose_name_plural = "Пользователи"
        app_label = 'users'

    @property
    def access_token(self) -> str:
        """
        Позволяет получить токен доступа из экземпляра модели User.
        :return: str
        """
        return str(RefreshToken.for_user(self).access_token)

    @property
    def refresh_token(self) -> str:
        """
        Позволяет получить рефереш токен из экземпляра модели User.
        :return: str
        """
        return str(RefreshToken.for_user(self))

    def __str__(self) -> str:
        """
        :returns:
            [str]: Отвечает за корректное отображение объекта.
        """

        return self.email
Далее необходимо указать Django какую модель пользователя необходимо использовать для аутентификации. Для этого в файле настроек пропишем следующую строчку:
AUTH_USER_MODEL = "users.User"
Теперь можно запустить сервер командой python manage.py runserver создать и провести миграции командами python manage.py makemigrations и python manage.py migrate. После этого в нашей базе данных создадутся необходимые таблицы для дальнейшей работы.
Получение токенов, регистрация пользователя, информация о пользователе
На данном этапе у нас все готово для написания основных точек API. Создадим файл users/serializers.py и напишем туда основные сериализаторы:
from typing import Dict
from rest_framework import serializers
from users.models import User


class RegistrationSerializer(serializers.ModelSerializer):
    """
    Сериализатор для регистрации нового пользователя
    """
    password = serializers.CharField(
        max_length=128,
        min_length=8,
        write_only=True
    )
    access_token = serializers.CharField(max_length=255, read_only=True)
    refresh_token = serializers.CharField(max_length=255, read_only=True)

    class Meta:
        model = User
        fields = ['email', 'first_name', 'last_name', 'password', 'access_token', 'refresh_token']

    def create(self, validated_data: Dict) -> User:
        # Используется метод из кастомного менеджера
        return User.objects.create_user(**validated_data)


class UserInfoSerializer(serializers.ModelSerializer):
    """
    Сериализатор для получения основной информации о пользователе
    """
    class Meta:
        model = User
        fields = ['email', 'first_name', 'last_name'
После чего в файле users/views.py напишем вью для регистрации и отдачи информации о пользователе:
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from users.serializers import RegistrationSerializer, UserInfoSerializer






class RegistrationAPIView(APIView):

    # Доступ к регистрации должны иметь все пользователи
    permission_classes = [AllowAny]
    serializer_class = RegistrationSerializer

    def post(self, request: Request) -> Response:

        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data, status=status.HTTP_201_CREATED)


class UserInfoAPIView(APIView):

    serializer_class = UserInfoSerializer
    permission_classes = [IsAuthenticated]

    def get(self, request: Request) -> Response:

        return Response(self.serializer_class(request.user).data, status=status.HTTP_200_OK)
Теперь необходимо определить маршруты для наших представлений в файле users/urls.py:
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken
class ResetTokenAPIView(APIView):
    """
    Добавляет все refresh токены пользователя в черный список
    """

    def post(self, request: Request) -> Response:
        tokens = OutstandingToken.objects.filter(user_id=request.user.id)
        for token in tokens:
            t, _ = BlacklistedToken.objects.get_or_create(token=token)

        return Response(status=status.HTTP_205_RESET_CONTENT)
И регистрируем в users/urls.py:
from users.views import RegistrationAPIView, UserInfoAPIView, ResetTokenAPIView

urlpatterns = [
    ...
    path('reset-all-token/', ResetTokenAPIView.as_view(), name='reset-all-token')
]

Тестирование API с помощью Postman

Теперь мы можем протестировать полученный API, для этого мы будем использовать Postman. Первое что нам нужно сделать это отправить следующий запрос:
Результаты тестирования в Postman
Стоит отметить, что благодаря тому, что мы определили access_token и refresh_token, как динамические свойства в модель User и указали их в сериализаторе, то не нужно дополнительно запрашивать их после регистрации.
После регистрации пользователь может получить информацию о своем аккаунте, для этого в запрос необходимо добавить заголовок Authorization с значением Bearer {access_token}:
Результаты тестирования в Postman