from functools import lru_cache from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.core.actor import is_visitor from app.core.config import get_settings from app.core.contracts import RequestContext from app.core.errors import UnauthorizedAgentError from app.core.security import JwtAuthenticator from app.service.identity_service import IdentityService _bearer = HTTPBearer(auto_error=False) # 与 `app/api/middleware.py` 的 `TRACE_ID_HEADER` 同名;认证阶段还不存在请求上下文, # 这个头是唯一可复用的追踪标识来源。 TRACE_ID_HEADER = "X-Trace-ID" @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: """认证 + 身份解析。 失败一律抛 `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("令牌缺失、无效或已吊销") if credentials is None or credentials.scheme.lower() != "bearer": raise unauthorized() try: context = _authenticator().authenticate(credentials.credentials) if not is_visitor(context): context = await IdentityService().resolve(context) except Exception as exc: # 令牌非法、账号停用、角色读取失败一律按 401 处理,形态完全一致。 raise unauthorized() from exc request.state.request_context = context if ( "customer" in context.roles and 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("请先完成开户风险测评问卷") return context