Отзыв 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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 клиента для журнала аудита.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user