diff --git a/backend/alembic/versions/0013_user_token_version.py b/backend/alembic/versions/0013_user_token_version.py new file mode 100644 index 0000000..b509484 --- /dev/null +++ b/backend/alembic/versions/0013_user_token_version.py @@ -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") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index 541fbd2..ddeadac 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -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 diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index a69fe9e..2541851 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -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, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 5b118bf..fd456da 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 клиента для журнала аудита. diff --git a/backend/app/core/token_revocation.py b/backend/app/core/token_revocation.py new file mode 100644 index 0000000..ad4597d --- /dev/null +++ b/backend/app/core/token_revocation.py @@ -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() diff --git a/backend/app/models.py b/backend/app/models.py index 993d68e..4c5d45d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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, diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 0448c95..06aa7e3 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5508402..acc9d87 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 648b056..b5298fb 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index b51bc1e..8763606 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8d51281..5f25d20 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index 1896b32..be34705 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -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):