登录接口配套:加人工具 + 给组员的转交文档(docs/29)

- tools/create_test_user.py:一条命令建"可登录的测试账号"(用户 + 角色 + bcrypt 密码),
  并用 IdentityService.resolve 打印**真实解析结果**。sys_user / sys_user_role 没有 ORM
  模型、全靠裸 SQL,手写容易漏必填字段;更要紧的是 assigned_at 那个静默陷阱(见下)。
  重复执行同一 --id 是覆盖语义,改角色也用它。
- tools/set_user_password.py:hash_password 改从 auth_service 取,消除第二份实现。
- app/service/auth_service.py:新增 hash_password,与 verify_password 放在一起,
  让"写密码"和"校验密码"永远同一套算法。
- docs/29-Agent组员登录接口使用说明.md:给组员的转交文档(接口契约、加人步骤、
  前端接入示例、常见问题、当前边界)。

文档里专门写清三条最容易踩的:

1. 登录用 username 而不是用户 id —— 演示账号是 cust_t / risk_t / admin_t,
   不是 9001/9002/9003。这条不写明,联调时一定有人按 id 试。
2. sys_user_role.assigned_at 的 DATETIME(0) 毫秒舍入陷阱:落在未来会让账号
   "登录成功但 roles=()",**不报错**。create_test_user 统一往前留 5 秒。
3. roles / data_scope 只用于前端分流界面,不是权限凭证 —— 鉴权每次请求查库解析,
   所以权限变更立即生效,前端也不该拿它们做安全判断。

另:create_test_user 的 ON DUPLICATE KEY UPDATE 用 MySQL 8.0.19+ 的 `AS new` 别名语法,
避开已弃用的 VALUES()(实测本机 8.0.27 会打弃用警告)。

