Аутентификация: JWT, login endpoint, Depends

Auth - один из самых важных и easy-to-mess-up аспектов backend. В этом уроке - современный подход для REST API: JWT токены, password hashing через bcrypt, login endpoint на классах OAuth2* из FastAPI (и почему это не OAuth2 password flow, хотя так пишут в туториалах), защита endpoints через Depends.

Что такое JWT

JWT (JSON Web Token) - формат токена с тремя частями:

header.payload.signature
  • header - алгоритм подписи (HS256, RS256)
  • payload - данные (user_id, expiry, custom claims)
  • signature - HMAC подпись для верификации

Пример:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM1MzE1MjAwfQ.SUm3yKjY8M1...

Сервер выпускает token при login, клиент шлёт в каждом запросе. Сервер верифицирует подпись и читает payload.

Преимущества JWT:

  • Stateless - не нужен server-side session storage
  • Самодостаточен - все данные в токене
  • Подписан - нельзя tampering без знания secret

Недостатки:

  • Нельзя отозвать (revoke) без дополнительных механизмов
  • Размер больше session-id

Установка библиотек

pip install "python-jose[cryptography]"      # JWT operations
pip install "passlib[bcrypt]"                 # password hashing
pip install "fastapi[all]"                    # FastAPI с form parsing

Password hashing - bcrypt

Никогда не храни пароли в plain text. Используй bcrypt:

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

# При регистрации
hashed = hash_password("user_password")
# Сохраняем hashed в БД

# При login
if verify_password(form_password, db_user.password_hash):
    # Пароль верный - выдаём токен (создание токена ниже в уроке).
    token = create_access_token({"sub": db_user.email})
else:
    # Один и тот же ответ для «нет пользователя» и «неверный пароль»:
    # разные ответы позволяют перебором узнать зарегистрированные адреса.
    raise HTTPException(status_code=401, detail="Incorrect email or password")

bcrypt:

  • One-way (нельзя расшифровать)
  • Slow by design (защита от brute force)
  • Включает salt (защита от rainbow tables)
  • Configurable cost factor

Создание JWT токена

import os
from datetime import datetime, timedelta, timezone

from jose import jwt

# Секрет только из окружения: значение в коде уезжает в git и остаётся
# в истории навсегда. os.environ[...] без дефолта падает на старте, если
# переменной нет, - это лучше, чем подписывать токены известной строкой.
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

token = create_access_token({"sub": "user@example.com"})
# eyJhbGc...

sub (subject) - идентификатор юзера. exp (expiration) - когда токен истечёт. Эти claims стандартизированы в RFC 7519.

SECRET_KEY критичен - кто знает, тот может выпускать valid токены. Должен быть длинной (256 bit для HS256), храниться в env (удобно подтягивать через Pydantic Settings), никогда в коде/git.

Декодирование и валидация

from jose import jwt, JWTError

def decode_token(token: str) -> dict:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

payload = decode_token(token)
# {"sub": "user@example.com", "exp": 1735315200}

jwt.decode автоматически проверяет:

  • Signature (через SECRET_KEY)
  • Expiration (exp)
  • Issuer/audience если заданы

Невалидная подпись или expired token → JWTError.

FastAPI security setup

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

class Token(BaseModel):
    access_token: str
    token_type: str

class User(BaseModel):
    email: str
    name: str

OAuth2PasswordBearer - dependency, которая читает Authorization: Bearer <token> header.

Login endpoint

@app.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    # OAuth2PasswordRequestForm reads username/password from form
    user = get_user_from_db(form_data.username)
    if not user or not verify_password(form_data.password, user.password_hash):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(data={"sub": user.email})
    return {"access_token": access_token, "token_type": "bearer"}
Формулировку «FastAPI-логин это OAuth2 password flow» повторяют и в туториалах, и в документации, но она путает две разные вещи.

Resource Owner Password Credentials (ROPC) - это грант, в котором приложение собирает пароль пользователя от другого сервиса и обменивает его в чужом authorization server на токен. Именно он по RFC 9700 §2.4 - это актуальный Security Best Current Practice для OAuth 2.0 - MUST NOT be used. Формулировки RFC: грант «небезопасно раскрывает учётные данные владельца ресурса клиенту», расширяет поверхность атаки (пароль утекает не только из authorization server, но и из всех мест, куда его донёс клиент) и «приучает пользователей вводить свои учётные данные не на authorization server» - то есть готовит их к фишингу. Плюс он архитектурно несовместим с двухфакторной аутентификацией и любым входом в несколько шагов, а с WebAuthn не реализуем вовсе. В черновике OAuth 2.1 грант убран из спецификации.

