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 отправляю в список отозванных. Если обновиться попробуют и украденный токен, и настоящий пользователь, второе использование провалится, и у меня появится сигнал, что что-то не так. Ничего экзотического здесь нет, это примерно сорок строк поверх наивной версии. Но именно это расстояние отделяет «я умею выпускать токен» от «я построил аутентификацию».