验证:文档守卫 38 份无编号冲突 / ruff 干净 / mypy 183 文件 0 错 /
登录集成测试 10 passed。
This commit is contained in:
2026-09-11 20:50:41 +08:00
parent 01ee68034d
commit b1429aa364
4 changed files with 472 additions and 5 deletions
+10
View File
@@ -103,6 +103,16 @@ def issue_access_token(user_id: int) -> tuple[str, int]:
return token, expires_in
def hash_password(password: str) -> str:
"""生成 bcrypt 哈希(成本因子用库默认值)。
与 `verify_password` 放在一起,是为了让"写密码"和"校验密码"永远用同一套算法 ——
两个工具脚本(`set_user_password.py` / `create_test_user.py`)都从这里取,
避免第三次复制粘贴出不一致的实现。
"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, stored_hash: str | None) -> bool:
"""常数时间的密码校验;任何异常都当校验失败。
@@ -0,0 +1,248 @@
# 登录接口使用说明(给 Agent 组员)
> **面向**:需要联调登录 / 需要造测试账号的人
> **接口**:`POST /api/v1/auth/tokens`(`docs/05` §19 的 **A034**)
> **归属**:认证是**平台级能力**,由平台侧维护。业务分支**不要**自己加登录路由
> (会绕开公共鉴权,违反 `AGENTS.md` 规则 7)。
> **日期**:2026-09-11
---
## 1. 一句话
用 **`username` + `password`** 换一个 **Bearer 令牌**,之后所有接口带
`Authorization: Bearer <token>` 即可。**令牌里只有用户 id**,你的角色、权限、能看多少数据
全部由服务端按库里的 RBAC 实时解析——所以业务代码**永远只从 `RequestContext` 取身份**,
不要自己解析令牌,也不要硬编码 `user_id`。
---
## 2. ⚠️ 先记住:登录用的是 `username`,不是用户 id
演示账号的用户名**不是** `9001/9002/9003`:
| 用户 id | **username(登录用这个)** | 密码 | 角色 | 进去该看到什么 |
|---|---|---|---|---|
| 9001 | **`cust_t`** | `123456` | `customer` | 客户界面:自己的会话、适当性、知识问答 |
| 9002 | **`risk_t`** | `666666` | `risk_operator` | 风控界面:预警队列、证据、日报 |
| 9003 | **`admin_t`** | `88888888` | `admin` | 管理界面:配置发布、模型端点、审计 |
> 密码由 `tools/set_user_password.py` 设置。**这三种弱口令仅用于演示**,
> 上线前必须全部更换。
---
## 3. 接口契约
### 请求
```http
POST /api/v1/auth/tokens
Content-Type: application/json
{"username": "cust_t", "password": "123456"}
```
- **不需要** `Authorization` 头(本来就没有令牌)。
- 请求体**只接受这两个字段**(`extra="forbid"`)。多传 `roles`、`user_id` 之类会 **422** ——
这是有意的,身份只能由服务端解析,不能由调用方声明。
- 建议带 `X-Trace-ID`(便于和服务端日志对齐;不带也能用)。
### 成功响应(`200`,`docs/05` §3.3 统一信封)
```json
{
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 1800,
"user_id": "9001",
"roles": ["customer"],
"data_scope": "self"
},
"meta": {"trace_id": "..."}
}
```
| 字段 | 用途 |
|---|---|
| `access_token` | 后续所有请求的 `Authorization: Bearer <它>` |
| `expires_in` | 有效期(秒),当前 **1800**(30 分钟) |
| `roles` | **前端据此决定进哪个界面** |
| `data_scope` | 该身份能看的数据范围(`self` / `own_customers` / `all`) |
> ⚠️ `roles` 与 `data_scope` 是**给前端做界面分流用的**,不是权限凭证。
> 真正的鉴权每次请求都由服务端查库解析,所以**权限被改后立刻生效**,
> 不用等令牌过期、也不要用它们在前端做安全判断。
### 失败响应
| 场景 | 状态码 | 错误码 |
|---|---|---|
| 用户名不存在 / 密码错 / 账号停用 / 从没设过密码 | **401** | `AUTHENTICATION_REQUIRED` |
| 缺少字段、多传字段 | **422** | `AGENT_INPUT_INVALID` |
| 登录尝试过于频繁(60 秒 10 次) | **429** | `RATE_LIMITED`(带 `Retry-After`) |
**401 的消息对所有失败原因都一样**("用户名或密码不正确")。这是有意设计——否则这个接口
就成了账号枚举器。**前端不要试图从 401 的 message 里区分原因**,统一提示"账号或密码错误"。
```json
{"error": {"code": "AUTHENTICATION_REQUIRED", "message": "用户名或密码不正确",
"retryable": false, "field_errors": []},
"meta": {"trace_id": "..."}}
```
---
## 4. 怎么加一个测试人员
### 一条命令
```powershell
# 先看现在有哪些账号
python tools/create_test_user.py --list
# 建一个客户账号
python tools/create_test_user.py --id 9010 --username test_cust --role customer --password abc12345
# 建一个风控专员账号
python tools/create_test_user.py --id 9011 --username test_risk --role risk_operator --password abc12345
```
**角色只有三个可选值**:`customer` / `risk_operator` / `admin`(`sys_role` 里现成的三个)。
要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),那超出这个脚本的范围,
找平台侧。
### 脚本会做三件事,并**验证第四件**
1. 写 `sys_user`(含 bcrypt 密码哈希);
2. 绑 `sys_user_role`;
3. 打印**真实解析结果**:
```
[OK] id=9010 username=test_cust role=customer 已写入
解析结果:roles=('customer',) data_scope=self
权限 10 项
[OK] 可以用它登录:{"username": "test_cust", "password": "<你刚设的>"}
```
4. 第 3 步的验证很关键——它调的是 `IdentityService.resolve`,也就是请求进来时走的**同一条
链路**。**不要只看"插入成功"**:`sys_user_role` 有个静默陷阱(下节)。
### ⚠️ 一个静默陷阱(脚本已处理,但你手写 SQL 时要当心)
`MySQL` 的 `DATETIME(0)` 会把微秒**四舍五入到秒**。如果 `sys_user_role.assigned_at`
用"当前时间"写入、进位后落在**未来**,而授权校验是 `assigned_at <= now`,那么:
> 账号建好了、密码也对、**但一个角色都拿不到** —— 表现为 `roles=()`,
> 登录照样成功,**不报错**。
`tools/create_test_user.py` 统一把 `assigned_at` **往前留 5 秒**避开这个窗口。
若你手写 SQL 或另写脚本,请照做。`tools/seed_test_rbac.py` 里也有这条注释。
### 重复执行同一个 `--id`
是**覆盖**语义(更新用户名 / 密码 / 角色),不会堆出重复行。改角色也用它:
```powershell
python tools/create_test_user.py --id 9010 --username test_cust --role admin --password abc12345
```
### 单独改密码
```powershell
python tools/set_user_password.py --list # 看现状
python tools/set_user_password.py --user 9010 --password newpass123 # 改一个
python tools/set_user_password.py # 按演示规则设置 9001/9002/9003
```
---
## 5. 前端怎么接
### 登录并保存令牌
```javascript
async function login(username, password) {
const res = await fetch("/api/v1/auth/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.status === 401) throw new Error("账号或密码错误"); // 不要区分原因
if (res.status === 429) throw new Error("尝试过于频繁,请稍后再试");
if (!res.ok) throw new Error("登录失败");
const { data } = await res.json();
localStorage.setItem("token", data.access_token); // 演示够用;
return data; // 生产建议 httpOnly Cookie
}
// 按角色分流界面
const { roles } = await login("cust_t", "123456");
if (roles.includes("admin")) router.push("/admin");
else if (roles.includes("risk_operator")) router.push("/risk");
else router.push("/chat");
```
### 之后每个请求
```javascript
fetch("/api/v1/risk/alerts", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
```
### 401 的处理
拿到 **401** 就清掉令牌、回到登录页——它涵盖"令牌缺失/无效/过期/被吊销/账号停用",
不用也无法区分。**不要**在 401 里重试登录,那会撞上 429。
### SSE 接口注意
`GET /api/v1/agent-runs/{run_id}/events` 这类 SSE 端点**必须**带 `Authorization` 头,
所以**不能用原生 `EventSource`**(它设不了自定义头),要用支持自定义头的 `fetch` 流式实现
或合规的 SSE 客户端(`docs/05` §3.2)。
---
## 6. 常见问题
| 现象 | 原因 |
|---|---|
| **登录一直 401** | ① 用错用户名——是 `cust_t` 不是 `9001`;② 密码没设过(`tools/set_user_password.py --list` 会显示"占位符,无法登录");③ 账号 `status` 不是 `正常` |
| **登录成功但接口全 403** | 角色的权限不够(不是登录问题)。看该接口要求的权限码,再核对 `sys_role_permission` |
| **登录成功但 `roles` 是 `()`** | 角色没绑上,多数是 `assigned_at` 落在未来——用 `create_test_user.py` 重跑,别手写 SQL |
| **429** | 60 秒内超过 10 次登录(含失败)。等 `Retry-After` 秒 |
| **令牌用一会儿就 401** | 有效期 30 分钟。**当前还没有刷新接口**,过期就重新登录 |
| **改了这个用户的角色,但界面没变** | 前端缓存了 `roles`。令牌里不含权限,重新调一次登录接口拿最新 `roles` 即可 |
---
## 7. 当前边界(做之前先看这里)
**已有**:
- 登录(账号密码换令牌)、限流、登录成功/失败审计(写在 `interaction_audit`)。
**还没有**(别假设它们存在):
- **刷新令牌**、**注销/吊销**、**改密码接口**、**注册**、**找回密码**。
其中刷新与注销按 `docs/05` §11 归"统一身份认证模块";
`app/core/security.py` 已留好 `RevocationStore` 协议,接上 Redis 即可,属于二期。
- 令牌**过期只能重新登录**(30 分钟)。
**审计**:每次登录成功与失败都会写一条 `interaction_audit`
(`action_type` 为 `auth.login_succeeded` / `auth.login_failed`)。
排查"谁登过"用 `GET /api/v1/admin/audit-records`。
---
## 8. 给平台侧的反馈
这个接口是平台侧新加的,边界是"**只做登录**"。如果你在联调中发现:
- 需要**刷新令牌**(不想每 30 分钟重登);
- 需要**注销**(切换账号、退出登录);
- 需要更多**角色**(`operator`、`advisor`、`super_admin` 在 `bootstrap.py` 的
`allowed_roles` 里已经被引用,但 `sys_role` 里还没有对应的角色行);
- 需要**改密码**;
直接提,别自己在业务分支里加路由——认证是共用的,两边各加一套会打架。
+213
View File
@@ -0,0 +1,213 @@
"""添加一个可登录的测试账号(用户 + 角色 + 密码),并**验证它真的能拿到权限**。
## 为什么需要它
`sys_user` / `sys_user_role` / `sys_role` 这些表**没有 ORM 模型**(全项目用裸 SQL 访问,
见 `tools/seed_test_rbac.py`),手写 INSERT 要凑齐 10 个字段、还要自己算密码哈希。
更要紧的是这里有个**静默陷阱**:
MySQL 的 DATETIME(0) 会把微秒**四舍五入到秒**。若 `sys_user_role.assigned_at`
用"当前时间"写入,进位后可能落在未来,而授权校验是 `assigned_at <= now` ——
于是刚建好的账号**一个角色都拿不到**,表现为 `roles=()`,**不报错也不失败**。
`seed_test_rbac.py` 已经踩过一次(它的注释里写着)。本脚本统一把 `assigned_at`
往前留 5 秒,并在最后**用 `IdentityService.resolve` 打印真实解析结果**而不是
"插入成功" —— 后者根本不能说明这个账号能用。
## 用法
python tools/create_test_user.py --list
python tools/create_test_user.py --id 9010 --username test_cust \\
--role customer --password abc12345
重复执行同一个 `--id` 是**覆盖**语义:更新用户名 / 密码 / 角色,不会产生重复行。
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory
from app.service.auth_service import hash_password
from app.service.identity_service import IdentityService
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: 库里现成的三个角色(`tools/seed_test_rbac.py` 建的)。新用户复用它们。
#: 要引入**新角色**得同时定义它的权限集合(`sys_role_permission`),超出本脚本范围。
ROLE_IDS: dict[str, int] = {
"customer": 9001,
"risk_operator": 9002,
"admin": 9003,
}
#: 角色 → `sys_user.user_type`。注意这是 `user_type`,与 `employee_role` 不是一回事。
ROLE_USER_TYPE: dict[str, str] = {
"customer": "customer",
"risk_operator": "employee",
"admin": "employee",
}
#: 客户的开户状态。风控扫描等链路会读它,写成 `closed` 会让部分规则不成立。
FUND_ACCOUNT_STATUS: dict[str, str] = {
"customer": "已开户",
"employee": "closed",
}
#: `assigned_at` 往前留的秒数,见模块文档里的静默陷阱。
ASSIGN_BACKDATE_SECONDS = 5
async def list_users() -> None:
"""列出所有账号、它的角色与密码状态。"""
async with SessionFactory() as session:
rows = (
await session.execute(
text(
"""
SELECT u.id, u.username, u.user_type, u.status,
u.password_hash,
GROUP_CONCAT(r.role_code ORDER BY r.role_code) AS roles
FROM sys_user u
LEFT JOIN sys_user_role ur ON ur.user_id = u.id
LEFT JOIN sys_role r ON r.id = ur.role_id
GROUP BY u.id, u.username, u.user_type, u.status, u.password_hash
ORDER BY u.id
"""
)
)
).mappings().all()
print(f"{'id':<8}{'username':<18}{'user_type':<12}{'status':<8}{'roles':<28}密码")
for row in rows:
has_password = str(row["password_hash"] or "").startswith(("$2a$", "$2b$", "$2y$"))
print(
f"{row['id']:<8}{str(row['username']):<18}{str(row['user_type']):<12}"
f"{str(row['status']):<8}{str(row['roles'] or '(无角色)') or '(无角色)':<28}"
f"{'已设' if has_password else '占位符,无法登录'}"
)
async def upsert_user(
*, user_id: int, username: str, role: str, password: str
) -> int:
"""建/更新账号并绑定角色,最后验证权限能解析出来。"""
role_id = ROLE_IDS[role]
user_type = ROLE_USER_TYPE[role]
now = datetime.now(UTC).replace(tzinfo=None)
assigned_at = now - timedelta(seconds=ASSIGN_BACKDATE_SECONDS)
async with SessionFactory() as session, session.begin():
role_exists = await session.scalar(
text("SELECT id FROM sys_role WHERE id = :role_id"), {"role_id": role_id}
)
if role_exists is None:
print(f"[失败] 角色 {role}(id={role_id})不存在,先跑 tools/seed_test_rbac.py")
return 1
# 覆盖语义:同一个 id 重跑不会堆出第二行。
await session.execute(
text(
"""
INSERT INTO sys_user
(id, user_no, username, password_hash, user_type,
professional_investor_status, fund_account_status, status,
created_at, updated_at)
VALUES
(:id, :user_no, :username, :password_hash, :user_type,
'none', :fund_status, '正常', :now, :now)
AS new
ON DUPLICATE KEY UPDATE
username = new.username,
password_hash = new.password_hash,
user_type = new.user_type,
fund_account_status = new.fund_account_status,
status = '正常',
updated_at = new.updated_at
"""
),
{
"id": user_id,
"user_no": f"T-{username.upper()[:20]}",
"username": username,
"password_hash": hash_password(password),
"user_type": user_type,
"fund_status": FUND_ACCOUNT_STATUS[user_type],
"now": now,
},
)
# 角色绑定先清后插:`sys_user_role` 没有唯一约束,直接插会堆重复行。
await session.execute(
text("DELETE FROM sys_user_role WHERE user_id = :user_id"), {"user_id": user_id}
)
await session.execute(
text(
"INSERT INTO sys_user_role (user_id, role_id, assigned_at)"
" VALUES (:user_id, :role_id, :assigned_at)"
),
{"user_id": user_id, "role_id": role_id, "assigned_at": assigned_at},
)
print(f"[OK] id={user_id} username={username} role={role} 已写入")
return await verify(user_id, username, role)
async def verify(user_id: int, username: str, role: str) -> int:
"""用真实链路解析身份 —— 这是唯一能证明"这个账号能用的"方式。"""
context = await IdentityService().resolve(
RequestContext(user_id=str(user_id), trace_id="create-test-user")
)
print(f" 解析结果:roles={context.roles} data_scope={context.data_scope}")
print(f" 权限 {len(context.permissions)} 项")
if role not in context.roles:
print(
"[失败] 角色没有解析出来。最可能的原因是 assigned_at 落在了未来"
"(DATETIME(0) 的毫秒舍入),请重跑本脚本。"
)
return 1
print(f"[OK] 可以用它登录:{{\"username\": \"{username}\", \"password\": \"<你刚设的>\"}}")
return 0
async def main() -> int:
parser = argparse.ArgumentParser(description="添加可登录的测试账号")
parser.add_argument("--list", action="store_true", help="列出所有账号与角色")
parser.add_argument("--id", type=int, help="用户 id(9001-9003 已被演示账号占用)")
parser.add_argument("--username", help="登录用户名")
parser.add_argument("--role", choices=sorted(ROLE_IDS), help="角色")
parser.add_argument("--password", help="登录密码(仅限演示环境)")
args = parser.parse_args()
if args.list:
await list_users()
return 0
missing = [
name
for name, value in (
("--id", args.id), ("--username", args.username),
("--role", args.role), ("--password", args.password),
)
if value is None
]
if missing:
print(f"[失败] 缺少参数:{' '.join(missing)}(或直接用 --list 看现有账号)")
return 1
return await upsert_user(
user_id=args.id,
username=args.username,
role=args.role,
password=args.password,
)
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+1 -5
View File
@@ -29,10 +29,10 @@ import asyncio
import sys
from datetime import UTC, datetime
import bcrypt
from sqlalchemy import text
from app.infrastructure.db import SessionFactory
from app.service.auth_service import hash_password
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
@@ -49,10 +49,6 @@ DEMO_PASSWORDS: dict[str, str] = {
BCRYPT_PREFIXES = ("$2a$", "$2b$", "$2y$")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _is_real_hash(value: str | None) -> bool:
return bool(value) and str(value).startswith(BCRYPT_PREFIXES)