diff --git a/document/development/2026-07-13/dev-logs/bugs/client-auth-forgery-and-bootstrap-reconfiguration.md b/document/development/2026-07-13/dev-logs/bugs/client-auth-forgery-and-bootstrap-reconfiguration.md new file mode 100644 index 0000000..066146b --- /dev/null +++ b/document/development/2026-07-13/dev-logs/bugs/client-auth-forgery-and-bootstrap-reconfiguration.md @@ -0,0 +1,8 @@ +## 修复记录 + +- 14:43:记录 bug 修复:客户端身份头可伪造且已初始化系统仍存在匿名重配置入口。 + - Git 提交检查:已执行 `git fetch --all --prune`;未发现 upstream 新提交,本地 ahead 1 个提交 `661990b2 feat(expenses): add transactional expense case events`,它是本任务上一切片的费用事件事务提交,当前认证改造继续建立在该提交之上。 + - 修改:新增 `AuthSession` 不透明 Bearer 会话和摘要存储,后端从服务端会话及员工表解析身份/角色;移除生产 `X-Auth-*` 授权来源,保护 Bootstrap、Settings、模型连通性、缓存、员工、分析、Agent、风险观测、审计及日志接口;前端统一 Bearer、`sessionStorage`、集中 `401` 和原子登出;Vite Setup 桥在初始化完成后锁定重配置。 + - 操作:新增 `20260713_0002_auth_sessions.py` 并只生成 upgrade/downgrade 离线 SQL,没有对持久化数据库执行迁移或重启;领域回归测试使用仅存在于测试目录的身份依赖覆盖,生产代码没有兼容伪造头。测试过程中发现规则初始化用例会重写 5 份 Excel 工件,已停止继续运行该类测试并保持这些文件不进入本次提交。 + - 验证:容器 `x-financial-local-linux` 内认证/Bootstrap/费用事件/OpenAPI 21 项通过,受保护端点 13 项通过,全量收集 805 项成功;前端 17 项通过,生产构建成功;认证安全核心文件 Ruff 通过,`git diff --check` 通过,生产源码未检出 `X-Auth-*`;Alembic 离线升级包含 `CREATE TABLE auth_sessions`,离线降级包含 `DROP TABLE auth_sessions`。 + - 影响:用户登录后必须使用服务端签发的短期 Bearer 会话,伪造用户名、角色或管理员头不再生效;业务经理不能冒充平台管理员,已初始化系统的基础设施信息与重配置入口不再向匿名请求开放。租户级查询守卫、会话清理/全部退出、登录限流和 SSO 仍按 P0 后续任务推进。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index 406b92d..21323fd 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -258,6 +258,9 @@ - 登录后签发服务端可验证会话或 JWT,服务端从会话和数据库解析用户、租户、角色和数据范围。 - 移除客户端 `X-Auth-*` 作为授权事实来源;管理面、Bootstrap、设置和模型连通性接口必须受平台管理员保护。 +- P0 采用不透明 Bearer 会话:明文 token 仅在登录成功时返回,数据库只保存 SHA-256 摘要;每次请求从服务端会话和员工数据重新解析当前身份与角色,过期、撤销或不存在的 token 统一拒绝。 +- Web 端只在 `sessionStorage` 保存 token 和过期时间,普通请求、流式请求及页面关闭收尾统一携带 Bearer;任一 `401` 触发本地会话清理,登出时会话指标与 token 撤销在同一事务完成。 +- 已初始化系统的 Bootstrap 状态只返回脱敏信息,Bootstrap 写入、系统设置、模型连通性、缓存、审计与系统日志等敏感管理面由平台管理员权限保护;Vite 本地 Setup 桥在初始化完成后锁定重新配置入口。 - P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。 - 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。 - 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。 @@ -439,6 +442,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ - 范围过大:费用闭环、AI 学习、价值分析和商业化不能同时全量实现,需要按 P0/P1/P2 阶段交付。 - 领域模型迁移:旧 `ReimbursementRequest`、`ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。 - 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。 +- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO;当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。 - 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。 - 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。 - 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。 @@ -480,3 +484,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ - 2026-07-13(事务修复):关联申请归档/解绑的内部审计改为 `flush`,由外层业务事务统一提交,避免审计日志提前提交付款或归档状态。 - 2026-07-13(迁移桥接):新增第一条 migration-owned schema revision;服务启动先执行 Alembic,旧 `create_all` 明确排除三张迁移表。完整历史 schema baseline、正式数据库 upgrade/rollback 和停止旧 DDL 仍未完成。 - 2026-07-13(验证):容器内新增测试与既有差旅主链路回归共 24 项通过;Alembic PostgreSQL upgrade/downgrade 离线 SQL、启动脚本语法、OpenAPI 路由和新增文件静态检查通过。未对持久化开发数据库执行迁移。 +- 2026-07-13(P0 认证安全切片):新增 `AuthSession` 不透明 Bearer 会话及 `20260713_0002_auth_sessions.py`,登录 token 只返回一次、数据库只保存摘要;`/auth/me`、会话结束和登出均从服务端会话解析身份,登出与使用指标收尾原子提交。 +- 2026-07-13(管理面收口):移除生产代码中的 `X-Auth-*` 授权来源,保护 Settings、模型连通性、缓存、员工、分析、Agent 运行/轨迹、风险观测、审计及系统日志;已初始化 Bootstrap 返回脱敏状态并拒绝匿名重配置,Vite Setup 桥同步锁定。 +- 2026-07-13(前端会话):Web 请求、流式响应和页面关闭收尾统一使用 Bearer,token 与过期时间只保存在 `sessionStorage`,集中处理 `401`、空闲过期和服务端过期;业务经理不再被前端视为平台管理员。 +- 2026-07-13(认证验证):容器内认证/Bootstrap/费用事件/OpenAPI 定向测试 21 项通过,受保护业务端点回归 13 项通过,全量测试收集 805 项成功;前端会话、请求、Setup 锁和权限测试 17 项通过,生产构建通过。迁移仅生成并检查 upgrade/downgrade 离线 SQL,未写入持久化开发数据库。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index 2a5d8bd..e3cdb71 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -48,8 +48,11 @@ ## 4. P0 后端实现:费用闭环与数据基础 -- [ ] [CONCEPT: 权限与安全] 实现服务端可验证会话/JWT,移除客户端身份头作为授权事实来源。 -- [ ] [CONCEPT: 权限与安全] 为管理面、Bootstrap、Settings、模型连通性、日志和规则接口补齐平台管理员保护。 +- [x] [CONCEPT: 权限与安全] 实现服务端可验证会话/JWT,移除客户端身份头作为授权事实来源。 + 证据:`auth_sessions.py`、`auth_session.py`、`deps.py`、`auth.py`、`authSessionStorage.js`;登录签发不透明 Bearer token,数据库仅保存 SHA-256 摘要,生产代码不再信任 `X-Auth-*`,过期/撤销/伪造会话回归测试通过。 +- [x] [CONCEPT: 权限与安全] 为管理面、Bootstrap、Settings、模型连通性、缓存、审计和系统日志补齐平台管理员保护。 + 证据:`bootstrap.py`、`settings.py`、`audit_logs.py`、`agent_traces.py`、`system_logs.py`、`vite.config.js`;已初始化 Bootstrap 脱敏且拒绝匿名重配,平台管理员/业务经理权限边界和 Vite Setup 锁测试通过。 +- [ ] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。 - [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。 - [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。 - [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。 @@ -134,7 +137,9 @@ - [x] [CONCEPT: 测试方案] 完成 Expense Case/Link、业务事件幂等、租户边界、同事务回滚、申请转报销同 Case 和付款归档事件首批测试。 证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。 - [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。 -- [ ] [CONCEPT: 测试方案] 为服务端会话、管理员保护、租户隔离、跨租户访问和敏感动作补充安全回归测试。 +- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。 + 证据:`test_auth_session_endpoints.py`、`test_auth_service.py`、`test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。 +- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。 - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 diff --git a/server/alembic/versions/20260713_0002_auth_sessions.py b/server/alembic/versions/20260713_0002_auth_sessions.py new file mode 100644 index 0000000..86b2edd --- /dev/null +++ b/server/alembic/versions/20260713_0002_auth_sessions.py @@ -0,0 +1,82 @@ +"""add opaque bearer authentication sessions + +Revision ID: 20260713_0002 +Revises: 20260713_0001 +Create Date: 2026-07-13 14:20:00 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260713_0002" +down_revision: str | None = "20260713_0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "auth_sessions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("principal_type", sa.String(length=20), nullable=False), + sa.Column("employee_id", sa.String(length=36), nullable=True), + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("metric_session_id", sa.String(length=64), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "last_seen_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash", name="uq_auth_sessions_token_hash"), + ) + op.create_index("ix_auth_sessions_tenant_id", "auth_sessions", ["tenant_id"]) + op.create_index("ix_auth_sessions_principal_type", "auth_sessions", ["principal_type"]) + op.create_index("ix_auth_sessions_employee_id", "auth_sessions", ["employee_id"]) + op.create_index("ix_auth_sessions_username", "auth_sessions", ["username"]) + op.create_index("ix_auth_sessions_metric_session_id", "auth_sessions", ["metric_session_id"]) + op.create_index("ix_auth_sessions_expires_at", "auth_sessions", ["expires_at"]) + op.create_index("ix_auth_sessions_revoked_at", "auth_sessions", ["revoked_at"]) + op.create_index( + "ix_auth_sessions_principal_active", + "auth_sessions", + ["principal_type", "revoked_at", "expires_at"], + ) + op.create_index( + "ix_auth_sessions_tenant_username", + "auth_sessions", + ["tenant_id", "username"], + ) + + +def downgrade() -> None: + op.drop_index("ix_auth_sessions_tenant_username", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_principal_active", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_revoked_at", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_expires_at", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_metric_session_id", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_username", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_employee_id", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_principal_type", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_tenant_id", table_name="auth_sessions") + op.drop_table("auth_sessions") diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index a7b94d7..7765ede 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -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 `。"), ] = 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( diff --git a/server/src/app/api/v1/endpoints/agent_runs.py b/server/src/app/api/v1/endpoints/agent_runs.py index 1e25bbe..c11c190 100644 --- a/server/src/app/api/v1/endpoints/agent_runs.py +++ b/server/src/app/api/v1/endpoints/agent_runs.py @@ -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)] diff --git a/server/src/app/api/v1/endpoints/agent_traces.py b/server/src/app/api/v1/endpoints/agent_traces.py index 6812626..b513036 100644 --- a/server/src/app/api/v1/endpoints/agent_traces.py +++ b/server/src/app/api/v1/endpoints/agent_traces.py @@ -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: diff --git a/server/src/app/api/v1/endpoints/analytics.py b/server/src/app/api/v1/endpoints/analytics.py index 74cf90b..5f67031 100644 --- a/server/src/app/api/v1/endpoints/analytics.py +++ b/server/src/app/api/v1/endpoints/analytics.py @@ -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)] diff --git a/server/src/app/api/v1/endpoints/audit_logs.py b/server/src/app/api/v1/endpoints/audit_logs.py index b340c92..49ff267 100644 --- a/server/src/app/api/v1/endpoints/audit_logs.py +++ b/server/src/app/api/v1/endpoints/audit_logs.py @@ -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)] diff --git a/server/src/app/api/v1/endpoints/auth.py b/server/src/app/api/v1/endpoints/auth.py index ee16a6b..9ced8d8 100644 --- a/server/src/app/api/v1/endpoints/auth.py +++ b/server/src/app/api/v1/endpoints/auth.py @@ -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, diff --git a/server/src/app/api/v1/endpoints/bootstrap.py b/server/src/app/api/v1/endpoints/bootstrap.py index 4d8e66b..f89f5d2 100644 --- a/server/src/app/api/v1/endpoints/bootstrap.py +++ b/server/src/app/api/v1/endpoints/bootstrap.py @@ -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) diff --git a/server/src/app/api/v1/endpoints/employees.py b/server/src/app/api/v1/endpoints/employees.py index 0adca17..0dfe940 100644 --- a/server/src/app/api/v1/endpoints/employees.py +++ b/server/src/app/api/v1/endpoints/employees.py @@ -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)] diff --git a/server/src/app/api/v1/endpoints/risk_observations.py b/server/src/app/api/v1/endpoints/risk_observations.py index dd9a766..6ad2e12 100644 --- a/server/src/app/api/v1/endpoints/risk_observations.py +++ b/server/src/app/api/v1/endpoints/risk_observations.py @@ -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)] diff --git a/server/src/app/api/v1/endpoints/settings.py b/server/src/app/api/v1/endpoints/settings.py index 069819e..c06a27f 100644 --- a/server/src/app/api/v1/endpoints/settings.py +++ b/server/src/app/api/v1/endpoints/settings.py @@ -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() diff --git a/server/src/app/api/v1/endpoints/system_logs.py b/server/src/app/api/v1/endpoints/system_logs.py index c6fbd0c..a307293 100644 --- a/server/src/app/api/v1/endpoints/system_logs.py +++ b/server/src/app/api/v1/endpoints/system_logs.py @@ -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 diff --git a/server/src/app/core/bootstrap.py b/server/src/app/core/bootstrap.py index 29e9c89..7608957 100644 --- a/server/src/app/core/bootstrap.py +++ b/server/src/app/core/bootstrap.py @@ -34,7 +34,11 @@ def build_database_url(payload: BootstrapSetupPayload) -> str: ) -def build_bootstrap_state(settings: Settings) -> BootstrapStateRead: +def build_bootstrap_state( + settings: Settings, + *, + redact_infrastructure: bool = False, +) -> BootstrapStateRead: return BootstrapStateRead( initialized=settings.setup_completed, company={ @@ -46,17 +50,23 @@ def build_bootstrap_state(settings: Settings) -> BootstrapStateRead: server={"host": settings.app_host, "port": settings.app_port}, database={ "driver": "postgresql", - "host": settings.postgres_host, + "host": "" if redact_infrastructure else settings.postgres_host, "port": settings.postgres_port, "name": settings.postgres_db, - "username": settings.postgres_user, + "username": "" if redact_infrastructure else settings.postgres_user, "password_configured": bool(settings.postgres_password), }, - redis={"enabled": bool(settings.redis_url), "url": settings.redis_url or ""}, + redis={ + "enabled": bool(settings.redis_url), + "url": "" if redact_infrastructure else settings.redis_url or "", + }, ) -def persist_bootstrap_config(payload: BootstrapSetupPayload, settings: Settings) -> BootstrapStateRead: +def persist_bootstrap_config( + payload: BootstrapSetupPayload, + settings: Settings, +) -> BootstrapStateRead: env_file = ensure_env_file() database_url = build_database_url(payload) vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}" diff --git a/server/src/app/core/openapi.py b/server/src/app/core/openapi.py index bffafcb..abd09d6 100644 --- a/server/src/app/core/openapi.py +++ b/server/src/app/core/openapi.py @@ -9,11 +9,9 @@ X-Financial 后端 OpenAPI 文档。 ## 鉴权约定 -- 知识库接口依赖以下请求头模拟当前用户: - - `X-Auth-Username` - - `X-Auth-Name` - - `X-Auth-Role-Codes` - - `X-Auth-Is-Admin` +- 用户登录成功后,业务接口统一使用: + - `Authorization: Bearer ` +- 用户名、角色和管理员身份均由服务端会话与数据库目录解析,不接受浏览器自报身份。 - Agent 资产写接口支持以下审计头: - `X-Actor` - `X-Request-Id` diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 8f430e5..8868945 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -1,5 +1,4 @@ from app.db.base_class import Base -from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_asset import ( AgentAsset, AgentAssetReview, @@ -7,14 +6,16 @@ from app.models.agent_asset import ( AgentAssetTestRun, AgentAssetVersion, ) +from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog +from app.models.auth_session import AuthSession from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction -from app.models.employee_change_log import EmployeeChangeLog -from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot from app.models.employee import Employee +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot +from app.models.employee_change_log import EmployeeChangeLog from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.few_shot_sample import FewShotSample from app.models.financial_record import ( @@ -53,6 +54,7 @@ __all__ = [ "AgentTraceEvent", "ApprovalRecord", "AuditLog", + "AuthSession", "BudgetAllocation", "BudgetReservation", "BudgetTransaction", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index 955c0a8..2e9d56f 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -1,13 +1,19 @@ +from app.models.agent_asset import ( + AgentAsset, + AgentAssetReview, + AgentAssetRuleFeedback, + AgentAssetVersion, +) from app.models.agent_conversation import AgentConversation, AgentConversationMessage -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetRuleFeedback, AgentAssetVersion from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog +from app.models.auth_session import AuthSession from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction -from app.models.employee_change_log import EmployeeChangeLog -from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot from app.models.employee import Employee +from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot +from app.models.employee_change_log import EmployeeChangeLog from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink from app.models.few_shot_sample import FewShotSample from app.models.financial_record import ( @@ -44,6 +50,7 @@ __all__ = [ "AgentTraceEvent", "ApprovalRecord", "AuditLog", + "AuthSession", "BudgetAllocation", "BudgetReservation", "BudgetTransaction", diff --git a/server/src/app/models/auth_session.py b/server/src/app/models/auth_session.py new file mode 100644 index 0000000..c1b445d --- /dev/null +++ b/server/src/app/models/auth_session.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class AuthSession(Base): + __tablename__ = "auth_sessions" + __table_args__ = ( + Index("ix_auth_sessions_principal_active", "principal_type", "revoked_at", "expires_at"), + Index("ix_auth_sessions_tenant_username", "tenant_id", "username"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) + principal_type: Mapped[str] = mapped_column(String(20), index=True) + employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + username: Mapped[str] = mapped_column(String(255), index=True) + metric_session_id: Mapped[str] = mapped_column(String(64), default="", index=True) + issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/src/app/schemas/auth.py b/server/src/app/schemas/auth.py index fa6fa04..ceccda1 100644 --- a/server/src/app/schemas/auth.py +++ b/server/src/app/schemas/auth.py @@ -36,6 +36,14 @@ class LoginResponse(BaseModel): detail: str = "登录成功。" user: AuthUserRead sessionId: str = "" + accessToken: str + tokenType: str = "Bearer" + expiresAt: datetime + + +class LogoutResponse(BaseModel): + ok: bool = True + detail: str = "已退出登录。" class SessionFinishRequest(BaseModel): @@ -45,6 +53,10 @@ class SessionFinishRequest(BaseModel): pagePath: str = Field(default="", max_length=512) +class LogoutRequest(SessionFinishRequest): + sessionId: str = Field(default="", max_length=64) + + class SessionFinishResponse(BaseModel): ok: bool = True detail: str = "会话已结算。" diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index 790fab2..1755bcd 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -39,6 +39,7 @@ logger = get_logger("app.services.agent_foundation") _foundation_ready_lock = threading.RLock() _foundation_ready_keys: set[str] = set() MIGRATION_OWNED_TABLES = { + "auth_sessions", "expense_cases", "expense_case_links", "business_events", diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index 66e3c26..8df9458 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -11,9 +11,11 @@ from sqlalchemy.orm import Session, selectinload from app.core.config import get_settings from app.core.logging import get_logger from app.core.security import verify_password +from app.models.auth_session import AuthSession from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse +from app.services.auth_sessions import AuthSessionService from app.services.employee import EmployeeService from app.services.employee_seed import ROLE_DISPLAY_ORDER from app.services.settings import SettingsService @@ -50,6 +52,8 @@ class AuthenticatedUser: email: str avatar: str is_admin: bool = False + employee_id: str | None = None + tenant_id: str = "default" class AuthService: @@ -79,12 +83,61 @@ class AuthService: raise ValueError("账号或密码错误。") def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse: - session = UserSessionMetricService(self.db).start_session(user) - return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id) + settings_snapshot = SettingsService(self.db).get_settings_snapshot() + timeout_minutes = settings_snapshot.adminForm.sessionTimeout + try: + metric_session = UserSessionMetricService(self.db).start_session(user, commit=False) + access_token, auth_session = AuthSessionService(self.db).issue( + user, + metric_session_id=metric_session.session_id, + timeout_minutes=timeout_minutes, + ) + self.db.commit() + self.db.refresh(auth_session) + except Exception: + self.db.rollback() + raise + + return LoginResponse( + user=self._serialize_user(user), + sessionId=metric_session.session_id, + accessToken=access_token, + expiresAt=auth_session.expires_at, + ) + + def get_session_user(self, auth_session: AuthSession) -> AuthenticatedUser | None: + if auth_session.principal_type == "admin": + record = SettingsService(self.db).get_admin_credentials() + if record is None: + return None + allowed_identifiers = { + str(record.account or "").strip().casefold(), + str(record.email or "").strip().casefold(), + } + if auth_session.username.strip().casefold() not in allowed_identifiers: + return None + return self._build_admin_user(record) + + if auth_session.principal_type != "employee": + return None + + stmt = select(Employee).options( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + ) + if auth_session.employee_id: + stmt = stmt.where(Employee.id == auth_session.employee_id) + else: + stmt = stmt.where(func.lower(Employee.email) == auth_session.username.lower()) + employee = self.db.execute(stmt).scalars().first() + if employee is None or employee.employment_status == "停用": + return None + return self._build_employee_user(employee) def get_user_snapshot(self, identifier: str) -> AuthUserRead | None: normalized = identifier.strip() - if not normalized or not self.settings.setup_completed: + if not normalized: return None employee = self._find_employee_by_email(normalized) @@ -101,6 +154,10 @@ class AuthService: if record is None: return None + return self._build_admin_user(record) + + @staticmethod + def _build_admin_user(record: Any) -> AuthenticatedUser: admin_username = record.account.strip() admin_email = record.email.strip() display_name = admin_username or admin_email or "系统管理员" @@ -169,7 +226,9 @@ class AuthService: ) role_codes = [role.role_code for role in sorted_roles] primary_role_code = role_codes[0] if role_codes else "user" - department = employee.organization_unit.name if employee.organization_unit is not None else "" + department = ( + employee.organization_unit.name if employee.organization_unit is not None else "" + ) manager_name = self._resolve_manager_name(employee) return AuthenticatedUser( @@ -189,6 +248,7 @@ class AuthService: email=employee.email, avatar=(employee.name or "?")[:1].upper(), is_admin=False, + employee_id=employee.id, ) @staticmethod @@ -235,7 +295,11 @@ class AuthService: "riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json), "draftClaimCount": sum(1 for claim in claims if claim.status == "draft"), "recentRiskFlags": recent_risk_flags, - "lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "", + "lastClaimAt": ( + claims[0].occurred_at.isoformat() + if claims and claims[0].occurred_at + else "" + ), } @staticmethod diff --git a/server/src/app/services/auth_sessions.py b/server/src/app/services/auth_sessions.py new file mode 100644 index 0000000..d0232bb --- /dev/null +++ b/server/src/app/services/auth_sessions.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import hashlib +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.auth_session import AuthSession + +DEFAULT_SESSION_TIMEOUT_MINUTES = 30 +MIN_SESSION_TIMEOUT_MINUTES = 5 +MAX_SESSION_TIMEOUT_MINUTES = 240 + + +class AuthSessionService: + def __init__(self, db: Session) -> None: + self.db = db + + def issue( + self, + user: Any, + *, + metric_session_id: str, + timeout_minutes: int = DEFAULT_SESSION_TIMEOUT_MINUTES, + ) -> tuple[str, AuthSession]: + now = datetime.now(UTC) + normalized_timeout = max( + MIN_SESSION_TIMEOUT_MINUTES, + min( + MAX_SESSION_TIMEOUT_MINUTES, + int(timeout_minutes or DEFAULT_SESSION_TIMEOUT_MINUTES), + ), + ) + access_token = secrets.token_urlsafe(32) + auth_session = AuthSession( + token_hash=self.hash_token(access_token), + tenant_id=str(getattr(user, "tenant_id", "default") or "default").strip() or "default", + principal_type="admin" if bool(getattr(user, "is_admin", False)) else "employee", + employee_id=str(getattr(user, "employee_id", "") or "").strip() or None, + username=str(getattr(user, "username", "") or "").strip(), + metric_session_id=str(metric_session_id or "").strip(), + issued_at=now, + expires_at=now + timedelta(minutes=normalized_timeout), + last_seen_at=now, + ) + self.db.add(auth_session) + self.db.flush() + return access_token, auth_session + + def authenticate(self, access_token: str) -> AuthSession | None: + normalized_token = str(access_token or "").strip() + if not normalized_token: + return None + + auth_session = self.db.scalars( + select(AuthSession).where(AuthSession.token_hash == self.hash_token(normalized_token)) + ).first() + if auth_session is None or auth_session.revoked_at is not None: + return None + + now = datetime.now(UTC) + if self._as_utc(auth_session.expires_at) <= now: + return None + + auth_session.last_seen_at = now + return auth_session + + def revoke(self, session_id: str, *, commit: bool = True) -> bool: + normalized_session_id = str(session_id or "").strip() + if not normalized_session_id: + return False + + auth_session = self.db.get(AuthSession, normalized_session_id) + if auth_session is None: + return False + + if auth_session.revoked_at is None: + auth_session.revoked_at = datetime.now(UTC) + if commit: + self.db.commit() + else: + self.db.flush() + return True + + @staticmethod + def hash_token(access_token: str) -> str: + return hashlib.sha256(str(access_token or "").encode("utf-8")).hexdigest() + + @staticmethod + def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/server/src/app/services/user_session_metrics.py b/server/src/app/services/user_session_metrics.py index 15bd44d..65d51d8 100644 --- a/server/src/app/services/user_session_metrics.py +++ b/server/src/app/services/user_session_metrics.py @@ -1,8 +1,8 @@ from __future__ import annotations +import threading import uuid from datetime import UTC, datetime -import threading from typing import Any from sqlalchemy import or_, select @@ -46,6 +46,7 @@ class UserSessionMetricService: user: Any, *, event: dict[str, Any] | None = None, + commit: bool = True, ) -> UserSessionMetric: self.ensure_storage_ready() now = datetime.now(UTC) @@ -64,27 +65,36 @@ class UserSessionMetricService: event_json=event or {}, ) self.db.add(session) - self.db.commit() - self.db.refresh(session) + if commit: + self.db.commit() + self.db.refresh(session) + else: + self.db.flush() return session def finish_session( self, *, session_id: str, + expected_username: str = "", reason: str = "manual", last_activity_at: datetime | None = None, activity_event_count: int = 0, event: dict[str, Any] | None = None, + commit: bool = True, ) -> UserSessionMetric | None: self.ensure_storage_ready() normalized_session_id = str(session_id or "").strip() if not normalized_session_id: return None - session = self.db.scalars( - select(UserSessionMetric).where(UserSessionMetric.session_id == normalized_session_id) - ).first() + stmt = select(UserSessionMetric).where( + UserSessionMetric.session_id == normalized_session_id + ) + normalized_username = str(expected_username or "").strip() + if normalized_username: + stmt = stmt.where(UserSessionMetric.username == normalized_username) + session = self.db.scalars(stmt).first() if session is None: return None @@ -93,7 +103,11 @@ class UserSessionMetricService: logout_at = datetime.now(UTC) session.logout_at = logout_at - session.last_activity_at = self._normalize_last_activity(last_activity_at, session.login_at, logout_at) + session.last_activity_at = self._normalize_last_activity( + last_activity_at, + session.login_at, + logout_at, + ) session.duration_ms = self._duration_ms(session.login_at, logout_at) session.activity_event_count = max(0, int(activity_event_count or 0)) session.logout_reason = str(reason or "manual").strip()[:40] or "manual" @@ -102,8 +116,11 @@ class UserSessionMetricService: **(session.event_json or {}), "finish": event or {}, } - self.db.commit() - self.db.refresh(session) + if commit: + self.db.commit() + self.db.refresh(session) + else: + self.db.flush() return session def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int: diff --git a/server/tests/auth_helpers.py b/server/tests/auth_helpers.py new file mode 100644 index 0000000..902017e --- /dev/null +++ b/server/tests/auth_helpers.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import FastAPI, Header + +from app.api.deps import CurrentUserContext, get_current_user + + +def install_legacy_header_auth_override(app: FastAPI) -> None: + """让领域接口测试专注业务断言,不把旧请求头带回生产认证链路。""" + app.dependency_overrides[get_current_user] = _read_test_user_headers + + +def _read_test_user_headers( + username: Annotated[str | None, Header(alias="X-Auth-Username")] = None, + name: Annotated[str | None, Header(alias="X-Auth-Name")] = None, + role_codes: Annotated[str | None, Header(alias="X-Auth-Role-Codes")] = None, + is_admin: Annotated[str | None, Header(alias="X-Auth-Is-Admin")] = None, + department: Annotated[str | None, Header(alias="X-Auth-Department")] = None, + cost_center: Annotated[str | None, Header(alias="X-Auth-Cost-Center")] = None, + position: Annotated[str | None, Header(alias="X-Auth-Position")] = None, + grade: Annotated[str | None, Header(alias="X-Auth-Grade")] = None, + employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None, + manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None, +) -> CurrentUserContext: + normalized_username = str(username or "").strip() + normalized_name = str(name or normalized_username).strip() + if not normalized_username and not normalized_name: + normalized_username = "test-admin" + normalized_name = "Test Admin" + is_admin = "true" + + normalized_roles = [ + normalized + for item in str(role_codes or "").split(",") + if (normalized := item.strip().lower()) + ] + admin_flag = str(is_admin or "").strip().lower() in {"1", "true", "yes", "on"} + admin_flag = admin_flag or normalized_username.lower() in {"admin", "superadmin"} + admin_flag = admin_flag or bool(set(normalized_roles) & {"admin", "superadmin"}) + return CurrentUserContext( + username=normalized_username or normalized_name, + name=normalized_name or normalized_username, + role_codes=normalized_roles, + is_admin=admin_flag, + department_name=str(department or "").strip(), + cost_center=str(cost_center or "").strip(), + position=str(position or "").strip(), + grade=str(grade or "").strip(), + employee_no=str(employee_no or "").strip(), + manager_name=str(manager_name or "").strip(), + ) diff --git a/server/tests/test_agent_foundation_endpoints.py b/server/tests/test_agent_foundation_endpoints.py index 4834cb6..66e0c7c 100644 --- a/server/tests/test_agent_foundation_endpoints.py +++ b/server/tests/test_agent_foundation_endpoints.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -23,7 +24,8 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) - app = create_app() + app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_agent_trace_service.py b/server/tests/test_agent_trace_service.py index b223b45..0681daa 100644 --- a/server/tests/test_agent_trace_service.py +++ b/server/tests/test_agent_trace_service.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime, timedelta +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -37,6 +38,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_attachment_association_jobs.py b/server/tests/test_attachment_association_jobs.py index cf286db..084818e 100644 --- a/server/tests/test_attachment_association_jobs.py +++ b/server/tests/test_attachment_association_jobs.py @@ -5,6 +5,7 @@ from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -17,8 +18,8 @@ from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests -from app.services.expense_claims import ExpenseClaimService from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage +from app.services.expense_claims import ExpenseClaimService from app.services.ocr import OcrService from app.services.receipt_folder import ReceiptFolderService from app.test_helpers.db import build_in_memory_session_factory @@ -27,6 +28,7 @@ from app.test_helpers.db import build_in_memory_session_factory def build_client(monkeypatch) -> tuple[TestClient, object]: session_factory = build_in_memory_session_factory() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index f6605c3..f53d93d 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -1,13 +1,17 @@ -from __future__ import annotations - -from sqlalchemy import create_engine +from __future__ import annotations + +import pytest +from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool -from app.db.base import Base +from app.db.base import Base +from app.models.auth_session import AuthSession +from app.models.user_session_metric import UserSessionMetric from app.schemas.auth import LoginRequest from app.schemas.settings import SettingsWrite -from app.services.auth import AuthService, AuthenticatedUser +from app.services.auth import AuthenticatedUser, AuthService +from app.services.auth_sessions import AuthSessionService from app.services.employee import EmployeeService from app.services.settings import SettingsService @@ -37,6 +41,11 @@ def test_employee_can_login_with_seed_default_password() -> None: assert result.user.grade == employee.grade assert result.user.roleCodes assert result.user.isAdmin is False + assert result.accessToken + assert result.tokenType == "Bearer" + stored_session = db.query(AuthSession).one() + assert stored_session.token_hash == AuthSessionService.hash_token(result.accessToken) + assert stored_session.token_hash != result.accessToken def test_current_user_snapshot_refreshes_employee_position() -> None: @@ -113,8 +122,15 @@ def test_employee_login_skips_directory_bootstrap_when_employee_exists(monkeypat calls.append("ensure_directory_ready") raise AssertionError("existing employee login should not run directory bootstrap") - monkeypatch.setattr(AuthService, "_find_employee_by_email", lambda self, _: ExistingEmployee()) - monkeypatch.setattr("app.services.auth.verify_password", lambda password, password_hash: True) + monkeypatch.setattr( + AuthService, + "_find_employee_by_email", + lambda self, _: ExistingEmployee(), + ) + monkeypatch.setattr( + "app.services.auth.verify_password", + lambda password, password_hash: True, + ) monkeypatch.setattr( AuthService, "_build_employee_user", @@ -143,3 +159,35 @@ def test_employee_login_skips_directory_bootstrap_when_employee_exists(monkeypat assert user is not None assert user.username == "demo@example.com" assert calls == [] + + +def test_login_session_write_rolls_back_metric_when_token_issue_fails(monkeypatch) -> None: + with build_session() as db: + user = AuthenticatedUser( + username="rollback@example.com", + name="Rollback User", + role="使用者", + department="", + position="", + grade="", + employee_no="", + manager_name="", + location="", + cost_center="", + finance_owner_name="", + risk_profile={}, + role_codes=["user"], + email="rollback@example.com", + avatar="R", + ) + + def fail_issue(*args, **kwargs): + raise RuntimeError("token issue failed") + + monkeypatch.setattr(AuthSessionService, "issue", fail_issue) + + with pytest.raises(RuntimeError, match="token issue failed"): + AuthService(db)._build_login_response(user) + + assert db.query(AuthSession).count() == 0 + assert db.query(UserSessionMetric).count() == 0 diff --git a/server/tests/test_auth_session_endpoints.py b/server/tests/test_auth_session_endpoints.py new file mode 100644 index 0000000..3b742d8 --- /dev/null +++ b/server/tests/test_auth_session_endpoints.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext, get_db, require_platform_admin_user +from app.db.base import Base +from app.main import create_app +from app.models.auth_session import AuthSession +from app.models.user_session_metric import UserSessionMetric +from app.schemas.settings import SettingsWrite +from app.services.auth_sessions import AuthSessionService +from app.services.settings import SettingsService + + +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + with session_factory() as db: + settings_service = SettingsService(db) + payload = settings_service.get_settings_snapshot().model_dump() + payload["adminForm"]["adminAccount"] = "auth-admin" + payload["adminForm"]["newPassword"] = "safe-admin-password" + payload["adminForm"]["confirmPassword"] = "safe-admin-password" + settings_service.save_settings_snapshot(SettingsWrite(**payload)) + + app = create_app() + + def override_db() -> Generator[Session, None, None]: + with session_factory() as db: + yield db + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def login_admin(client: TestClient) -> dict: + response = client.post( + "/api/v1/auth/login", + json={"username": "auth-admin", "password": "safe-admin-password"}, + ) + assert response.status_code == 200 + return response.json() + + +def test_login_issues_opaque_bearer_token_and_me_uses_server_session() -> None: + client, session_factory = build_client() + payload = login_admin(client) + token = payload["accessToken"] + + assert payload["tokenType"] == "Bearer" + assert payload["expiresAt"] + with session_factory() as db: + auth_session = db.scalars(select(AuthSession)).one() + assert auth_session.token_hash == AuthSessionService.hash_token(token) + assert auth_session.token_hash != token + + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "auth-admin" + assert response.json()["isAdmin"] is True + + +def test_forged_identity_headers_no_longer_authenticate() -> None: + client, _ = build_client() + response = client.get( + "/api/v1/auth/me", + headers={ + "X-Auth-Username": "superadmin", + "X-Auth-Role-Codes": "manager", + "X-Auth-Is-Admin": "true", + }, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + + +def test_expired_and_revoked_tokens_are_rejected() -> None: + client, session_factory = build_client() + first_payload = login_admin(client) + first_token = first_payload["accessToken"] + + with session_factory() as db: + auth_session = db.scalars( + select(AuthSession).where( + AuthSession.token_hash == AuthSessionService.hash_token(first_token) + ) + ).one() + auth_session.expires_at = datetime.now(UTC) - timedelta(seconds=1) + db.commit() + + expired_response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {first_token}"}, + ) + assert expired_response.status_code == 401 + + second_payload = login_admin(client) + second_token = second_payload["accessToken"] + logout_response = client.post( + "/api/v1/auth/logout", + headers={"Authorization": f"Bearer {second_token}"}, + json={"sessionId": second_payload["sessionId"], "reason": "manual"}, + ) + assert logout_response.status_code == 200 + with session_factory() as db: + metric_session = db.scalars( + select(UserSessionMetric).where( + UserSessionMetric.session_id == second_payload["sessionId"] + ) + ).one() + assert metric_session.status == "closed" + + revoked_response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {second_token}"}, + ) + assert revoked_response.status_code == 401 + + +def test_manager_role_is_not_platform_admin() -> None: + manager = CurrentUserContext( + username="manager@example.com", + name="Manager", + role_codes=["manager"], + is_admin=False, + ) + + with pytest.raises(HTTPException) as exc_info: + require_platform_admin_user(manager) + + assert exc_info.value.status_code == 403 diff --git a/server/tests/test_backend_pagination.py b/server/tests/test_backend_pagination.py index 57280db..1e4a0a7 100644 --- a/server/tests/test_backend_pagination.py +++ b/server/tests/test_backend_pagination.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from decimal import Decimal +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -25,6 +26,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() @@ -112,7 +114,10 @@ def test_expense_claims_support_page_envelope_and_keep_legacy_list() -> None: def test_employee_directory_supports_backend_pagination() -> None: client, _ = build_client() - response = client.get("/api/v1/employees?page=2&page_size=10") + response = client.get( + "/api/v1/employees?page=2&page_size=10", + headers={"x-auth-username": "admin"}, + ) assert response.status_code == 200 payload = response.json() diff --git a/server/tests/test_bootstrap_security.py b/server/tests/test_bootstrap_security.py new file mode 100644 index 0000000..92f8e6c --- /dev/null +++ b/server/tests/test_bootstrap_security.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints import bootstrap as bootstrap_endpoint +from app.schemas.bootstrap import BootstrapSetupPayload + + +def completed_settings() -> SimpleNamespace: + return SimpleNamespace( + setup_completed=True, + company_name="X-Financial", + company_code="XF-001", + admin_email="admin@example.com", + web_host="0.0.0.0", + web_port=5273, + app_host="0.0.0.0", + app_port=8000, + postgres_host="postgres.internal", + postgres_port=5432, + postgres_db="x_financial", + postgres_user="postgres-admin", + postgres_password="secret", + redis_url="redis://redis.internal:6379/0", + ) + + +def setup_payload() -> BootstrapSetupPayload: + return BootstrapSetupPayload( + company_name="X-Financial", + company_code="XF-001", + admin_email="admin@example.com", + postgres_host="postgres.internal", + postgres_port=5432, + postgres_db="x_financial", + postgres_user="postgres-admin", + postgres_password="secret", + redis_url="redis://redis.internal:6379/0", + ) + + +def test_completed_bootstrap_state_redacts_infrastructure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bootstrap_endpoint, "get_settings", completed_settings) + + state = bootstrap_endpoint.get_bootstrap_state() + + assert state.initialized is True + assert state.database.host == "" + assert state.database.username == "" + assert state.database.password_configured is True + assert state.redis.url == "" + + +def test_completed_bootstrap_rejects_anonymous_reconfiguration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(bootstrap_endpoint, "get_settings", completed_settings) + + with pytest.raises(HTTPException) as exc_info: + bootstrap_endpoint.initialize_bootstrap(setup_payload(), None) + + assert exc_info.value.status_code == 403 diff --git a/server/tests/test_budget_endpoints.py b/server/tests/test_budget_endpoints.py index 779d918..eb3002e 100644 --- a/server/tests/test_budget_endpoints.py +++ b/server/tests/test_budget_endpoints.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from decimal import Decimal +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -32,6 +33,7 @@ def build_session_factory() -> sessionmaker[Session]: def build_client() -> tuple[TestClient, sessionmaker[Session]]: session_factory = build_session_factory() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_employee_behavior_profile_service.py b/server/tests/test_employee_behavior_profile_service.py index 1a4ebda..f9176b5 100644 --- a/server/tests/test_employee_behavior_profile_service.py +++ b/server/tests/test_employee_behavior_profile_service.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, date, datetime, timedelta from decimal import Decimal +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -249,6 +250,7 @@ def test_latest_profile_endpoint_returns_approval_payload() -> None: seed_profile_data(db) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() @@ -303,6 +305,7 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None: ) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() @@ -370,6 +373,7 @@ def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None: db.commit() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() @@ -414,6 +418,7 @@ def test_current_admin_profile_endpoint_uses_online_session_without_agent_runs() ) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() @@ -464,6 +469,7 @@ def test_finish_session_endpoint_closes_active_session() -> None: db.commit() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_expense_case_service.py b/server/tests/test_expense_case_service.py index a18b080..e229c54 100644 --- a/server/tests/test_expense_case_service.py +++ b/server/tests/test_expense_case_service.py @@ -137,7 +137,12 @@ def test_legacy_bootstrap_excludes_migration_owned_tables( table_names = set(inspect(engine).get_table_names()) assert "employees" in table_names - assert {"expense_cases", "expense_case_links", "business_events"}.isdisjoint(table_names) + assert { + "auth_sessions", + "expense_cases", + "expense_case_links", + "business_events", + }.isdisjoint(table_names) def test_event_write_is_idempotent_for_same_business_operation() -> None: diff --git a/server/tests/test_linked_reimbursement_draft_jobs.py b/server/tests/test_linked_reimbursement_draft_jobs.py index 7db3942..8788402 100644 --- a/server/tests/test_linked_reimbursement_draft_jobs.py +++ b/server/tests/test_linked_reimbursement_draft_jobs.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy.orm import Session @@ -12,7 +13,9 @@ from app.main import create_app from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary -from app.services.linked_reimbursement_draft_jobs import clear_linked_reimbursement_draft_jobs_for_tests +from app.services.linked_reimbursement_draft_jobs import ( + clear_linked_reimbursement_draft_jobs_for_tests, +) from app.services.orchestrator import OrchestratorService from app.test_helpers.db import build_in_memory_session_factory @@ -53,6 +56,7 @@ def seed_employee_and_application(db: Session) -> None: def build_client(monkeypatch) -> tuple[TestClient, object]: session_factory = build_in_memory_session_factory() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_notification_states.py b/server/tests/test_notification_states.py index 4ce91b8..32b59ac 100644 --- a/server/tests/test_notification_states.py +++ b/server/tests/test_notification_states.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -34,6 +35,7 @@ def build_client() -> TestClient: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_ocr_endpoints.py b/server/tests/test_ocr_endpoints.py index 8853a5d..becf341 100644 --- a/server/tests/test_ocr_endpoints.py +++ b/server/tests/test_ocr_endpoints.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -11,7 +12,12 @@ from app.api.deps import get_db from app.core.config import get_settings from app.db.base import Base from app.main import create_app -from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead, OcrRecognizeLineRead +from app.schemas.ocr import ( + OcrRecognizeBatchRead, + OcrRecognizeDocumentRead, + OcrRecognizeFieldRead, + OcrRecognizeLineRead, +) from app.services.ocr import OcrService @@ -24,6 +30,7 @@ def build_client() -> TestClient: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index cb3551d..a0d5d2a 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections.abc import Generator import pytest +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool -from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus from app.api.deps import get_db +from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus from app.db.base import Base from app.schemas.ontology import OntologyParseRequest from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService @@ -31,7 +32,8 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: session_factory = build_session_factory() from app.main import create_app - app = create_app() + app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 7c03a69..68078f2 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -6,6 +6,7 @@ from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -38,6 +39,7 @@ def build_session_factory() -> sessionmaker[Session]: def build_client() -> tuple[TestClient, sessionmaker[Session]]: session_factory = build_session_factory() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_risk_observations_service.py b/server/tests/test_risk_observations_service.py index cf060d5..a5b11cd 100644 --- a/server/tests/test_risk_observations_service.py +++ b/server/tests/test_risk_observations_service.py @@ -5,12 +5,14 @@ from datetime import UTC, datetime from decimal import Decimal import pytest +from auth_helpers import install_legacy_header_auth_override from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder from app.api.deps import get_db from app.api.v1.endpoints.risk_observations import router as risk_observations_router from app.db.base import Base @@ -18,7 +20,6 @@ from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.models.risk_observation import RiskObservation from app.schemas.risk_observation import RiskObservationFeedbackCreate -from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder from app.services.risk_observations import RiskObservationService @@ -266,6 +267,7 @@ def _build_client() -> tuple[TestClient, sessionmaker[Session]]: session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = FastAPI() app.include_router(risk_observations_router, prefix="/api/v1") + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_risk_rule_feedback.py b/server/tests/test_risk_rule_feedback.py index 0c774e0..87bc93d 100644 --- a/server/tests/test_risk_rule_feedback.py +++ b/server/tests/test_risk_rule_feedback.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker @@ -45,6 +46,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_risk_rule_revision_endpoints.py b/server/tests/test_risk_rule_revision_endpoints.py index b36d3de..c9fa86c 100644 --- a/server/tests/test_risk_rule_revision_endpoints.py +++ b/server/tests/test_risk_rule_revision_endpoints.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -13,8 +14,8 @@ from app.db.base import Base from app.main import create_app from app.models.agent_asset import AgentAsset from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest -from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_assets import AgentAssetService from app.services.risk_rule_generation import RiskRuleGenerationService @@ -33,6 +34,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_risk_rule_template_catalog.py b/server/tests/test_risk_rule_template_catalog.py index 879872f..729da65 100644 --- a/server/tests/test_risk_rule_template_catalog.py +++ b/server/tests/test_risk_rule_template_catalog.py @@ -1,5 +1,6 @@ from __future__ import annotations +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from app.main import create_app @@ -49,11 +50,13 @@ def test_risk_rule_template_catalog_groups_and_dsl_examples() -> None: def test_risk_rule_template_endpoint_requires_login_and_returns_groups() -> None: - client = TestClient(create_app()) + app = create_app() + client = TestClient(app) unauthorized = client.get("/api/v1/agent-assets/risk-rules/templates") assert unauthorized.status_code == 401 + install_legacy_header_auth_override(app) response = client.get( "/api/v1/agent-assets/risk-rules/templates", headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"}, diff --git a/server/tests/test_steward_action_executor.py b/server/tests/test_steward_action_executor.py index f2ceb50..1bc57f9 100644 --- a/server/tests/test_steward_action_executor.py +++ b/server/tests/test_steward_action_executor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker @@ -30,6 +31,7 @@ def build_session_factory() -> sessionmaker[Session]: def build_client() -> tuple[TestClient, sessionmaker[Session]]: session_factory = build_session_factory() app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/server/tests/test_system_cache_endpoints.py b/server/tests/test_system_cache_endpoints.py index be05477..301eb7d 100644 --- a/server/tests/test_system_cache_endpoints.py +++ b/server/tests/test_system_cache_endpoints.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from auth_helpers import install_legacy_header_auth_override from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -23,6 +24,7 @@ def build_client() -> TestClient: Base.metadata.create_all(bind=engine) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) app = create_app() + install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: db = session_factory() diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index efefa94..e6e1b9f 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -8,7 +8,10 @@ import { testBootstrapDatabase, testBootstrapRuntime } from '../services/bootstrap.js' -import { fetchCurrentAuthUser, login as loginByAccount } from '../services/auth.js' +import { + fetchCurrentAuthUser, + login as loginByAccount +} from '../services/auth.js' import { setRuntimeApiBaseUrl } from '../services/api.js' import { checkBackendHealth } from './useBackendHealth.js' import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' @@ -16,15 +19,22 @@ import { useToast } from './useToast.js' import { fetchSettings } from '../services/settings.js' import { setThemeSkin } from './useThemeSkin.js' import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js' +import { + AUTH_SESSION_EXPIRED_EVENT, + clearAuthCredentials, + hasValidAuthSession, + persistAuthCredentials, + readAuthAccessToken, + readAuthExpiresAt +} from '../utils/authSessionStorage.js' import { clearAuthSessionMetrics, + finalizeAndRevokeAuthSession, finalizeAuthSession, incrementAuthActivityCount, persistAuthSessionMetrics } from '../utils/authSessionMetrics.js' -const AUTH_STORAGE_KEY = 'x-financial-authenticated' -const AUTH_USERNAME_KEY = 'x-financial-auth-username' const AUTH_USER_KEY = 'x-financial-auth-user' const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity' const DEFAULT_USER_NAME = '系统管理员' @@ -78,20 +88,8 @@ function readClientBootstrapState() { } } -function readAuthState() { - if (typeof window === 'undefined') { - return false - } - - return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true' -} - -function readStoredUsername() { - if (typeof window === 'undefined') { - return '' - } - - return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || '' +function readAuthState() { + return hasValidAuthSession() } function buildAnonymousUser() { @@ -116,31 +114,6 @@ function buildAnonymousUser() { } } -function buildLegacyAdminUser(username = '') { - const normalized = String(username || '').trim() - const name = normalized || DEFAULT_USER_NAME - - return { - username: normalized, - name, - role: DEFAULT_USER_ROLE, - department: '', - departmentName: '', - position: DEFAULT_USER_ROLE, - grade: '', - employeeNo: '', - managerName: '', - location: '', - costCenter: '', - financeOwnerName: '', - riskProfile: {}, - roleCodes: ['manager'], - email: '', - avatar: name.slice(0, 1).toUpperCase(), - isAdmin: true - } -} - function resolvePlatformAdminFlag(payload, roleCodes = []) { return resolveAuthUserAdminFlag(payload, roleCodes) } @@ -171,12 +144,11 @@ function readStoredUser() { return normalizeStoredAuthUser(payload) } } catch { - return buildLegacyAdminUser(readStoredUsername()) - } - } - - const legacyUsername = readStoredUsername() - return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser() + return buildAnonymousUser() + } + } + + return buildAnonymousUser() } function readLastActivityAt() { @@ -187,10 +159,14 @@ function readLastActivityAt() { return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0) } -function isSessionExpired(now = Date.now()) { - if (!readAuthState()) { - return false - } +function isSessionExpired(now = Date.now()) { + if (!readAuthAccessToken()) { + return false + } + + if (!hasValidAuthSession(now)) { + return true + } const lastActivityAt = readLastActivityAt() @@ -201,24 +177,24 @@ function isSessionExpired(now = Date.now()) { return now - lastActivityAt > authIdleTimeoutMs } -function persistAuthState(value, user = null, sessionId = '') { +function persistAuthState(value, user = null, sessionId = '', accessToken = '', expiresAt = '') { if (typeof window === 'undefined') { return } - if (value) { - window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true') + if (value) { const normalizedUser = user || buildAnonymousUser() - window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim()) window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) + persistAuthCredentials(accessToken, expiresAt) persistAuthSessionMetrics(sessionId) return } - - window.sessionStorage.removeItem(AUTH_STORAGE_KEY) - window.sessionStorage.removeItem(AUTH_USERNAME_KEY) + window.sessionStorage.removeItem(AUTH_USER_KEY) window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY) + window.sessionStorage.removeItem('x-financial-authenticated') + window.sessionStorage.removeItem('x-financial-auth-username') + clearAuthCredentials() clearAuthSessionMetrics() } @@ -228,7 +204,6 @@ function persistAuthUserSnapshot(user = {}) { } const normalizedUser = user || buildAnonymousUser() - window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim()) window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) } @@ -269,7 +244,9 @@ function scheduleSessionTimeout() { return } - const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt) + const idleRemaining = authIdleTimeoutMs - (Date.now() - lastActivityAt) + const tokenRemaining = readAuthExpiresAt() - Date.now() + const remaining = Math.min(idleRemaining, tokenRemaining) if (remaining <= 0) { logout('timeout', { notify: true }) @@ -307,6 +284,13 @@ function handleSessionActivity(event) { touchAuthActivity() } +function handleAuthSessionExpired() { + if (!readAuthAccessToken()) { + return + } + logout('expired', { notify: true, revoke: false }) +} + function handleSessionUnload(event) { if (event?.type === 'pagehide' && event.persisted) { return @@ -323,6 +307,7 @@ function installSessionMonitoring() { SESSION_ACTIVITY_EVENTS.forEach((eventName) => { window.addEventListener(eventName, handleSessionActivity, { passive: true }) }) + window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, handleAuthSessionExpired) window.addEventListener('pagehide', handleSessionUnload, { passive: true }) window.addEventListener('beforeunload', handleSessionUnload, { passive: true }) } @@ -428,11 +413,11 @@ const runtimeTestMessage = ref('') const databaseTestMessage = ref('') const loginSubmitting = ref(false) const loginError = ref('') -const loggedIn = ref(readAuthState() && !isSessionExpired()) +const loggedIn = ref(readAuthState() && !isSessionExpired()) const currentUser = ref(readStoredUser()) -if (!loggedIn.value && readAuthState()) { - persistAuthState(false) +if (!loggedIn.value && readAuthAccessToken()) { + persistAuthState(false) } const { toast } = useToast() @@ -674,7 +659,13 @@ async function handleLogin(credentials) { isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes) } loggedIn.value = true - persistAuthState(true, user, response?.sessionId || '') + persistAuthState( + true, + user, + response?.sessionId || '', + response?.accessToken || '', + response?.expiresAt || '' + ) currentUser.value = user touchAuthActivity(true) return true @@ -688,18 +679,27 @@ async function handleLogin(credentials) { } } -function logout(reason = 'manual', options = {}) { +function logout(reason = 'manual', options = {}) { const notify = options.notify ?? reason === 'timeout' const redirect = options.redirect ?? reason !== 'invalid' + const revoke = options.revoke ?? (reason !== 'invalid' && reason !== 'expired') - finalizeAuthSession(reason) + if (revoke && readAuthAccessToken()) { + finalizeAndRevokeAuthSession(reason).catch((error) => { + console.warn('Failed to revoke auth session:', error) + }) + } loggedIn.value = false persistAuthState(false) currentUser.value = buildAnonymousUser() clearSessionTimeout() - if (notify) { - toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。') + if (notify) { + toast( + reason === 'timeout' || reason === 'expired' + ? '登录已失效,请重新登录。' + : '已退出登录。' + ) } if (redirect) { diff --git a/web/src/services/api.js b/web/src/services/api.js index 6175e25..1e65ad0 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -1,7 +1,10 @@ -import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js' +import { + buildBearerHeaders, + notifyAuthSessionExpired, + readAuthAccessToken +} from '../utils/authSessionStorage.js' const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' -const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' function isHeaderValueSafe(value) { const normalized = String(value || '').trim() @@ -33,85 +36,6 @@ export function pickSafeHeaderValue(value, fallback = '') { return '' } -function readCurrentUserHeaders() { - if (typeof window === 'undefined') { - return {} - } - - const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) - if (!raw) { - return {} - } - - try { - const payload = JSON.parse(raw) - const user = normalizeAuthUserSnapshot(payload) - const username = user.username - const name = user.name || username - const roleCodes = user.roleCodes - const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes) - const department = user.department || user.departmentName - const costCenter = user.costCenter - const position = user.position - const grade = user.grade - const employeeNo = user.employeeNo - const managerName = user.managerName - const safeUsername = pickSafeHeaderValue(username) - const safeName = pickSafeHeaderValue(name) - const safeDepartment = pickSafeHeaderValue(department) - const safeCostCenter = pickSafeHeaderValue(costCenter) - const safePosition = pickSafeHeaderValue(position) - const safeGrade = pickSafeHeaderValue(grade) - const safeEmployeeNo = pickSafeHeaderValue(employeeNo) - const safeManagerName = pickSafeHeaderValue(managerName) - - if (!safeUsername && !safeName) { - return {} - } - - const headers = { - 'x-auth-role-codes': roleCodes.join(','), - 'x-auth-is-admin': String(isAdmin) - } - - if (safeUsername) { - headers['x-auth-username'] = safeUsername - } - - if (safeName) { - headers['x-auth-name'] = safeName - } - - if (safeDepartment) { - headers['x-auth-department'] = safeDepartment - } - - if (safeCostCenter) { - headers['x-auth-cost-center'] = safeCostCenter - } - - if (safePosition) { - headers['x-auth-position'] = safePosition - } - - if (safeGrade) { - headers['x-auth-grade'] = safeGrade - } - - if (safeEmployeeNo) { - headers['x-auth-employee-no'] = safeEmployeeNo - } - - if (safeManagerName) { - headers['x-auth-manager-name'] = safeManagerName - } - - return headers - } catch { - return {} - } -} - function normalizeApiBaseUrl(value) { return String(value || '/api/v1').replace(/\/$/, '') } @@ -260,8 +184,20 @@ function sanitizeHeaders(headers) { return nextHeaders } +function buildApiResponseError(response, payload, { auth, handleUnauthorized }) { + const error = new Error(resolveErrorMessage(payload)) + error.status = response.status + if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) { + error.code = 'AUTH_SESSION_EXPIRED' + notifyAuthSessionExpired() + } + return error +} + export async function apiRequest(path, options = {}) { const { + auth = true, + handleUnauthorized = true, contentType = 'application/json', responseType = 'json', headers: customHeaders, @@ -270,10 +206,15 @@ export async function apiRequest(path, options = {}) { ...fetchOptions } = options - const headers = sanitizeHeaders({ - ...readCurrentUserHeaders(), - ...(customHeaders || {}) - }) + const headers = sanitizeHeaders(customHeaders || {}) + if (auth) { + Object.keys(headers).forEach((key) => { + if (key.toLowerCase() === 'authorization') { + delete headers[key] + } + }) + Object.assign(headers, buildBearerHeaders()) + } if (contentType !== null && typeof headers['Content-Type'] === 'undefined') { headers['Content-Type'] = contentType @@ -327,7 +268,7 @@ export async function apiRequest(path, options = {}) { payload = null } - throw new Error(resolveErrorMessage(payload)) + throw buildApiResponseError(response, payload, { auth, handleUnauthorized }) } return response.blob() @@ -341,7 +282,7 @@ export async function apiRequest(path, options = {}) { } if (!response.ok) { - throw new Error(resolveErrorMessage(payload)) + throw buildApiResponseError(response, payload, { auth, handleUnauthorized }) } return payload diff --git a/web/src/services/auth.js b/web/src/services/auth.js index 593444c..6925552 100644 --- a/web/src/services/auth.js +++ b/web/src/services/auth.js @@ -1,7 +1,10 @@ import { apiRequest, getRuntimeApiBaseUrl } from './api.js' +import { buildBearerHeaders } from '../utils/authSessionStorage.js' export function login(payload) { return apiRequest('/auth/login', { + auth: false, + handleUnauthorized: false, method: 'POST', body: JSON.stringify(payload) }) @@ -11,6 +14,14 @@ export function fetchCurrentAuthUser() { return apiRequest('/auth/me') } +export function revokeCurrentAuthSession(payload) { + return apiRequest('/auth/logout', { + method: 'POST', + handleUnauthorized: false, + body: JSON.stringify(payload || {}) + }) +} + export function finishSession(sessionId, payload) { return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, { method: 'POST', @@ -29,13 +40,12 @@ export function finishSessionOnUnload(sessionId, payload) { const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish` const body = JSON.stringify(payload || {}) - if (typeof window.navigator?.sendBeacon === 'function') { - return window.navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })) - } - window.fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...buildBearerHeaders() + }, body, keepalive: true }).catch(() => {}) diff --git a/web/src/services/steward.js b/web/src/services/steward.js index af9d8d6..99d24ae 100644 --- a/web/src/services/steward.js +++ b/web/src/services/steward.js @@ -1,4 +1,8 @@ import { apiRequest, getRuntimeApiBaseUrl } from './api.js' +import { + buildBearerHeaders, + notifyAuthSessionExpired +} from '../utils/authSessionStorage.js' export function fetchStewardPlan(payload, options = {}) { return apiRequest('/steward/plans', { @@ -63,7 +67,8 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = { response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...buildBearerHeaders() }, body: JSON.stringify(payload), signal: controller?.signal @@ -78,6 +83,9 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = { if (!response.ok) { clearAbortTimer() + if (response.status === 401) { + notifyAuthSessionExpired() + } throw new Error(await resolveStreamError(response)) } diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 563c485..a55d9d6 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -242,6 +242,10 @@ export function canAccessAppView(user, viewId) { return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) } + if (viewId === 'settings') { + return isPlatformAdminUser(user) + } + if (isManagerUser(user)) { return true } diff --git a/web/src/utils/authSessionMetrics.js b/web/src/utils/authSessionMetrics.js index 879109f..5c26244 100644 --- a/web/src/utils/authSessionMetrics.js +++ b/web/src/utils/authSessionMetrics.js @@ -1,6 +1,7 @@ import { finishSession, - finishSessionOnUnload + finishSessionOnUnload, + revokeCurrentAuthSession } from '../services/auth.js' const AUTH_SESSION_ID_KEY = 'x-financial-auth-session-id' @@ -88,3 +89,11 @@ export function finalizeAuthSession(reason, options = {}) { console.warn('Failed to finish auth session:', error) }) } + +export function finalizeAndRevokeAuthSession(reason) { + const sessionId = readStoredSessionId() + return revokeCurrentAuthSession({ + ...buildSessionFinishPayload(reason), + sessionId + }) +} diff --git a/web/src/utils/authSessionStorage.js b/web/src/utils/authSessionStorage.js new file mode 100644 index 0000000..56c8a9a --- /dev/null +++ b/web/src/utils/authSessionStorage.js @@ -0,0 +1,71 @@ +export const AUTH_ACCESS_TOKEN_KEY = 'x-financial-auth-access-token' +export const AUTH_EXPIRES_AT_KEY = 'x-financial-auth-expires-at' +export const AUTH_SESSION_EXPIRED_EVENT = 'x-financial:auth-expired' + +function canUseSessionStorage() { + return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined' +} + +export function readAuthAccessToken() { + if (!canUseSessionStorage()) { + return '' + } + return String(window.sessionStorage.getItem(AUTH_ACCESS_TOKEN_KEY) || '').trim() +} + +export function readAuthExpiresAt() { + if (!canUseSessionStorage()) { + return 0 + } + const raw = String(window.sessionStorage.getItem(AUTH_EXPIRES_AT_KEY) || '').trim() + if (!raw) { + return 0 + } + const timestamp = Date.parse(raw) + return Number.isFinite(timestamp) ? timestamp : 0 +} + +export function hasValidAuthSession(now = Date.now()) { + const accessToken = readAuthAccessToken() + const expiresAt = readAuthExpiresAt() + return Boolean(accessToken && expiresAt && expiresAt > now) +} + +export function persistAuthCredentials(accessToken, expiresAt) { + if (!canUseSessionStorage()) { + return + } + const normalizedToken = String(accessToken || '').trim() + const normalizedExpiresAt = String(expiresAt || '').trim() + if (!normalizedToken || !normalizedExpiresAt || !Number.isFinite(Date.parse(normalizedExpiresAt))) { + throw new Error('登录接口未返回有效的认证凭证。') + } + window.sessionStorage.setItem(AUTH_ACCESS_TOKEN_KEY, normalizedToken) + window.sessionStorage.setItem(AUTH_EXPIRES_AT_KEY, normalizedExpiresAt) +} + +export function clearAuthCredentials() { + if (!canUseSessionStorage()) { + return + } + window.sessionStorage.removeItem(AUTH_ACCESS_TOKEN_KEY) + window.sessionStorage.removeItem(AUTH_EXPIRES_AT_KEY) +} + +export function buildBearerHeaders() { + const accessToken = readAuthAccessToken() + if (!accessToken) { + return {} + } + return { Authorization: `Bearer ${accessToken}` } +} + +export function notifyAuthSessionExpired() { + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') { + return + } + const event = typeof CustomEvent === 'function' + ? new CustomEvent(AUTH_SESSION_EXPIRED_EVENT) + : { type: AUTH_SESSION_EXPIRED_EVENT } + window.dispatchEvent(event) +} diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index d1a4004..92205fd 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -108,6 +108,11 @@ test('budget center is visible to platform admin, budget monitor, and executive assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false) }) +test('system settings are visible to platform admin instead of business managers', () => { + assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'settings'), true) + assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'settings'), false) +}) + test('budget edit and department switching are limited to admin and senior finance', () => { assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true) assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true) diff --git a/web/tests/api-request.test.mjs b/web/tests/api-request.test.mjs index f4d2c02..5780aab 100644 --- a/web/tests/api-request.test.mjs +++ b/web/tests/api-request.test.mjs @@ -45,9 +45,11 @@ async function testSupportsBlobResponses() { assert.equal(payload, blob) } -async function testInjectsAuthenticatedUserHeaders() { - const sessionStorage = new Map([ - [ +async function testInjectsBearerTokenWithoutUserControlledIdentityHeaders() { + const sessionStorage = new Map([ + ['x-financial-auth-access-token', 'opaque-access-token'], + ['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'], + [ 'x-financial-auth-user', JSON.stringify({ username: 'admin', @@ -84,27 +86,16 @@ async function testInjectsAuthenticatedUserHeaders() { await apiRequest('/knowledge/library') - assert.equal(capturedOptions.headers['x-auth-username'], 'admin') - assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User') - assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager') - assert.equal(capturedOptions.headers['x-auth-grade'], 'M5') - assert.equal(capturedOptions.headers['x-auth-employee-no'], 'E-001') - assert.equal(capturedOptions.headers['x-auth-manager-name'], 'Approver User') - assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') - assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') + assert.equal(capturedOptions.headers.Authorization, 'Bearer opaque-access-token') + assert.equal(capturedOptions.headers['x-auth-username'], undefined) + assert.equal(capturedOptions.headers['x-auth-role-codes'], undefined) + assert.equal(capturedOptions.headers['x-auth-is-admin'], undefined) } -async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() { +async function testLoginCanDisableBearerInjection() { const sessionStorage = new Map([ - [ - 'x-financial-auth-user', - JSON.stringify({ - username: 'superadmin', - name: 'superadmin', - roleCodes: ['manager'], - is_admin: true - }) - ] + ['x-financial-auth-access-token', 'stale-token'], + ['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'] ]) global.window = { @@ -127,11 +118,74 @@ async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() { } } - await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' }) + await apiRequest('/auth/login', { + auth: false, + handleUnauthorized: false, + method: 'POST', + body: '{}' + }) - assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin') - assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') - assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') + assert.equal(capturedOptions.headers.Authorization, undefined) +} + +async function testRejectsCustomAuthorizationOverride() { + const sessionStorage = new Map([ + ['x-financial-auth-access-token', 'server-issued-token'], + ['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'] + ]) + global.window = { + sessionStorage: { + getItem(key) { + return sessionStorage.get(key) ?? null + } + } + } + let capturedOptions = null + global.fetch = async (_url, options) => { + capturedOptions = options + return { ok: true, async json() { return { ok: true } } } + } + + await apiRequest('/knowledge/library', { + headers: { Authorization: 'Bearer attacker-token' } + }) + + assert.equal(capturedOptions.headers.Authorization, 'Bearer server-issued-token') +} + +async function testUnauthorizedResponsePublishesSessionExpiredEvent() { + const sessionStorage = new Map([ + ['x-financial-auth-access-token', 'expired-token'], + ['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'] + ]) + const events = [] + global.window = { + sessionStorage: { + getItem(key) { + return sessionStorage.get(key) ?? null + } + }, + dispatchEvent(event) { + events.push(event.type) + } + } + global.fetch = async () => ({ + ok: false, + status: 401, + async json() { + return { detail: '登录会话已失效。' } + } + }) + + await assert.rejects( + () => apiRequest('/auth/me'), + (error) => { + assert.equal(error.status, 401) + assert.equal(error.code, 'AUTH_SESSION_EXPIRED') + return true + } + ) + assert.deepEqual(events, ['x-financial:auth-expired']) } async function testFormatsValidationErrors() { @@ -192,8 +246,10 @@ async function testRejectsWithCustomTimeoutMessage() { async function run() { await testUsesCustomContentTypeHeader() await testSupportsBlobResponses() - await testInjectsAuthenticatedUserHeaders() - await testInjectsLegacyAdminHeaderFromSnakeCaseFlag() + await testInjectsBearerTokenWithoutUserControlledIdentityHeaders() + await testLoginCanDisableBearerInjection() + await testRejectsCustomAuthorizationOverride() + await testUnauthorizedResponsePublishesSessionExpiredEvent() await testFormatsValidationErrors() await testRejectsWithCustomTimeoutMessage() console.log('api-request tests passed') diff --git a/web/tests/auth-session-storage.test.mjs b/web/tests/auth-session-storage.test.mjs new file mode 100644 index 0000000..d83e4db --- /dev/null +++ b/web/tests/auth-session-storage.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' + +import { + AUTH_ACCESS_TOKEN_KEY, + AUTH_EXPIRES_AT_KEY, + buildBearerHeaders, + clearAuthCredentials, + hasValidAuthSession, + persistAuthCredentials, + readAuthAccessToken, + readAuthExpiresAt +} from '../src/utils/authSessionStorage.js' + +const storage = new Map() +global.window = { + sessionStorage: { + getItem(key) { + return storage.get(key) ?? null + }, + setItem(key, value) { + storage.set(key, String(value)) + }, + removeItem(key) { + storage.delete(key) + } + } +} + +const expiresAt = '2099-01-01T00:00:00.000Z' +persistAuthCredentials('opaque-token', expiresAt) +assert.equal(readAuthAccessToken(), 'opaque-token') +assert.equal(readAuthExpiresAt(), Date.parse(expiresAt)) +assert.equal(hasValidAuthSession(Date.parse('2098-01-01T00:00:00.000Z')), true) +assert.deepEqual(buildBearerHeaders(), { Authorization: 'Bearer opaque-token' }) + +storage.set(AUTH_EXPIRES_AT_KEY, '2020-01-01T00:00:00.000Z') +assert.equal(hasValidAuthSession(Date.parse('2021-01-01T00:00:00.000Z')), false) + +storage.set(AUTH_ACCESS_TOKEN_KEY, '') +assert.deepEqual(buildBearerHeaders(), {}) + +assert.throws( + () => persistAuthCredentials('', expiresAt), + /未返回有效的认证凭证/ +) + +clearAuthCredentials() +assert.equal(storage.has(AUTH_ACCESS_TOKEN_KEY), false) +assert.equal(storage.has(AUTH_EXPIRES_AT_KEY), false) + +console.log('auth session storage tests passed') diff --git a/web/tests/vite-setup-lock.test.mjs b/web/tests/vite-setup-lock.test.mjs new file mode 100644 index 0000000..56116d8 --- /dev/null +++ b/web/tests/vite-setup-lock.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' + +import { isSetupCompletedState, normalizeState } from '../vite.config.js' + +const completedEnv = { + SETUP_COMPLETED: 'true', + POSTGRES_HOST: 'database.internal', + POSTGRES_PORT: '5432', + POSTGRES_DB: 'x_financial', + POSTGRES_USER: 'postgres-admin', + POSTGRES_PASSWORD: 'secret', + REDIS_URL: 'redis://redis.internal:6379/0' +} + +assert.equal(isSetupCompletedState(completedEnv, true), true) +assert.equal(isSetupCompletedState(completedEnv, false), false) +assert.equal(isSetupCompletedState({ SETUP_COMPLETED: 'false' }, true), false) + +const publicState = normalizeState(completedEnv, { adminConfigured: true }) +assert.equal(publicState.initialized, true) +assert.equal(publicState.database.host, '') +assert.equal(publicState.database.username, '') +assert.equal(publicState.redis.url, '') +assert.equal(publicState.database.password_configured, true) + +const setupState = normalizeState( + { ...completedEnv, SETUP_COMPLETED: 'false' }, + { adminConfigured: false } +) +assert.equal(setupState.initialized, false) +assert.equal(setupState.database.host, 'database.internal') +assert.equal(setupState.database.username, 'postgres-admin') +assert.equal(setupState.redis.url, 'redis://redis.internal:6379/0') + +console.log('vite setup lock tests passed') diff --git a/web/vite.config.js b/web/vite.config.js index 9b9d5c4..d811540 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -17,8 +17,9 @@ const adminSecretDir = path.join(rootDir, 'server', '.secrets') const adminSecretFile = path.join(adminSecretDir, 'admin.json') const adminScryptOptions = { N: 16384, r: 8, p: 1 } const adminScryptKeyLength = 64 -let backendStartPromise = null -let backendStartState = createBackendStartState() +let backendStartPromise = null +let backendStartState = createBackendStartState() +let backendStartAuthorized = false function createBackendStartState() { return { @@ -400,11 +401,21 @@ function buildClientEnvUpdates(payload, apiBaseUrl) { } } -function normalizeState(env) { - const adminConfigured = Boolean(readAdminSecret()) - - return { - initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured, +export function isSetupCompletedState(env, adminConfigured) { + return String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured +} + +function isSetupCompleted() { + return isSetupCompletedState(readEnvState(), Boolean(readAdminSecret())) +} + +export function normalizeState(env, options = {}) { + const adminConfigured = options.adminConfigured ?? Boolean(readAdminSecret()) + const initialized = isSetupCompletedState(env, adminConfigured) + const redactInfrastructure = options.redactInfrastructure ?? initialized + + return { + initialized, company: { name: env.COMPANY_NAME || '', code: env.COMPANY_CODE || '', @@ -423,18 +434,26 @@ function normalizeState(env) { }, database: { driver: 'postgresql', - host: env.POSTGRES_HOST || '127.0.0.1', + host: redactInfrastructure ? '' : env.POSTGRES_HOST || '127.0.0.1', port: Number(env.POSTGRES_PORT || 5432), name: env.POSTGRES_DB || 'x_financial', - username: env.POSTGRES_USER || 'postgres', + username: redactInfrastructure ? '' : env.POSTGRES_USER || 'postgres', password_configured: Boolean(env.POSTGRES_PASSWORD) }, redis: { enabled: Boolean(env.REDIS_URL), - url: env.REDIS_URL || '' + url: redactInfrastructure ? '' : env.REDIS_URL || '' } - } -} + } +} + +function rejectCompletedSetup(res) { + if (!isSetupCompleted()) { + return false + } + sendJson(res, 403, { detail: '系统已完成初始化,本地初始化桥已锁定。' }) + return true +} async function readJsonBody(req) { const chunks = [] @@ -827,9 +846,12 @@ function localSetupPlugin() { server.watcher.unwatch(path.join(rootDir, 'server', 'storage')) server.watcher.unwatch(path.join(rootDir, 'test-results')) - server.middlewares.use('/__setup/auth/login', async (req, res) => { - try { - if (req.method !== 'POST') { + server.middlewares.use('/__setup/auth/login', async (req, res) => { + try { + if (rejectCompletedSetup(res)) { + return + } + if (req.method !== 'POST') { sendJson(res, 405, { detail: 'Method not allowed' }) return } @@ -864,9 +886,12 @@ function localSetupPlugin() { } }) - server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => { - try { - if (req.method !== 'PUT') { + server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => { + try { + if (rejectCompletedSetup(res)) { + return + } + if (req.method !== 'PUT') { sendJson(res, 405, { detail: 'Method not allowed' }) return } @@ -895,9 +920,12 @@ function localSetupPlugin() { } }) - server.middlewares.use('/__setup/bootstrap/database', async (req, res) => { - try { - if (req.method !== 'PUT') { + server.middlewares.use('/__setup/bootstrap/database', async (req, res) => { + try { + if (rejectCompletedSetup(res)) { + return + } + if (req.method !== 'PUT') { sendJson(res, 405, { detail: 'Method not allowed' }) return } @@ -928,21 +956,29 @@ function localSetupPlugin() { server.middlewares.use('/__setup/bootstrap/backend', async (req, res) => { try { - if (req.method === 'GET') { - const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log') - backendStartState.logTail = readBackendLogTail(logFile) - sendJson(res, 200, cloneBackendStartState()) - return + if (req.method === 'GET') { + const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log') + backendStartState.logTail = isSetupCompleted() ? '' : readBackendLogTail(logFile) + sendJson(res, 200, cloneBackendStartState()) + return } - if (req.method !== 'POST') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - try { - const result = await startBackendAndWait() - sendJson(res, 200, result) + if (req.method !== 'POST') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + if (isSetupCompleted() && !backendStartAuthorized) { + sendJson(res, 403, { detail: '系统已完成初始化,后端启动桥已锁定。' }) + return + } + + try { + const result = await startBackendAndWait() + if (result.completed) { + backendStartAuthorized = false + } + sendJson(res, 200, result) } catch (error) { sendJson(res, 500, { ok: false, @@ -963,12 +999,16 @@ function localSetupPlugin() { return } - if (req.method !== 'POST') { - sendJson(res, 405, { detail: 'Method not allowed' }) - return - } - - const currentEnv = readEnvState() + if (req.method !== 'POST') { + sendJson(res, 405, { detail: 'Method not allowed' }) + return + } + + if (rejectCompletedSetup(res)) { + return + } + + const currentEnv = readEnvState() const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv) const validationError = validateSetupPayload(payload) @@ -991,7 +1031,7 @@ function localSetupPlugin() { const apiBaseUrl = buildApiBaseUrl(payload, currentEnv) - updateEnvFile({ + updateEnvFile({ SETUP_COMPLETED: 'true', COMPANY_NAME: String(payload.company_name || '').trim(), COMPANY_CODE: String(payload.company_code || '').trim(), @@ -1009,10 +1049,11 @@ function localSetupPlugin() { REDIS_URL: String(payload.redis_url || '').trim(), CORS_ORIGINS: buildCorsOrigins(payload), VITE_API_BASE_URL: apiBaseUrl, - ...buildClientEnvUpdates(payload, apiBaseUrl) - }) - - sendJson(res, 201, normalizeState(readEnvState())) + ...buildClientEnvUpdates(payload, apiBaseUrl) + }) + + backendStartAuthorized = true + sendJson(res, 201, normalizeState(readEnvState())) } catch (error) { sendJson(res, 500, { detail: error instanceof Error ? error.message : '初始化写入失败。'