Шаг 08. JWT-аутентификация с SimpleJWT

📁 Серия: Капстоун C ⏱️ ~50 мин 🎯 Сложность: Средняя
#SimpleJWT #access-token #refresh-token #registration #TokenObtainPair

⚡ Кратко: что делаем на этом шаге

Цель: Добавить JWT-аутентификацию через SimpleJWT. Эндпоинты: регистрация (POST /api/auth/register/), логин (POST /api/auth/token/), обновление токена (POST /api/auth/token/refresh/). Подключить JWTAuthentication глобально.

  • Установка: pip install djangorestframework-simplejwt
  • Файлы: apps/users/serializers.py, apps/users/views.py, apps/users/urls.py, config/settings.py, config/urls.py
  • Проверка: POST /api/auth/token/ → получить access+refresh токены

🎯 Цель этапа

До этого шага наш API открыт — кто угодно может читать и писать данные. На этом шаге добавляем JWT-аутентификацию: пользователь получает два токена — access (короткоживущий, для запросов) и refresh (долгоживущий, для обновления access).

Концепция JWT в нашем API:
  1. Пользователь регистрируется — POST /api/auth/register/
  2. Пользователь логинится — POST /api/auth/token/ → получает access + refresh
  3. Для защищённых эндпоинтов: Authorization: Bearer <access_token>
  4. Когда access истёк — POST /api/auth/token/refresh/ → получает новый access

После этого шага у нас будет

  • Регистрация нового пользователя через API
  • Получение JWT access+refresh через логин
  • Обновление access-токена через refresh
  • Глобальная защита API через JWTAuthentication

📄 Затрагиваемые файлы

ФайлДействиеОписание
apps/users/serializers.pyОбновитьRegisterSerializer с валидацией пароля
apps/users/views.pyСоздать/обновитьRegisterView (CreateAPIView)
apps/users/urls.pyСоздать/обновитьМаршруты: register, token, token/refresh
config/settings.pyОбновитьSIMPLE_JWT настройки + DEFAULT_AUTHENTICATION_CLASSES
config/urls.pyОбновитьПодключить /api/auth/ маршруты

🔨 Шаги

1. Установка SimpleJWT

💻 Терминал
pip install djangorestframework-simplejwt
pip freeze | grep simplejwt >> requirements.txt

2. Настройки в settings.py

📄 config/settings.py
from datetime import timedelta

# Добавить в INSTALLED_APPS:
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "rest_framework_simplejwt",                  # ← добавить
    "rest_framework_simplejwt.token_blacklist",  # ← для blacklist при ротации (+ manage.py migrate)
    "django_filters",
    # ...
]

