69 lines
2.9 KiB
Python
69 lines
2.9 KiB
Python
from functools import lru_cache
|
||
|
||
from fastapi import Depends, Request
|
||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||
|
||
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 "visitor" not in context.roles:
|
||
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
|