feat(auth): support trusted device (#52)

New API to maintain sessions and devices:

- GET /api/private/admin/sessions
- DELETE /api/private/admin/sessions/{session_id}
- GET /api/private/admin/trusted-devices
- DELETE /api/private/admin/trusted-devices/{device_id}

Auth:

web clients request `/oauth/token` and `/api/v2/session/verify` with `X-UUID` header to save the client as trusted device.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
MingxuanGame
2025-10-03 11:26:43 +08:00
committed by GitHub
parent f34ed53a55
commit 40670c094b
28 changed files with 897 additions and 1456 deletions

View File

@@ -10,11 +10,13 @@ from collections.abc import Callable
from typing import ClassVar
from app.auth import get_token_by_access_token
from app.const import SUPPORT_TOTP_VERIFICATION_VER
from app.database.lazer_user import User
from app.database.verification import LoginSession
from app.dependencies.database import get_redis, with_db
from app.log import logger
from app.service.verification_service import LoginSessionService
from app.utils import extract_user_agent
from fastapi import Request, Response, status
from fastapi.responses import JSONResponse
@@ -34,7 +36,9 @@ class VerifySessionMiddleware(BaseHTTPMiddleware):
SKIP_VERIFICATION_ROUTES: ClassVar[set[str]] = {
"/api/v2/session/verify",
"/api/v2/session/verify/reissue",
"/api/v2/session/verify/mail-fallback",
"/api/v2/me",
"/api/v2/me/",
"/api/v2/logout",
"/oauth/token",
"/health",
@@ -44,10 +48,8 @@ class VerifySessionMiddleware(BaseHTTPMiddleware):
"/redoc",
}
# 需要强制验证的路由模式(敏感操作)
# 总是需要验证的路由前缀
ALWAYS_VERIFY_PATTERNS: ClassVar[set[str]] = {
"/api/v2/account/",
"/api/v2/settings/",
"/api/private/admin/",
}
@@ -110,9 +112,6 @@ class VerifySessionMiddleware(BaseHTTPMiddleware):
if path.startswith(pattern):
return True
# 特权用户或非活跃用户需要验证
# if hasattr(user, 'is_privileged') and user.is_privileged():
# return True
if not user.is_active:
return True
@@ -154,6 +153,14 @@ class VerifySessionMiddleware(BaseHTTPMiddleware):
try:
# 提取会话token这里简化为使用相同的auth token
auth_header = request.headers.get("Authorization", "")
api_version = 0
raw_api_version = request.headers.get("x-api-version")
if raw_api_version is not None:
try:
api_version = int(raw_api_version)
except ValueError:
api_version = 0
if not auth_header.startswith("Bearer "):
return None
@@ -168,7 +175,7 @@ class VerifySessionMiddleware(BaseHTTPMiddleware):
if not session or session.user_id != user.id:
return None
return SessionState(session, user, redis, db)
return SessionState(session, user, redis, db, api_version)
except Exception as e:
logger.error(f"[Verify Session Middleware] Error getting session state: {e}")
@@ -178,8 +185,6 @@ class VerifySessionMiddleware(BaseHTTPMiddleware):
"""启动验证流程"""
try:
method = await state.get_method()
# 如果是邮件验证,可以在这里触发发送邮件
if method == "mail":
await state.issue_mail_if_needed()
@@ -202,11 +207,12 @@ class SessionState:
简化版本的会话状态管理
"""
def __init__(self, session: LoginSession, user: User, redis: Redis, db: AsyncSession):
def __init__(self, session: LoginSession, user: User, redis: Redis, db: AsyncSession, api_version: int = 0) -> None:
self.session = session
self.user = user
self.redis = redis
self.db = db
self.api_version = api_version
self._verification_method: str | None = None
def is_verified(self) -> bool:
@@ -223,14 +229,15 @@ class SessionState:
self.user.id, token_id, self.redis
)
# 如果没有设置,智能选择
if self._verification_method is None:
# 检查用户是否有TOTP密钥
await self.user.awaitable_attrs.totp_key # 预加载
totp_key = getattr(self.user, "totp_key", None)
if self.api_version < SUPPORT_TOTP_VERIFICATION_VER:
self._verification_method = "mail"
return self._verification_method
await self.user.awaitable_attrs.totp_key
totp_key = self.user.totp_key
self._verification_method = "totp" if totp_key else "mail"
# 保存选择的方法
token_id = self.session.token_id
if token_id is not None:
await LoginSessionService.set_login_method(
@@ -244,8 +251,15 @@ class SessionState:
try:
token_id = self.session.token_id
if token_id is not None:
await LoginSessionService.mark_session_verified(self.db, self.redis, self.user.id, token_id)
self.session.is_verified = True # 更新本地状态
await LoginSessionService.mark_session_verified(
self.db,
self.redis,
self.user.id,
token_id,
self.session.ip_address,
extract_user_agent(self.session.user_agent),
self.session.web_uuid,
)
except Exception as e:
logger.error(f"[Session State] Error marking verified: {e}")
@@ -266,10 +280,12 @@ class SessionState:
"""获取会话密钥"""
return str(self.session.id) if self.session.id else ""
def get_key_for_event(self) -> str:
@property
def key_for_event(self) -> str:
"""获取用于事件广播的会话密钥"""
return LoginSessionService.get_key_for_event(self.get_key())
@property
def user_id(self) -> int:
"""获取用户ID"""
return self.user.id