Отзыв 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:
@@ -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")
|
||||||
@@ -21,9 +21,14 @@ def get_current_user(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||||
except jwt.PyJWTError as exc: # noqa: F841
|
except jwt.PyJWTError as exc: # noqa: F841
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
|
if security.is_session_revoked(payload):
|
||||||
|
raise AuthError("Сессия недействительна.")
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
|
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||||
|
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||||
|
raise AuthError("Сессия недействительна.")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise AuthError("Сессия администратора недействительна.")
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
|
if security.is_session_revoked(payload):
|
||||||
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or user.role != "admin" or not user.is_active:
|
if user is None or user.role != "admin" or not user.is_active:
|
||||||
raise ForbiddenError("Доступ только для администратора.")
|
raise ForbiddenError("Доступ только для администратора.")
|
||||||
|
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||||
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def establish_session(
|
|||||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
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(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jwt
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.token_revocation import revoked_tokens
|
||||||
|
|
||||||
USER_COOKIE = "fs_session"
|
USER_COOKIE = "fs_session"
|
||||||
ADMIN_COOKIE = "fs_admin"
|
ADMIN_COOKIE = "fs_admin"
|
||||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
|||||||
|
|
||||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
# ─── 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)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": str(subject),
|
"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()),
|
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||||
"jti": secrets.token_hex(8),
|
"jti": secrets.token_hex(8),
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||||
|
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||||
|
"ver": token_version,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
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")
|
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
|
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(
|
response.set_cookie(
|
||||||
key=USER_COOKIE,
|
key=USER_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
|||||||
_set_csrf_cookie(response)
|
_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
|
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(
|
response.set_cookie(
|
||||||
key=ADMIN_COOKIE,
|
key=ADMIN_COOKIE,
|
||||||
value=token,
|
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)
|
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:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
|
|||||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||||
)
|
)
|
||||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
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(
|
active_group_id: int | None = Field(
|
||||||
sa_column=Column(
|
sa_column=Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def admin_login(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.AdminMe:
|
) -> s.AdminMe:
|
||||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
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(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=admin.id,
|
actor_id=admin.id,
|
||||||
@@ -56,7 +56,8 @@ def admin_login(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
@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)
|
security.clear_admin_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ def telegram_register(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout", response_model=s.OkResponse)
|
@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)
|
security.clear_user_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Роутер текущего пользователя."""
|
"""Роутер текущего пользователя."""
|
||||||
from __future__ import annotations
|
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 fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
from app.auth.password import check_current_password
|
from app.auth.password import check_current_password
|
||||||
from app.auth.telegram import TelegramProvider
|
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.core.errors import NotFoundError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -122,6 +122,7 @@ def update_my_profile(
|
|||||||
def change_my_password(
|
def change_my_password(
|
||||||
body: s.PasswordChange,
|
body: s.PasswordChange,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> s.MeRead:
|
) -> s.MeRead:
|
||||||
@@ -130,6 +131,9 @@ def change_my_password(
|
|||||||
if had_password:
|
if had_password:
|
||||||
check_current_password(user, body.current_password)
|
check_current_password(user, body.current_password)
|
||||||
user_service.set_password(session, user, body.new_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(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
|
|||||||
@@ -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:
|
def set_password(session: Session, user: User, new_password: str) -> User:
|
||||||
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
|
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
|
||||||
|
|
||||||
|
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
|
||||||
|
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
|
||||||
|
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
|
||||||
validate_new_password(new_password)
|
validate_new_password(new_password)
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.token_version = (user.token_version or 0) + 1
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
|||||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||||
from app.core.ratelimit import login_throttle # noqa: E402
|
from app.core.ratelimit import login_throttle # noqa: E402
|
||||||
from app.core.security import hash_password # 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.db.session import get_session # noqa: E402
|
||||||
from app.main import app # noqa: E402
|
from app.main import app # noqa: E402
|
||||||
from app.models import AuthIdentity, GroupMember, User # 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
|
app.dependency_overrides[get_session] = _get_session
|
||||||
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||||
|
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from tests.conftest import csrf_headers
|
from tests.conftest import csrf_headers
|
||||||
from tests.test_auth import _telegram_payload
|
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
|
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 ───────────────────────────────────────────────────────
|
# ─── Привязка Telegram ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
||||||
|
|||||||
Reference in New Issue
Block a user