🏠 Домашнее задание 20 — Урок 44

← К оглавлению урока

📋 ДЗ 20: Работа с логином и регистрацией 🎯 Сложность: Средняя / Продвинутая

⚡ Что нужно сделать

  • Задание 1: Регистрация — валидация полей, уникальность email/логина, сложность пароля, хэширование
  • Задание 2: Логин — вернуть JWT access + refresh токены в httpOnly-куках при успехе
  • Задание 3: Логаут — поместить refresh-токен в blacklist и удалить оба токена из куки

Текст задания из LMS (ДЗ 20)

Домашнее задание: Работа с логином и регистрацией

Задание 1: Регистрация пользователя

Реализовать механизм регистрации пользователя в системе, учесть:

  • Обязательная валидация полей (проверка наличия, формат, уникальность email/логина).
  • Реализация минимальных требований к сложности пароля.
  • Хэширование и сохранение пароля в БД.

Задание 2: Вход в аккаунт

Реализовать механизм входа в аккаунт. Проверять правильность вводимых данных и наличие пользователя. Если пользователь присутствует и данные входа валидны — возвращать JWT access и refresh токены.

В функционале должно быть учтено:

  • Проверка корректности вводимых данных и существования пользователя.
  • При успешной аутентификации — возвращение JWT access и refresh токенов.
  • Безопасное хранение токенов на клиенте (httpOnly cookies) и механизм обновления access токена через refresh токен, с возможностью аннулирования.

Задание 3: Выход из аккаунта

Реализовать механизм выхода из аккаунта. При выходе из текущего аккаунта токены должны помещаться в blacklist и удаляться.

Подготовка окружения

1. Создание проекта

# Создать директорию и войти в неё
mkdir hw20_jwt_auth
cd hw20_jwt_auth

# Создать и активировать виртуальное окружение
python -m venv venv
venv\Scripts\activate    # Windows
# source venv/bin/activate  # macOS/Linux

# Установить зависимости
pip install django djangorestframework djangorestframework-simplejwt

# Зафиксировать зависимости
pip freeze > requirements.txt

2. Создание Django-проекта и приложения

django-admin startproject core .
python manage.py startapp accounts

3. Инициализация git

git init
# Создать .gitignore
echo "venv/
*.pyc
__pycache__/
db.sqlite3
.env" > .gitignore

git add .
git commit -m "initial: project scaffold"

Пошаговое решение

Шаг 1: Настройка settings.py

Связь с теорией: Теория → Часть 1: Настройка Simple JWT

# core/settings.py

from pathlib import Path
from datetime import timedelta

BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'your-secret-key-change-in-production'
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1']

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'rest_framework_simplejwt',
    'rest_framework_simplejwt.token_blacklist',  # Для задания 3
    'accounts',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'core.urls'
TEMPLATES = [{'BACKEND': 'django.template.backends.django.DjangoTemplates',
              'DIRS': [], 'APP_DIRS': True,
              'OPTIONS': {'context_processors': [
                  'django.template.context_processors.debug',
                  'django.template.context_processors.request',
                  'django.contrib.auth.context_processors.auth',
                  'django.contrib.messages.context_processors.messages',
              ]}}]
WSGI_APPLICATION = 'core.wsgi.application'
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3',
                          'NAME': BASE_DIR / 'db.sqlite3'}}
AUTH_PASSWORD_VALIDATORS = [
    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'

# DRF: глобальная аутентификация через JWT
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

# SimpleJWT: настройки токенов
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'AUTH_HEADER_TYPES': ('Bearer',),
}

Шаг 2: Применить миграции

python manage.py migrate
⚠️ Убедитесь, что rest_framework_simplejwt.token_blacklist создал таблицы — при следующем запросе проверьте, что команда отработала без ошибок.

Шаг 3: serializers.py — валидация регистрации (Задание 1)

Связь с теорией: Теория → Часть 6: RegisterSerializer

# accounts/serializers.py

from rest_framework import serializers
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from rest_framework.validators import UniqueValidator


