新增登录接口:账号密码换访问令牌(POST /api/v1/auth/tokens)

背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
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。
This commit is contained in:
2026-09-11 20:43:21 +08:00
parent 62501747a6
commit 01ee68034d
10 changed files with 690 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
"""登录接口的端到端验证(真实 MySQL + 真实 HTTP 栈)。
这里刻意**不用替身**:登录的价值就在于"签出来的令牌能不能真的用",
用 mock 验证等于只测了自己写的桩。所以每个用例都走 `app.main.app` 的 ASGI 栈,
并且至少有一个用例拿令牌去调**另一个真实接口**。
前置:`python tools/seed_test_rbac.py`(用户与角色)与
`python tools/set_user_password.py`(演示口令)。
覆盖的安全约定(与 `app/service/auth_service.py` 的模块文档一一对应):
1. 三个角色各自能登录,且拿到的 `roles` 正确 —— 这正是"区分客户/员工/管理员"的落点;
2. 密码错与外挂账号**返回完全相同的响应**,接口不能当账号枚举器;
3. 从没设过密码的账号(占位符哈希)不能登录,且不能变成 500。
"""
from typing import Any
import httpx
import pytest
from app.api.dependencies.rate_limit import LOGIN_MAX_ATTEMPTS
from app.main import app
pytestmark = pytest.mark.integration
LOGIN_PATH = "/api/v1/auth/tokens"
#: 演示账号(tools/set_user_password.py 设置)。
DEMO_ACCOUNTS = (
("cust_t", "123456", "customer"),
("risk_t", "666666", "risk_operator"),
("admin_t", "88888888", "admin"),
)
#: `sys_user.password_hash` 仍是占位符的账号(没设过密码,不该能登录)。
PLACEHOLDER_ACCOUNTS = ("review_t", "offsite_worker")
def client() -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30
)
class _AlwaysAllowBackend:
"""恒放行:计数 1,远低于上限。"""
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
del key, window_seconds
return (1, 0)
class _AlwaysDenyBackend:
"""恒超限:用来验证登录闸门确实会拦。"""
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
del key, window_seconds
return (LOGIN_MAX_ATTEMPTS + 1, 30)
@pytest.fixture(autouse=True)
def _replace_rate_limit_backend(monkeypatch: pytest.MonkeyPatch) -> None:
"""把限流后端换成恒放行替身,只作用于本文件。
为什么必须换:本文件所有用例加起来要发十几次登录请求,而登录闸门是 60 秒 10 次。
限流对所有请求生效(包括测试自己发的),Redis 里的计数还会**跨测试累积** ——
于是后面的用例拿到 429 而不是想断言的 200/401。那是用例互相污染,不是产品缺陷。
`get_counter_backend` 正是为此留的替换点(见它的文档字符串:"模块级函数是唯一的
替换点(测试注入替身,不连 Redis)")。限流本身由下面那个用例单独验证,
不会被这个替身掩盖掉。
"""
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_counter_backend",
lambda: _AlwaysAllowBackend(),
)
@pytest.mark.asyncio
async def test_login_is_actually_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
"""登录闸门必须真的会拦 —— 它是密码爆破的唯一防线。
用一个恒超限的替身后端验证"接了闸门且会抛 429",与上面那些替身用例互补:
那些证明认证逻辑对,这个证明防线在。
"""
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_counter_backend",
lambda: _AlwaysDenyBackend(),
)
async with client() as http:
response = await login(http, "cust_t", "123456")
assert response.status_code == 429
assert response.json()["error"]["code"] == "RATE_LIMITED"
assert response.json()["error"]["retryable"] is True
async def login(
http: httpx.AsyncClient, username: str, password: str
) -> httpx.Response:
return await http.post(LOGIN_PATH, json={"username": username, "password": password})
@pytest.mark.parametrize(("username", "password", "expected_role"), DEMO_ACCOUNTS)
@pytest.mark.asyncio
async def test_each_role_can_login_with_its_own_role(
username: str, password: str, expected_role: str
) -> None:
"""客户、员工、管理员各自登录,拿到的 `roles` 就是区分三种登录的落点。"""
async with client() as http:
response = await login(http, username, password)
assert response.status_code == 200, response.text
body: dict[str, Any] = response.json()
# docs/05 §3.3:业务字段全在 data 里,meta 只有 trace_id。
assert set(body) == {"data", "meta"}
assert set(body["meta"]) == {"trace_id"}
data = body["data"]
assert data["token_type"] == "Bearer"
assert data["expires_in"] == 1800
assert expected_role in data["roles"], f"{username} 的角色里没有 {expected_role}"
assert data["access_token"]
@pytest.mark.asyncio
async def test_issued_token_actually_works_on_a_real_endpoint() -> None:
"""签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。
`GET /api/v1/users/me/memory-profile` 需要 `memory:read:self`(客户角色有),
走的是 `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve`
这条真实链路:令牌只带 `sub`,角色与权限全部查库解析。
"""
async with client() as http:
response = await login(http, "cust_t", "123456")
assert response.status_code == 200, response.text
token = response.json()["data"]["access_token"]
authorized = await http.get(
"/api/v1/users/me/memory-profile",
headers={"Authorization": f"Bearer {token}"},
)
# 200=有画像,404=该客户还没有画像行;两者都说明**令牌被接受并通过了 RBAC**。
# 401/403 则说明令牌或身份解析链有问题。
assert authorized.status_code in (200, 404), authorized.text
@pytest.mark.asyncio
async def test_missing_and_malformed_token_are_rejected() -> None:
async with client() as http:
missing = await http.get("/api/v1/users/me/memory-profile")
malformed = await http.get(
"/api/v1/users/me/memory-profile",
headers={"Authorization": "Bearer not-a-jwt"},
)
assert missing.status_code == 401
assert malformed.status_code == 401
# auth.py 的约定:令牌缺失/非法/吊销不区分,都不泄露内部原因。
assert missing.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
assert malformed.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
@pytest.mark.asyncio
async def test_wrong_password_and_unknown_user_are_indistinguishable() -> None:
"""接口不能当账号枚举器:两种失败的**状态码与消息**必须完全一致。"""
async with client() as http:
wrong_password = await login(http, "cust_t", "definitely-wrong")
unknown_user = await login(http, "no-such-user-at-all", "whatever")
assert wrong_password.status_code == 401
assert unknown_user.status_code == 401
assert wrong_password.json()["error"]["message"] == unknown_user.json()["error"]["message"]
assert wrong_password.json()["error"]["code"] == unknown_user.json()["error"]["code"]
# 也不该回显是哪个字段错了。
assert wrong_password.json()["error"]["field_errors"] == []
@pytest.mark.parametrize("username", PLACEHOLDER_ACCOUNTS)
@pytest.mark.asyncio
async def test_account_without_real_password_cannot_login(username: str) -> None:
"""没设过密码的账号(`password_hash` 是占位符)必须 401,而不是 500。
`'x'` 与 `!worker-only-no-password-login!` 都不是合法 bcrypt 格式,
`bcrypt.checkpw` 会抛 `ValueError` —— `verify_password` 吞掉它并返回 False。
"""
async with client() as http:
response = await login(http, username, "123456")
assert response.status_code == 401, response.text
@pytest.mark.asyncio
async def test_extra_fields_in_login_body_are_rejected() -> None:
"""`extra="forbid"`:调用方不能借登录接口塞身份字段。"""
async with client() as http:
response = await http.post(
LOGIN_PATH,
json={"username": "cust_t", "password": "123456", "roles": ["admin"]},
)
assert response.status_code == 422