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:
@@ -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, # 使用配置决定是否启用多设备支持
|
||||
)
|
||||
|
||||
|
||||
@@ -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
157
app/router/private/admin.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user