REST_FRAMEWORK = {
    # Глобальный класс аутентификации — JWT
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
    # Глобальный класс разрешений — требуем аутентификацию по умолчанию
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

# Конфигурация SimpleJWT
SIMPLE_JWT = {
    # Время жизни access-токена — 15 минут (рекомендация для безопасности)
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    # Время жизни refresh-токена — 7 дней
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    # Ротация refresh-токенов: при обновлении выдаётся новый refresh
    "ROTATE_REFRESH_TOKENS": True,
    # Занести старый refresh в blacklist, чтобы он реально стал недействительным.
    # Требует app "rest_framework_simplejwt.token_blacklist" + python manage.py migrate
    "BLACKLIST_AFTER_ROTATION": True,
    # Алгоритм подписи
    "ALGORITHM": "HS256",
    # Поле из User-модели, которое кладётся в payload как user_id
    "USER_ID_FIELD": "id",
    "USER_ID_CLAIM": "user_id",
    # Добавить в payload email — для отладки и клиентской идентификации
    "TOKEN_OBTAIN_SERIALIZER": "apps.users.serializers.CustomTokenObtainPairSerializer",
}
💡 ACCESS_TOKEN_LIFETIME = 15 минут: Короткое время жизни access-токена — это намеренное решение безопасности. Если токен будет перехвачен, злоумышленник использует его максимум 15 минут. Пользователь при этом не замечает неудобств: клиент автоматически обновляет токен через refresh-эндпоинт.

3. Кастомный TokenObtainPairSerializer — добавляем email в payload

📄 apps/users/serializers.py
# apps/users/serializers.py
from django.contrib.auth import get_user_model
from rest_framework import serializers
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

User = get_user_model()


class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
    """
    Расширяем стандартный payload SimpleJWT.
    По умолчанию payload содержит: token_type, exp, iat, jti, user_id.
    Добавляем: email, username — для удобства клиента (не надо делать GET /users/me/).
    """

    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        # Дополнительные поля в payload
        token["email"] = user.email
        token["username"] = user.username
        return token


class RegisterSerializer(serializers.ModelSerializer):
    """
    Сериализатор регистрации пользователя.
    Поля: email, username, password, password_confirm, bio (опционально).
    """

    password = serializers.CharField(
        write_only=True,
        min_length=8,
        style={"input_type": "password"},
    )
    password_confirm = serializers.CharField(
        write_only=True,
        style={"input_type": "password"},
    )

    class Meta:
        model = User
        fields = ["email", "username", "password", "password_confirm", "bio"]
        extra_kwargs = {
            "bio": {"required": False, "allow_blank": True},
        }

    def validate(self, attrs):
        if attrs["password"] != attrs["password_confirm"]:
            raise serializers.ValidationError(
                {"password_confirm": "Пароли не совпадают."}
            )
        return attrs

    def create(self, validated_data):
        # Убираем password_confirm — его нет в модели
        validated_data.pop("password_confirm")
        password = validated_data.pop("password")
        user = User(**validated_data)
        # Хешируем пароль через встроенный метод Django
        user.set_password(password)
        user.save()
        return user


class UserProfileSerializer(serializers.ModelSerializer):
    """Профиль пользователя — используется в /api/users/me/."""

    class Meta:
        model = User
        fields = ["id", "email", "username", "bio", "avatar", "date_joined"]
        read_only_fields = ["id", "email", "date_joined"]

4. View для регистрации

📄 apps/users/views.py
# apps/users/views.py
from django.contrib.auth import get_user_model
from rest_framework import generics, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import RegisterSerializer, UserProfileSerializer

User = get_user_model()


class RegisterView(generics.CreateAPIView):
    """
    POST /api/auth/register/

    Регистрация нового пользователя. Открытый эндпоинт — аутентификация не нужна.
    """
    queryset = User.objects.all()
    serializer_class = RegisterSerializer
    permission_classes = [permissions.AllowAny]


class MeView(APIView):
    """
    GET  /api/auth/me/   — получить профиль текущего пользователя
    PATCH /api/auth/me/  — обновить bio/avatar
    """
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request):
        serializer = UserProfileSerializer(request.user)
        return Response(serializer.data)

    def patch(self, request):
        serializer = UserProfileSerializer(
            request.user, data=request.data, partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)

5. URL-маршруты пользователей

📄 apps/users/urls.py
# apps/users/urls.py
from django.urls import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)
from .views import RegisterView, MeView

urlpatterns = [
    # Регистрация нового пользователя
    path("register/", RegisterView.as_view(), name="auth-register"),

    # Логин: POST {email, password} → {access, refresh}
    path("token/", TokenObtainPairView.as_view(), name="token-obtain"),

    # Обновление access-токена: POST {refresh} → {access}
    path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),

    # Верификация токена: POST {token} → 200 OK или 401
    path("token/verify/", TokenVerifyView.as_view(), name="token-verify"),

    # Профиль текущего пользователя
    path("me/", MeView.as_view(), name="auth-me"),
]

6. Подключение в корневых URL

📄 config/urls.py
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    # Auth: регистрация, логин, refresh, me
    path("api/auth/", include("apps.users.urls")),
    # API ресурсов
    path("api/", include("apps.projects.urls")),
    path("api/", include("apps.tasks.urls")),
    # Swagger — добавим на шаге 12
]
⚠️ httpOnly-куки — best practice для production: В нашем примере токены передаются в JSON-ответе и хранятся в localStorage/sessionStorage клиента. Это удобно, но уязвимо к XSS-атакам. Best practice для production: хранить токены в httpOnly-куках (недоступны JavaScript). SimpleJWT поддерживает это через AUTH_COOKIE-настройки или пакет djangorestframework-simplejwt[cookie].

Для учебного проекта JSON-ответ достаточен; для production-систем обязательно реализуйте httpOnly-куки и CSRF-защиту.

