背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
bootstrap.py 里各 Agent 的 allowed_roles 一直是分开的(CustomerServiceAgent 只要
customer、RiskAgent 要 risk_operator/admin、PlatformProbeAgent 只要 admin),
唯独缺"怎么证明你是谁";sys_user.password_hash 字段也一直存在,只是全是占位符
(种子写 'x'、worker 身份写 !worker-only-no-password-login!),从没写过真实密码。
实现:
- app/service/auth_service.py:bcrypt 校验 + 签发只含 sub 的 JWT + 审计。令牌里只放 sub
是刻意的:角色/权限/数据范围由 IdentityService 每次请求查库解析
(identity_repository.load_context),权限变更因此立即生效,现有鉴权链路一行未改。
- app/api/controllers/auth.py + app/api/schemas/auth.py:POST /api/v1/auth/tokens,
响应含 roles/data_scope 供前端决定进哪个界面(鉴权仍以库里实时数据为准)。
- tools/set_user_password.py:设密码(客户 123456 / 员工 666666 / 管理员 88888888)。
⚠️ 脚本与文档均标注"仅限演示环境",这三种弱口令上线前必须更换。
- pyproject / requirements 加 bcrypt(cryptography 只用于 JWT,不提供密码哈希)。
安全约定(逐条有实现与测试):失败不区分原因 —— 用户不存在/密码错/账号停用返回同一条
401,否则接口就成了账号枚举器;用户不存在时也跑一次 bcrypt 以抹掉时序差异;
成功与失败都写 interaction_audit(actor_id 可空正是为失败场景准备的);绝不记录密码。
过程中踩到一个自己挖的坑:给登录路由挂了通用的 enforce_rate_limit,而它声明依赖
build_request_context ⇒ 变成"要登录先登录",所有登录都 401。改为新增
enforce_login_rate_limit:按客户端 IP 独立限流(60 秒 10 次)、不依赖认证上下文。
集成测试据此调整:注入恒放行替身隔离跨用例的计数累积,同时保留一个恒超限用例验证闸门
确实会拦 —— 不能因为加了替身就把这道防线测丢。
接口登记:docs/05 §19 加 A034;并更新 §11 —— 那里原写"JWT 签发、刷新、注销由统一身份
认证模块负责,Agent 平台不重复实现",现注明平台只做登录这一步,刷新/注销仍归该模块。
验证:ruff 干净 / mypy 183 文件 0 错 / 文档守卫 37 份无重号(此前因 docs/21 重号失败)/
unit+contract 1140 passed / integration 90 passed。
80 lines
2.2 KiB
TOML
80 lines
2.2 KiB
TOML
[build-system]
|
||
requires = ["setuptools>=75"]
|
||
build-backend = "setuptools.build_meta"
|
||
|
||
[project]
|
||
name = "jr-agent-platform"
|
||
version = "0.1.0"
|
||
description = "金融系统 Agent 通用底座"
|
||
requires-python = ">=3.13,<3.14"
|
||
dependencies = [
|
||
"fastapi>=0.115,<1",
|
||
"uvicorn[standard]>=0.34,<1",
|
||
"pydantic>=2.10,<3",
|
||
"pydantic-settings>=2.7,<3",
|
||
"python-dotenv>=1.0,<2",
|
||
"greenlet>=3.1,<4",
|
||
"sqlalchemy>=2.0,<3",
|
||
"alembic>=1.14,<2",
|
||
"asyncmy>=0.2,<1",
|
||
"pymysql>=1.1,<2",
|
||
"redis>=5.2,<6",
|
||
"neo4j>=5.28,<6",
|
||
"pymilvus>=2.5,<3",
|
||
"PyJWT>=2.10,<3",
|
||
"cryptography>=44,<51",
|
||
"bcrypt>=4.0,<5",
|
||
"httpx>=0.28,<1",
|
||
"tzdata>=2025.1,<2027",
|
||
"alibabacloud_docmind_api20220711==1.4.14",
|
||
"alibabacloud_tea_openapi>=0.4.3,<1",
|
||
"alibabacloud_tea_util>=0.3.13,<1",
|
||
"alibabacloud_credentials>=1.0.2,<2",
|
||
"python-pptx>=1.0,<2",
|
||
"python-docx>=1.1,<2",
|
||
"openpyxl>=3.1,<4",
|
||
"Pillow>=10,<12",
|
||
"matplotlib>=3.9,<4",
|
||
"python-multipart>=0.0.20,<1",
|
||
]
|
||
|
||
[project.optional-dependencies]
|
||
dev = [
|
||
"pytest>=8.3,<9",
|
||
"pytest-asyncio>=0.25,<1",
|
||
"ruff>=0.9,<1",
|
||
"mypy>=1.14,<2",
|
||
# 测试用:场外邮件 Worker 等用例走 SQLAlchemy 的 SQLite 内存库。
|
||
# 原先没声明 —— 别人的环境装完依赖跑测试,这 6 个用例会直接
|
||
# ModuleNotFoundError: No module named 'aiosqlite'。
|
||
"aiosqlite>=0.20,<1",
|
||
]
|
||
|
||
[tool.setuptools.packages.find]
|
||
include = ["app*"]
|
||
|
||
[tool.pytest.ini_options]
|
||
testpaths = ["tests"]
|
||
asyncio_mode = "auto"
|
||
asyncio_default_fixture_loop_scope = "function"
|
||
markers = ["integration: requires local database services"]
|
||
# 临时目录的落点由 `tests/conftest.py` 覆盖 `tmp_path` 决定(落在仓库内 `.workdir/pytest-tmp`)。
|
||
# 这里**不**写 `basetemp`:pytest 只在命令行认它,写在 ini 里会被静默忽略(实测无效),
|
||
# 留着会让人误以为已经配好了。系统临时目录权限坏掉的原因与修法见 conftest 的说明。
|
||
|
||
[tool.ruff]
|
||
line-length = 100
|
||
target-version = "py313"
|
||
|
||
[tool.ruff.lint]
|
||
select = ["E", "F", "I", "B", "UP"]
|
||
|
||
[tool.ruff.lint.per-file-ignores]
|
||
"alembic/versions/*.py" = ["E501"]
|
||
"tools/*.py" = ["E501", "I001", "B007"]
|
||
|
||
[tool.mypy]
|
||
python_version = "3.13"
|
||
strict = true
|
||
packages = ["app"]
|