Код выше - не ROPC. Здесь твой же сервис владеет паролями, сам их проверяет и выдаёт свой токен. Это обычный login endpoint; OAuth в нём - только имя класса OAuth2PasswordRequestForm, который читает username и password из формы, и схема Bearer в заголовке. Никакой делегации доступа нет, потому что делегировать некому.

Практический вывод из различия:

  • Свой frontend к своему API - показанный endpoint подходит. Не называй его «password flow»: назовёшь так - и рано или поздно кто-то выставит его наружу как OAuth-эндпоинт для сторонних клиентов.
  • Сторонние приложения к твоему API, или твоё приложение к чужому провайдеру (Google, GitLab, Keycloak) - только Authorization Code + PKCE. Пароль вводится на странице провайдера, приложение его никогда не видит.
  • Никогда - не принимай в своём API пароль от чужого сервиса, чтобы сходить с ним куда-то за токеном. Это и есть запрещённый случай.

Защищённые endpoints

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email = payload.get("sub")
        if email is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = get_user_from_db(email)
    if user is None:
        raise credentials_exception
    return user

@app.get("/me", response_model=User)
async def read_me(user: User = Depends(get_current_user)):
    return user

@app.get("/protected")
async def protected(user: User = Depends(get_current_user)):
    return {"message": f"Hello, {user.name}"}

get_current_user - dependency, которая:

  1. Извлекает token из Authorization header
  2. Декодирует JWT
  3. Находит юзера в БД
  4. Возвращает User объект или 401

Любой endpoint с Depends(get_current_user) защищён.

Refresh tokens

Access токены живут коротко (15-30 мин). При истечении нужен новый - но без повторного login. Решение: refresh tokens:

import secrets

from fastapi import Cookie
from fastapi.responses import JSONResponse

def create_refresh_token(data: dict) -> tuple[str, str]:
    """Возвращает (токен, jti). jti нужен, чтобы токен можно было отозвать."""
    jti = secrets.token_urlsafe(16)
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(days=30)   # дольше живёт
    to_encode.update({"exp": expire, "type": "refresh", "jti": jti})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM), jti

@app.post("/refresh", response_model=Token)
async def refresh_token(
    # Cookie, а НЕ обычный параметр функции.
    #
    # `refresh_token: str` в FastAPI - это query-параметр, то есть токен
    # уехал бы в URL: access-логи, история браузера, заголовок Referer.
    # Для токена, живущего 30 дней, это равносильно его публикации.
    refresh_token: str = Cookie(...),
    db: AsyncSession = Depends(get_db),
):
    try:
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
        if payload.get("type") != "refresh":
            raise HTTPException(401, "Invalid token type")
        email = payload.get("sub")
        jti = payload.get("jti")
    except JWTError:
        raise HTTPException(401, "Invalid refresh token")

    # Ротация: старый refresh гасится, выдаётся новая пара.
    #
    # Именно поэтому refresh-токены приходится хранить на сервере -
    # хотя бы их идентификаторы (jti). Без этого «отозвать» токен
    # невозможно: подпись остаётся валидной до exp.
    stored = await db.get(RefreshToken, jti)
    if stored is None or stored.used_at is not None:
        # Токен неизвестен или уже использован. Второе - признак кражи:
        # легитимный клиент обменял бы его один раз. Гасим всю цепочку
        # сессий пользователя, а не только этот токен.
        await revoke_all_sessions(db, email)
        raise HTTPException(401, "Refresh token reuse detected")

    stored.used_at = datetime.now(timezone.utc)
    new_refresh, new_jti = create_refresh_token({"sub": email})
    db.add(RefreshToken(jti=new_jti, user_email=email))
    await db.commit()

    response = JSONResponse({
        "access_token": create_access_token({"sub": email}),
        "token_type": "bearer",
    })
    response.set_cookie(
        "refresh_token", new_refresh,
        httponly=True, secure=True, samesite="lax", path="/refresh",
    )
    return response
Первая версия этого примера просто выдавала новый access-токен по старому refresh, и рядом стоял текст: «`POST /logout` → refresh token отозван, новую пару не получить». Обещание было неисполнимо: **сервер токен не хранил, поэтому отзывать было нечего.** Украденный refresh работал все 30 дней параллельно с легитимным клиентом, и повторное использование никак не обнаруживалось.

