feat: add django user center auth integration
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.core.user_context import UserContext, get_user_context
|
||||
from app.schemas.auth import AuthMeResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=ApiResponse[AuthMeResponse])
|
||||
async def auth_me(ctx: UserContext = Depends(get_user_context)):
|
||||
"""当前用户:返回经 Django 用户中心或 Demo Header 标准化后的用户信息。"""
|
||||
return ok(
|
||||
AuthMeResponse(
|
||||
user_id=ctx.user_id,
|
||||
source=ctx.auth_source,
|
||||
username=ctx.username,
|
||||
display_name=ctx.display_name,
|
||||
tenant_id=ctx.tenant_id,
|
||||
role=ctx.role,
|
||||
)
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import agent, cases, evaluations, imports, knowledge, llm_test, sessions
|
||||
from app.api import agent, auth, cases, evaluations, imports, knowledge, llm_test, sessions
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(agent.router, tags=["agent"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(cases.router, prefix="/cases", tags=["cases"])
|
||||
api_router.include_router(sessions.router, prefix="/sessions", tags=["sessions"])
|
||||
api_router.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"])
|
||||
|
||||
@@ -83,6 +83,11 @@ class Settings(BaseModel):
|
||||
runtime_memory_ttl_seconds: int = Field(default_factory=lambda: int(os.getenv("RUNTIME_MEMORY_TTL_SECONDS", "7200")))
|
||||
runtime_memory_backend: str = Field(default_factory=lambda: os.getenv("RUNTIME_MEMORY_BACKEND", "memory"))
|
||||
redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0"))
|
||||
auth_validate_enabled: bool = Field(default_factory=lambda: os.getenv("AUTH_VALIDATE_ENABLED", "false").lower() == "true")
|
||||
auth_user_me_url: str = Field(default_factory=lambda: os.getenv("AUTH_USER_ME_URL", ""))
|
||||
auth_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("AUTH_TIMEOUT_SECONDS", "5")))
|
||||
auth_cache_ttl_seconds: int = Field(default_factory=lambda: int(os.getenv("AUTH_CACHE_TTL_SECONDS", "300")))
|
||||
auth_allow_demo_user_id: bool = Field(default_factory=lambda: os.getenv("AUTH_ALLOW_DEMO_USER_ID", "true").lower() == "true")
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
"""配置展示:返回允许暴露给 Demo 前端的功能开关。"""
|
||||
@@ -102,6 +107,8 @@ class Settings(BaseModel):
|
||||
"llm_reasoning_effort": self.llm_reasoning_effort,
|
||||
"llm_fast_max_tokens": self.llm_fast_max_tokens,
|
||||
"runtime_memory_backend": self.runtime_memory_backend,
|
||||
"auth_validate_enabled": self.auth_validate_enabled,
|
||||
"auth_source": "django_user_center" if self.auth_validate_enabled else "demo_header",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,6 @@ class UserContext:
|
||||
request_id: str | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
username: str | None = None
|
||||
display_name: str | None = None
|
||||
auth_source: str = "demo_header"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from fastapi import Header, Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import UserContext
|
||||
from app.core.exceptions import AppError
|
||||
from app.services.external_auth_service import ExternalAuthService
|
||||
|
||||
|
||||
async def get_user_context(
|
||||
@@ -13,7 +15,26 @@ async def get_user_context(
|
||||
x_entry_scene: str | None = Header(default=None, alias="X-Entry-Scene"),
|
||||
x_request_id: str | None = Header(default=None, alias="X-Request-Id"),
|
||||
) -> UserContext:
|
||||
"""用户校验:读取请求头并强制校验 `X-User-Id`。"""
|
||||
"""用户校验:正式联调优先调用 Django 用户中心,Demo 模式兼容 X-User-Id。"""
|
||||
if settings.auth_validate_enabled and (request.headers.get("Authorization") or request.headers.get("Cookie")):
|
||||
user = await ExternalAuthService().authenticate(request)
|
||||
return UserContext(
|
||||
user_id=user.user_id,
|
||||
tenant_id=user.tenant_id or x_tenant_id,
|
||||
role=user.role or x_user_role,
|
||||
class_id=x_class_id,
|
||||
entry_scene=x_entry_scene,
|
||||
request_id=x_request_id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
auth_source=user.source,
|
||||
)
|
||||
|
||||
if settings.auth_validate_enabled and not settings.auth_allow_demo_user_id:
|
||||
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization or Cookie is required", 401)
|
||||
|
||||
if not x_user_id or not x_user_id.strip():
|
||||
raise AppError("USER_ID_REQUIRED", "X-User-Id header is required", 401)
|
||||
|
||||
@@ -26,4 +47,5 @@ async def get_user_context(
|
||||
request_id=x_request_id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
auth_source="demo_header",
|
||||
)
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ def create_app() -> FastAPI:
|
||||
"http://127.0.0.1:5174",
|
||||
"http://localhost:5174",
|
||||
],
|
||||
allow_origin_regex=r"^http://(127\.0\.0\.1|localhost):\d+$",
|
||||
allow_credentials=False,
|
||||
allow_origin_regex=r"^http://(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+):\d+$",
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuthMeResponse(BaseModel):
|
||||
"""认证用户响应:返回医疗问诊 Agent 标准化后的当前用户信息。"""
|
||||
|
||||
user_id: str
|
||||
source: str
|
||||
username: str | None = None
|
||||
display_name: str | None = None
|
||||
tenant_id: str | None = None
|
||||
role: str | None = None
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import AppError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedUser:
|
||||
"""外部认证用户:承载从 Django 用户中心解析出的标准用户信息。"""
|
||||
|
||||
user_id: str
|
||||
source: str = "django_user_center"
|
||||
username: str | None = None
|
||||
display_name: str | None = None
|
||||
tenant_id: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class ExternalAuthService:
|
||||
"""外部认证服务:调用 Django `/api/user/users/me/` 并标准化当前登录用户。"""
|
||||
|
||||
_cache: dict[str, tuple[float, AuthenticatedUser]] = {}
|
||||
|
||||
async def authenticate(self, request: Request) -> AuthenticatedUser:
|
||||
"""用户鉴权:转发前端鉴权凭证到 Django 用户中心,成功后返回标准用户信息。"""
|
||||
if not settings.auth_user_me_url:
|
||||
raise AppError("AUTH_CONFIG_MISSING", "auth user me url is not configured", 500)
|
||||
|
||||
outbound_headers = self._build_forward_headers(request)
|
||||
if not outbound_headers:
|
||||
raise AppError("AUTH_CREDENTIAL_REQUIRED", "Authorization or Cookie is required", 401)
|
||||
|
||||
cache_key = self._cache_key(outbound_headers)
|
||||
cached = self._read_cache(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.auth_timeout_seconds, follow_redirects=False) as client:
|
||||
response = await client.get(settings.auth_user_me_url, headers=outbound_headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AppError("AUTH_USER_CENTER_UNAVAILABLE", "auth user center request timeout", 503) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise AppError("AUTH_USER_CENTER_UNAVAILABLE", "auth user center unavailable", 503) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
raise AppError("AUTH_USER_INVALID", "current login user is invalid", 401)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise AppError("AUTH_USER_PARSE_FAILED", "auth user center returned invalid json", 502) from exc
|
||||
|
||||
user = self._parse_user(payload)
|
||||
self._write_cache(cache_key, user)
|
||||
return user
|
||||
|
||||
def _build_forward_headers(self, request: Request) -> dict[str, str]:
|
||||
"""认证转发:只透传鉴权必要 Header,避免把无关内部头转发给用户中心。"""
|
||||
headers: dict[str, str] = {}
|
||||
authorization = request.headers.get("Authorization")
|
||||
cookie = request.headers.get("Cookie")
|
||||
if authorization:
|
||||
headers["Authorization"] = authorization
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
if request.headers.get("X-CSRFToken"):
|
||||
headers["X-CSRFToken"] = request.headers["X-CSRFToken"]
|
||||
return headers
|
||||
|
||||
def _parse_user(self, payload: dict[str, Any]) -> AuthenticatedUser:
|
||||
"""用户解析:兼容常见 Django 用户接口结构,并提取稳定 user_id。"""
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||||
user_id = self._first_present(data, ["id", "user_id", "uid", "pk", "uuid"])
|
||||
if user_id is None:
|
||||
raise AppError("AUTH_USER_PARSE_FAILED", "auth user id is missing", 502)
|
||||
username = self._first_present(data, ["username", "account", "mobile", "phone"])
|
||||
display_name = self._first_present(data, ["display_name", "name", "nickname", "real_name"])
|
||||
role = self._first_present(data, ["role", "user_role"])
|
||||
tenant_id = self._first_present(data, ["tenant_id", "org_id", "organization_id"])
|
||||
return AuthenticatedUser(
|
||||
user_id=str(user_id),
|
||||
username=str(username) if username is not None else None,
|
||||
display_name=str(display_name) if display_name is not None else None,
|
||||
role=str(role) if role is not None else None,
|
||||
tenant_id=str(tenant_id) if tenant_id is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _first_present(data: dict[str, Any], keys: list[str]) -> Any:
|
||||
"""用户解析:按优先级读取第一个非空字段。"""
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(headers: dict[str, str]) -> str:
|
||||
"""认证缓存:基于鉴权凭证生成不可逆缓存键,避免保存明文 token。"""
|
||||
raw = "|".join(f"{key}:{value}" for key, value in sorted(headers.items()))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def _read_cache(self, cache_key: str) -> AuthenticatedUser | None:
|
||||
"""认证缓存:读取进程内短期用户缓存,减少频繁请求 Django 用户中心。"""
|
||||
item = self._cache.get(cache_key)
|
||||
if not item:
|
||||
return None
|
||||
expires_at, user = item
|
||||
if expires_at <= time.time():
|
||||
self._cache.pop(cache_key, None)
|
||||
return None
|
||||
return user
|
||||
|
||||
def _write_cache(self, cache_key: str, user: AuthenticatedUser) -> None:
|
||||
"""认证缓存:写入短期用户缓存,缓存只保存标准用户信息,不保存 token 明文。"""
|
||||
ttl = max(settings.auth_cache_ttl_seconds, 0)
|
||||
if ttl <= 0:
|
||||
return
|
||||
self._cache[cache_key] = (time.time() + ttl, user)
|
||||
Reference in New Issue
Block a user