feat(auth): add opaque bearer sessions
This commit is contained in:
@@ -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 后续任务推进。
|
||||||
@@ -258,6 +258,9 @@
|
|||||||
|
|
||||||
- 登录后签发服务端可验证会话或 JWT,服务端从会话和数据库解析用户、租户、角色和数据范围。
|
- 登录后签发服务端可验证会话或 JWT,服务端从会话和数据库解析用户、租户、角色和数据范围。
|
||||||
- 移除客户端 `X-Auth-*` 作为授权事实来源;管理面、Bootstrap、设置和模型连通性接口必须受平台管理员保护。
|
- 移除客户端 `X-Auth-*` 作为授权事实来源;管理面、Bootstrap、设置和模型连通性接口必须受平台管理员保护。
|
||||||
|
- P0 采用不透明 Bearer 会话:明文 token 仅在登录成功时返回,数据库只保存 SHA-256 摘要;每次请求从服务端会话和员工数据重新解析当前身份与角色,过期、撤销或不存在的 token 统一拒绝。
|
||||||
|
- Web 端只在 `sessionStorage` 保存 token 和过期时间,普通请求、流式请求及页面关闭收尾统一携带 Bearer;任一 `401` 触发本地会话清理,登出时会话指标与 token 撤销在同一事务完成。
|
||||||
|
- 已初始化系统的 Bootstrap 状态只返回脱敏信息,Bootstrap 写入、系统设置、模型连通性、缓存、审计与系统日志等敏感管理面由平台管理员权限保护;Vite 本地 Setup 桥在初始化完成后锁定重新配置入口。
|
||||||
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
|
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
|
||||||
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
|
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
|
||||||
- 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。
|
- 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。
|
||||||
@@ -439,6 +442,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
|||||||
- 范围过大:费用闭环、AI 学习、价值分析和商业化不能同时全量实现,需要按 P0/P1/P2 阶段交付。
|
- 范围过大:费用闭环、AI 学习、价值分析和商业化不能同时全量实现,需要按 P0/P1/P2 阶段交付。
|
||||||
- 领域模型迁移:旧 `ReimbursementRequest`、`ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。
|
- 领域模型迁移:旧 `ReimbursementRequest`、`ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。
|
||||||
- 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。
|
- 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。
|
||||||
|
- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO;当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。
|
||||||
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
|
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
|
||||||
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。
|
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 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(事务修复):关联申请归档/解绑的内部审计改为 `flush`,由外层业务事务统一提交,避免审计日志提前提交付款或归档状态。
|
||||||
- 2026-07-13(迁移桥接):新增第一条 migration-owned schema revision;服务启动先执行 Alembic,旧 `create_all` 明确排除三张迁移表。完整历史 schema baseline、正式数据库 upgrade/rollback 和停止旧 DDL 仍未完成。
|
- 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(验证):容器内新增测试与既有差旅主链路回归共 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,未写入持久化开发数据库。
|
||||||
|
|||||||
@@ -48,8 +48,11 @@
|
|||||||
|
|
||||||
## 4. P0 后端实现:费用闭环与数据基础
|
## 4. P0 后端实现:费用闭环与数据基础
|
||||||
|
|
||||||
- [ ] [CONCEPT: 权限与安全] 实现服务端可验证会话/JWT,移除客户端身份头作为授权事实来源。
|
- [x] [CONCEPT: 权限与安全] 实现服务端可验证会话/JWT,移除客户端身份头作为授权事实来源。
|
||||||
- [ ] [CONCEPT: 权限与安全] 为管理面、Bootstrap、Settings、模型连通性、日志和规则接口补齐平台管理员保护。
|
证据:`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: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。
|
||||||
- [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
|
- [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
|
||||||
- [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。
|
- [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。
|
||||||
@@ -134,7 +137,9 @@
|
|||||||
- [x] [CONCEPT: 测试方案] 完成 Expense Case/Link、业务事件幂等、租户边界、同事务回滚、申请转报销同 Case 和付款归档事件首批测试。
|
- [x] [CONCEPT: 测试方案] 完成 Expense Case/Link、业务事件幂等、租户边界、同事务回滚、申请转报销同 Case 和付款归档事件首批测试。
|
||||||
证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。
|
证据:容器内 `pytest -q server/tests/test_expense_case_service.py` 7 项通过;联合差旅主链路定向回归共 24 项通过。
|
||||||
- [ ] [CONCEPT: 测试方案] 为 Expense Case 状态机、事件账本、AI 决策、结果、记忆、自动化和节省服务补充单元测试。
|
- [ ] [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: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
|
||||||
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
||||||
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
||||||
|
|||||||
82
server/alembic/versions/20260713_0002_auth_sessions.py
Normal file
82
server/alembic/versions/20260713_0002_auth_sessions.py
Normal file
@@ -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")
|
||||||
@@ -6,10 +6,8 @@ from fastapi import Depends, Header, HTTPException, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
|
from app.services.auth import AuthService
|
||||||
|
from app.services.auth_sessions import AuthSessionService
|
||||||
PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"}
|
|
||||||
ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Generator[Session, None, None]:
|
def get_db() -> Generator[Session, None, None]:
|
||||||
@@ -33,82 +31,72 @@ class CurrentUserContext:
|
|||||||
grade: str = ""
|
grade: str = ""
|
||||||
employee_no: str = ""
|
employee_no: str = ""
|
||||||
manager_name: str = ""
|
manager_name: str = ""
|
||||||
|
employee_id: str = ""
|
||||||
|
auth_session_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
x_auth_username: Annotated[
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
authorization: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
Header(description="当前登录用户名。知识库接口至少需要提供用户名或姓名。"),
|
Header(description="登录接口签发的 `Authorization: Bearer <token>`。"),
|
||||||
] = 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="当前登录人的直属领导。"),
|
|
||||||
] = None,
|
] = None,
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
role_codes = [
|
return _authenticate_bearer_user(db, authorization)
|
||||||
_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,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not username and not name:
|
|
||||||
raise HTTPException(
|
def get_optional_current_user(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
db: Annotated[Session, Depends(get_db)],
|
||||||
detail="请先登录后再访问知识库。",
|
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(
|
return CurrentUserContext(
|
||||||
username=username or name,
|
username=user.username,
|
||||||
name=name or username,
|
name=user.name,
|
||||||
role_codes=role_codes,
|
role_codes=[_normalize_role_code(item) for item in user.role_codes],
|
||||||
is_admin=is_admin,
|
is_admin=user.is_admin,
|
||||||
tenant_id="default",
|
tenant_id=user.tenant_id,
|
||||||
department_name=(x_auth_department or "").strip(),
|
department_name=user.department,
|
||||||
cost_center=(x_auth_cost_center or "").strip(),
|
cost_center=user.cost_center,
|
||||||
position=(x_auth_position or "").strip(),
|
position=user.position,
|
||||||
grade=(x_auth_grade or "").strip(),
|
grade=user.grade,
|
||||||
employee_no=(x_auth_employee_no or "").strip(),
|
employee_no=user.employee_no,
|
||||||
manager_name=(x_auth_manager_name or "").strip(),
|
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]:
|
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)}
|
return {
|
||||||
|
_normalize_role_code(item)
|
||||||
|
for item in current_user.role_codes
|
||||||
def _resolve_platform_admin_flag(
|
if _normalize_role_code(item)
|
||||||
*,
|
|
||||||
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(),
|
|
||||||
}
|
}
|
||||||
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(
|
def require_admin_user(
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
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.agent_run import AgentRunRead, AgentRunStatsRead
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.services.agent_runs import AgentRunService
|
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)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
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 (
|
from app.schemas.agent_trace import (
|
||||||
AgentConversationTraceRead,
|
AgentConversationTraceRead,
|
||||||
AgentTraceDetailRead,
|
AgentTraceDetailRead,
|
||||||
@@ -26,7 +26,7 @@ DbSession = Annotated[Session, Depends(get_db)]
|
|||||||
)
|
)
|
||||||
def list_agent_traces(
|
def list_agent_traces(
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
_: Annotated[object, Depends(require_admin_user)],
|
_: Annotated[object, Depends(require_platform_admin_user)],
|
||||||
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
|
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
|
||||||
status_value: Annotated[
|
status_value: Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
@@ -56,7 +56,7 @@ def list_agent_traces(
|
|||||||
def get_conversation_trace(
|
def get_conversation_trace(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
_: Annotated[object, Depends(require_admin_user)],
|
_: Annotated[object, Depends(require_platform_admin_user)],
|
||||||
) -> AgentConversationTraceRead:
|
) -> AgentConversationTraceRead:
|
||||||
return AgentTraceService(db).get_conversation_trace(conversation_id)
|
return AgentTraceService(db).get_conversation_trace(conversation_id)
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ def get_conversation_trace(
|
|||||||
def get_agent_trace(
|
def get_agent_trace(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
_: Annotated[object, Depends(require_admin_user)],
|
_: Annotated[object, Depends(require_platform_admin_user)],
|
||||||
) -> AgentTraceDetailRead:
|
) -> AgentTraceDetailRead:
|
||||||
trace = AgentTraceService(db).get_trace(run_id)
|
trace = AgentTraceService(db).get_trace(run_id)
|
||||||
if trace is None:
|
if trace is None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
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.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
||||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||||
from app.schemas.system_dashboard import SystemDashboardRead
|
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.finance_dashboard_snapshot import FinanceDashboardSnapshotService
|
||||||
from app.services.system_dashboard import SystemDashboardService
|
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)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
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.schemas.audit_log import AuditLogRead
|
||||||
from app.services.audit import AuditLogService
|
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)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ from app.schemas.auth import (
|
|||||||
AuthUserRead,
|
AuthUserRead,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
LogoutRequest,
|
||||||
|
LogoutResponse,
|
||||||
SessionFinishRequest,
|
SessionFinishRequest,
|
||||||
SessionFinishResponse,
|
SessionFinishResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.services.auth import AuthService
|
from app.services.auth import AuthService
|
||||||
|
from app.services.auth_sessions import AuthSessionService
|
||||||
from app.services.user_session_metrics import UserSessionMetricService
|
from app.services.user_session_metrics import UserSessionMetricService
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth")
|
router = APIRouter(prefix="/auth")
|
||||||
@@ -44,7 +47,7 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
|
|||||||
"/me",
|
"/me",
|
||||||
response_model=AuthUserRead,
|
response_model=AuthUserRead,
|
||||||
summary="读取当前登录用户",
|
summary="读取当前登录用户",
|
||||||
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
|
description="根据 Bearer 会话刷新前端登录态中的员工姓名、部门、岗位和职级。",
|
||||||
)
|
)
|
||||||
def get_current_auth_user(
|
def get_current_auth_user(
|
||||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
@@ -68,12 +71,49 @@ def get_current_auth_user(
|
|||||||
managerName=current_user.manager_name,
|
managerName=current_user.manager_name,
|
||||||
costCenter=current_user.cost_center,
|
costCenter=current_user.cost_center,
|
||||||
roleCodes=current_user.role_codes or ["manager"],
|
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(),
|
avatar=name[:1].upper(),
|
||||||
isAdmin=True,
|
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(
|
@router.post(
|
||||||
@@ -84,10 +124,12 @@ def get_current_auth_user(
|
|||||||
def finish_session(
|
def finish_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
payload: SessionFinishRequest,
|
payload: SessionFinishRequest,
|
||||||
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> SessionFinishResponse:
|
) -> SessionFinishResponse:
|
||||||
session = UserSessionMetricService(db).finish_session(
|
session = UserSessionMetricService(db).finish_session(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
expected_username=current_user.username,
|
||||||
reason=payload.reason,
|
reason=payload.reason,
|
||||||
last_activity_at=payload.lastActivityAt,
|
last_activity_at=payload.lastActivityAt,
|
||||||
activity_event_count=payload.activityEventCount,
|
activity_event_count=payload.activityEventCount,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
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.bootstrap import build_bootstrap_state, persist_bootstrap_config
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
|
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
|
||||||
@@ -16,7 +19,11 @@ router = APIRouter(prefix="/bootstrap")
|
|||||||
description="返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。",
|
description="返回当前系统是否已完成初始化,以及公司、数据库和缓存配置快照。",
|
||||||
)
|
)
|
||||||
def get_bootstrap_state() -> BootstrapStateRead:
|
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(
|
@router.post(
|
||||||
@@ -26,5 +33,17 @@ def get_bootstrap_state() -> BootstrapStateRead:
|
|||||||
summary="写入初始化配置",
|
summary="写入初始化配置",
|
||||||
description="保存系统初始化配置,并刷新运行时数据库连接。",
|
description="保存系统初始化配置,并刷新运行时数据库连接。",
|
||||||
)
|
)
|
||||||
def initialize_bootstrap(payload: BootstrapSetupPayload) -> BootstrapStateRead:
|
def initialize_bootstrap(
|
||||||
return persist_bootstrap_config(payload, get_settings())
|
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)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
|
|||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session
|
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.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||||
from app.schemas.common import ErrorResponse, PaginatedResponse
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
from app.schemas.employee import (
|
from app.schemas.employee import (
|
||||||
@@ -19,7 +19,7 @@ from app.schemas.employee import (
|
|||||||
from app.services.employee import EmployeeService
|
from app.services.employee import EmployeeService
|
||||||
from app.services.employee_pagination import EmployeePaginationService
|
from app.services.employee_pagination import EmployeePaginationService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(dependencies=[Depends(require_admin_user)])
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
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.common import ErrorResponse
|
||||||
from app.schemas.risk_observation import (
|
from app.schemas.risk_observation import (
|
||||||
RiskObservationDashboardRead,
|
RiskObservationDashboardRead,
|
||||||
@@ -16,7 +16,7 @@ from app.schemas.risk_observation import (
|
|||||||
)
|
)
|
||||||
from app.services.risk_observations import RiskObservationService
|
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)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
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.core.config import get_settings as get_runtime_settings
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.schemas.settings import (
|
from app.schemas.settings import (
|
||||||
@@ -52,7 +57,10 @@ def require_hermes_agent_token(
|
|||||||
summary="读取系统设置",
|
summary="读取系统设置",
|
||||||
description="返回公司、管理员、模型、日志、邮件和 ONLYOFFICE 的设置快照。",
|
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()
|
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:
|
try:
|
||||||
return SettingsService(db).save_settings_snapshot(payload)
|
return SettingsService(db).save_settings_snapshot(payload)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -84,6 +96,7 @@ def update_settings(payload: SettingsWrite, db: DbSession) -> SettingsRead:
|
|||||||
def test_model_connectivity(
|
def test_model_connectivity(
|
||||||
payload: ModelConnectivityTestRequest,
|
payload: ModelConnectivityTestRequest,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||||
) -> ModelConnectivityTestRead:
|
) -> ModelConnectivityTestRead:
|
||||||
resolved_payload = payload
|
resolved_payload = payload
|
||||||
|
|
||||||
@@ -99,7 +112,10 @@ def test_model_connectivity(
|
|||||||
"/cache/clear",
|
"/cache/clear",
|
||||||
response_model=SettingsCacheClearRead,
|
response_model=SettingsCacheClearRead,
|
||||||
summary="清理系统缓存",
|
summary="清理系统缓存",
|
||||||
description="清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存,不删除业务文件或数据库记录。",
|
description=(
|
||||||
|
"清理 OCR、模型失败冷却、知识库索引和运行时配置等进程内缓存,"
|
||||||
|
"不删除业务文件或数据库记录。"
|
||||||
|
),
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_403_FORBIDDEN: {
|
status.HTTP_403_FORBIDDEN: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
@@ -108,7 +124,7 @@ def test_model_connectivity(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def clear_system_cache(
|
def clear_system_cache(
|
||||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||||
) -> SettingsCacheClearRead:
|
) -> SettingsCacheClearRead:
|
||||||
return SystemCacheService().clear_all()
|
return SystemCacheService().clear_all()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
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.common import ErrorResponse
|
||||||
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
|
from app.schemas.system_log import SystemLogEntryRead, SystemLogFileRead, SystemLogTailRead
|
||||||
from app.services.system_logs import SystemLogService
|
from app.services.system_logs import SystemLogService
|
||||||
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/system-logs")
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def list_system_log_entries(
|
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,
|
limit: Annotated[int, Query(ge=20, le=1000, description="返回的日志记录数。")] = 300,
|
||||||
) -> list[SystemLogEntryRead]:
|
) -> list[SystemLogEntryRead]:
|
||||||
return SystemLogService().list_entries(entry_limit=limit)
|
return SystemLogService().list_entries(entry_limit=limit)
|
||||||
@@ -49,12 +49,15 @@ def list_system_log_entries(
|
|||||||
)
|
)
|
||||||
def get_system_log_entry(
|
def get_system_log_entry(
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||||
) -> SystemLogEntryRead:
|
) -> SystemLogEntryRead:
|
||||||
try:
|
try:
|
||||||
return SystemLogService().get_entry(entry_id)
|
return SystemLogService().get_entry(entry_id)
|
||||||
except FileNotFoundError as exc:
|
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(
|
@router.get(
|
||||||
@@ -70,7 +73,7 @@ def get_system_log_entry(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
def list_system_log_files(
|
def list_system_log_files(
|
||||||
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
_: Annotated[CurrentUserContext, Depends(require_platform_admin_user)],
|
||||||
) -> list[SystemLogFileRead]:
|
) -> list[SystemLogFileRead]:
|
||||||
return SystemLogService().list_files()
|
return SystemLogService().list_files()
|
||||||
|
|
||||||
@@ -93,10 +96,13 @@ def list_system_log_files(
|
|||||||
)
|
)
|
||||||
def get_system_log_tail(
|
def get_system_log_tail(
|
||||||
file_name: str,
|
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,
|
lines: Annotated[int, Query(ge=20, le=1000, description="返回的日志行数。")] = 300,
|
||||||
) -> SystemLogTailRead:
|
) -> SystemLogTailRead:
|
||||||
try:
|
try:
|
||||||
return SystemLogService().read_tail(file_name, line_limit=lines)
|
return SystemLogService().read_tail(file_name, line_limit=lines)
|
||||||
except FileNotFoundError as exc:
|
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
|
||||||
|
|||||||
@@ -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(
|
return BootstrapStateRead(
|
||||||
initialized=settings.setup_completed,
|
initialized=settings.setup_completed,
|
||||||
company={
|
company={
|
||||||
@@ -46,17 +50,23 @@ def build_bootstrap_state(settings: Settings) -> BootstrapStateRead:
|
|||||||
server={"host": settings.app_host, "port": settings.app_port},
|
server={"host": settings.app_host, "port": settings.app_port},
|
||||||
database={
|
database={
|
||||||
"driver": "postgresql",
|
"driver": "postgresql",
|
||||||
"host": settings.postgres_host,
|
"host": "" if redact_infrastructure else settings.postgres_host,
|
||||||
"port": settings.postgres_port,
|
"port": settings.postgres_port,
|
||||||
"name": settings.postgres_db,
|
"name": settings.postgres_db,
|
||||||
"username": settings.postgres_user,
|
"username": "" if redact_infrastructure else settings.postgres_user,
|
||||||
"password_configured": bool(settings.postgres_password),
|
"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()
|
env_file = ensure_env_file()
|
||||||
database_url = build_database_url(payload)
|
database_url = build_database_url(payload)
|
||||||
vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}"
|
vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}"
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ X-Financial 后端 OpenAPI 文档。
|
|||||||
|
|
||||||
## 鉴权约定
|
## 鉴权约定
|
||||||
|
|
||||||
- 知识库接口依赖以下请求头模拟当前用户:
|
- 用户登录成功后,业务接口统一使用:
|
||||||
- `X-Auth-Username`
|
- `Authorization: Bearer <accessToken>`
|
||||||
- `X-Auth-Name`
|
- 用户名、角色和管理员身份均由服务端会话与数据库目录解析,不接受浏览器自报身份。
|
||||||
- `X-Auth-Role-Codes`
|
|
||||||
- `X-Auth-Is-Admin`
|
|
||||||
- Agent 资产写接口支持以下审计头:
|
- Agent 资产写接口支持以下审计头:
|
||||||
- `X-Actor`
|
- `X-Actor`
|
||||||
- `X-Request-Id`
|
- `X-Request-Id`
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
|
||||||
from app.models.agent_asset import (
|
from app.models.agent_asset import (
|
||||||
AgentAsset,
|
AgentAsset,
|
||||||
AgentAssetReview,
|
AgentAssetReview,
|
||||||
@@ -7,14 +6,16 @@ from app.models.agent_asset import (
|
|||||||
AgentAssetTestRun,
|
AgentAssetTestRun,
|
||||||
AgentAssetVersion,
|
AgentAssetVersion,
|
||||||
)
|
)
|
||||||
|
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||||
from app.models.agent_feedback import AgentOperationFeedback
|
from app.models.agent_feedback import AgentOperationFeedback
|
||||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||||
from app.models.approval import ApprovalRecord
|
from app.models.approval import ApprovalRecord
|
||||||
from app.models.audit_log import AuditLog
|
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.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 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.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||||
from app.models.few_shot_sample import FewShotSample
|
from app.models.few_shot_sample import FewShotSample
|
||||||
from app.models.financial_record import (
|
from app.models.financial_record import (
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
"AgentTraceEvent",
|
"AgentTraceEvent",
|
||||||
"ApprovalRecord",
|
"ApprovalRecord",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"AuthSession",
|
||||||
"BudgetAllocation",
|
"BudgetAllocation",
|
||||||
"BudgetReservation",
|
"BudgetReservation",
|
||||||
"BudgetTransaction",
|
"BudgetTransaction",
|
||||||
|
|||||||
@@ -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_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_feedback import AgentOperationFeedback
|
||||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||||
from app.models.approval import ApprovalRecord
|
from app.models.approval import ApprovalRecord
|
||||||
from app.models.audit_log import AuditLog
|
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.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 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.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||||
from app.models.few_shot_sample import FewShotSample
|
from app.models.few_shot_sample import FewShotSample
|
||||||
from app.models.financial_record import (
|
from app.models.financial_record import (
|
||||||
@@ -44,6 +50,7 @@ __all__ = [
|
|||||||
"AgentTraceEvent",
|
"AgentTraceEvent",
|
||||||
"ApprovalRecord",
|
"ApprovalRecord",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"AuthSession",
|
||||||
"BudgetAllocation",
|
"BudgetAllocation",
|
||||||
"BudgetReservation",
|
"BudgetReservation",
|
||||||
"BudgetTransaction",
|
"BudgetTransaction",
|
||||||
|
|||||||
34
server/src/app/models/auth_session.py
Normal file
34
server/src/app/models/auth_session.py
Normal file
@@ -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())
|
||||||
@@ -36,6 +36,14 @@ class LoginResponse(BaseModel):
|
|||||||
detail: str = "登录成功。"
|
detail: str = "登录成功。"
|
||||||
user: AuthUserRead
|
user: AuthUserRead
|
||||||
sessionId: str = ""
|
sessionId: str = ""
|
||||||
|
accessToken: str
|
||||||
|
tokenType: str = "Bearer"
|
||||||
|
expiresAt: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
detail: str = "已退出登录。"
|
||||||
|
|
||||||
|
|
||||||
class SessionFinishRequest(BaseModel):
|
class SessionFinishRequest(BaseModel):
|
||||||
@@ -45,6 +53,10 @@ class SessionFinishRequest(BaseModel):
|
|||||||
pagePath: str = Field(default="", max_length=512)
|
pagePath: str = Field(default="", max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequest(SessionFinishRequest):
|
||||||
|
sessionId: str = Field(default="", max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class SessionFinishResponse(BaseModel):
|
class SessionFinishResponse(BaseModel):
|
||||||
ok: bool = True
|
ok: bool = True
|
||||||
detail: str = "会话已结算。"
|
detail: str = "会话已结算。"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ logger = get_logger("app.services.agent_foundation")
|
|||||||
_foundation_ready_lock = threading.RLock()
|
_foundation_ready_lock = threading.RLock()
|
||||||
_foundation_ready_keys: set[str] = set()
|
_foundation_ready_keys: set[str] = set()
|
||||||
MIGRATION_OWNED_TABLES = {
|
MIGRATION_OWNED_TABLES = {
|
||||||
|
"auth_sessions",
|
||||||
"expense_cases",
|
"expense_cases",
|
||||||
"expense_case_links",
|
"expense_case_links",
|
||||||
"business_events",
|
"business_events",
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from sqlalchemy.orm import Session, selectinload
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
|
from app.models.auth_session import AuthSession
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
|
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 import EmployeeService
|
||||||
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
from app.services.employee_seed import ROLE_DISPLAY_ORDER
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
@@ -50,6 +52,8 @@ class AuthenticatedUser:
|
|||||||
email: str
|
email: str
|
||||||
avatar: str
|
avatar: str
|
||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
|
employee_id: str | None = None
|
||||||
|
tenant_id: str = "default"
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@@ -79,12 +83,61 @@ class AuthService:
|
|||||||
raise ValueError("账号或密码错误。")
|
raise ValueError("账号或密码错误。")
|
||||||
|
|
||||||
def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse:
|
def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse:
|
||||||
session = UserSessionMetricService(self.db).start_session(user)
|
settings_snapshot = SettingsService(self.db).get_settings_snapshot()
|
||||||
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id)
|
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:
|
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
|
||||||
normalized = identifier.strip()
|
normalized = identifier.strip()
|
||||||
if not normalized or not self.settings.setup_completed:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
employee = self._find_employee_by_email(normalized)
|
employee = self._find_employee_by_email(normalized)
|
||||||
@@ -101,6 +154,10 @@ class AuthService:
|
|||||||
if record is None:
|
if record is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return self._build_admin_user(record)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_admin_user(record: Any) -> AuthenticatedUser:
|
||||||
admin_username = record.account.strip()
|
admin_username = record.account.strip()
|
||||||
admin_email = record.email.strip()
|
admin_email = record.email.strip()
|
||||||
display_name = admin_username or admin_email or "系统管理员"
|
display_name = admin_username or admin_email or "系统管理员"
|
||||||
@@ -169,7 +226,9 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
role_codes = [role.role_code for role in sorted_roles]
|
role_codes = [role.role_code for role in sorted_roles]
|
||||||
primary_role_code = role_codes[0] if role_codes else "user"
|
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)
|
manager_name = self._resolve_manager_name(employee)
|
||||||
|
|
||||||
return AuthenticatedUser(
|
return AuthenticatedUser(
|
||||||
@@ -189,6 +248,7 @@ class AuthService:
|
|||||||
email=employee.email,
|
email=employee.email,
|
||||||
avatar=(employee.name or "?")[:1].upper(),
|
avatar=(employee.name or "?")[:1].upper(),
|
||||||
is_admin=False,
|
is_admin=False,
|
||||||
|
employee_id=employee.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -235,7 +295,11 @@ class AuthService:
|
|||||||
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
|
||||||
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
|
||||||
"recentRiskFlags": recent_risk_flags,
|
"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
|
@staticmethod
|
||||||
|
|||||||
96
server/src/app/services/auth_sessions.py
Normal file
96
server/src/app/services/auth_sessions.py
Normal file
@@ -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)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import threading
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select
|
||||||
@@ -46,6 +46,7 @@ class UserSessionMetricService:
|
|||||||
user: Any,
|
user: Any,
|
||||||
*,
|
*,
|
||||||
event: dict[str, Any] | None = None,
|
event: dict[str, Any] | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> UserSessionMetric:
|
) -> UserSessionMetric:
|
||||||
self.ensure_storage_ready()
|
self.ensure_storage_ready()
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -64,27 +65,36 @@ class UserSessionMetricService:
|
|||||||
event_json=event or {},
|
event_json=event or {},
|
||||||
)
|
)
|
||||||
self.db.add(session)
|
self.db.add(session)
|
||||||
self.db.commit()
|
if commit:
|
||||||
self.db.refresh(session)
|
self.db.commit()
|
||||||
|
self.db.refresh(session)
|
||||||
|
else:
|
||||||
|
self.db.flush()
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def finish_session(
|
def finish_session(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
expected_username: str = "",
|
||||||
reason: str = "manual",
|
reason: str = "manual",
|
||||||
last_activity_at: datetime | None = None,
|
last_activity_at: datetime | None = None,
|
||||||
activity_event_count: int = 0,
|
activity_event_count: int = 0,
|
||||||
event: dict[str, Any] | None = None,
|
event: dict[str, Any] | None = None,
|
||||||
|
commit: bool = True,
|
||||||
) -> UserSessionMetric | None:
|
) -> UserSessionMetric | None:
|
||||||
self.ensure_storage_ready()
|
self.ensure_storage_ready()
|
||||||
normalized_session_id = str(session_id or "").strip()
|
normalized_session_id = str(session_id or "").strip()
|
||||||
if not normalized_session_id:
|
if not normalized_session_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
session = self.db.scalars(
|
stmt = select(UserSessionMetric).where(
|
||||||
select(UserSessionMetric).where(UserSessionMetric.session_id == normalized_session_id)
|
UserSessionMetric.session_id == normalized_session_id
|
||||||
).first()
|
)
|
||||||
|
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:
|
if session is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -93,7 +103,11 @@ class UserSessionMetricService:
|
|||||||
|
|
||||||
logout_at = datetime.now(UTC)
|
logout_at = datetime.now(UTC)
|
||||||
session.logout_at = logout_at
|
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.duration_ms = self._duration_ms(session.login_at, logout_at)
|
||||||
session.activity_event_count = max(0, int(activity_event_count or 0))
|
session.activity_event_count = max(0, int(activity_event_count or 0))
|
||||||
session.logout_reason = str(reason or "manual").strip()[:40] or "manual"
|
session.logout_reason = str(reason or "manual").strip()[:40] or "manual"
|
||||||
@@ -102,8 +116,11 @@ class UserSessionMetricService:
|
|||||||
**(session.event_json or {}),
|
**(session.event_json or {}),
|
||||||
"finish": event or {},
|
"finish": event or {},
|
||||||
}
|
}
|
||||||
self.db.commit()
|
if commit:
|
||||||
self.db.refresh(session)
|
self.db.commit()
|
||||||
|
self.db.refresh(session)
|
||||||
|
else:
|
||||||
|
self.db.flush()
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int:
|
def sum_duration_ms(self, identifiers: set[str], cutoff: datetime) -> int:
|
||||||
|
|||||||
53
server/tests/auth_helpers.py
Normal file
53
server/tests/auth_helpers.py
Normal file
@@ -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(),
|
||||||
|
)
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -24,6 +25,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
|||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -37,6 +38,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from collections.abc import Generator
|
|||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
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.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
|
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
|
||||||
from app.services.attachment_association_jobs import clear_attachment_association_jobs_for_tests
|
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_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
from app.services.receipt_folder import ReceiptFolderService
|
from app.services.receipt_folder import ReceiptFolderService
|
||||||
from app.test_helpers.db import build_in_memory_session_factory
|
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]:
|
def build_client(monkeypatch) -> tuple[TestClient, object]:
|
||||||
session_factory = build_in_memory_session_factory()
|
session_factory = build_in_memory_session_factory()
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
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.auth import LoginRequest
|
||||||
from app.schemas.settings import SettingsWrite
|
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.employee import EmployeeService
|
||||||
from app.services.settings import SettingsService
|
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.grade == employee.grade
|
||||||
assert result.user.roleCodes
|
assert result.user.roleCodes
|
||||||
assert result.user.isAdmin is False
|
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:
|
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")
|
calls.append("ensure_directory_ready")
|
||||||
raise AssertionError("existing employee login should not run directory bootstrap")
|
raise AssertionError("existing employee login should not run directory bootstrap")
|
||||||
|
|
||||||
monkeypatch.setattr(AuthService, "_find_employee_by_email", lambda self, _: ExistingEmployee())
|
monkeypatch.setattr(
|
||||||
monkeypatch.setattr("app.services.auth.verify_password", lambda password, password_hash: True)
|
AuthService,
|
||||||
|
"_find_employee_by_email",
|
||||||
|
lambda self, _: ExistingEmployee(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.auth.verify_password",
|
||||||
|
lambda password, password_hash: True,
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
AuthService,
|
AuthService,
|
||||||
"_build_employee_user",
|
"_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 is not None
|
||||||
assert user.username == "demo@example.com"
|
assert user.username == "demo@example.com"
|
||||||
assert calls == []
|
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
|
||||||
|
|||||||
148
server/tests/test_auth_session_endpoints.py
Normal file
148
server/tests/test_auth_session_endpoints.py
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ from collections.abc import Generator
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -25,6 +26,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
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:
|
def test_employee_directory_supports_backend_pagination() -> None:
|
||||||
client, _ = build_client()
|
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
|
assert response.status_code == 200
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
|||||||
65
server/tests/test_bootstrap_security.py
Normal file
65
server/tests/test_bootstrap_security.py
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ from collections.abc import Generator
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -32,6 +33,7 @@ def build_session_factory() -> sessionmaker[Session]:
|
|||||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from collections.abc import Generator
|
|||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -249,6 +250,7 @@ def test_latest_profile_endpoint_returns_approval_payload() -> None:
|
|||||||
seed_profile_data(db)
|
seed_profile_data(db)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
@@ -303,6 +305,7 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
@@ -370,6 +373,7 @@ def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
@@ -414,6 +418,7 @@ def test_current_admin_profile_endpoint_uses_online_session_without_agent_runs()
|
|||||||
)
|
)
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
@@ -464,6 +469,7 @@ def test_finish_session_endpoint_closes_active_session() -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -137,7 +137,12 @@ def test_legacy_bootstrap_excludes_migration_owned_tables(
|
|||||||
|
|
||||||
table_names = set(inspect(engine).get_table_names())
|
table_names = set(inspect(engine).get_table_names())
|
||||||
assert "employees" in 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:
|
def test_event_write_is_idempotent_for_same_business_operation() -> None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ from app.main import create_app
|
|||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
|
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.services.orchestrator import OrchestratorService
|
||||||
from app.test_helpers.db import build_in_memory_session_factory
|
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]:
|
def build_client(monkeypatch) -> tuple[TestClient, object]:
|
||||||
session_factory = build_in_memory_session_factory()
|
session_factory = build_in_memory_session_factory()
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -34,6 +35,7 @@ def build_client() -> TestClient:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
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.core.config import get_settings
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
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
|
from app.services.ocr import OcrService
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +30,7 @@ def build_client() -> TestClient:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ from __future__ import annotations
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
|
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.schemas.ontology import OntologyParseRequest
|
from app.schemas.ontology import OntologyParseRequest
|
||||||
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
||||||
@@ -32,6 +33,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
|||||||
from app.main import create_app
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from collections.abc import Generator
|
|||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -38,6 +39,7 @@ def build_session_factory() -> sessionmaker[Session]:
|
|||||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ from datetime import UTC, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
|
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
|
||||||
from app.db.base import Base
|
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.financial_record import ExpenseClaim
|
||||||
from app.models.risk_observation import RiskObservation
|
from app.models.risk_observation import RiskObservation
|
||||||
from app.schemas.risk_observation import RiskObservationFeedbackCreate
|
from app.schemas.risk_observation import RiskObservationFeedbackCreate
|
||||||
from app.algorithem.risk_graph.replay import AlgorithmReplaySetBuilder
|
|
||||||
from app.services.risk_observations import RiskObservationService
|
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)
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(risk_observations_router, prefix="/api/v1")
|
app.include_router(risk_observations_router, prefix="/api/v1")
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -45,6 +46,7 @@ def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -13,8 +14,8 @@ from app.db.base import Base
|
|||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.agent_asset import AgentAsset
|
from app.models.agent_asset import AgentAsset
|
||||||
from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest
|
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_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
|
||||||
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
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)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import create_app
|
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:
|
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")
|
unauthorized = client.get("/api/v1/agent-assets/risk-rules/templates")
|
||||||
assert unauthorized.status_code == 401
|
assert unauthorized.status_code == 401
|
||||||
|
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/api/v1/agent-assets/risk-rules/templates",
|
"/api/v1/agent-assets/risk-rules/templates",
|
||||||
headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"},
|
headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -30,6 +31,7 @@ def build_session_factory() -> sessionmaker[Session]:
|
|||||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
install_legacy_header_auth_override(app)
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from auth_helpers import install_legacy_header_auth_override
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -23,6 +24,7 @@ def build_client() -> TestClient:
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
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]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
testBootstrapDatabase,
|
testBootstrapDatabase,
|
||||||
testBootstrapRuntime
|
testBootstrapRuntime
|
||||||
} from '../services/bootstrap.js'
|
} 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 { setRuntimeApiBaseUrl } from '../services/api.js'
|
||||||
import { checkBackendHealth } from './useBackendHealth.js'
|
import { checkBackendHealth } from './useBackendHealth.js'
|
||||||
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
||||||
@@ -16,15 +19,22 @@ import { useToast } from './useToast.js'
|
|||||||
import { fetchSettings } from '../services/settings.js'
|
import { fetchSettings } from '../services/settings.js'
|
||||||
import { setThemeSkin } from './useThemeSkin.js'
|
import { setThemeSkin } from './useThemeSkin.js'
|
||||||
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
|
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
|
||||||
|
import {
|
||||||
|
AUTH_SESSION_EXPIRED_EVENT,
|
||||||
|
clearAuthCredentials,
|
||||||
|
hasValidAuthSession,
|
||||||
|
persistAuthCredentials,
|
||||||
|
readAuthAccessToken,
|
||||||
|
readAuthExpiresAt
|
||||||
|
} from '../utils/authSessionStorage.js'
|
||||||
import {
|
import {
|
||||||
clearAuthSessionMetrics,
|
clearAuthSessionMetrics,
|
||||||
|
finalizeAndRevokeAuthSession,
|
||||||
finalizeAuthSession,
|
finalizeAuthSession,
|
||||||
incrementAuthActivityCount,
|
incrementAuthActivityCount,
|
||||||
persistAuthSessionMetrics
|
persistAuthSessionMetrics
|
||||||
} from '../utils/authSessionMetrics.js'
|
} 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_USER_KEY = 'x-financial-auth-user'
|
||||||
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
||||||
const DEFAULT_USER_NAME = '系统管理员'
|
const DEFAULT_USER_NAME = '系统管理员'
|
||||||
@@ -79,19 +89,7 @@ function readClientBootstrapState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readAuthState() {
|
function readAuthState() {
|
||||||
if (typeof window === 'undefined') {
|
return hasValidAuthSession()
|
||||||
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 buildAnonymousUser() {
|
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 = []) {
|
function resolvePlatformAdminFlag(payload, roleCodes = []) {
|
||||||
return resolveAuthUserAdminFlag(payload, roleCodes)
|
return resolveAuthUserAdminFlag(payload, roleCodes)
|
||||||
}
|
}
|
||||||
@@ -171,12 +144,11 @@ function readStoredUser() {
|
|||||||
return normalizeStoredAuthUser(payload)
|
return normalizeStoredAuthUser(payload)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return buildLegacyAdminUser(readStoredUsername())
|
return buildAnonymousUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyUsername = readStoredUsername()
|
return buildAnonymousUser()
|
||||||
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLastActivityAt() {
|
function readLastActivityAt() {
|
||||||
@@ -188,10 +160,14 @@ function readLastActivityAt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSessionExpired(now = Date.now()) {
|
function isSessionExpired(now = Date.now()) {
|
||||||
if (!readAuthState()) {
|
if (!readAuthAccessToken()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasValidAuthSession(now)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const lastActivityAt = readLastActivityAt()
|
const lastActivityAt = readLastActivityAt()
|
||||||
|
|
||||||
if (!lastActivityAt) {
|
if (!lastActivityAt) {
|
||||||
@@ -201,24 +177,24 @@ function isSessionExpired(now = Date.now()) {
|
|||||||
return now - lastActivityAt > authIdleTimeoutMs
|
return now - lastActivityAt > authIdleTimeoutMs
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistAuthState(value, user = null, sessionId = '') {
|
function persistAuthState(value, user = null, sessionId = '', accessToken = '', expiresAt = '') {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
|
||||||
const normalizedUser = user || buildAnonymousUser()
|
const normalizedUser = user || buildAnonymousUser()
|
||||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
|
|
||||||
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
||||||
|
persistAuthCredentials(accessToken, expiresAt)
|
||||||
persistAuthSessionMetrics(sessionId)
|
persistAuthSessionMetrics(sessionId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
|
||||||
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
|
||||||
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
||||||
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
||||||
|
window.sessionStorage.removeItem('x-financial-authenticated')
|
||||||
|
window.sessionStorage.removeItem('x-financial-auth-username')
|
||||||
|
clearAuthCredentials()
|
||||||
clearAuthSessionMetrics()
|
clearAuthSessionMetrics()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +204,6 @@ function persistAuthUserSnapshot(user = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedUser = user || buildAnonymousUser()
|
const normalizedUser = user || buildAnonymousUser()
|
||||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
|
|
||||||
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +244,9 @@ function scheduleSessionTimeout() {
|
|||||||
return
|
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) {
|
if (remaining <= 0) {
|
||||||
logout('timeout', { notify: true })
|
logout('timeout', { notify: true })
|
||||||
@@ -307,6 +284,13 @@ function handleSessionActivity(event) {
|
|||||||
touchAuthActivity()
|
touchAuthActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAuthSessionExpired() {
|
||||||
|
if (!readAuthAccessToken()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logout('expired', { notify: true, revoke: false })
|
||||||
|
}
|
||||||
|
|
||||||
function handleSessionUnload(event) {
|
function handleSessionUnload(event) {
|
||||||
if (event?.type === 'pagehide' && event.persisted) {
|
if (event?.type === 'pagehide' && event.persisted) {
|
||||||
return
|
return
|
||||||
@@ -323,6 +307,7 @@ function installSessionMonitoring() {
|
|||||||
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
|
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
|
||||||
window.addEventListener(eventName, handleSessionActivity, { passive: true })
|
window.addEventListener(eventName, handleSessionActivity, { passive: true })
|
||||||
})
|
})
|
||||||
|
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, handleAuthSessionExpired)
|
||||||
window.addEventListener('pagehide', handleSessionUnload, { passive: true })
|
window.addEventListener('pagehide', handleSessionUnload, { passive: true })
|
||||||
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
|
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
|
||||||
}
|
}
|
||||||
@@ -431,7 +416,7 @@ const loginError = ref('')
|
|||||||
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
||||||
const currentUser = ref(readStoredUser())
|
const currentUser = ref(readStoredUser())
|
||||||
|
|
||||||
if (!loggedIn.value && readAuthState()) {
|
if (!loggedIn.value && readAuthAccessToken()) {
|
||||||
persistAuthState(false)
|
persistAuthState(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,7 +659,13 @@ async function handleLogin(credentials) {
|
|||||||
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
|
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
|
||||||
}
|
}
|
||||||
loggedIn.value = true
|
loggedIn.value = true
|
||||||
persistAuthState(true, user, response?.sessionId || '')
|
persistAuthState(
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
response?.sessionId || '',
|
||||||
|
response?.accessToken || '',
|
||||||
|
response?.expiresAt || ''
|
||||||
|
)
|
||||||
currentUser.value = user
|
currentUser.value = user
|
||||||
touchAuthActivity(true)
|
touchAuthActivity(true)
|
||||||
return true
|
return true
|
||||||
@@ -691,15 +682,24 @@ async function handleLogin(credentials) {
|
|||||||
function logout(reason = 'manual', options = {}) {
|
function logout(reason = 'manual', options = {}) {
|
||||||
const notify = options.notify ?? reason === 'timeout'
|
const notify = options.notify ?? reason === 'timeout'
|
||||||
const redirect = options.redirect ?? reason !== 'invalid'
|
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
|
loggedIn.value = false
|
||||||
persistAuthState(false)
|
persistAuthState(false)
|
||||||
currentUser.value = buildAnonymousUser()
|
currentUser.value = buildAnonymousUser()
|
||||||
clearSessionTimeout()
|
clearSessionTimeout()
|
||||||
|
|
||||||
if (notify) {
|
if (notify) {
|
||||||
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。')
|
toast(
|
||||||
|
reason === 'timeout' || reason === 'expired'
|
||||||
|
? '登录已失效,请重新登录。'
|
||||||
|
: '已退出登录。'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
|
|||||||
@@ -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 API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
||||||
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
|
||||||
|
|
||||||
function isHeaderValueSafe(value) {
|
function isHeaderValueSafe(value) {
|
||||||
const normalized = String(value || '').trim()
|
const normalized = String(value || '').trim()
|
||||||
@@ -33,85 +36,6 @@ export function pickSafeHeaderValue(value, fallback = '') {
|
|||||||
return ''
|
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) {
|
function normalizeApiBaseUrl(value) {
|
||||||
return String(value || '/api/v1').replace(/\/$/, '')
|
return String(value || '/api/v1').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
@@ -260,8 +184,20 @@ function sanitizeHeaders(headers) {
|
|||||||
return nextHeaders
|
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 = {}) {
|
export async function apiRequest(path, options = {}) {
|
||||||
const {
|
const {
|
||||||
|
auth = true,
|
||||||
|
handleUnauthorized = true,
|
||||||
contentType = 'application/json',
|
contentType = 'application/json',
|
||||||
responseType = 'json',
|
responseType = 'json',
|
||||||
headers: customHeaders,
|
headers: customHeaders,
|
||||||
@@ -270,10 +206,15 @@ export async function apiRequest(path, options = {}) {
|
|||||||
...fetchOptions
|
...fetchOptions
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const headers = sanitizeHeaders({
|
const headers = sanitizeHeaders(customHeaders || {})
|
||||||
...readCurrentUserHeaders(),
|
if (auth) {
|
||||||
...(customHeaders || {})
|
Object.keys(headers).forEach((key) => {
|
||||||
})
|
if (key.toLowerCase() === 'authorization') {
|
||||||
|
delete headers[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Object.assign(headers, buildBearerHeaders())
|
||||||
|
}
|
||||||
|
|
||||||
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
||||||
headers['Content-Type'] = contentType
|
headers['Content-Type'] = contentType
|
||||||
@@ -327,7 +268,7 @@ export async function apiRequest(path, options = {}) {
|
|||||||
payload = null
|
payload = null
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(resolveErrorMessage(payload))
|
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob()
|
return response.blob()
|
||||||
@@ -341,7 +282,7 @@ export async function apiRequest(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(resolveErrorMessage(payload))
|
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
|
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
|
||||||
|
import { buildBearerHeaders } from '../utils/authSessionStorage.js'
|
||||||
|
|
||||||
export function login(payload) {
|
export function login(payload) {
|
||||||
return apiRequest('/auth/login', {
|
return apiRequest('/auth/login', {
|
||||||
|
auth: false,
|
||||||
|
handleUnauthorized: false,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
@@ -11,6 +14,14 @@ export function fetchCurrentAuthUser() {
|
|||||||
return apiRequest('/auth/me')
|
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) {
|
export function finishSession(sessionId, payload) {
|
||||||
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
|
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -29,13 +40,12 @@ export function finishSessionOnUnload(sessionId, payload) {
|
|||||||
const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish`
|
const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish`
|
||||||
const body = JSON.stringify(payload || {})
|
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, {
|
window.fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...buildBearerHeaders()
|
||||||
|
},
|
||||||
body,
|
body,
|
||||||
keepalive: true
|
keepalive: true
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
|
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
|
||||||
|
import {
|
||||||
|
buildBearerHeaders,
|
||||||
|
notifyAuthSessionExpired
|
||||||
|
} from '../utils/authSessionStorage.js'
|
||||||
|
|
||||||
export function fetchStewardPlan(payload, options = {}) {
|
export function fetchStewardPlan(payload, options = {}) {
|
||||||
return apiRequest('/steward/plans', {
|
return apiRequest('/steward/plans', {
|
||||||
@@ -63,7 +67,8 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
|||||||
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
|
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
...buildBearerHeaders()
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
signal: controller?.signal
|
signal: controller?.signal
|
||||||
@@ -78,6 +83,9 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearAbortTimer()
|
clearAbortTimer()
|
||||||
|
if (response.status === 401) {
|
||||||
|
notifyAuthSessionExpired()
|
||||||
|
}
|
||||||
throw new Error(await resolveStreamError(response))
|
throw new Error(await resolveStreamError(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,10 @@ export function canAccessAppView(user, viewId) {
|
|||||||
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
|
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewId === 'settings') {
|
||||||
|
return isPlatformAdminUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
if (isManagerUser(user)) {
|
if (isManagerUser(user)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
finishSession,
|
finishSession,
|
||||||
finishSessionOnUnload
|
finishSessionOnUnload,
|
||||||
|
revokeCurrentAuthSession
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
|
|
||||||
const AUTH_SESSION_ID_KEY = 'x-financial-auth-session-id'
|
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)
|
console.warn('Failed to finish auth session:', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function finalizeAndRevokeAuthSession(reason) {
|
||||||
|
const sessionId = readStoredSessionId()
|
||||||
|
return revokeCurrentAuthSession({
|
||||||
|
...buildSessionFinishPayload(reason),
|
||||||
|
sessionId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
71
web/src/utils/authSessionStorage.js
Normal file
71
web/src/utils/authSessionStorage.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -108,6 +108,11 @@ test('budget center is visible to platform admin, budget monitor, and executive
|
|||||||
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false)
|
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', () => {
|
test('budget edit and department switching are limited to admin and senior finance', () => {
|
||||||
assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true)
|
assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true)
|
||||||
assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true)
|
assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true)
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ async function testSupportsBlobResponses() {
|
|||||||
assert.equal(payload, blob)
|
assert.equal(payload, blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testInjectsAuthenticatedUserHeaders() {
|
async function testInjectsBearerTokenWithoutUserControlledIdentityHeaders() {
|
||||||
const sessionStorage = new Map([
|
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',
|
'x-financial-auth-user',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -84,27 +86,16 @@ async function testInjectsAuthenticatedUserHeaders() {
|
|||||||
|
|
||||||
await apiRequest('/knowledge/library')
|
await apiRequest('/knowledge/library')
|
||||||
|
|
||||||
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
assert.equal(capturedOptions.headers.Authorization, 'Bearer opaque-access-token')
|
||||||
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User')
|
assert.equal(capturedOptions.headers['x-auth-username'], undefined)
|
||||||
assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager')
|
assert.equal(capturedOptions.headers['x-auth-role-codes'], undefined)
|
||||||
assert.equal(capturedOptions.headers['x-auth-grade'], 'M5')
|
assert.equal(capturedOptions.headers['x-auth-is-admin'], undefined)
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
|
async function testLoginCanDisableBearerInjection() {
|
||||||
const sessionStorage = new Map([
|
const sessionStorage = new Map([
|
||||||
[
|
['x-financial-auth-access-token', 'stale-token'],
|
||||||
'x-financial-auth-user',
|
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
|
||||||
JSON.stringify({
|
|
||||||
username: 'superadmin',
|
|
||||||
name: 'superadmin',
|
|
||||||
roleCodes: ['manager'],
|
|
||||||
is_admin: true
|
|
||||||
})
|
|
||||||
]
|
|
||||||
])
|
])
|
||||||
|
|
||||||
global.window = {
|
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.Authorization, undefined)
|
||||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
}
|
||||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
|
||||||
|
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() {
|
async function testFormatsValidationErrors() {
|
||||||
@@ -192,8 +246,10 @@ async function testRejectsWithCustomTimeoutMessage() {
|
|||||||
async function run() {
|
async function run() {
|
||||||
await testUsesCustomContentTypeHeader()
|
await testUsesCustomContentTypeHeader()
|
||||||
await testSupportsBlobResponses()
|
await testSupportsBlobResponses()
|
||||||
await testInjectsAuthenticatedUserHeaders()
|
await testInjectsBearerTokenWithoutUserControlledIdentityHeaders()
|
||||||
await testInjectsLegacyAdminHeaderFromSnakeCaseFlag()
|
await testLoginCanDisableBearerInjection()
|
||||||
|
await testRejectsCustomAuthorizationOverride()
|
||||||
|
await testUnauthorizedResponsePublishesSessionExpiredEvent()
|
||||||
await testFormatsValidationErrors()
|
await testFormatsValidationErrors()
|
||||||
await testRejectsWithCustomTimeoutMessage()
|
await testRejectsWithCustomTimeoutMessage()
|
||||||
console.log('api-request tests passed')
|
console.log('api-request tests passed')
|
||||||
|
|||||||
51
web/tests/auth-session-storage.test.mjs
Normal file
51
web/tests/auth-session-storage.test.mjs
Normal file
@@ -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')
|
||||||
35
web/tests/vite-setup-lock.test.mjs
Normal file
35
web/tests/vite-setup-lock.test.mjs
Normal file
@@ -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')
|
||||||
@@ -19,6 +19,7 @@ const adminScryptOptions = { N: 16384, r: 8, p: 1 }
|
|||||||
const adminScryptKeyLength = 64
|
const adminScryptKeyLength = 64
|
||||||
let backendStartPromise = null
|
let backendStartPromise = null
|
||||||
let backendStartState = createBackendStartState()
|
let backendStartState = createBackendStartState()
|
||||||
|
let backendStartAuthorized = false
|
||||||
|
|
||||||
function createBackendStartState() {
|
function createBackendStartState() {
|
||||||
return {
|
return {
|
||||||
@@ -400,11 +401,21 @@ function buildClientEnvUpdates(payload, apiBaseUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeState(env) {
|
export function isSetupCompletedState(env, adminConfigured) {
|
||||||
const adminConfigured = Boolean(readAdminSecret())
|
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 {
|
return {
|
||||||
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true' && adminConfigured,
|
initialized,
|
||||||
company: {
|
company: {
|
||||||
name: env.COMPANY_NAME || '',
|
name: env.COMPANY_NAME || '',
|
||||||
code: env.COMPANY_CODE || '',
|
code: env.COMPANY_CODE || '',
|
||||||
@@ -423,19 +434,27 @@ function normalizeState(env) {
|
|||||||
},
|
},
|
||||||
database: {
|
database: {
|
||||||
driver: 'postgresql',
|
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),
|
port: Number(env.POSTGRES_PORT || 5432),
|
||||||
name: env.POSTGRES_DB || 'x_financial',
|
name: env.POSTGRES_DB || 'x_financial',
|
||||||
username: env.POSTGRES_USER || 'postgres',
|
username: redactInfrastructure ? '' : env.POSTGRES_USER || 'postgres',
|
||||||
password_configured: Boolean(env.POSTGRES_PASSWORD)
|
password_configured: Boolean(env.POSTGRES_PASSWORD)
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
enabled: Boolean(env.REDIS_URL),
|
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) {
|
async function readJsonBody(req) {
|
||||||
const chunks = []
|
const chunks = []
|
||||||
|
|
||||||
@@ -829,6 +848,9 @@ function localSetupPlugin() {
|
|||||||
|
|
||||||
server.middlewares.use('/__setup/auth/login', async (req, res) => {
|
server.middlewares.use('/__setup/auth/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (rejectCompletedSetup(res)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
sendJson(res, 405, { detail: 'Method not allowed' })
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
return
|
return
|
||||||
@@ -866,6 +888,9 @@ function localSetupPlugin() {
|
|||||||
|
|
||||||
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
|
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (rejectCompletedSetup(res)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (req.method !== 'PUT') {
|
if (req.method !== 'PUT') {
|
||||||
sendJson(res, 405, { detail: 'Method not allowed' })
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
return
|
return
|
||||||
@@ -897,6 +922,9 @@ function localSetupPlugin() {
|
|||||||
|
|
||||||
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
|
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (rejectCompletedSetup(res)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (req.method !== 'PUT') {
|
if (req.method !== 'PUT') {
|
||||||
sendJson(res, 405, { detail: 'Method not allowed' })
|
sendJson(res, 405, { detail: 'Method not allowed' })
|
||||||
return
|
return
|
||||||
@@ -930,7 +958,7 @@ function localSetupPlugin() {
|
|||||||
try {
|
try {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log')
|
const logFile = path.join(rootDir, 'server', 'logs', 'bootstrap-backend.log')
|
||||||
backendStartState.logTail = readBackendLogTail(logFile)
|
backendStartState.logTail = isSetupCompleted() ? '' : readBackendLogTail(logFile)
|
||||||
sendJson(res, 200, cloneBackendStartState())
|
sendJson(res, 200, cloneBackendStartState())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -940,8 +968,16 @@ function localSetupPlugin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSetupCompleted() && !backendStartAuthorized) {
|
||||||
|
sendJson(res, 403, { detail: '系统已完成初始化,后端启动桥已锁定。' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await startBackendAndWait()
|
const result = await startBackendAndWait()
|
||||||
|
if (result.completed) {
|
||||||
|
backendStartAuthorized = false
|
||||||
|
}
|
||||||
sendJson(res, 200, result)
|
sendJson(res, 200, result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 500, {
|
sendJson(res, 500, {
|
||||||
@@ -968,6 +1004,10 @@ function localSetupPlugin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rejectCompletedSetup(res)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const currentEnv = readEnvState()
|
const currentEnv = readEnvState()
|
||||||
const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv)
|
const payload = resolveRuntimePayload(await readJsonBody(req), currentEnv)
|
||||||
const validationError = validateSetupPayload(payload)
|
const validationError = validateSetupPayload(payload)
|
||||||
@@ -1012,6 +1052,7 @@ function localSetupPlugin() {
|
|||||||
...buildClientEnvUpdates(payload, apiBaseUrl)
|
...buildClientEnvUpdates(payload, apiBaseUrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
backendStartAuthorized = true
|
||||||
sendJson(res, 201, normalizeState(readEnvState()))
|
sendJson(res, 201, normalizeState(readEnvState()))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 500, {
|
sendJson(res, 500, {
|
||||||
|
|||||||
Reference in New Issue
Block a user