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

@@ -8,12 +8,13 @@ from typing import Annotated, Literal
from app.auth import check_totp_backup_code, verify_totp_key_with_replay_protection
from app.config import settings
from app.const import BACKUP_CODE_LENGTH
from app.const import BACKUP_CODE_LENGTH, SUPPORT_TOTP_VERIFICATION_VER
from app.database.auth import TotpKeys
from app.dependencies.api_version import APIVersion
from app.dependencies.database import Database, get_redis
from app.dependencies.geoip import get_client_ip
from app.dependencies.user import UserAndToken, get_client_user_and_token
from app.dependencies.user_agent import UserAgentInfo
from app.log import logger
from app.service.login_log_service import LoginLogService
from app.service.verification_service import (
@@ -23,7 +24,7 @@ from app.service.verification_service import (
from .router import router
from fastapi import Depends, Form, HTTPException, Request, Security, status
from fastapi import Depends, Form, Header, HTTPException, Request, Security, status
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from redis.asyncio import Redis
@@ -62,9 +63,11 @@ async def verify_session(
request: Request,
db: Database,
api_version: APIVersion,
user_agent: UserAgentInfo,
redis: Annotated[Redis, Depends(get_redis)],
verification_key: str = Form(..., description="8 位邮件验证码或者 6 位 TOTP 代码或 10 位备份码 g0v0 扩展支持)"),
user_and_token: UserAndToken = Security(get_client_user_and_token),
web_uuid: str | None = Header(None, include_in_schema=False, alias="X-UUID"),
) -> Response:
current_user = user_and_token[0]
token_id = user_and_token[1].id
@@ -74,11 +77,12 @@ async def verify_session(
return Response(status_code=status.HTTP_204_NO_CONTENT)
verify_method: str | None = (
"mail" if api_version < 20250913 else await LoginSessionService.get_login_method(user_id, token_id, redis)
"mail"
if api_version < SUPPORT_TOTP_VERIFICATION_VER
else await LoginSessionService.get_login_method(user_id, token_id, redis)
)
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "Unknown")
login_method = "password"
try:
@@ -130,10 +134,11 @@ async def verify_session(
user_id=user_id,
request=request,
login_method=login_method,
user_agent=user_agent.raw_ua,
login_success=True,
notes=f"{login_method} 验证成功",
)
await LoginSessionService.mark_session_verified(db, redis, user_id, token_id)
await LoginSessionService.mark_session_verified(db, redis, user_id, token_id, ip_address, user_agent, web_uuid)
await db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -179,6 +184,7 @@ async def verify_session(
async def reissue_verification_code(
request: Request,
db: Database,
user_agent: UserAgentInfo,
api_version: APIVersion,
redis: Annotated[Redis, Depends(get_redis)],
user_and_token: UserAndToken = Security(get_client_user_and_token),
@@ -198,7 +204,6 @@ async def reissue_verification_code(
try:
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "Unknown")
user_id = current_user.id
success, message = await EmailVerificationService.resend_verification_code(
db,
@@ -227,6 +232,7 @@ async def reissue_verification_code(
)
async def fallback_email(
db: Database,
user_agent: UserAgentInfo,
request: Request,
redis: Annotated[Redis, Depends(get_redis)],
user_and_token: UserAndToken = Security(get_client_user_and_token),
@@ -237,7 +243,6 @@ async def fallback_email(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前会话不需要回退")
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "Unknown")
await LoginSessionService.set_login_method(current_user.id, token_id, "mail", redis)
success, message = await EmailVerificationService.resend_verification_code(