Урок курса

Вход и JWT access token

FastAPI для начинающих: API с базой данных и тестами

В прошлом уроке POST /api/users сохранил хеш Argon2 и вернул только id и email. Пароль нигде не попал в ответ. Теперь нужен следующий шаг: пользователь присылает email и пароль повторно, а сервер должен убедиться, что пароль верный, и выдать токен, с которым можно обращаться к защищённым маршрутам. Этот токен — JWT access token.

create_access_token: sub, срок exp и подпись HS256

Продолжайте тот же проект auth_demo. Добавьте строку в requirements.txt:

PyJWT==2.10.1

Переустановите зависимости:

pip install -r requirements.txt

Прежде чем запускать приложение, задайте SECRET_KEY в переменных окружения. Это не пароль, который придумывает человек, — это 32 случайных байта, закодированных в urlsafe-Base64. В bash:

export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"

В PowerShell:

$env:SECRET_KEY = python -c "import secrets; print(secrets.token_urlsafe(32))"

Значение генерируется один раз и остаётся постоянным на всё время жизни сервиса. Ключ не коммитится в репозиторий, не возвращается через /info и не меняется при каждом перезапуске. Если ключ сменился — все ранее выданные токены, подписанные старым ключом, станут непроверяемыми: новый ключ просто даст другую подпись, которая не совпадёт.

Новый файл app/tokens.py содержит единственную функцию:

from datetime import datetime, timedelta, timezone
import jwt

def create_access_token(token_subject: str, secret_key: str, expires_minutes: int = 30) -> str:
    payload = {
        "sub": token_subject,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
    }
    return jwt.encode(payload, secret_key, algorithm="HS256")

Функция принимает token_subject — это User.token_subject, постоянная случайная строка учётной записи, которую не нужно путать с email или числовым id. В sub кладут стабильный идентификатор, по которому сервер потом найдёт пользователя; email туда не помещают, потому что он может измениться.

exp — момент в UTC через 30 минут от текущего времени. PyJWT принимает объект datetime и сам преобразует его в Unix timestamp. Обратите внимание: exp фиксируется в момент выпуска токена. Это не означает, что токен автоматически отклоняется по истечении срока — принимающая сторона обязана явно проверить срок при каждом запросе.

Функция получает secret_key аргументом, а не обращается к get_settings напрямую. Это упрощает тестирование: в тестах можно передать любой ключ, не трогая окружение. В продакшн-коде ключ передаётся из get_settings — та же зависимость, что уже есть в проекте.

POST /api/auth/login: verify перед выдачей access_token

Добавьте два новых файла и замените main.py.

app/schemas/auth.py — входной и выходной контракт эндпоинта:

from pydantic import BaseModel, EmailStr, Field

class LoginRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=128)

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

app/routers/auth.py — сам маршрут:

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.passwords import pwd_context
from app.schemas.auth import LoginRequest, TokenResponse
from app.settings import Settings, get_settings
from app.tokens import create_access_token

router = APIRouter()

@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
    user = db.scalars(select(User).where(User.email == str(data.email))).first()
    if user is None or not pwd_context.verify(data.password, user.hashed_password):
        raise HTTPException(status_code=401, detail="Invalid email or password",
                            headers={"WWW-Authenticate": "Bearer"})
    return {"access_token": create_access_token(user.token_subject, settings.secret_key), "token_type": "bearer"}

Порядок операций принципиален. Сначала ищем пользователя по email. Если пользователь не найден или pwd_context.verify вернул False — возвращаем один и тот же ответ: 401 с detail="Invalid email or password". Разный текст для «нет такого email» и «неверный пароль» подсказал бы атакующему, какие адреса зарегистрированы. Одинаковый JSON этот канал частично закрывает, но не устраняет разницу по времени ответа: verify у Argon2 занимает заметно больше времени, чем простой SELECT. Полная защита от перечисления email — отдельная задача, выходящая за пределы этого урока.

Заголовок WWW-Authenticate: Bearer — требование спецификации OAuth 2.0 для ответов на защищённые ресурсы.

Токен выдаётся только после успешного verify. Пароль и хеш в payload не попадают — только token_subject, который уже существует в модели User.

app/main.py с подключением нового роутера:

from fastapi import FastAPI, Depends
from app.routers import rooms, bookings, users, auth
from app.settings import Settings, get_settings

app = FastAPI()
app.include_router(rooms.router, prefix="/api/rooms")
app.include_router(bookings.router, prefix="/api/rooms")
app.include_router(users.router, prefix="/api/users")
app.include_router(auth.router, prefix="/api/auth")

@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
    return {"app": settings.app_name}

Создайте test_login.py в корне auth_demo. Сохраните conftest.py и прежние тесты без изменений:

from app.passwords import pwd_context
from app.models import User
from app.database import get_db

def test_login_success_and_rejections(client):
    body = {"email": "owner@example.com", "password": "student-pass-123"}
    registered = client.post("/api/users", json=body)
    assert registered.status_code == 201
    response = client.post("/api/auth/login", json=body)
    assert response.status_code == 200
    assert set(response.json()) == {"access_token", "token_type"}
    assert response.json()["token_type"] == "bearer"
    assert response.json()["access_token"].count(".") == 2
    for email, password in [(body["email"], "other-pass-123"), ("missing@example.com", body["password"])]:
        rejected = client.post("/api/auth/login", json={"email": email, "password": password})
        assert rejected.status_code == 401
        assert rejected.json() == {"detail": "Invalid email or password"}

