Аутентификация: 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"}
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, которая:
- Извлекает token из Authorization header
- Декодирует JWT
- Находит юзера в БД
- Возвращает 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
Минимум, который делает отзыв возможным: таблица с 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 cookie | JS не имеет доступа, XSS не достаёт напрямую | появляется вектор CSRF; уходит с каждым запросом; для API на другом домене нужен CORS с credentials |
| Memory (state приложения) | нечего украсть после закрытия вкладки | теряется при F5 - нужен refresh; скрипт на странице всё же способен прочитать состояние |
Рабочая комбинация для SPA: access token в памяти, refresh token в HttpOnly
cookie с Secure и SameSite. Тогда долгоживущий токен недостижим для JS,
а короткоживущий не пишется на диск.
Что действительно не зависит от модели угроз: токен не должен попадать
в параметры 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
Что дальше
- Web - Cookie, session, localStorage - как клиент хранит токены: httpOnly cookies vs localStorage, атрибуты Secure и SameSite
Освоили auth. В следующем уроке - Docker и CI/CD для Python приложений. Это инфраструктурная часть production deployment.