Аутентификация: OAuth2, токены и JWT

Аутентификация в FastAPI строится на зависимостях: OAuth2PasswordBearer извлекает токен из заголовка, а зависимость get_current_user проверяет JWT и возвращает пользователя — защита эндпоинта сводится к одному параметру.

Идея: вход выдаёт подписанный токен (JWT), а каждый защищённый эндпоинт просто требует зависимость «текущий пользователь», которая этот токен проверяет. Пароли при этом хранятся только в виде хеша.

Классический поток — OAuth2 Password Flow с JWT. Пользователь присылает логин и пароль на /token; сервер сверяет пароль с хешем в базе и, если всё верно, выдаёт подписанный JWT с временем жизни. Дальше клиент шлёт этот токен в заголовке Authorization: Bearer ..., а сервер на каждом защищённом эндпоинте проверяет подпись и срок действия. Никаких сессий на сервере — токен самодостаточен.

from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import Annotated
import jwt

app = FastAPI()
oauth2 = OAuth2PasswordBearer(tokenUrl="token")
SECRET = "..."

@app.post("/token")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = authenticate(form.username, form.password)   # сверяем хеш
    if not user:
        raise HTTPException(status_code=401, detail="неверные данные")
    token = jwt.encode({"sub": user.id}, SECRET, algorithm="HS256")
    return {"access_token": token, "token_type": "bearer"}

def get_current_user(token: Annotated[str, Depends(oauth2)]):
    try:
        payload = jwt.decode(token, SECRET, algorithms=["HS256"])
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail="невалидный токен")
    return load_user(payload["sub"])

CurrentUser = Annotated[dict, Depends(get_current_user)]

@app.get("/me")
async def me(user: CurrentUser):
    return user

Два кита безопасности здесь — хеширование паролей и подпись токенов. Пароль никогда не хранится открытым: при регистрации считается его хеш (bcrypt/argon2), при входе сверяется хеш введённого. JWT подписывается секретом: клиент не может подделать содержимое, не зная ключа, а сервер проверяет подпись и срок.

Как работает под капотом

JWT — это base64-кодированные «полезные данные» плюс подпись от них. Подделать данные нельзя, не зная секрета, потому что подпись перестанет сходиться. Смоделируем суть подписи и проверки на stdlib (HMAC):

import hmac, hashlib, json, base64

SECRET = b"super-secret-key"

def sign(payload: dict) -> str:
    body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
    sig = hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()
    return f"{body}.{sig}"

def verify(token: str):
    body, sig = token.split(".")
    expected = hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):     # защита от подмены
        return None
    return json.loads(base64.urlsafe_b64decode(body))

token = sign({"sub": 42, "role": "user"})
print("токен:", token[:40], "...")
print("проверка валидного:", verify(token))

# попытка подделки: меняем тело, подпись не сойдётся
forged = token.replace("sub", "sub")[:-3] + "bad"
print("проверка подделки:", verify(forged))

Попробуй сам ▶ Подделанный токен не проходит проверку, потому что HMAC-подпись не совпадает. Это и есть суть защиты JWT.

Частые ошибки

Первая и тяжёлая — хранить пароли в открытом виде или со слабым/самописным хешированием вместо bcrypt/argon2. Вторая — держать секрет JWT в коде/репозитории вместо переменных окружения. Третья — выдавать токены без срока жизни (exp), делая их вечными. Четвёртая — забывать перехватывать ошибку декодирования и отдавать 500 вместо 401. Пятая — слать токены по HTTP без TLS, открывая их перехвату.

Best practices

  • Храните только хеш пароля (bcrypt/argon2), сверяйте при входе.
  • Секрет JWT — в переменных окружения, не в коде; токены с обязательным exp.
  • Защиту эндпоинта сводите к зависимости get_current_user; не дублируйте проверку токена.
  • Всегда работайте по HTTPS; на невалидный токен возвращайте 401, а не 500.

Авторизация: роли и права после аутентификации

Важно различать два понятия, которые часто смешивают. Аутентификация отвечает на вопрос «кто ты» (проверка токена, установление личности), авторизация — «что тебе можно» (проверка прав на действие). JWT решает первую задачу: подтверждает, что запрос от конкретного пользователя. Вторую решают поверх: зависимость get_current_user устанавливает личность, а дальше другие зависимости проверяют роли и права — например, require_admin допускает только администраторов. Удобно строить эти проверки как зависимости-фабрики, накладывая их на отдельные эндпоинты или целые роутеры. В сам токен иногда кладут роли (claim scopes), чтобы не ходить за ними в базу на каждый запрос, но тогда важно помнить о времени жизни: пока токен не истёк, права в нём «заморожены». Понимание границы «аутентификация против авторизации» избавляет от путаницы и дыр в безопасности, когда личность проверена, а права — нет.

Итог: аутентификация в FastAPI — это связка «вход выдаёт подписанный JWT» и «зависимость проверяет его на каждом защищённом эндпоинте». Пароли хранятся хешами, токены подписываются секретом и имеют срок; защита маршрута — один параметр-зависимость.

Проверьте себя
1. Как в FastAPI обычно защищают эндпоинт, требующий аутентификации?
AПроверяют пароль в каждом обработчике вручную
BДобавляют параметр-зависимость вроде user: Annotated[dict, Depends(get_current_user)], которая проверяет JWT
CВключают глобальный пароль в настройках
DШифруют весь ответ
2. Почему подделать содержимое JWT, не зная секретного ключа, невозможно?
AJWT зашифрован целиком
BДанные подписаны секретом (HMAC/RSA); при изменении тела подпись перестаёт совпадать при проверке
CJWT хранится только на сервере
DБраузер запрещает изменять токен