Отзыв 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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user