class RegisterSerializer(serializers.ModelSerializer):
    """
    Сериализатор для регистрации.
    Задание 1: валидация полей, уникальность, сложность пароля, хэширование.
    """
    email = serializers.EmailField(
        required=True,
        validators=[UniqueValidator(queryset=User.objects.all(),
                                    message="Пользователь с таким email уже существует.")]
    )
    password = serializers.CharField(
        write_only=True,
        required=True,
        validators=[validate_password]  # MinimumLength, Common, Numeric, Similarity
    )
    password2 = serializers.CharField(write_only=True, required=True)

    class Meta:
        model = User
        fields = ['username', 'email', 'password', 'password2']
        extra_kwargs = {
            'username': {
                'validators': [
                    UniqueValidator(queryset=User.objects.all(),
                                    message="Пользователь с таким именем уже существует.")
                ]
            }
        }

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

    def create(self, validated_data):
        """
        Создание пользователя.
        create_user() автоматически хэширует пароль через set_password().
        """
        validated_data.pop('password2')  # Удаляем подтверждение пароля
        user = User.objects.create_user(
            username=validated_data['username'],
            email=validated_data['email'],
            password=validated_data['password']
        )
        return user

Шаг 4: views.py — LoginView, RegisterView, LogoutView

Связь с теорией: LoginView | RegisterView | LogoutView

# accounts/views.py

from datetime import datetime, timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.contrib.auth import authenticate
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework.permissions import AllowAny
from .serializers import RegisterSerializer


def set_jwt_cookies(response, user):
    """Установить JWT-токены в httpOnly-куки ответа."""
    refresh = RefreshToken.for_user(user)
    access_token = refresh.access_token

    access_expiry = datetime.fromtimestamp(access_token['exp'], tz=timezone.utc)
    refresh_expiry = datetime.fromtimestamp(refresh['exp'], tz=timezone.utc)

    response.set_cookie(
        key='access_token',
        value=str(access_token),
        httponly=True,
        secure=False,   # True в продакшне
        samesite='Lax',
        expires=access_expiry
    )
    response.set_cookie(
        key='refresh_token',
        value=str(refresh),
        httponly=True,
        secure=False,
        samesite='Lax',
        expires=refresh_expiry
    )
    return refresh  # Возвращаем refresh для возможного использования


class RegisterView(APIView):
    """
    Задание 1: Регистрация пользователя.
    Валидирует данные, хэширует пароль, возвращает JWT-токены.
    """
    permission_classes = [AllowAny]

    def post(self, request):
        serializer = RegisterSerializer(data=request.data)
        if serializer.is_valid():
            user = serializer.save()
            response = Response({
                'message': 'Регистрация успешна.',
                'user': {
                    'id': user.id,
                    'username': user.username,
                    'email': user.email
                }
            }, status=status.HTTP_201_CREATED)
            set_jwt_cookies(response, user)
            return response
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class LoginView(APIView):
    """
    Задание 2: Вход в аккаунт.
    Проверяет учётные данные, возвращает JWT access + refresh в httpOnly-куках.
    """
    permission_classes = [AllowAny]

    def post(self, request, *args, **kwargs):
        username = request.data.get('username')
        password = request.data.get('password')

        if not username or not password:
            return Response(
                {"detail": "Необходимо указать username и password."},
                status=status.HTTP_400_BAD_REQUEST
            )

        user = authenticate(request, username=username, password=password)

        if user:
            response = Response(
                {"message": f"Добро пожаловать, {user.username}!"},
                status=status.HTTP_200_OK
            )
            set_jwt_cookies(response, user)
            return response
        else:
            return Response(
                {"detail": "Неверный логин или пароль."},
                status=status.HTTP_401_UNAUTHORIZED
            )


class LogoutView(APIView):
    """
    Задание 3: Выход из аккаунта.
    Помещает refresh-токен в blacklist и удаляет оба токена из куки.
    """

    def post(self, request, *args, **kwargs):
        refresh_token_str = request.COOKIES.get('refresh_token')

        if refresh_token_str:
            try:
                refresh = RefreshToken(refresh_token_str)
                refresh.blacklist()  # Аннулируем refresh-токен
            except TokenError:
                # Токен уже истёк или невалиден — нечего инвалидировать
                pass

        response = Response(
            {"message": "Выход выполнен успешно."},
            status=status.HTTP_200_OK
        )
        response.delete_cookie('access_token')
        response.delete_cookie('refresh_token')
        return response


