2026-09-09 21:55:37 +08:00
|
|
|
|
from functools import lru_cache
|
|
|
|
|
|
|
2026-09-10 15:55:54 +08:00
|
|
|
|
from fastapi import Depends, Request
|
2026-09-09 21:55:37 +08:00
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
2026-09-10 15:55:54 +08:00
|
|
|
|
from app.core.errors import UnauthorizedAgentError
|
2026-09-09 21:55:37 +08:00
|
|
|
|
from app.core.security import JwtAuthenticator
|
|
|
|
|
|
from app.service.identity_service import IdentityService
|
|
|
|
|
|
|
|
|
|
|
|
_bearer = HTTPBearer(auto_error=False)
|
|
|
|
|
|
|
2026-09-10 15:55:54 +08:00
|
|
|
|
# 与 `app/api/middleware.py` 的 `TRACE_ID_HEADER` 同名;认证阶段还不存在请求上下文,
|
|
|
|
|
|
# 这个头是唯一可复用的追踪标识来源。
|
|
|
|
|
|
TRACE_ID_HEADER = "X-Trace-ID"
|
|
|
|
|
|
|
2026-09-09 21:55:37 +08:00
|
|
|
|
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
|
|
|
|
def _authenticator() -> JwtAuthenticator:
|
|
|
|
|
|
return JwtAuthenticator(get_settings())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def build_request_context(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), # noqa: B008
|
|
|
|
|
|
) -> RequestContext:
|
2026-09-10 15:55:54 +08:00
|
|
|
|
"""认证 + 身份解析。
|
|
|
|
|
|
|
|
|
|
|
|
失败一律抛 `UnauthorizedAgentError`(`401 AUTHENTICATION_REQUIRED`),由
|
|
|
|
|
|
`app/main.py` 的 `AgentError` 处理器输出文档 §3.4 的统一错误信封——此前这里抛
|
|
|
|
|
|
FastAPI 原生 `HTTPException`,响应体是 `{"detail": "Unauthorized"}`,调用方无法用
|
|
|
|
|
|
同一套信封解析。令牌非法与 RBAC/身份解析失败**仍不区分**,都返回 401。
|
|
|
|
|
|
|
|
|
|
|
|
`meta.trace_id` 的来源(文档 §3.4):此时请求上下文尚未建立,因此先记住请求头里的
|
|
|
|
|
|
`X-Trace-ID`;没有就是空字符串,不凭空生成 id。
|
|
|
|
|
|
"""
|
|
|
|
|
|
request.state.trace_id = request.headers.get(TRACE_ID_HEADER)
|
|
|
|
|
|
|
|
|
|
|
|
def unauthorized() -> UnauthorizedAgentError:
|
|
|
|
|
|
"""消息固定成一句,涵盖令牌缺失、非法、过期、吊销与账号不可用。
|
|
|
|
|
|
|
|
|
|
|
|
文档 §3.6 只给了一个 401 码,因此客户端无法(也不应)据 `message` 区分失败类型,
|
|
|
|
|
|
更不会看到内部异常原文。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return UnauthorizedAgentError("令牌缺失、无效或已吊销")
|
|
|
|
|
|
|
2026-09-09 21:55:37 +08:00
|
|
|
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
2026-09-10 15:55:54 +08:00
|
|
|
|
raise unauthorized()
|
2026-09-09 21:55:37 +08:00
|
|
|
|
try:
|
|
|
|
|
|
context = _authenticator().authenticate(credentials.credentials)
|
2026-09-10 13:56:08 +08:00
|
|
|
|
if "visitor" not in context.roles:
|
|
|
|
|
|
context = await IdentityService().resolve(context)
|
2026-09-09 21:55:37 +08:00
|
|
|
|
except Exception as exc:
|
2026-09-10 15:55:54 +08:00
|
|
|
|
# 令牌非法、账号停用、角色读取失败一律按 401 处理,形态完全一致。
|
|
|
|
|
|
raise unauthorized() from exc
|
2026-09-09 21:55:37 +08:00
|
|
|
|
request.state.request_context = context
|
2026-09-11 12:57:07 +08:00
|
|
|
|
if not request.url.path.startswith("/api/v1/onboarding/"):
|
|
|
|
|
|
from app.service.risk_questionnaire_service import RiskQuestionnaireService
|
|
|
|
|
|
|
|
|
|
|
|
if await RiskQuestionnaireService().is_required(context):
|
|
|
|
|
|
from app.core.errors import OnboardingRequiredError
|
|
|
|
|
|
|
|
|
|
|
|
raise OnboardingRequiredError("请先完成开户风险测评问卷")
|
2026-09-09 21:55:37 +08:00
|
|
|
|
return context
|