Отзыв JWT при выходе и смене пароля

logout отзывает предъявленный токен по jti (in-memory denylist до exp);
смена и сброс пароля инкрементят users.token_version (claim ver в JWT,
сверка в auth/deps) — все прежние сессии отзываются. Своё устройство при
смене пароля остаётся в сессии (перевыдача cookie). Миграция 0013. #57

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
This commit is contained in:
2026-09-13 17:09:57 +03:00
co-authored by Claude Opus 4.8
parent d84cef2491
commit 3f8667b561
12 changed files with 236 additions and 12 deletions
@@ -0,0 +1,44 @@
"""Пользователь: версия сессий (token_version) для отзыва JWT.
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57).
Revision ID: 0013_user_token_version
Revises: 0012_match_finish_draft
Create Date: 2026-09-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0013_user_token_version"
down_revision: Union[str, None] = "0012_match_finish_draft"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "token_version" not in cols:
b.add_column(
sa.Column(
"token_version", sa.Integer(), nullable=False, server_default="0"
)
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "token_version" in cols:
b.drop_column("token_version")
+9
View File
@@ -21,9 +21,14 @@ def get_current_user(
payload = security.decode_token(token, security.AUDIENCE_USER)
except jwt.PyJWTError as exc: # noqa: F841
raise AuthError("Сессия недействительна.")
if security.is_session_revoked(payload):
raise AuthError("Сессия недействительна.")
user = session.get(User, int(payload["sub"]))
if user is None or not user.is_active:
raise AuthError("Сессия недействительна.")
if int(payload.get("ver", 0)) != int(user.token_version or 0):
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
raise AuthError("Сессия недействительна.")
return user
@@ -37,7 +42,11 @@ def get_current_admin(
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
except jwt.PyJWTError:
raise AuthError("Сессия администратора недействительна.")
if security.is_session_revoked(payload):
raise AuthError("Сессия администратора недействительна.")
user = session.get(User, int(payload["sub"]))
if user is None or user.role != "admin" or not user.is_active:
raise ForbiddenError("Доступ только для администратора.")
if int(payload.get("ver", 0)) != int(user.token_version or 0):
raise AuthError("Сессия администратора недействительна.")
return user
+1 -1
View File
@@ -22,7 +22,7 @@ def establish_session(
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
if not user.is_active:
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
audit_service.record(
session,
actor_id=user.id,
+42 -5
View File
@@ -9,6 +9,7 @@ import jwt
from fastapi import Request, Response
from app.core.config import settings
from app.core.token_revocation import revoked_tokens
USER_COOKIE = "fs_session"
ADMIN_COOKIE = "fs_admin"
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
# ─── JWT ─────────────────────────────────────────────────────────────────────
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
def create_token(
subject: str | int,
audience: str,
ttl_minutes: int,
provider: str = "",
token_version: int = 0,
) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(subject),
@@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider:
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
"jti": secrets.token_hex(8),
"provider": provider,
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
"ver": token_version,
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
@@ -95,9 +105,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
def set_user_session(response: Response, user_id: int, provider: str) -> None:
def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None:
ttl = settings.jwt_user_ttl_minutes
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
response.set_cookie(
key=USER_COOKIE,
value=token,
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
_set_csrf_cookie(response)
def set_admin_session(response: Response, admin_id: int) -> None:
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
ttl = settings.jwt_admin_ttl_minutes
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
response.set_cookie(
key=ADMIN_COOKIE,
value=token,
@@ -135,6 +145,33 @@ def clear_admin_session(response: Response) -> None:
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None:
"""Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout.
Убивает именно этот токен (украденный/оставленный), не трогая другие устройства.
Некорректный/просроченный токен отзывать нечего — молча выходим."""
token = request.cookies.get(cookie_name)
if not token:
return
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=[settings.jwt_algorithm],
audience=audience,
)
except jwt.PyJWTError:
return
exp = payload.get("exp")
if exp is not None:
revoked_tokens.revoke(payload.get("jti"), float(exp))
def is_session_revoked(payload: dict) -> bool:
"""Отозван ли этот токен точечно (через logout)."""
return revoked_tokens.is_revoked(payload.get("jti"))
def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита.
+53
View File
@@ -0,0 +1,53 @@
"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена).
Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn.
Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый
процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля
используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список.
"""
from __future__ import annotations
import threading
import time
# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память.
_PRUNE_ABOVE = 10_000
class RevokedTokens:
"""Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT)."""
def __init__(self) -> None:
self._revoked: dict[str, float] = {}
self._lock = threading.Lock()
def revoke(self, jti: str | None, expires_at: float) -> None:
if not jti:
return
now = time.time()
with self._lock:
if len(self._revoked) > _PRUNE_ABOVE:
for key, exp in list(self._revoked.items()):
if exp <= now:
del self._revoked[key]
self._revoked[jti] = expires_at
def is_revoked(self, jti: str | None) -> bool:
if not jti:
return False
now = time.time()
with self._lock:
exp = self._revoked.get(jti)
if exp is None:
return False
if exp <= now:
del self._revoked[jti] # протухла — заодно вычищаем
return False
return True
def clear(self) -> None:
with self._lock:
self._revoked.clear()
revoked_tokens = RevokedTokens()
+7
View File
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
sa_column=Column(String(16), nullable=False, server_default="stub"),
)
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
token_version: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default="0"),
)
active_group_id: int | None = Field(
sa_column=Column(
Integer,
+3 -2
View File
@@ -41,7 +41,7 @@ def admin_login(
session: Session = Depends(get_session),
) -> s.AdminMe:
admin = admin_service.authenticate_admin(session, body.username, body.password)
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
audit_service.record(
session,
actor_id=admin.id,
@@ -56,7 +56,8 @@ def admin_login(
@router.post("/auth/logout", response_model=s.OkResponse)
def admin_logout(response: Response) -> s.OkResponse:
def admin_logout(request: Request, response: Response) -> s.OkResponse:
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
security.clear_admin_session(response)
return s.OkResponse()
+4 -1
View File
@@ -116,6 +116,9 @@ def telegram_register(
@router.post("/logout", response_model=s.OkResponse)
def logout(response: Response) -> s.OkResponse:
def logout(request: Request, response: Response) -> s.OkResponse:
# Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная
# cookie перестаёт работать сразу, а не живёт до конца TTL (#57).
security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER)
security.clear_user_session(response)
return s.OkResponse()
+6 -2
View File
@@ -1,14 +1,14 @@
"""Роутер текущего пользователя."""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.auth.password import check_current_password
from app.auth.telegram import TelegramProvider
from app.core.security import client_ip
from app.core.security import client_ip, set_user_session
from app.core.errors import NotFoundError
from app.db.session import get_session
from app.models import User
@@ -122,6 +122,7 @@ def update_my_profile(
def change_my_password(
body: s.PasswordChange,
request: Request,
response: Response,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
@@ -130,6 +131,9 @@ def change_my_password(
if had_password:
check_current_password(user, body.current_password)
user_service.set_password(session, user, body.new_password)
# set_password инкрементит token_version → все ранее выданные токены отозваны (#57).
# Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные.
set_user_session(response, user.id, user.auth_provider, user.token_version)
audit_service.record(
session,
actor_id=user.id,
+6 -1
View File
@@ -161,9 +161,14 @@ def register_local(session: Session, nickname: str, password: str) -> User:
def set_password(session: Session, user: User, new_password: str) -> User:
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
validate_new_password(new_password)
user.password_hash = hash_password(new_password)
user.token_version = (user.token_version or 0) + 1
session.add(user)
session.commit()
session.refresh(user)
+2
View File
@@ -20,6 +20,7 @@ from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
import app.models # noqa: F401,E402 (регистрация моделей)
from app.core.ratelimit import login_throttle # noqa: E402
from app.core.security import hash_password # noqa: E402
from app.core.token_revocation import revoked_tokens # noqa: E402
from app.db.session import get_session # noqa: E402
from app.main import app # noqa: E402
from app.models import AuthIdentity, GroupMember, User # noqa: E402
@@ -50,6 +51,7 @@ def client(engine):
app.dependency_overrides[get_session] = _get_session
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
+59
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app.main import app
from app.models import User
from tests.conftest import csrf_headers
from tests.test_auth import _telegram_payload
@@ -251,6 +252,64 @@ def test_set_password_requires_session(client: TestClient):
assert _set_password(client, PASSWORD).status_code == 401
# ─── Отзыв токена: logout и смена пароля (#57, F2) ────────────────────────────
def _me_with_token(cookie_name: str, token: str):
"""Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie)."""
return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"})
def test_logout_revokes_presented_token(client: TestClient):
_register(client)
tok = client.cookies.get("fs_session")
assert _me_with_token("fs_session", tok).status_code == 200 # пока жив
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
# тот же токен, предъявленный после выхода, больше не принимается
assert _me_with_token("fs_session", tok).status_code == 401
def test_logout_does_not_revoke_other_devices(client: TestClient):
_register(client) # устройство A
tok_a = client.cookies.get("fs_session")
# устройство B: независимый вход тем же аккаунтом (свой jti)
b = TestClient(app)
assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200
tok_b = b.cookies.get("fs_session")
assert tok_a and tok_b and tok_a != tok_b
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел
assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут
def test_password_change_revokes_old_sessions_keeps_current(client: TestClient):
_register(client)
old = client.cookies.get("fs_session")
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
# это устройство осталось в сессии (cookie перевыдан со свежим ver)
assert client.get("/api/users/me").status_code == 200
# старый токен (другое устройство/утёкший) отозван инкрементом token_version
assert _me_with_token("fs_session", old).status_code == 401
def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin):
player = _telegram_login(client, monkeypatch).json()
stolen = client.cookies.get("fs_session") # действующая сессия игрока
assert _me_with_token("fs_session", stolen).status_code == 200
client.cookies.clear()
_admin_login(client, make_admin)
r = client.put(
f"/api/admin/users/{player['id']}/password",
json={"new_password": "from-admin-1"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
# сброс пароля админом обрывает прежние сессии игрока (в т.ч. злоумышленника)
assert _me_with_token("fs_session", stolen).status_code == 401
# ─── Привязка Telegram ───────────────────────────────────────────────────────
def _link_telegram(client: TestClient, monkeypatch, **fields):