Минимум, который делает отзыв возможным: таблица с jti выданных refresh и отметкой об использовании. Тогда появляются обе нужные вещи:

  • отзыв - /logout помечает токены сессии использованными;
  • детект кражи - повторное предъявление уже использованного токена означает, что копия есть у двух сторон. Правильная реакция - погасить всю цепочку, а не только предъявленный токен: кто из двоих легитимный, сервер не знает.

Это и есть цена stateless-подхода: как только нужен отзыв, состояние на сервере возвращается. Разница с сессиями в том, что читается оно только при refresh, а не на каждом запросе - подробнее в Основах аутентификации.

Access-токен клиент держит в памяти приложения, refresh - в HttpOnly cookie с path=/refresh: так он не отправляется с каждым запросом и недоступен JavaScript. Почему именно так и чем за это платят - в таблице хранилищ ниже.

Token revocation

JWT нельзя отозвать напрямую - сервер его не хранит, а подпись остаётся валидной до exp. Отсюда практический вывод, который стоит проговорить до кода:

POST /logout
  → refresh token отозван, новую пару не получить
  → выданный access token работает до exp

То есть /logout завершает сессию, а не действие уже выданного access token. Для большинства приложений это приемлемо - потому и делают его короткоживущим. Если нужен мгновенный отзыв, придётся добавить серверное состояние, и тогда проверка на каждом запросе перестаёт быть локальной - подробнее в общем уроке про аутентификацию.

Решения:

1. Короткое время жизни access token (15 мин) Если accounts compromised - max 15 минут вреда.

2. Blocklist скомпрометированных tokens в Redis Проверять каждый request - есть ли token в blocklist. До expiry хранить.

3. JWT с versioning В payload user_version. Login инкрементирует версию в таблице пользователей. При logout - тоже. Проверять что версия в token == версия в БД.

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    # jwt.decode бросает JWTError на истёкшем, подделанном и просто
    # непарсящемся токене. Без перехвата это 500 вместо 401: клиент
    # решит, что сломался сервер, а в логи посыпятся трейсбеки
    # от обычного перебора.
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError:
        raise HTTPException(401, "Invalid or expired token")

    user = get_user_from_db(payload["sub"])
    # Пользователя могли удалить, пока токен ещё валиден по подписи.
    # Без этой проверки следующая строка даёт AttributeError на None.
    if user is None:
        raise HTTPException(401, "Invalid or expired token")
    if user.token_version != payload.get("ver"):
        raise HTTPException(401, "Token revoked")
    return user

Полностью stateless проверка и мгновенный отзыв несовместимы - это tradeoff, а не недоработка: stateless относится к проверке токена, управление сессиями и отзывом состояния требует (разбор в общем уроке).

Scopes - permissions

oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    scopes={"read": "Read access", "write": "Write access", "admin": "Admin"},
)

def create_access_token(data: dict, scopes: list[str]):
    to_encode = data.copy()
    to_encode.update({"scopes": scopes, "exp": ...})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError:
        # 401, а не 500: токен невалиден - это ожидаемая ситуация,
        # а не сбой сервера.
        raise HTTPException(401, "Invalid or expired token")

    token_scopes = payload.get("scopes", [])
    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise HTTPException(403, f"Missing scope: {scope}")
    return get_user_from_db(payload["sub"])

@app.get("/items", dependencies=[Security(get_current_user, scopes=["read"])])
def list_items():
    ...

@app.post("/items", dependencies=[Security(get_current_user, scopes=["write"])])
def create_item():
    ...

Scopes - стандарт OAuth2 для тонкого контроля разрешений.

CSRF и cookies

Если используешь cookies для auth, нужна CSRF защита:

import secrets

from fastapi import Cookie, Depends, Form, HTTPException

@app.post("/transfer")
async def transfer(
    user = Depends(get_user_from_cookie),
    csrf_token: str = Cookie(...),
    csrf_form: str = Form(...),
):
    # compare_digest, а не !=.
    #
    # Обычное сравнение строк прекращается на первом различающемся байте,
    # поэтому время ответа зависит от того, сколько символов совпало,
    # и токен можно подобрать побайтово. compare_digest сравнивает
    # за постоянное время.
    if not secrets.compare_digest(csrf_token, csrf_form):
        raise HTTPException(403, "CSRF token mismatch")

С header-based auth (Authorization: Bearer) CSRF меньше проблема - browser не шлёт custom headers automatically.

Безопасное хранение JWT на клиенте

