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

@@ -21,6 +21,7 @@ from app.database.auth import TotpKeys
from app.database.statistics import UserStatistics
from app.dependencies.database import Database, get_redis
from app.dependencies.geoip import get_client_ip, get_geoip_helper
from app.dependencies.user_agent import UserAgentInfo
from app.helpers.geoip_helper import GeoIPHelper
from app.log import logger
from app.models.extended_auth import ExtendedTokenResponse
@@ -39,7 +40,7 @@ from app.service.verification_service import (
)
from app.utils import utcnow
from fastapi import APIRouter, Depends, Form, Request
from fastapi import APIRouter, Depends, Form, Header, Request
from fastapi.responses import JSONResponse
from redis.asyncio import Redis
from sqlalchemy import text
@@ -199,6 +200,7 @@ async def register_user(
async def oauth_token(
db: Database,
request: Request,
user_agent: UserAgentInfo,
grant_type: Literal["authorization_code", "refresh_token", "password", "client_credentials"] = Form(
..., description="授权类型:密码/刷新令牌/授权码/客户端凭证"
),
@@ -211,12 +213,10 @@ async def oauth_token(
refresh_token: str | None = Form(None, description="刷新令牌(仅刷新令牌模式需要)"),
redis: Redis = Depends(get_redis),
geoip: GeoIPHelper = Depends(get_geoip_helper),
web_uuid: str | None = Header(None, include_in_schema=False, alias="X-UUID"),
):
scopes = scope.split(" ")
# 打印请求头
# logger.info(f"Request headers: {request.headers}")
client = (
await db.exec(
select(OAuthClient).where(
@@ -306,19 +306,19 @@ async def oauth_token(
access_token,
refresh_token_str,
settings.access_token_expire_minutes * 60,
settings.refresh_token_expire_minutes * 60,
allow_multiple_devices=settings.enable_multi_device_login, # 使用配置决定是否启用多设备支持
)
token_id = token.id
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
# 获取国家代码
geo_info = geoip.lookup(ip_address)
country_code = geo_info.get("country_iso", "XX")
# 检查是否为新位置登录
is_new_location = await LoginSessionService.check_new_location(db, user_id, ip_address, country_code)
trusted_device = await LoginSessionService.check_trusted_device(db, user_id, ip_address, user_agent, web_uuid)
session_verification_method = None
if settings.enable_totp_verification and totp_key is not None:
@@ -331,18 +331,12 @@ async def oauth_token(
login_method="password_pending_verification",
notes="需要 TOTP 验证",
)
elif is_new_location and settings.enable_email_verification:
# 如果是新位置登录,需要邮件验证
elif not trusted_device and settings.enable_email_verification:
# 如果是新设备登录,需要邮件验证
# 刷新用户对象以确保属性已加载
await db.refresh(user)
session_verification_method = "mail"
# 使用智能验证发送邮件
(
verification_sent,
verification_message,
client_info,
) = await EmailVerificationService.send_smart_verification_email(
await EmailVerificationService.send_verification_email(
db,
redis,
user_id,
@@ -350,36 +344,30 @@ async def oauth_token(
user.email,
ip_address,
user_agent,
client_id,
country_code,
is_new_location,
)
# 记录需要二次验证的登录尝试
client_display_name = client_info.client_type if client_info else "unknown"
await LoginLogService.record_login(
db=db,
user_id=user_id,
request=request,
login_success=True,
login_method="password_pending_verification",
notes=f"智能验证: {verification_message} - 客户端: {client_display_name}, "
f"IP: {ip_address}, 国家: {country_code}",
notes=(
f"邮箱验证: User-Agent: {user_agent.raw_ua}, 客户端: {user_agent.displayed_name} "
f"IP: {ip_address}, 国家: {country_code}"
),
)
elif not trusted_device:
# 新设备登录但邮件验证功能被禁用,直接标记会话为已验证
await LoginSessionService.mark_session_verified(
db, redis, user_id, token_id, ip_address, user_agent, web_uuid
)
if not verification_sent:
# 邮件发送失败,记录错误
logger.error(f"[Auth] Smart verification failed for user {user_id}: {verification_message}")
else:
logger.info(f"[Auth] Smart verification result for user {user_id}: {verification_message}")
elif is_new_location:
# 新位置登录但邮件验证功能被禁用,直接标记会话为已验证
await LoginSessionService.mark_session_verified(db, redis, user_id, token_id)
logger.debug(
f"[Auth] New location login detected but email verification disabled, auto-verifying user {user_id}"
)
else:
# 不是新位置登录,正常登录
# 不是新设备登录,正常登录
await LoginLogService.record_login(
db=db,
user_id=user_id,
@@ -391,12 +379,12 @@ async def oauth_token(
if session_verification_method:
await LoginSessionService.create_session(
db, redis, user_id, token_id, ip_address, user_agent, country_code, is_new_location, False
db, user_id, token_id, ip_address, user_agent.raw_ua, trusted_device, web_uuid, False
)
await LoginSessionService.set_login_method(user_id, token_id, session_verification_method, redis)
else:
await LoginSessionService.create_session(
db, redis, user_id, token_id, ip_address, user_agent, country_code, is_new_location, True
db, user_id, token_id, ip_address, user_agent.raw_ua, trusted_device, web_uuid, True
)
return TokenResponse(
@@ -449,6 +437,7 @@ async def oauth_token(
access_token,
new_refresh_token,
settings.access_token_expire_minutes * 60,
settings.refresh_token_expire_minutes * 60,
allow_multiple_devices=settings.enable_multi_device_login, # 使用配置决定是否启用多设备支持
)
return TokenResponse(
@@ -514,6 +503,7 @@ async def oauth_token(
access_token,
refresh_token_str,
settings.access_token_expire_minutes * 60,
settings.refresh_token_expire_minutes * 60,
allow_multiple_devices=settings.enable_multi_device_login, # 使用配置决定是否启用多设备支持
)
@@ -561,6 +551,7 @@ async def oauth_token(
access_token,
refresh_token_str,
settings.access_token_expire_minutes * 60,
settings.refresh_token_expire_minutes * 60,
allow_multiple_devices=settings.enable_multi_device_login, # 使用配置决定是否启用多设备支持
)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from app.config import settings
from . import audio_proxy, avatar, beatmapset, cover, oauth, relationship, score, team, username # noqa: F401
from . import admin, audio_proxy, avatar, beatmapset, cover, oauth, relationship, score, team, username # noqa: F401
from .router import router as private_router
if settings.enable_totp_verification:

157
app/router/private/admin.py Normal file
View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from app.database.auth import OAuthToken
from app.database.verification import LoginSession, LoginSessionResp, TrustedDevice, TrustedDeviceResp
from app.dependencies.database import Database
from app.dependencies.geoip import get_geoip_helper
from app.dependencies.user import UserAndToken, get_client_user_and_token
from app.helpers.geoip_helper import GeoIPHelper
from .router import router
from fastapi import Depends, HTTPException, Security
from pydantic import BaseModel
from sqlmodel import col, select
class SessionsResp(BaseModel):
total: int
current: int = 0
sessions: list[LoginSessionResp]
@router.get(
"/admin/sessions",
name="获取当前用户的登录会话列表",
tags=["用户会话", "g0v0 API", "管理"],
response_model=SessionsResp,
)
async def get_sessions(
session: Database,
user_and_token: UserAndToken = Security(get_client_user_and_token),
geoip: GeoIPHelper = Depends(get_geoip_helper),
):
current_user, token = user_and_token
sessions = (
await session.exec(
select(
LoginSession,
)
.where(LoginSession.user_id == current_user.id, col(LoginSession.is_verified).is_(True))
.order_by(col(LoginSession.created_at).desc())
)
).all()
return SessionsResp(
total=len(sessions),
current=token.id,
sessions=[LoginSessionResp.from_db(s, geoip) for s in sessions],
)
@router.delete(
"/admin/sessions/{session_id}",
name="注销指定的登录会话",
tags=["用户会话", "g0v0 API", "管理"],
status_code=204,
)
async def delete_session(
session: Database,
session_id: int,
user_and_token: UserAndToken = Security(get_client_user_and_token),
):
current_user, token = user_and_token
if session_id == token.id:
raise HTTPException(status_code=400, detail="Cannot delete the current session")
db_session = await session.get(LoginSession, session_id)
if not db_session or db_session.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Session not found")
await session.delete(db_session)
token = await session.get(OAuthToken, db_session.token_id or 0)
if token:
await session.delete(token)
await session.commit()
return
class TrustedDevicesResp(BaseModel):
total: int
current: int = 0
devices: list[TrustedDeviceResp]
@router.get(
"/admin/trusted-devices",
name="获取当前用户的受信任设备列表",
tags=["用户会话", "g0v0 API", "管理"],
response_model=TrustedDevicesResp,
)
async def get_trusted_devices(
session: Database,
user_and_token: UserAndToken = Security(get_client_user_and_token),
geoip: GeoIPHelper = Depends(get_geoip_helper),
):
current_user, token = user_and_token
devices = (
await session.exec(
select(TrustedDevice)
.where(TrustedDevice.user_id == current_user.id)
.order_by(col(TrustedDevice.last_used_at).desc())
)
).all()
current_device_id = (
await session.exec(
select(TrustedDevice.id)
.join(LoginSession, col(LoginSession.device_id) == TrustedDevice.id)
.where(
LoginSession.token_id == token.id,
TrustedDevice.user_id == current_user.id,
)
.limit(1)
)
).first()
return TrustedDevicesResp(
total=len(devices),
current=current_device_id or 0,
devices=[TrustedDeviceResp.from_db(device, geoip) for device in devices],
)
@router.delete(
"/admin/trusted-devices/{device_id}",
name="移除受信任设备",
tags=["用户会话", "g0v0 API", "管理"],
status_code=204,
)
async def delete_trusted_device(
session: Database,
device_id: int,
user_and_token: UserAndToken = Security(get_client_user_and_token),
):
current_user, token = user_and_token
device = await session.get(TrustedDevice, device_id)
current_device_id = (
await session.exec(
select(TrustedDevice.id)
.join(LoginSession, col(LoginSession.device_id) == TrustedDevice.id)
.where(
LoginSession.token_id == token.id,
TrustedDevice.user_id == current_user.id,
)
.limit(1)
)
).first()
if device_id == current_device_id:
raise HTTPException(status_code=400, detail="Cannot delete the current trusted device")
if not device or device.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Trusted device not found")
await session.delete(device)
await session.commit()
return

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from app.database import MeResp, User
from app.database.lazer_user import ALL_INCLUDED
from app.dependencies import get_current_user
from app.dependencies.database import Database
from app.dependencies.user import UserAndToken, get_current_user_and_token
@@ -33,7 +32,7 @@ async def get_user_info_with_ruleset(
ruleset: GameMode = Path(description="指定 ruleset"),
user_and_token: UserAndToken = Security(get_current_user_and_token, scopes=["identify"]),
):
user_resp = await MeResp.from_db(user_and_token[0], session, ALL_INCLUDED, ruleset, token_id=user_and_token[1].id)
user_resp = await MeResp.from_db(user_and_token[0], session, ruleset, token_id=user_and_token[1].id)
return user_resp
@@ -48,7 +47,7 @@ async def get_user_info_default(
session: Database,
user_and_token: UserAndToken = Security(get_current_user_and_token, scopes=["identify"]),
):
user_resp = await MeResp.from_db(user_and_token[0], session, ALL_INCLUDED, None, token_id=user_and_token[1].id)
user_resp = await MeResp.from_db(user_and_token[0], session, None, token_id=user_and_token[1].id)
return user_resp

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(