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

Немного о 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 и пропишем:

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 django.urls.conf import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)


from users.views import RegistrationAPIView, UserInfoAPIView, ResetTokenAPIView


urlpatterns = [
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('registartion/', RegistrationAPIView.as_view(), name='registartion'),
    path('info/', UserInfoAPIView.as_view(), name='info'),
    ]

Стоит отметить, что представления для получения токена и рефреша нам предоставляет библиотека rest_framework_simplejwt и нет необходимости писать их вручную.

Занесения существующих токенов в «Черный список»

Теперь пользователь может зарегистрироваться в нашем приложении и получить о себе информацию. Осталось дать возможность разлогиниться со всех устройств(внести токены в черный список). Библиотека simple_jwt предоставляет нам две модели OutstandingToken и BlacklistedToken. Их мы и будем использовать для занесения токенов в черный список.

Для этого напишем еще одно вью в файле users/views.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
Результаты тестирования в postman

Стоит отметить, что благодаря тому, что мы определили access_token и refresh_token, как динамические свойства в модель User и указали их в сериализаторе, то не нужно дополнительно запрашивать их после регистрации.

После регистрации пользователь может получить информацию о своем аккаунте, для этого в запрос необходимо добавить заголовок Authorization с значением Bearer {access_token}:

Результаты тестирования в postman
Результаты тестирования в postman

Комментарии (3)


  1. eandr_67
    20.10.2023 10:41
    +9

    Зачем копипастить на Хабр статью, опубликованную полтора года назад: https://purpleplane.ru/tpost/40kt5vzma1-kak-sozdat-chernii-spisok-dlya-tokenov-j?

    Смысл JWT в том, что авторизация полностью отделена от API. API, к которому идёт обращение, получает всю информацию о правах сделавшего запрос к API клиента непосредственно из самого токена - без обращения к каким-либо сервисам. API ничего не знает о сервисе авторизации - он лишь проверяет переданный в JWT список прав. Тратя на это минимум времени и ресурсов и не обращаясь к каким-либо сторонним сервисам.

    Использование "чёрного списка" требует, чтобы при каждом запросе к API этот API внутри себя обращался к сервису авторизации для проверки внесения полученного JWT в чёрный список. Это увеличивает нагрузку на сервис авторизации на несколько порядков и автоматически делает бессмысленным использование JWT - т.к. мы лишаемся того единственного, ради чего выбирают именно JWT: автономности JWT-токена.

    Если мы используем чёрный список, то зачем передавать в токене информацию о пользователе и списке его прав? Ведь мы из API обращаемся к сервису авторизации для проверки токена и эта проверка может в своём ответе вернуть список прав. JWT становится не нужен - достаточно токена в виде простого ключа без какой-либо структуры.


    1. PurplePlane Автор
      20.10.2023 10:41
      -1

      Логика копипаста статьи в том, что на хабре ее не было, а материал для публикации кажется нам интересным.

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


      1. eandr_67
        20.10.2023 10:41
        +5

        Какой смысл использовать JWT в монолите? JWT нужен именно для того, чтобы полностью отделить сервис авторизации от API. Монолит же имеет прямой доступ ко всей информации, которая передаётся в JWT - через прямое обращение к методам модуля авторизации, без потери времени на сетевой запрос/ответ. Так зачем передавать эти данные в токене? Для получения такого же функционала в монолите достаточно использовать многократно более короткие токены-ключи, не имеющие внутренней структуры. А для увеличения производительности всю ту информацию, которая передаётся в JWT, хранить, например, в Redis. И вместо "чёрного списка" просто удалять ключ из Redis.

        А использование JWT в монолите лишь увеличивает размер запроса - без реальной в этом необходимости.