🧠 Объяснение логики

Структура JWT

JWT состоит из трёх частей, разделённых точкой: header.payload.signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9  ← header (base64)
.eyJ1c2VyX2lkIjoxLCJlbWFpbCI6ImFkbWluQGV4YW1wbGUuY29tIn0=  ← payload (base64)
.HMACSHA256(header + payload, SECRET_KEY)  ← подпись

Сервер НЕ хранит токены — он только проверяет подпись. Это делает JWT масштабируемым: любой узел кластера может валидировать токен без обращения к БД.

Access vs Refresh — почему два токена

ПараметрAccess tokenRefresh token
Время жизни15 минут7 дней
ИспользованиеВ каждом запросеТолько для получения нового access
Хранение (rec.)Память / httpOnly cookiehttpOnly cookie
При компрометацииРиск минимален (15 мин)Нужна инвалидация (blacklist)

USERNAME_FIELD = "email" — нюанс SimpleJWT

Наш User использует email как USERNAME_FIELD. SimpleJWT по умолчанию использует поле username для логина. TokenObtainPairSerializer автоматически использует USERNAME_FIELD модели — поэтому в теле запроса нужно передавать email, а не username.

ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION

При каждом обновлении выдаётся новый refresh-токен (ROTATE_REFRESH_TOKENS=True), а старый заносится в blacklist (BLACKLIST_AFTER_ROTATION=True) и становится действительно недействительным. Без blacklist старый refresh оставался бы валидным до истечения срока — поэтому оба флага работают в паре. Это усиливает безопасность: если злоумышленник похитил refresh-токен, то после первого обновления легитимным пользователем украденный токен перестанет работать.

⚠️ Blacklist хранит токены в БД — нужен app rest_framework_simplejwt.token_blacklist в INSTALLED_APPS и python manage.py migrate.

✅ Проверка

1. Регистрация пользователя

💻 curl
curl -s -X POST http://127.0.0.1:8000/api/auth/register/ \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "username": "testuser",
    "password": "Str0ngPass!",
    "password_confirm": "Str0ngPass!"
  }' | python -m json.tool
Ожидаемый ответ (201 Created):
{
  "id": 2,
  "email": "user@example.com",
  "username": "testuser",
  "bio": ""
}

2. Получение JWT-токенов (логин)

💻 curl
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "Str0ngPass!"}' \
  | python -m json.tool
Ожидаемый ответ (200 OK):
{
  "access": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}

3. Запрос к защищённому эндпоинту с токеном

💻 curl
# Сохраняем access-токен в переменную
ACCESS=$(curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "Str0ngPass!"}' \
  | python -c "import sys,json; print(json.load(sys.stdin)['access'])")

# Запрос с Bearer-токеном
curl -s http://127.0.0.1:8000/api/tasks/ \
  -H "Authorization: Bearer $ACCESS" \
  | python -m json.tool

4. Обновление access-токена

💻 curl
REFRESH="ваш_refresh_токен_здесь"

curl -s -X POST http://127.0.0.1:8000/api/auth/token/refresh/ \
  -H "Content-Type: application/json" \
  -d "{\"refresh\": \"$REFRESH\"}" \
  | python -m json.tool

5. Проверка профиля

💻 curl
curl -s http://127.0.0.1:8000/api/auth/me/ \
  -H "Authorization: Bearer $ACCESS" \
  | python -m json.tool

Диагностика ошибок

  • 401 при логине — убедитесь что передаёте email, а не username (USERNAME_FIELD = "email")
  • detail: No active account found — пользователь не создан или email неверный
  • 401 при обращении к API — токен истёк (15 минут), обновите через /api/auth/token/refresh/
  • ImportError: CustomTokenObtainPairSerializer — проверьте путь в SIMPLE_JWT["TOKEN_OBTAIN_SERIALIZER"]

➡️ Что дальше

JWT-аутентификация настроена. Теперь каждый запрос к API требует токена. Следующий шаг — разграничение прав: не каждый аутентифицированный пользователь должен мочь редактировать любой объект. Нужны permissions на уровне объекта.

  • Готово: регистрация, JWT login/refresh, защита всех эндпоинтов
  • Шаг 09: кастомные permissions — IsProjectOwner, IsProjectMember, IsTaskAssignee