Тест регистрирует пользователя, проверяет успешный вход и убеждается, что оба вида отказа — неверный пароль и несуществующий email — дают идентичный 401. Проверка count(".") == 2 подтверждает только наличие двух разделителей. Она не проверяет содержимое токена или подпись. Запустите все тесты из корня auth_demo:

python -m pytest

Выдача токена — не защита маршрутов. Маршруты /api/rooms и /api/rooms/{room_id}/bookings пока не требуют токен и не проверяют его.

Чтение payload: кодирование не подтверждает подлинность

JWT выглядит как непрозрачная строка, но на деле состоит из трёх частей, разделённых точками: header.payload.signature. Header и payload кодируются Base64url — это не шифрование. Любой, кто получил токен, может прочитать его содержимое без единого ключа.

Вот локальная функция для просмотра payload собственного токена:

import base64
import json

def inspect_payload(token):
    encoded = token.split(".")[1]
    padded = encoded + "=" * (-len(encoded) % 4)
    return json.loads(base64.urlsafe_b64decode(padded))

В частях JWT завершающие символы = из Base64url опускаются. Формула (-len(encoded) % 4) вычисляет, сколько нужно дописать для декодера Python. Сам Base64url допускает строки с =.

Возьмите свежий токен из успешного ответа /api/auth/login и вызовите inspect_payload(token). В словаре будет sub из 64 шестнадцатеричных символов — значение User.token_subject — и числовой exp. Его конкретное значение зависит от времени выпуска токена: в нашей функции это примерно 30 минут после выпуска, а не фиксированная дата.

sub — это token_subject пользователя, exp — Unix timestamp момента истечения. Ни пароль, ни его хеш, ни SECRET_KEY туда не попали — именно потому, что payload читается без ключа.

Важно понять, что именно здесь происходит: вы просто декодируете строку. Это не аутентификация. Подтвердить, что токен не был подменён или изменён, можно только через подпись — третью часть JWT. Подпись создаётся с помощью SECRET_KEY и алгоритма HS256; проверить её можно только тем же ключом. Если атакующий изменит sub в payload и пересчитает Base64url, подпись перестанет совпадать — но только при условии, что сервер действительно её проверяет.

Поэтому правило простое: всё, что нельзя показывать клиенту открытым текстом, не помещают в payload JWT.

Задача 1

Клиент знает свой JWT, но не знает SECRET_KEY. Какие сведения он может прочитать из payload без проверки подписи?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

У User есть id=17, email="owner@example.com" и token_subject из 64 символов. Вход по паролю уже проверен. Какой первый аргумент нужно передать в create_access_token, чтобы поле sub соответствовало контракту этого проекта?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

После смены SECRET_KEY сервер проверяет прежний токен только новым ключом. Что произойдёт с проверкой его HS256-подписи?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Две ошибки входа — неверный пароль и несуществующий email — дают одинаковые 401 и одинаковый JSON. Какой вывод об утечке информации об email оправдан?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

Токен для проверенной учётной записи

Roomly выдаёт временный токен для уже проверенной учётной записи. Токен должен содержать её постоянный token_subject и срок действия, быть подписан секретным ключом сервиса и не включать пароль или хеш пароля.

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

Что уже дано

Это отдельный учебный стенд в одном видимом файле. В нём уже есть импорты, объект FastAPI app, серверные данные app.state.verified_user, учебный ключ app.state.secret_key и готовый POST /__lab__/token. Маршрут не принимает учётную запись или ключ из HTTP-запроса: он вызывает вашу функцию с серверными значениями и возвращает access_token и token_type. Данные представляют уже проверенную учётную запись; здесь нет проверки пароля, регистрации или базы данных. Не переносите этот служебный маршрут и учебный ключ в публикуемый Roomly. Полный вход с настоящей проверкой пароля остаётся в локальном проекте урока.

Служебный переходник запускается перед видимым файлом, но не создаёт app и не реализует выпуск токена. Он вызывает настоящий TestClient в процессе приложения, передаёт ответы скрытой проверке и позволяет ей вызывать вашу функцию с другими доверенными аргументами. Писать переходник не нужно.

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

Дополните только create_access_token(token_subject: str, secret_key: str) -> str. Аргумент token_subject — постоянная случайная строка существующей проверенной учётной записи, не её числовой id и не email; secret_key — непустой доверенный ключ сервера. Функция должна работать с переданными значениями, а не брать их из глобального учебного примера.

Верните строку JWT, подписанную переданным ключом через PyJWT с алгоритмом HS256. Payload содержит ровно два поля: sub, равный переданному token_subject, и exp, момент истечения через 30 минут от текущего времени UTC. В токене exp представлен числовым Unix timestamp; datetime при передаче в PyJWT также допустим. Пароль, хеш и секрет в payload не входят. Сохраните готовый маршрут и его ответ; новую проверку входа писать не требуется.

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно. После выполнения файла проверка обращается к верхнеуровневым app и create_access_token. Она отправляет запросы к app через TestClient и вызывает функцию с обычными строковыми аргументами. Результат функции — строка JWT; результат готового POST — HTTP 200 с JSON, содержащим access_token и token_type="bearer". Запускать Uvicorn, pytest или сеть не требуется.

Например, для token_subject="a" * 64 и учебного ключа из шаблона вызов POST /__lab__/token без тела возвращает 200. В access_token находится подписанная строка; при чтении её payload значение sub равно строке из 64 букв a, а exp соответствует времени выпуска плюс 30 минут. Сам токен и точный timestamp меняются со временем, поэтому фиксированная строка ответа здесь не задаётся.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку