Назад в блог
Безопасность
3 мин чтения
FastAPISecurityJWT

JWT-аутентификация в FastAPI: access, refresh и то, что пропускают руководства

Большинство руководств по JWT в FastAPI заканчиваются на «вот вам токен». По-настоящему важное начинается позже: ротация refresh, HTTP-only cookie и то, как вообще разлогинить человека.

Почти каждое руководство по аутентификации в FastAPI заканчивается в одном и том же месте: захешировать пароль, подписать JWT, вернуть его. Это лёгкие 20 процентов. Остальные 80, то есть та часть, которая и решает, безопасна ли ваша аутентификация, всплывают только после двух скучных вопросов: где токен живёт в браузере и как разлогинить пользователя?

Начнём с хеширования паролей, потому что ошибиться здесь непростительно. Я использую argon2id и ничего больше. Он требователен к памяти, а это ровно то, что нужно против атакующего со стойкой GPU. bcrypt всё ещё работает, но сегодня правильный ответ это argon2.

from argon2 import PasswordHasher

ph = PasswordHasher()
hashed = ph.hash(plain_password)      # on register
ph.verify(hashed, plain_password)     # on login, raises on mismatch

Теперь токены. Паттерн, к которому я прибегаю, состоит из двух: короткоживущий access-токен (15 минут) и долгоживущий refresh-токен (7 дней). Access-токен подтверждает вашу личность на каждом запросе; refresh-токен существует только для того, чтобы выпускать новые access-токены по истечении срока, и пользователю не приходится входить заново каждые четверть часа.

from datetime import datetime, timedelta, UTC
from uuid import uuid4
import jwt

def create_token(sub: str, minutes: int, kind: str) -> str:
    now = datetime.now(UTC)
    payload = {
        "sub": sub,
        "type": kind,
        "iat": now,
        "exp": now + timedelta(minutes=minutes),
        "jti": uuid4().hex,
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

Вот решение, которое пропускают руководства: где всё это хранится? Не в localStorage. Всё, что доступно JavaScript, находится в одном XSS от кражи. Оба токена уходят в cookie с флагами HttpOnly, Secure и SameSite, поэтому браузер отправляет их сам, а никакой скрипт прочитать их не может. Платой становится CSRF-токен на изменяющих запросах, и на этот обмен я согласен всегда.

Вторая пропускаемая часть это выход из системы. JWT действителен до истечения срока, в этом и весь смысл, и вся проблема. Если человек нажал «выйти», его токен криптографически остаётся годным ещё до 15 минут. Поэтому я держу в Redis список отозванных токенов по ключу jti с TTL, равным остатку жизни токена. Каждый запрос сверяется с ним, а выход добавляет в него запись.

async def require_user(token: str = Depends(cookie_scheme)) -> User:
    payload = decode(token)                 # verifies signature + exp
    if await redis.exists(f"denylist:{payload['jti']}"):
        raise HTTPException(401, "Token revoked")
    return await load_user(payload["sub"])

И ротация refresh: каждый раз при использовании refresh-токена я выдаю новый, а старый jti отправляю в список отозванных. Если обновиться попробуют и украденный токен, и настоящий пользователь, второе использование провалится, и у меня появится сигнал, что что-то не так. Ничего экзотического здесь нет, это примерно сорок строк поверх наивной версии. Но именно это расстояние отделяет «я умею выпускать токен» от «я построил аутентификацию».

Поделиться:Поделиться в Telegram
Ещё статьи