ХранениеЧто даётЧем платишь
localStorageпереживает перезагрузку и закрытие браузерачитается любым скриптом на странице: XSS выносит токен целиком
sessionStorageто же, но чистится при закрытии вкладкитак же читается скриптом
HttpOnly cookieJS не имеет доступа, XSS не достаёт напрямуюпоявляется вектор CSRF; уходит с каждым запросом; для API на другом домене нужен CORS с credentials
Memory (state приложения)нечего украсть после закрытия вкладкитеряется при F5 - нужен refresh; скрипт на странице всё же способен прочитать состояние

Рабочая комбинация для SPA: access token в памяти, refresh token в HttpOnly cookie с Secure и SameSite. Тогда долгоживущий токен недостижим для JS, а короткоживущий не пишется на диск.

Рейтинг хранилищ зависит от того, от чего защищаешься. `HttpOnly` cookie сильнее против XSS и слабее против CSRF; память сильнее против кражи с диска и слабее против перезагрузки страницы. Совет «всегда используй X» стоит читать как «в модели угроз автора важнее было Y».

Что действительно не зависит от модели угроз: токен не должен попадать в параметры URL (логи, история, Referer) и в код фронтенда как константа. Остальное - выбор компромисса, и его стоит делать осознанно.

Распространённые ошибки

1. Слабый SECRET_KEY

SECRET_KEY = "secret"   # КАТАСТРОФА

256+ битный random. Хранить в env var, не в коде. Регулярная ротация.

2. Algorithm = None атака

payload = jwt.decode(token, SECRET_KEY, algorithms=None)   # принимает любой

Atttacker может подделать token с alg=none. Всегда явный list: algorithms=["HS256"].

3. Нет expiration

to_encode = {"sub": email}   # нет exp
jwt.encode(to_encode, ...)

Token живёт вечно. ALWAYS set exp.

4. Логирование токенов

logger.info(f"User authenticated: {token}")   # ТОКЕН В ЛОГАХ

Никогда. Tokens это credentials. Если попадут в логи - breach.

5. Plain text passwords

if user.password == form.password:   # ПЛОХО - plain text сравнение

Always hash. Always verify против hash. Никогда не храни pure text.

6. JWT для session с large data

to_encode = {"sub": email, "all_permissions": [...100 items...], "user_data": {...}}

Token становится огромным (16KB), каждый request тащит. Используй JWT для identity, остальное - server-side через user lookup.

Полный пример

from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel

# Config
SECRET_KEY = os.getenv("JWT_SECRET")   # 256+ bit random
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# Fake DB
users_db = {
    "alice@example.com": {
        "email": "alice@example.com",
        "name": "Alice",
        "password_hash": "$2b$12$KIX0nWiTczE3ZdyDmZcMxe...",   # hashed "secret"
    }
}

# Auth setup
app = FastAPI()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

class Token(BaseModel):
    access_token: str
    token_type: str

class User(BaseModel):
    email: str
    name: str

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

def authenticate(email: str, password: str):
    user = users_db.get(email)
    if not user or not verify_password(password, user["password_hash"]):
        return None
    return user

def create_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid token",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email = payload.get("sub")
        if not email:
            raise credentials_exception
    except JWTError:
        raise credentials_exception
    user_data = users_db.get(email)
    if not user_data:
        raise credentials_exception
    return User(**user_data)

# Endpoints
@app.post("/token", response_model=Token)
async def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate(form.username, form.password)
    if not user:
        raise HTTPException(401, "Incorrect username or password")
    token = create_token({"sub": user["email"]})
    return {"access_token": token, "token_type": "bearer"}

@app.get("/me", response_model=User)
async def me(user: User = Depends(get_current_user)):
    return user

Сравнение с Go и PHP

В Go популярны golang-jwt/jwt для JWT, crypto/bcrypt для password:

import "github.com/golang-jwt/jwt/v5"

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "sub": userEmail,
    "exp": time.Now().Add(15 * time.Minute).Unix(),
})
tokenString, _ := token.SignedString([]byte(secretKey))

В PHP - firebase/php-jwt популярный, password_hash() для bcrypt built-in.

Концепции одинаковые - JWT универсальный стандарт, bcrypt де-факто для паролей. Различаются конкретные библиотеки и framework integration.

Мини-задание

Минимальный auth с FastAPI - смотри полный пример выше. Запусти, попробуй:

# Получить токен
curl -X POST -d "username=alice@example.com&password=secret" http://localhost:8000/token

# Использовать токен
curl -H "Authorization: Bearer <TOKEN>" http://localhost:8000/me

Что дальше

Освоили auth. В следующем уроке - Docker и CI/CD для Python приложений. Это инфраструктурная часть production deployment.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.