feat(auth): add opaque bearer sessions

This commit is contained in:
caoxiaozhu
2026-07-13 14:45:36 +08:00
parent 661990b27b
commit 653eda0596
59 changed files with 1408 additions and 408 deletions

View File

@@ -6,10 +6,8 @@ from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_session_factory
PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"}
ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"}
from app.services.auth import AuthService
from app.services.auth_sessions import AuthSessionService
def get_db() -> Generator[Session, None, None]:
@@ -33,82 +31,72 @@ class CurrentUserContext:
grade: str = ""
employee_no: str = ""
manager_name: str = ""
employee_id: str = ""
auth_session_id: str = ""
def get_current_user(
x_auth_username: Annotated[
db: Annotated[Session, Depends(get_db)],
authorization: Annotated[
str | None,
Header(description="当前登录用户名。知识库接口至少需要提供用户名或姓名"),
] = None,
x_auth_name: Annotated[
str | None,
Header(description="当前登录人展示姓名。未传时默认回退到用户名。"),
] = None,
x_auth_role_codes: Annotated[
str | None,
Header(description="角色编码列表,多个角色使用英文逗号分隔,例如 `manager,finance`。"),
] = None,
x_auth_is_admin: Annotated[
str | None,
Header(description="是否管理员,支持 `true/false/1/0`。"),
] = None,
x_auth_department: Annotated[
str | None,
Header(description="当前登录人的所属部门。"),
] = None,
x_auth_cost_center: Annotated[
str | None,
Header(description="当前登录人的成本中心。"),
] = None,
x_auth_position: Annotated[
str | None,
Header(description="当前登录人的岗位。"),
] = None,
x_auth_grade: Annotated[
str | None,
Header(description="当前登录人的职级。"),
] = None,
x_auth_employee_no: Annotated[
str | None,
Header(description="当前登录人的员工编号。"),
] = None,
x_auth_manager_name: Annotated[
str | None,
Header(description="当前登录人的直属领导。"),
Header(description="登录接口签发的 `Authorization: Bearer <token>`"),
] = None,
) -> CurrentUserContext:
role_codes = [
_normalize_role_code(item)
for item in (x_auth_role_codes or "").split(",")
if _normalize_role_code(item)
]
username = (x_auth_username or "").strip()
name = (x_auth_name or username).strip()
is_admin = _resolve_platform_admin_flag(
username=username,
name=name,
role_codes=role_codes,
header_value=x_auth_is_admin,
)
return _authenticate_bearer_user(db, authorization)
if not username and not name:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录后再访问知识库。",
)
def get_optional_current_user(
db: Annotated[Session, Depends(get_db)],
authorization: Annotated[
str | None,
Header(description="系统已初始化时用于管理员校验的 Bearer Token。"),
] = None,
) -> CurrentUserContext | None:
if not str(authorization or "").strip():
return None
return _authenticate_bearer_user(db, authorization)
def _authenticate_bearer_user(db: Session, authorization: str | None) -> CurrentUserContext:
access_token = _extract_bearer_token(authorization)
auth_session = AuthSessionService(db).authenticate(access_token)
if auth_session is None:
raise _unauthorized("登录会话不存在、已过期或已退出,请重新登录。")
user = AuthService(db).get_session_user(auth_session)
if user is None:
raise _unauthorized("当前登录用户不存在、已停用或权限已失效。")
return CurrentUserContext(
username=username or name,
name=name or username,
role_codes=role_codes,
is_admin=is_admin,
tenant_id="default",
department_name=(x_auth_department or "").strip(),
cost_center=(x_auth_cost_center or "").strip(),
position=(x_auth_position or "").strip(),
grade=(x_auth_grade or "").strip(),
employee_no=(x_auth_employee_no or "").strip(),
manager_name=(x_auth_manager_name or "").strip(),
username=user.username,
name=user.name,
role_codes=[_normalize_role_code(item) for item in user.role_codes],
is_admin=user.is_admin,
tenant_id=user.tenant_id,
department_name=user.department,
cost_center=user.cost_center,
position=user.position,
grade=user.grade,
employee_no=user.employee_no,
manager_name=user.manager_name,
employee_id=user.employee_id or "",
auth_session_id=auth_session.id,
)
def _extract_bearer_token(authorization: str | None) -> str:
normalized = str(authorization or "").strip()
scheme, separator, token = normalized.partition(" ")
if not separator or scheme.casefold() != "bearer" or not token.strip():
raise _unauthorized("请先登录后再访问。")
return token.strip()
def _unauthorized(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
@@ -120,25 +108,11 @@ def _normalize_role_code(value: str | None) -> str:
def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]:
return {_normalize_role_code(item) for item in current_user.role_codes if _normalize_role_code(item)}
def _resolve_platform_admin_flag(
*,
username: str,
name: str,
role_codes: list[str],
header_value: str | None,
) -> bool:
if str(header_value or "").strip().lower() in ADMIN_HEADER_TRUE_VALUES:
return True
identities = {
str(username or "").strip().lower(),
str(name or "").strip().lower(),
return {
_normalize_role_code(item)
for item in current_user.role_codes
if _normalize_role_code(item)
}
normalized_role_codes = {_normalize_role_code(item) for item in role_codes}
return bool(identities & PLATFORM_ADMIN_IDENTITIES) or bool(normalized_role_codes & PLATFORM_ADMIN_IDENTITIES)
def require_admin_user(

View File

@@ -5,12 +5,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import get_current_user, get_db
from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead
from app.schemas.common import ErrorResponse
from app.services.agent_runs import AgentRunService
router = APIRouter(prefix="/agent-runs")
router = APIRouter(prefix="/agent-runs", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_admin_user
from app.api.deps import get_db, require_platform_admin_user
from app.schemas.agent_trace import (
AgentConversationTraceRead,
AgentTraceDetailRead,
@@ -26,7 +26,7 @@ DbSession = Annotated[Session, Depends(get_db)]
)
def list_agent_traces(
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
_: Annotated[object, Depends(require_platform_admin_user)],
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
status_value: Annotated[
str | None,
@@ -56,7 +56,7 @@ def list_agent_traces(
def get_conversation_trace(
conversation_id: str,
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
_: Annotated[object, Depends(require_platform_admin_user)],
) -> AgentConversationTraceRead:
return AgentTraceService(db).get_conversation_trace(conversation_id)
@@ -76,7 +76,7 @@ def get_conversation_trace(
def get_agent_trace(
run_id: str,
db: DbSession,
_: Annotated[object, Depends(require_admin_user)],
_: Annotated[object, Depends(require_platform_admin_user)],
) -> AgentTraceDetailRead:
trace = AgentTraceService(db).get_trace(run_id)
if trace is None:

View File

@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import get_current_user, get_db
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead
@@ -14,7 +14,7 @@ from app.services.digital_employee_dashboard import DigitalEmployeeDashboardServ
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
from app.services.system_dashboard import SystemDashboardService
router = APIRouter(prefix="/analytics")
router = APIRouter(prefix="/analytics", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,11 +5,11 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import get_db, require_platform_admin_user
from app.schemas.audit_log import AuditLogRead
from app.services.audit import AuditLogService
router = APIRouter(prefix="/audit-logs")
router = APIRouter(prefix="/audit-logs", dependencies=[Depends(require_platform_admin_user)])
DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -10,11 +10,14 @@ from app.schemas.auth import (
AuthUserRead,
LoginRequest,
LoginResponse,
LogoutRequest,
LogoutResponse,
SessionFinishRequest,
SessionFinishResponse,
)
from app.schemas.common import ErrorResponse
from app.services.auth import AuthService
from app.services.auth_sessions import AuthSessionService
from app.services.user_session_metrics import UserSessionMetricService
router = APIRouter(prefix="/auth")
@@ -44,7 +47,7 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
"/me",
response_model=AuthUserRead,
summary="读取当前登录用户",
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
description="根据 Bearer 会话刷新前端登录态中的员工姓名、部门、岗位和职级。",
)
def get_current_auth_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
@@ -68,12 +71,49 @@ def get_current_auth_user(
managerName=current_user.manager_name,
costCenter=current_user.cost_center,
roleCodes=current_user.role_codes or ["manager"],
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local",
email=(
current_user.username
if "@" in current_user.username
else f"{current_user.username}@local"
),
avatar=name[:1].upper(),
isAdmin=True,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="当前登录用户不存在或已停用",
)
@router.post(
"/logout",
response_model=LogoutResponse,
summary="退出当前登录会话",
description="撤销当前 Bearer Token撤销后该凭证不可再次使用。",
)
def logout(
payload: LogoutRequest,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> LogoutResponse:
try:
if payload.sessionId:
UserSessionMetricService(db).finish_session(
session_id=payload.sessionId,
expected_username=current_user.username,
reason=payload.reason,
last_activity_at=payload.lastActivityAt,
activity_event_count=payload.activityEventCount,
event={"page_path": payload.pagePath},
commit=False,
)
AuthSessionService(db).revoke(current_user.auth_session_id, commit=False)
db.commit()
except Exception:
db.rollback()
raise
return LogoutResponse()
@router.post(
@@ -84,10 +124,12 @@ def get_current_auth_user(
def finish_session(
session_id: str,
payload: SessionFinishRequest,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> SessionFinishResponse:
session = UserSessionMetricService(db).finish_session(
session_id=session_id,
expected_username=current_user.username,
reason=payload.reason,
last_activity_at=payload.lastActivityAt,
activity_event_count=payload.activityEventCount,

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter, status
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import CurrentUserContext, get_optional_current_user
from app.core.bootstrap import build_bootstrap_state, persist_bootstrap_config
from app.core.config import get_settings
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
@@ -16,7 +19,11 @@ router = APIRouter(prefix="/bootstrap")
description="返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。",
)
def get_bootstrap_state() -> BootstrapStateRead:
return build_bootstrap_state(get_settings())
settings = get_settings()
return build_bootstrap_state(
settings,
redact_infrastructure=settings.setup_completed,
)
@router.post(
@@ -26,5 +33,17 @@ def get_bootstrap_state() -> BootstrapStateRead:
summary="写入初始化配置",
description="保存系统初始化配置,并刷新运行时数据库连接。",
)
def initialize_bootstrap(payload: BootstrapSetupPayload) -> BootstrapStateRead:
return persist_bootstrap_config(payload, get_settings())
def initialize_bootstrap(
payload: BootstrapSetupPayload,
current_user: Annotated[
CurrentUserContext | None,
Depends(get_optional_current_user),
],
) -> BootstrapStateRead:
settings = get_settings()
if settings.setup_completed and (current_user is None or not current_user.is_admin):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="系统已完成初始化,只有平台管理员可以重新写入初始化配置。",
)
return persist_bootstrap_config(payload, settings)

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import get_db, require_admin_user
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.employee import (
@@ -19,7 +19,7 @@ from app.schemas.employee import (
from app.services.employee import EmployeeService
from app.services.employee_pagination import EmployeePaginationService
router = APIRouter()
router = APIRouter(dependencies=[Depends(require_admin_user)])
DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.risk_observation import (
RiskObservationDashboardRead,
@@ -16,7 +16,7 @@ from app.schemas.risk_observation import (
)
from app.services.risk_observations import RiskObservationService
router = APIRouter(prefix="/risk-observations")
router = APIRouter(prefix="/risk-observations", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]

View File

@@ -5,7 +5,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_db, require_admin_user
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.core.config import get_settings as get_runtime_settings
from app.schemas.common import ErrorResponse
from app.schemas.settings import (
@@ -52,7 +57,10 @@ def require_hermes_agent_token(
summary="读取系统设置",
description="返回公司、管理员、模型、日志、邮件和 ONLYOFFICE 的设置快照。",
)
def get_settings(db: DbSession) -> SettingsRead:
def get_settings(
db: DbSession,
_: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> SettingsRead:
return SettingsService(db).get_settings_snapshot()
@@ -68,7 +76,11 @@ def get_settings(db: DbSession) -> SettingsRead:
}
},
)
def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead:
def update_settings(
payload: SettingsWrite,
db: DbSession,
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> SettingsRead:
try:
return SettingsService(db).save_settings_snapshot(payload)
except ValueError as exc:
@@ -84,6 +96,7 @@ def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead:
def test_model_connectivity(
payload: ModelConnectivityTestRequest,
db: DbSession,
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> ModelConnectivityTestRead:
resolved_payload = payload
@@ -99,7 +112,10 @@ def test_model_connectivity(
"/cache/clear",
response_model=SettingsCacheClearRead,
summary="清理系统缓存",
description="清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存不删除业务文件或数据库记录。",
description=(
"清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存"
"不删除业务文件或数据库记录。"
),
responses={
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponse,
@@ -108,7 +124,7 @@ def test_model_connectivity(
},
)
def clear_system_cache(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> SettingsCacheClearRead:
return SystemCacheService().clear_all()

View File

@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.deps import CurrentUserContext, require_admin_user
from app.api.deps import CurrentUserContext, require_platform_admin_user
from app.schemas.common import ErrorResponse
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
from app.services.system_logs import SystemLogService
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/system-logs")
},
)
def list_system_log_entries(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
limit: Annotated[int, Query(ge=20, le=1000, description="返回的日志记录数。")] = 300,
) -> list[SystemLogEntryRead]:
return SystemLogService().list_entries(entry_limit=limit)
@@ -49,12 +49,15 @@ def list_system_log_entries(
)
def get_system_log_entry(
entry_id: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> SystemLogEntryRead:
try:
return SystemLogService().get_entry(entry_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志记录不存在。") from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="日志记录不存在。",
) from exc
@router.get(
@@ -70,7 +73,7 @@ def get_system_log_entry(
},
)
def list_system_log_files(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
) -> list[SystemLogFileRead]:
return SystemLogService().list_files()
@@ -93,10 +96,13 @@ def list_system_log_files(
)
def get_system_log_tail(
file_name: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
lines: Annotated[int, Query(ge=20, le=1000, description="返回的日志行数。")] = 300,
) -> SystemLogTailRead:
try:
return SystemLogService().read_tail(file_name, line_limit=lines)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="日志文件不存在。") from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="日志文件不存在。",
) from exc