class ProfileView(APIView):
    """Пример защищённого эндпоинта для проверки аутентификации."""

    def get(self, request):
        return Response({
            'id': request.user.id,
            'username': request.user.username,
            'email': request.user.email,
            'is_staff': request.user.is_staff,
        })

Шаг 5: urls.py

# accounts/urls.py

from django.urls import path
from .views import RegisterView, LoginView, LogoutView, ProfileView

urlpatterns = [
    path('register/', RegisterView.as_view(), name='register'),
    path('login/', LoginView.as_view(), name='login'),
    path('logout/', LogoutView.as_view(), name='logout'),
    path('profile/', ProfileView.as_view(), name='profile'),
]
# core/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/auth/', include('accounts.urls')),
]

Шаг 6: Запустить сервер

python manage.py runserver

Проверка в VS Code

Запуск через терминал VS Code

  1. Открыть папку проекта: File → Open Folder → hw20_jwt_auth
  2. Открыть терминал: Ctrl+` (backtick)
  3. Активировать venv и запустить сервер:
    venv\Scripts\activate
    python manage.py runserver
  4. Сервер запущен: http://127.0.0.1:8000/

Запуск через F5 (launch.json)

Создать файл .vscode/launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Django Server",
            "type": "debugpy",
            "request": "launch",
            "program": "${workspaceFolder}/manage.py",
            "args": ["runserver", "8000"],
            "django": true,
            "env": {
                "PYTHONPATH": "${workspaceFolder}"
            },
            "console": "integratedTerminal",
            "justMyCode": true
        }
    ]
}

После этого нажмите F5 или Run → Start Debugging.

Точки останова (breakpoints)

Для отладки views:

  1. Откройте accounts/views.py
  2. Кликните на поле слева от номера строки в LoginView.post, например, на строке с user = authenticate(...)
  3. Появится красная точка — это breakpoint
  4. Запустите через F5, отправьте POST-запрос
  5. VS Code остановится на точке; в панели Variables видны все переменные (username, password, user)
  6. Используйте F10 (Step Over) для пошагового выполнения

Тестирование в Postman

Подготовка: создать пользователя-администратора

python manage.py createsuperuser
# Введите username, email, password при запросе

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

  • Метод: POST
  • URL: http://127.0.0.1:8000/api/auth/register/
  • Headers: Content-Type: application/json
  • Body (JSON):
    {
        "username": "testuser",
        "email": "test@example.com",
        "password": "SecurePass123!",
        "password2": "SecurePass123!"
    }
  • Ожидаемый ответ: 201 Created, JSON с данными пользователя, Set-Cookie в заголовках

Тест 2: Регистрация с неверными данными (валидация)

  • Слабый пароль: "password": "123" → 400 Bad Request, ошибки валидации
  • Несовпадение паролей: password ≠ password2 → 400 Bad Request
  • Дублирующий email → 400 Bad Request с описанием ошибки

Тест 3: Вход в аккаунт

  • Метод: POST
  • URL: http://127.0.0.1:8000/api/auth/login/
  • Body (JSON):
    {
        "username": "testuser",
        "password": "SecurePass123!"
    }
  • Ожидаемый ответ: 200 OK с приветствием, Set-Cookie: access_token + refresh_token

Тест 4: Неверные учётные данные

  • Неверный пароль → 401 Unauthorized
  • Несуществующий пользователь → 401 Unauthorized

Тест 5: Доступ к защищённому профилю

  • Метод: GET
  • URL: http://127.0.0.1:8000/api/auth/profile/
  • Postman должен автоматически отправить куки из теста 3 (Cookies включены)
  • Ожидаемый ответ: 200 OK, данные пользователя
  • Без куки (очистить Cookies) → 403 Forbidden

Тест 6: Выход из аккаунта

  • Метод: POST
  • URL: http://127.0.0.1:8000/api/auth/logout/
  • Ожидаемый ответ: 200 OK, "Выход выполнен успешно."
  • После этого GET /profile/ → 403 (куки удалены)

Тест 7: Проверка blacklist

  1. Вход → получить refresh-токен из куки
  2. Выход → refresh-токен добавлен в blacklist
  3. Попытка использовать старый refresh-токен через POST /api/token/refresh/ с body {"refresh": "..."} → 401 Token is blacklisted