Files
Aether/_deprecated_py_src/api/auth/routes.py

1006 lines
40 KiB
Python
Raw Normal View History

2025-12-10 20:52:44 +08:00
"""
认证相关API端点
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPBearer
2025-12-10 20:52:44 +08:00
from pydantic import ValidationError
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.config import config
2025-12-10 20:52:44 +08:00
from src.core.exceptions import InvalidRequestException
from src.core.logger import logger
from src.database import get_db
from src.models.api import (
LoginRequest,
LoginResponse,
LogoutResponse,
RefreshTokenResponse,
RegisterRequest,
RegisterResponse,
RegistrationSettingsResponse,
SendVerificationCodeRequest,
SendVerificationCodeResponse,
VerificationStatusRequest,
VerificationStatusResponse,
VerifyEmailRequest,
VerifyEmailResponse,
2025-12-10 20:52:44 +08:00
)
from src.models.database import AuditEventType, User, UserRole
from src.services.auth.refresh_cookie import (
clear_refresh_token_cookie,
error_response_with_cleared_cookie,
set_refresh_token_cookie,
)
from src.services.auth.service import AuthService
from src.services.auth.session_service import SessionService
from src.services.cache.user_cache import UserCacheService
from src.services.email import EmailSenderService, EmailVerificationService
2025-12-10 20:52:44 +08:00
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService
2025-12-10 20:52:44 +08:00
from src.services.user.service import UserService
from src.services.wallet import WalletService
2025-12-10 20:52:44 +08:00
from src.utils.request_utils import get_client_ip, get_user_agent
def validate_email_suffix(db: Session, email: str) -> tuple[bool, str | None]:
"""
验证邮箱后缀是否允许注册
Args:
db: 数据库会话
email: 邮箱地址
Returns:
(是否允许, 错误信息)
"""
# 获取邮箱后缀限制配置
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
if mode == "none":
return True, None
# 获取邮箱后缀列表
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
if not suffix_list:
# 没有配置后缀列表时,不限制
return True, None
# 确保 suffix_list 是列表类型
if isinstance(suffix_list, str):
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
# 获取邮箱后缀
if "@" not in email:
return False, "邮箱格式无效"
email_suffix = email.split("@")[1].lower()
if mode == "whitelist":
# 白名单模式:只允许列出的后缀
if email_suffix not in suffix_list:
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
elif mode == "blacklist":
# 黑名单模式:拒绝列出的后缀
if email_suffix in suffix_list:
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
return True, None
def _issue_session_bound_tokens(
*,
db: Session,
user_id: str,
user_role: UserRole,
user_created_at: Any,
user: User | None,
request: Request,
) -> tuple[str, str, str]:
session_id = str(uuid.uuid4())
access_token = AuthService.create_access_token(
data={
"user_id": user_id,
"role": user_role.value,
"created_at": user_created_at.isoformat() if user_created_at else None,
"session_id": session_id,
}
)
refresh_token = AuthService.create_refresh_token(
data={
"user_id": user_id,
"created_at": user_created_at.isoformat() if user_created_at else None,
"session_id": session_id,
"jti": str(uuid.uuid4()),
}
)
if user is None:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
client_device_id = SessionService.extract_client_device_id(request)
client_context = SessionService.build_client_context(
client_device_id=client_device_id,
client_ip=get_client_ip(request),
user_agent=get_user_agent(request),
headers=dict(request.headers),
)
SessionService.create_session(
db,
user=user,
session_id=session_id,
refresh_token=refresh_token,
expires_at=AuthService.get_refresh_token_expiry(),
client=client_context,
)
return session_id, access_token, refresh_token
async def _logout_with_refresh_cookie_fallback(
request: Request,
db: Session,
) -> dict[str, Any] | None:
refresh_token_value = request.cookies.get(config.auth_refresh_cookie_name)
if not refresh_token_value:
return None
try:
token_payload = await AuthService.verify_token(refresh_token_value, token_type="refresh")
user_id = token_payload.get("user_id")
session_id = token_payload.get("session_id")
if not user_id or not session_id:
return None
client_device_id = SessionService.extract_client_device_id(request)
session = SessionService.get_session_for_user(
db,
user_id=str(user_id),
session_id=str(session_id),
)
user = db.query(User).filter(User.id == user_id).first()
if session and not session.is_revoked and not session.is_expired:
SessionService.assert_session_device_matches(session, client_device_id)
SessionService.revoke_session(
db,
session=session,
reason="user_logout",
audit_user_id=str(user_id),
ip_address=get_client_ip(request),
user_agent=get_user_agent(request),
)
AuditService.log_event(
db=db,
event_type=AuditEventType.LOGOUT,
description=f"User logged out via refresh cookie: {user.email if user else user_id}",
user_id=str(user_id),
ip_address=get_client_ip(request),
user_agent=get_user_agent(request),
metadata={
"user_id": str(user_id),
"email": user.email if user else None,
"logout_via": "refresh_cookie",
},
)
db.commit()
request.state.tx_committed_by_route = True
logger.info("用户通过 refresh cookie 登出成功: {}", user.email if user else user_id)
return LogoutResponse(message="登出成功", success=True).model_dump()
except HTTPException as exc:
logger.info("Refresh cookie logout fallback skipped: {}", exc.detail)
return None
except Exception as exc:
logger.warning("Refresh cookie logout fallback failed: {}", exc)
return None
2025-12-10 20:52:44 +08:00
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
security = HTTPBearer()
pipeline = get_pipeline()
2025-12-10 20:52:44 +08:00
# API端点
@router.get("/registration-settings", response_model=RegistrationSettingsResponse)
async def registration_settings(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取注册相关配置
返回系统注册配置包括是否开放注册是否需要邮箱验证等
此接口为公开接口无需认证
"""
adapter = AuthRegistrationSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
2026-01-02 16:17:24 +08:00
@router.get("/settings")
async def auth_settings(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取认证设置
返回系统支持的认证方式如本地认证LDAP 认证等
前端据此判断显示哪些登录选项此接口为公开接口无需认证
"""
2026-01-02 16:17:24 +08:00
adapter = AuthSettingsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/login", response_model=LoginResponse, response_model_exclude_none=True)
async def login(request: Request, response: Response, db: Session = Depends(get_db)) -> Any:
"""
用户登录
使用邮箱和密码登录成功后返回 JWT access_token
- **access_token**: 用于后续 API 调用有效期 24 小时
- **refresh_token**: 通过 HttpOnly Cookie 下发不出现在响应体
速率限制: 20/分钟/IP
"""
2025-12-10 20:52:44 +08:00
adapter = AuthLoginAdapter()
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
refresh_token = result.pop("_refresh_token", None) if isinstance(result, dict) else None
if refresh_token:
set_refresh_token_cookie(response, refresh_token)
return result
2025-12-10 20:52:44 +08:00
@router.post("/refresh", response_model=RefreshTokenResponse, response_model_exclude_none=True)
async def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)) -> Any:
"""
刷新访问令牌
使用 HttpOnly Cookie 中的 refresh_token 获取新的 access_token
refresh_token 刷新后失效并轮换 Cookie
"""
2025-12-10 20:52:44 +08:00
adapter = AuthRefreshAdapter()
try:
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
except HTTPException as exc:
return error_response_with_cleared_cookie(exc)
refresh_token_value = result.pop("_refresh_token", None) if isinstance(result, dict) else None
if refresh_token_value:
set_refresh_token_cookie(response, refresh_token_value)
return result
2025-12-10 20:52:44 +08:00
@router.post("/register", response_model=RegisterResponse)
async def register(request: Request, db: Session = Depends(get_db)) -> Any:
"""
用户注册
创建新用户账号需要系统开放注册功能
如果系统开启了邮箱验证需先通过 /send-verification-code /verify-email 完成邮箱验证
速率限制: 10/分钟/IP
"""
2025-12-10 20:52:44 +08:00
adapter = AuthRegisterAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/me")
async def get_current_user_info(request: Request, db: Session = Depends(get_db)) -> Any:
"""
获取当前用户信息
返回当前登录用户的基本信息包括邮箱用户名角色钱包信息等
需要 Bearer Token 认证
"""
2025-12-10 20:52:44 +08:00
adapter = AuthCurrentUserAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/logout", response_model=LogoutResponse)
async def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> Any:
"""
用户登出
将当前 Token 加入黑名单使其失效
即使 access_token 过期也始终清除 refresh_token cookie
"""
try:
adapter = AuthLogoutAdapter()
result = await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
except HTTPException as exc:
fallback_result = await _logout_with_refresh_cookie_fallback(request, db)
if fallback_result is not None:
clear_refresh_token_cookie(response)
return fallback_result
# 即使认证失败(如 access_token 过期),也要清除 cookie 防止会话残留
return error_response_with_cleared_cookie(exc)
clear_refresh_token_cookie(response)
return result
2025-12-10 20:52:44 +08:00
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(request: Request, db: Session = Depends(get_db)) -> None:
"""
发送邮箱验证码
向指定邮箱发送验证码用于注册前的邮箱验证
验证码有效期 5 分钟同一邮箱 60 秒内只能发送一次
速率限制: 5/分钟/IP
"""
adapter = AuthSendVerificationCodeAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verify-email", response_model=VerifyEmailResponse)
async def verify_email(request: Request, db: Session = Depends(get_db)) -> Any:
"""
验证邮箱验证码
验证邮箱收到的验证码是否正确
验证成功后邮箱会被标记为已验证状态可用于注册
速率限制: 20/分钟/IP
"""
adapter = AuthVerifyEmailAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/verification-status", response_model=VerificationStatusResponse)
async def verification_status(request: Request, db: Session = Depends(get_db)) -> Any:
"""
查询邮箱验证状态
查询指定邮箱的验证状态包括是否有待验证的验证码是否已验证等
速率限制: 20/分钟/IP
"""
adapter = AuthVerificationStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
2025-12-10 20:52:44 +08:00
# ============== 适配器实现 ==============
class AuthPublicAdapter(ApiAdapter):
mode = ApiMode.PUBLIC
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
2025-12-10 20:52:44 +08:00
return None
class AuthLoginAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
2025-12-10 20:52:44 +08:00
db = context.db
payload = context.ensure_json_body()
try:
login_request = LoginRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
user_agent = get_user_agent(context.request)
# IP 速率限制检查登录接口5次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(client_ip, "login")
if not allowed:
logger.warning(f"登录请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录请求过于频繁,请在 {reset_after} 秒后重试",
)
perf: 优化请求鉴权链路并批量化统计/调度查询 - 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body - 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险 - 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环 - 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息 - 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效 - 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射 - 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复 - 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合 - 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算 - 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加 - 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询 - 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数 - 为用户日统计增加批量聚合入口,替代逐用户循环聚合 - 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询 - 为管理员用户列表增加钱包批量查询,避免逐用户回表 - 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图 - 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序 - 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描 - 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返 - 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑 - 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、 scheduler、wallet、request candidate 等回归与契约测试
2026-03-09 22:57:23 +08:00
authenticated_user = await AuthService.authenticate_user_threadsafe(
2026-01-02 16:17:24 +08:00
db, login_request.email, login_request.password, login_request.auth_type
)
perf: 优化请求鉴权链路并批量化统计/调度查询 - 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body - 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险 - 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环 - 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息 - 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效 - 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射 - 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复 - 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合 - 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算 - 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加 - 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询 - 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数 - 为用户日统计增加批量聚合入口,替代逐用户循环聚合 - 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询 - 为管理员用户列表增加钱包批量查询,避免逐用户回表 - 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图 - 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序 - 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描 - 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返 - 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑 - 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、 scheduler、wallet、request candidate 等回归与契约测试
2026-03-09 22:57:23 +08:00
if not authenticated_user:
2025-12-10 20:52:44 +08:00
AuditService.log_login_attempt(
db=db,
email=login_request.email,
success=False,
ip_address=client_ip,
user_agent=user_agent,
error_reason="邮箱或密码错误",
)
db.commit()
context.request.state.tx_committed_by_route = True
2025-12-10 20:52:44 +08:00
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
db_user = db.query(User).filter(User.id == authenticated_user.user_id).first()
if db_user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
_, access_token, refresh_token = _issue_session_bound_tokens(
db=db,
user_id=authenticated_user.user_id,
user_role=authenticated_user.role,
user_created_at=authenticated_user.created_at,
user=db_user,
request=context.request,
)
2025-12-10 20:52:44 +08:00
AuditService.log_login_attempt(
db=db,
email=login_request.email,
success=True,
ip_address=client_ip,
user_agent=user_agent,
perf: 优化请求鉴权链路并批量化统计/调度查询 - 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body - 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险 - 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环 - 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息 - 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效 - 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射 - 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复 - 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合 - 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算 - 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加 - 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询 - 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数 - 为用户日统计增加批量聚合入口,替代逐用户循环聚合 - 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询 - 为管理员用户列表增加钱包批量查询,避免逐用户回表 - 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图 - 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序 - 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描 - 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返 - 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑 - 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、 scheduler、wallet、request candidate 等回归与契约测试
2026-03-09 22:57:23 +08:00
user_id=authenticated_user.user_id,
2025-12-10 20:52:44 +08:00
)
db_user.last_login_at = datetime.now(timezone.utc)
2025-12-10 20:52:44 +08:00
db.commit()
context.request.state.tx_committed_by_route = True
await UserCacheService.invalidate_user_cache(
authenticated_user.user_id,
authenticated_user.email or "",
2025-12-10 20:52:44 +08:00
)
logger.info("用户登录成功: {} (ID: {})", login_request.email, authenticated_user.user_id)
2025-12-10 20:52:44 +08:00
response = LoginResponse(
access_token=access_token,
token_type="bearer",
expires_in=86400,
perf: 优化请求鉴权链路并批量化统计/调度查询 - 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body - 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险 - 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环 - 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息 - 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效 - 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射 - 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复 - 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合 - 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算 - 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加 - 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询 - 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数 - 为用户日统计增加批量聚合入口,替代逐用户循环聚合 - 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询 - 为管理员用户列表增加钱包批量查询,避免逐用户回表 - 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图 - 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序 - 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描 - 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返 - 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑 - 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、 scheduler、wallet、request candidate 等回归与契约测试
2026-03-09 22:57:23 +08:00
user_id=authenticated_user.user_id,
email=authenticated_user.email,
username=authenticated_user.username,
role=authenticated_user.role.value,
).model_dump()
response["_refresh_token"] = refresh_token
return response
2025-12-10 20:52:44 +08:00
class AuthRefreshAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
2025-12-10 20:52:44 +08:00
db = context.db
if context.request.headers.get("content-length") not in (None, "0"):
payload = context.ensure_json_body()
if payload:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="刷新接口不接受请求体,请使用 Cookie",
)
refresh_token_value = context.request.cookies.get(config.auth_refresh_cookie_name)
if not refresh_token_value:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少刷新令牌")
2025-12-10 20:52:44 +08:00
try:
token_payload = await AuthService.verify_token(
refresh_token_value, token_type="refresh"
2025-12-10 20:52:44 +08:00
)
user_id = token_payload.get("user_id")
session_id = token_payload.get("session_id")
if not user_id or not session_id:
2025-12-10 20:52:44 +08:00
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的刷新令牌"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的刷新令牌"
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已禁用")
if user.is_deleted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="用户不存在或已禁用"
)
if not AuthService.token_identity_matches_user(token_payload, user):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的刷新令牌"
)
2025-12-10 20:52:44 +08:00
client_device_id = SessionService.extract_client_device_id(context.request)
session, is_prev = SessionService.validate_refresh_session(
db,
user_id=str(user_id),
session_id=str(session_id),
refresh_token=refresh_token_value,
ip_address=get_client_ip(context.request),
user_agent=get_user_agent(context.request),
)
if session:
SessionService.assert_session_device_matches(session, client_device_id)
if not session:
db.commit()
context.request.state.tx_committed_by_route = True
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="登录会话已失效,请重新登录"
)
2025-12-10 20:52:44 +08:00
new_access_token = AuthService.create_access_token(
data={
"user_id": user.id,
"role": user.role.value,
"created_at": user.created_at.isoformat() if user.created_at else None,
"session_id": session.id,
2025-12-10 20:52:44 +08:00
}
)
new_refresh_token: str | None = None
if not is_prev:
new_refresh_token = AuthService.create_refresh_token(
data={
"user_id": user.id,
"created_at": user.created_at.isoformat() if user.created_at else None,
"session_id": session.id,
"jti": str(uuid.uuid4()),
}
)
SessionService.rotate_refresh_token(
session,
refresh_token=new_refresh_token,
expires_at=AuthService.get_refresh_token_expiry(),
client_ip=get_client_ip(context.request),
user_agent=get_user_agent(context.request),
)
db.commit()
context.request.state.tx_committed_by_route = True
logger.info(f"令牌刷新成功: user_id={user.id}")
response = RefreshTokenResponse(
2025-12-10 20:52:44 +08:00
access_token=new_access_token,
token_type="bearer",
expires_in=86400,
).model_dump()
if new_refresh_token:
response["_refresh_token"] = new_refresh_token
return response
2025-12-10 20:52:44 +08:00
except HTTPException:
raise
except Exception as exc:
logger.error(f"刷新令牌失败: {exc}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败")
class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""公开返回注册相关配置"""
db = context.db
enable_registration = SystemConfigService.get_config(
db, "enable_registration", default=False
)
require_verification = SystemConfigService.get_config(
db, "require_email_verification", default=False
)
email_configured = EmailSenderService.is_smtp_configured(db)
# 如果邮箱服务未配置,强制 require_email_verification 为 False
if not email_configured:
require_verification = False
return RegistrationSettingsResponse(
enable_registration=bool(enable_registration),
require_email_verification=bool(require_verification),
email_configured=email_configured,
password_policy_level=SystemConfigService.get_password_policy_level(db),
).model_dump()
2026-01-02 16:17:24 +08:00
class AuthSettingsAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
2026-01-02 16:17:24 +08:00
"""公开返回认证设置"""
from src.core.modules.hooks import AUTH_GET_METHODS, get_hook_dispatcher
2026-01-02 16:17:24 +08:00
db = context.db
dispatcher = get_hook_dispatcher()
auth_methods = await dispatcher.dispatch(AUTH_GET_METHODS, db=db)
2026-01-02 16:17:24 +08:00
# 从钩子返回的认证方法列表中解析各模块状态
ldap_info = next((m for m in auth_methods if m.get("type") == "ldap"), None)
ldap_enabled = ldap_info is not None
ldap_exclusive = ldap_info.get("exclusive", False) if ldap_info else False
2026-01-02 16:17:24 +08:00
return {
"local_enabled": not ldap_exclusive,
"ldap_enabled": ldap_enabled,
"ldap_exclusive": ldap_exclusive,
}
2025-12-10 20:52:44 +08:00
class AuthRegisterAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.models.database import SystemConfig
2025-12-10 20:52:44 +08:00
db = context.db
payload = context.ensure_json_body()
register_request = RegisterRequest.model_validate(payload)
client_ip = get_client_ip(context.request)
user_agent = get_user_agent(context.request)
# IP 速率限制检查注册接口3次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(client_ip, "register")
if not allowed:
logger.warning(f"注册请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试",
)
# 通过钩子检查是否有模块阻止本地注册(如 LDAP 排他模式)
from src.core.modules.hooks import AUTH_CHECK_REGISTRATION, get_hook_dispatcher
block_result = await get_hook_dispatcher().dispatch(AUTH_CHECK_REGISTRATION, db=db)
if block_result and block_result.get("blocked"):
2026-01-04 16:27:02 +08:00
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=block_result.get("reason", "注册已被禁止"),
2026-01-04 16:27:02 +08:00
)
2025-12-10 20:52:44 +08:00
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()
if allow_registration and not allow_registration.value:
AuditService.log_event(
db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration attempt rejected - registration disabled: {register_request.username}",
2025-12-10 20:52:44 +08:00
ip_address=client_ip,
user_agent=user_agent,
metadata={"username": register_request.username, "reason": "registration_disabled"},
2025-12-10 20:52:44 +08:00
)
db.commit()
context.request.state.tx_committed_by_route = True
2025-12-10 20:52:44 +08:00
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
email = register_request.email
email_configured = EmailSenderService.is_smtp_configured(db)
require_verification = SystemConfigService.get_config(
db, "require_email_verification", default=False
)
# 如果邮箱服务未配置,强制不要求邮箱验证
if not email_configured:
require_verification = False
# 如果系统要求邮箱验证,则必须提供邮箱
if require_verification:
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="系统要求邮箱验证,请填写邮箱",
)
# 检查邮箱是否已验证
is_verified = await EmailVerificationService.is_email_verified(email)
if not is_verified:
logger.warning(f"注册失败:邮箱未验证: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先完成邮箱验证。请发送验证码并验证后再注册。",
)
# 如果提供了邮箱,进行后缀验证
if email:
suffix_allowed, suffix_error = validate_email_suffix(db, email)
if not suffix_allowed:
logger.warning(f"注册失败:邮箱后缀不允许: {email}")
AuditService.log_event(
db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration attempt rejected - email suffix not allowed: {email}",
ip_address=client_ip,
user_agent=user_agent,
metadata={"email": email, "reason": "email_suffix_not_allowed"},
)
db.commit()
context.request.state.tx_committed_by_route = True
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=suffix_error,
)
2025-12-10 20:52:44 +08:00
try:
# 读取系统配置的默认初始赠款
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# email_verified 逻辑:
# - 要求邮箱验证且已通过验证True
# - 提供了邮箱但不要求验证False用户可后续自行验证
# - 未提供邮箱False
2025-12-10 20:52:44 +08:00
user = UserService.create_user(
db=db,
email=email, # 可以为 None
2025-12-10 20:52:44 +08:00
username=register_request.username,
password=register_request.password,
role=UserRole.USER,
initial_gift_usd=default_initial_gift,
email_verified=bool(require_verification and email),
2025-12-10 20:52:44 +08:00
)
AuditService.log_event(
db=db,
event_type=AuditEventType.USER_CREATED,
description=f"User registered: {user.username}"
+ (f" ({user.email})" if user.email else ""),
2025-12-10 20:52:44 +08:00
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
metadata={"email": user.email, "username": user.username, "role": user.role.value},
)
2025-12-10 20:52:44 +08:00
db.commit()
context.request.state.tx_committed_by_route = True
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
if require_verification and email:
try:
await EmailVerificationService.clear_verification(email)
except Exception as e:
logger.warning(f"清理验证状态失败: {e}")
2025-12-10 20:52:44 +08:00
return RegisterResponse(
user_id=user.id,
email=user.email,
username=user.username,
message="注册成功",
).model_dump()
except ValueError as exc:
db.rollback()
2025-12-10 20:52:44 +08:00
AuditService.log_event(
db=db,
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
description=f"Registration failed: {register_request.username} - {exc}",
2025-12-10 20:52:44 +08:00
ip_address=client_ip,
user_agent=user_agent,
metadata={"username": register_request.username, "error": str(exc)},
2025-12-10 20:52:44 +08:00
)
db.commit()
context.request.state.tx_committed_by_route = True
2025-12-10 20:52:44 +08:00
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
2025-12-10 20:52:44 +08:00
user = context.user
wallet = WalletService.get_wallet(context.db, user_id=user.id)
2025-12-10 20:52:44 +08:00
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"is_active": user.is_active,
"billing": WalletService.serialize_wallet_summary(wallet),
2025-12-10 20:52:44 +08:00
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
2025-12-10 20:52:44 +08:00
"allowed_models": user.allowed_models,
"created_at": user.created_at.isoformat(),
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"auth_source": user.auth_source.value,
2025-12-10 20:52:44 +08:00
}
class AuthLogoutAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
2025-12-10 20:52:44 +08:00
"""用户登出,将 Token 加入黑名单"""
user = context.user
client_ip = get_client_ip(context.request)
current_session_id = getattr(context.request.state, "user_session_id", None)
2025-12-10 20:52:44 +08:00
# 从 Authorization header 获取 Token与 pipeline 保持一致的大小写无关匹配)
auth_header = context.request.headers.get("Authorization") or ""
if not auth_header.lower().startswith("bearer "):
2025-12-10 20:52:44 +08:00
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少认证令牌")
token = auth_header[7:].strip()
2025-12-10 20:52:44 +08:00
# 将 Token 加入黑名单
success = await AuthService.logout(token)
if current_session_id:
session = SessionService.get_session_for_user(
context.db, user_id=user.id, session_id=str(current_session_id)
)
if session:
SessionService.revoke_session(
context.db,
session=session,
reason="user_logout",
audit_user_id=user.id,
ip_address=client_ip,
user_agent=get_user_agent(context.request),
)
if success or current_session_id:
2025-12-10 20:52:44 +08:00
# 记录审计日志
AuditService.log_event(
db=context.db,
event_type=AuditEventType.LOGOUT,
description=f"User logged out: {user.email}",
user_id=user.id,
ip_address=client_ip,
user_agent=get_user_agent(context.request),
metadata={"user_id": user.id, "email": user.email},
)
context.db.commit()
context.request.state.tx_committed_by_route = True
2025-12-10 20:52:44 +08:00
logger.info(f"用户登出成功: {user.email}")
return LogoutResponse(message="登出成功", success=True).model_dump()
else:
logger.warning(f"用户登出失败Redis不可用: {user.email}")
return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump()
class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""发送邮箱验证码"""
db = context.db
payload = context.ensure_json_body()
try:
send_request = SendVerificationCodeRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = send_request.email
# IP 速率限制检查验证码发送3次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_send"
)
if not allowed:
logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 检查邮箱是否已注册
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
)
# 检查邮箱后缀是否允许
suffix_allowed, suffix_error = validate_email_suffix(db, email)
if not suffix_allowed:
logger.warning(f"邮箱后缀不允许: {email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=suffix_error,
)
# 生成并发送验证码(使用服务中的默认配置)
success, code_or_error, error_detail = (
await EmailVerificationService.send_verification_code(email)
)
if not success:
logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail or code_or_error,
)
# 发送邮件
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
email_success, email_error = await EmailSenderService.send_verification_code(
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
)
if not email_success:
logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}")
# 不向用户暴露 SMTP 详细错误信息,防止信息泄露
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="发送验证码失败,请稍后重试",
)
logger.info(f"验证码已发送: {email}")
return SendVerificationCodeResponse(
message="验证码已发送,请查收邮件",
success=True,
expire_minutes=expire_minutes,
).model_dump()
class AuthVerifyEmailAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""验证邮箱验证码"""
payload = context.ensure_json_body()
try:
verify_request = VerifyEmailRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = verify_request.email
code = verify_request.code
# IP 速率限制检查验证码验证10次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_verify"
)
if not allowed:
logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 验证验证码
success, message = await EmailVerificationService.verify_code(email, code)
if not success:
logger.warning(f"验证码验证失败: {email}, 原因: {message}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
logger.info(f"邮箱验证成功: {email}")
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
class AuthVerificationStatusAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""查询邮箱验证状态"""
payload = context.ensure_json_body()
try:
status_request = VerificationStatusRequest.model_validate(payload)
except ValidationError as exc:
errors = []
for error in exc.errors():
field = " -> ".join(str(x) for x in error["loc"])
errors.append(f"{field}: {error['msg']}")
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
client_ip = get_client_ip(context.request)
email = status_request.email
# IP 速率限制检查验证状态查询20次/分钟)
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
client_ip, "verification_status", limit=20
)
if not allowed:
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
)
# 获取验证状态
status_data = await EmailVerificationService.get_verification_status(email)
# 计算冷却剩余时间
cooldown_remaining = None
if status_data.get("has_pending_code") and status_data.get("created_at"):
from datetime import datetime, timezone
created_at = datetime.fromisoformat(status_data["created_at"])
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
if cooldown > 0:
cooldown_remaining = cooldown
return VerificationStatusResponse(
email=email,
has_pending_code=status_data.get("has_pending_code", False),
is_verified=status_data.get("is_verified", False),
cooldown_remaining=cooldown_remaining,
code_expires_in=status_data.get("code_expires_in"),
).model_dump()