@pylonts/dsl 1.1.16 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/token.md CHANGED
@@ -1,327 +1,341 @@
1
- # Token 令牌体系 DSL 扩展提案
2
-
3
- > 状态:**提案(待决策)**
4
- > 关联代码:`pylon/src/types.ts`(User interface)、`pylon-fastify/src/common/auth/*`(签名/JWT 鉴权策略)、`pylon-sign-db-driver`/`pylon-sign-redis-driver`(密钥解析)、`dsl/src/dto.ts`(DTO)、`dsl/src/service.ts`(service)
5
- > 背景:BLE 案例 Customer 无处安放 + `@pylonts/core` 的 User 概念过弱
6
- > **定位(已决策):推翻现有的"HMAC 签名 + JWT 登录"双层鉴权体系,合并为 token 令牌体系**——签名/加密材料与身份数据统一由同一个两态对象承载
7
-
8
- ## 1. 背景:Customer 的身份问题
9
-
10
- BLE 案例(business-ble)中,Java 侧 `Customer.java` 是 **POS 服务器端映射对象**——它由服务器上下文注入(请求进来时从卡/设备上下文解析出的客户),不是数据库表、不是 DTO 消息、不是实体行对象。pylon 迁移时它被表达成三份互相独立的重复:
11
-
12
- | 位置 | 表达方式 | 问题 |
13
- |------|---------|------|
14
- | `utils_schema/ble-api/ble-wx/utils/ble.utils.ts` | `objectField({ properties: {...} })` 内联为 `getBsId/getOpId` 的参数 | 内联,无身份、无复用 |
15
- | `dto_schema/ble-wx/ble-charge.dto.ts` | `Customer` 是一个 `buildInput` DTO | 它根本不是消息契约,是身份对象 |
16
- | 同上 | `SubmitCpuRequest.customer` 内联 `dtoObjectField` 重复同一份结构 | 与 Customer DTO 结构重复、无单一事实来源 |
17
-
18
- 三份结构必须手工保持一致,字段变了要改三处。**根本原因:pylon 没有"服务器端映射对象"这个概念**。
19
-
20
- ## 2. 安全定级依据:等保 2.0 三级
21
-
22
- 涉及银联/支付宝支付的 app(商城、收单平台、支付小程序),定级逻辑(GB/T 22240-2020):涉及资金交易 + 大规模个人信息,受破坏后"对社会秩序和公共利益造成严重损害" → **第三级(监督保护级)**。金融行业惯例:支付清算核心系统四级;支付类平台/app 三级。银联/支付宝/微信支付的合作准入(收单外包、服务商尽调)均以等保三级为门槛(案例:地铁支付宝小程序、收钱吧)。三级义务:**每年至少一次等级测评 + 渗透测试**,近 300 项要求、73 类测评分类。
23
-
24
- 三级对应用/会话层的硬性要求(GB/T 22239-2019)与本设计的映射:
25
-
26
- | 条款 | 要求 | 对 token 令牌体系的意义 |
27
- |------|------|-------------------------------|
28
- | 8.1.4.1 身份鉴别 | 身份标识唯一;**两种以上鉴别技术组合**(即双因子);登录失败锁定(≤5 次/≥30 分钟);**会话空闲超时断开** | token 固定 30 分钟过期(Redis TTL)+ refreshToken 刷新;删 token = 终止会话;双因子是登录链要补的能力 |
29
- | 8.1.4.3 安全审计 | 审计覆盖每个用户和重要操作(支付/退款/核销),记录防篡改,**保存 ≥6 个月** | Token 的 `identity` 段正是审计"谁在什么时间做了什么"的锚点;服务端 Redis 对象比 JWT payload 更便于审计追踪 |
30
- | 8.1.4.7/8.1.4.8 数据完整性/保密性 | 传输加密(TLS)、**存储加密**(银行卡号等敏感数据) | 卡号/账户字段在 Token、表、DTO 全链路要有敏感字段标记(加密落库/脱敏展示) |
31
- | 8.1.4.10 个人信息保护 | 仅采集必要信息、去标识化 | Token 字段最小化(identity + 必要业务字段) |
32
- | 移动互联扩展(附录 A2) | 移动应用加固、会话管理、防逆向 | 客户端只存 token(纯 hash)而非完整身份信息 |
33
-
34
- 结论对提案的支撑:
35
-
36
- 1. **token 令牌体系与等保三级同向**:Redis 存对象 + token 纯 hash = 会话可终止、可审计、空闲超时可控、对象实时更新(改密后旧会话即时失效);JWT 反序列化模式在"会话终止"和"审计"上是短板。
37
- 2. **双因子认证**进入落地范围(登录链生成时考虑口令 + 短信/OTP 组合)。
38
- 3. **敏感字段标记**(卡号等)是 Token 设计要考虑的维度——等保要求存储加密,字段级敏感标记驱动加解密与脱敏。
39
-
40
- ### 2.1 token 体系等保三级合规性审查
41
-
42
- 逐条对照三级要求审查 token 体系是否成立:
43
-
44
- | 条款 | 要求 | token 体系是否满足 | 结论 |
45
- |------|------|-------------------|------|
46
- | 8.1.4.1 身份鉴别 | 身份标识唯一 | token 随机 hash 唯一(Redis key) | ✅ |
47
- | 8.1.4.1 身份鉴别 | 两种以上鉴别技术组合(双因子) | 登录 = 口令 + 签名密钥(密码技术因子,token 体系已保证客户端持有)——可论证成立;**但落地必须禁止纯口令登录** | ⚠️ 落地约束 |
48
- | 8.1.4.1 身份鉴别 | 登录失败锁定(≤5 次/≥30 分钟) | 未设计 | ⚠️ 落地必须补 |
49
- | 8.1.4.1 身份鉴别 | 会话空闲超时断开 / 终止会话 | Redis TTL + 删 token | ✅ |
50
- | 8.1.4.3 安全审计 | 每用户、重要操作审计,防篡改,≥6 个月 | identity 段作审计锚点;**Redis 不承担审计——审计日志独立持久化** | ⚠️ 边界注明 |
51
- | 8.1.4.4 入侵防范 | 防重放攻击 | 签名含 timestamp + nonce(**不退役**);token 不可解析防篡改 | ✅ |
52
- | 8.1.4.7 数据完整性 | 传输完整性保护 | 每请求签名,覆盖 body + token | ✅ |
53
- | 8.1.4.8 数据保密性 | 传输加密、存储加密 | TLS + 可选加密密钥;**Redis 中的签名密钥需加密存储 + Redis 自身按等保加固**(密码认证、访问控制、超时断开) | ⚠️ 落地约束 |
54
- | 8.1.4.10 个人信息保护 | 最小化、去标识化 | identity 段字段最小化(key + 必要业务字段) | ✅ |
55
- | 附录 A2 移动互联 | 移动应用加固、会话管理 | 客户端只存纯 hash token + 密钥,无身份数据 | ✅ |
56
-
57
- **审查结论:体系成立**,前提是落地时补四个硬约束:
58
-
59
- 1. **防重放不退役**:签名机制保留 timestamp + nonce(退役的只是独立密钥解析体系);
60
- 2. **双因子落地**:登录 = 口令 + 签名密钥,禁止纯口令登录(等保要求两种鉴别技术组合);
61
- 3. **登录失败锁定**:≤5 次 / 锁 ≥30 分钟;
62
- 4. **Redis 安全加固**:签名密钥加密存储(不落明文)、Redis 密码认证 + 访问控制 + 超时断开(等保对 Redis 有专项测评)、审计日志独立持久化(Redis 不作审计载体)。
63
-
64
- ## 3. 现状:签名 + 登录双层体系,两者独立
65
-
66
- 现有鉴权是**两个互不相关的层**(现状明文:"HMAC 证明'请求来自合法客户端',JWT 证明'用户是谁'——两者独立,互不相关"):
67
-
68
- | 层 | 机制 | 服务端载体 | 密钥/凭证 | 吊销 |
69
- |----|------|-----------|----------|------|
70
- | HMAC 签名层 | 客户端对每请求签名(x-app-key/x-timestamp/x-nonce/x-signature) | `SignatureStrategy` + SignUtils,密钥由 sign-db-driver / sign-redis-driver 按需查表解析 | appKey + secret(sign/issue 签发) | nonce 防重放窗口 |
71
- | JWT 登录层 | 登录后 getToken() 附 Authorization header | `JwtStrategy`:`jwtVerify()` 后 `request.user as User` 反序列化 payload | jwt secret + payload 对象 | 可选 blacklist 检查 |
72
-
73
- 双层体系的问题:
74
-
75
- 1. **身份与通信材料分离**:验签走密钥存储、认证走 token payload,两条路径、两套过期与吊销管理;
76
- 2. **每请求两轮验证**:先验签、再验 token;
77
- 3. **User 弱**:`@pylonts/core` 手写 interface `{ id; role?; type? }`,三处各自为政构建(login 手写字面量 / HMAC `{ id: appKey }` / JWT `as User` 强转),无声明、无校验、无生成,controller 全是 `_user: User` 弃用参数;
78
- 4. **DTO 注入只映射一个 id**:`__inject` 适配器(`InjectFn = (body, user) => void`)已有设计但 User 只有 id,多属性映射无能力;
79
- 5. **安全状态不达标**:JWT 反序列化在"会话终止/审计"上是短板(§2 等保三级要求),blacklist 是补丁式吊销。
80
-
81
- ## 4. 提案:TokenSchema —— 用户身份对象
82
-
83
- **定义**:Token 是服务器端映射的用户身份对象(登录主体、客户身份),由服务器上下文注入,具有**声明式定义、唯一身份、类型生成**三个一等公民属性。**没有多来源**:每个模块(api + app)一份身份,归属确定。
84
-
85
- **口径约定**:大写 **Token** = 服务端两态对象(本 DSL 声明的对象);小写 **token** = 客户端持有的随机 hash 字符串(纯引用,不可解析)。
86
-
87
- **与现有概念的边界**:
88
-
89
- | 概念 | 回答的问题 | 数据来源 |
90
- |------|-----------|---------|
91
- | TableSchema | 数据存在哪张表 | 数据库 DDL |
92
- | DtoMessage | 消息契约长什么样 | 请求/响应体 |
93
- | EntitySchema | DAO 行对象长什么样 | 表行 |
94
- | **TokenSchema** | **"我是谁"(用户身份)** | **服务器上下文注入** |
95
-
96
- ### 4.1 DSL 声明
97
-
98
- ```ts
99
- // token_schema/api/admin/token/admin-user.token.ts
100
- export const adminUserToken = defineToken({
101
- name: 'AdminUser',
102
- description: 'admin 后台登录主体',
103
- api: api, // 归属后端(模块双定:api + app)
104
- app: admin,
105
- security: { // 安全材料段:签名、加密数据(未登录即有)
106
- ...from(apiKeyTable, [apiKeyTable.columns.app_key, apiKeyTable.columns.secret, apiKeyTable.columns.cipher]),
107
- },
108
- identity: { // 身份段:登录后才有
109
- ...from(adminUserTable, [adminUserTable.columns.id, adminUserTable.columns.username]),
110
- },
111
- key: 'id', // 日志/审计用的身份锚点字段
112
- });
113
- ```
114
-
115
- **两段结构(已决策)**:Token 与一般身份对象不同,它存储**两类数据**,且两类数据的**存在时机不同**:
116
-
117
- | 段 | 内容 | 存在时机 | 作用 |
118
- |----|------|---------|------|
119
- | `security` 安全材料段 | 签名 key、加密数据(与客户端通信材料) | **未登录即有**(appKey 签名阶段) | HMAC 验签、通道加解密 |
120
- | `identity` 身份段 | 身份数据(id/username/bsId 等) | **登录后才有** | 业务消费"我是谁" |
121
-
122
- ```
123
- 未登录态:Token = { security } (匿名客户端:只有签名、加密数据)
124
- 登录态: Token = { security } + { identity } (登录后身份附着到同一对象)
125
- ```
126
-
127
- **与 JWT 的本质区别(已决策)**:JWT 是"登录后才签发身份凭证"——无登录无 token;Token 是**两态对象**——未登录就有对象(通信材料),登录只是给对象**附着身份**。token 始终只是对象引用,不携带任何数据。
128
-
129
- **声明规则**:
130
-
131
- | 规则 | 含义 |
132
- |------|------|
133
- | `api` + `app` 双必填 | Token 归属模块,一个后端一个身份,与 service/dao 的归属规则一致 |
134
- | **所有字段全部来源于表**(已决策) | 两段字段都必须 `from()` 表列,**可以是多张表**;不允许内联字段——身份数据不是凭空声明的,都有表落点(`from` 天然继承类型/校验/语义) |
135
- | `key` 指定身份锚点字段 | 必须指向 identity 段中存在的必填字段(日志、审计用),替代现在 `user.id` 的约定 |
136
-
137
- **身份有效数据的确定时机(已决策):login 时**——登录成功时从表读取 identity 段字段,附着到 Redis 已有对象(security 段在获取 token 时已存在);此后身份有效数据以对象为准(不再回查表)。对象失效(删 token)即重新登录。
138
-
139
- ### 4.2 token 令牌体系(已决策)
140
-
141
- **token 形态(已决策)**:token 是 **xx 位随机数(hash 值)**——只有令牌信息、无任何其他数据、**不可解析**。它只是一个引用,无 payload、无 exp(过期完全由 Redis 键 TTL 管理),**JWT 完全退役**。
142
-
143
- **客户端持有(已决策)**:
144
-
145
- | 材料 | 必须性 | 获取时机 |
146
- |------|--------|---------|
147
- | token(随机 hash) | 必须 | 调用"获取 token"接口后 |
148
- | 签名密钥 | 必须 | 调用"获取 token"接口后 |
149
- | 加密密钥 | 可选 | 调用"获取 token"接口后 |
150
- | 业务数据(身份数据) | 不一定需要 | 调用 login 后 |
151
-
152
- **客户端使用规则(已决策)**:
153
-
154
- - token **每次请求必须上送**——**唯一例外:获取 token 接口**(首个请求,无 token 可上送);
155
- - **所有接口都需要签名**(包括获取 token 接口——初始密钥见下方"bootstrap 已决策:方案 2");无 token 的请求(获取 token、未携带 token 的 login)用初始密钥签名;
156
- - 签名密钥对请求签名(token 在签名范围内),加密密钥用于可选通道加密。
157
-
158
- **接口清单(已决策)**:
159
-
160
- | 接口 | 场景 | 是否上送 token | 签名材料 | 返回 |
161
- |------|------|--------------|---------|------|
162
- | 获取 token | 仅未登录浏览场景(可选) | **否**(首个请求) | 初始密钥(方案 2) | token + 签名密钥(+ 加密密钥?);**不返回 refreshToken** |
163
- | login | 所有场景必选 | 有则上送(未登录浏览场景的匿名 token;微信等已登录场景无 token) | 有 token 用其 security 段;无 token 用初始密钥 | 新 token + refreshToken + 业务数据? |
164
- | refresh | 所有场景必选 | 否(上送 refreshToken) | **refreshToken 派生密钥**(`HMAC(refreshToken, 固定盐)`,两端可算,已决策) | 新 token |
165
-
166
- **标准配置 = login + refresh 两个接口**(微信等已有登录体系的场景);**支持未登录浏览时加"获取 token"接口,共 3 个**。refreshToken **只在 login 时返回**,获取 token 接口不返回。
167
-
168
- **服务端存储(已决策)**:**Redis token 存储信息**——签名密钥、加密密钥等通讯信息(security 段)+ 用户身份信息(identity 段,login 之后)。**token TTL 固定 30 分钟**,过期后经 refreshToken 重新生成。
169
-
170
- **生命周期**:
171
-
172
- ```
173
- 获取 token:生成 token(随机 hash)→ 生成签名密钥(+ 可选加密密钥)
174
- Redis token { security }
175
- → 返回客户端 { token, 签名密钥, 加密密钥? }(**不返回 refreshToken**)
176
- 登录: 客户端签名请求(上送凭据;未登录浏览场景同时上送匿名 token
177
- → 验签(有 token 用其 security 段;无 token 用初始密钥)
178
- → 凭据校验 → **单点确认**(查用户表旧 token → 删 Redis 旧对象)
179
- → **若上送匿名 token 则删除其 Redis 对象**(未登录浏览场景)
180
- → 生成新 token + refreshToken → 存用户表(token + refresh_token + login_at 列)
181
- 按 identity 段从表读身份数据 → Redis 存新对象(两态演进)
182
- 返回客户端 { 新 token, refreshToken, 业务数据? }
183
- 业务请求:客户端上送 token + 签名 服务端按 token Redis → 一次还原对象
184
- (security 段验签/加解密,identity 段供业务——原两轮验证合成一步)
185
- 查不到 = 401(删 token 即吊销,blacklist 退役;防重放仍靠签名 timestamp + nonce)
186
- 刷新: 上送 refreshToken → 按 refreshToken 查账户表定位用户(单点校验)
187
- 查表取 refreshToken 派生密钥验签(HMAC(refreshToken, 固定盐))
188
- 重新生成 token(新 hash)→ 更新 Redis 映射 → 返回新 token
189
- 退出: Redis 删除 token(服务器端主动失效)
190
- ```
191
-
192
- **刷新机制(已决策)**:
193
-
194
- - **token 有效期 30 分钟**(Redis 键 TTL),过期后可刷新;
195
- - **refreshToken 只在 login 时返回**(获取 token 接口不返回),用 refreshToken 重新生成 token(延长有效期);
196
- - **token + refreshToken 都存储于本系统账户表**(账户表 token 列 + refresh_token 列,一用户一行);
197
- - **login 时单点确认**:查询用户表已有 token → **删除旧 token 对应的数据**(Redis 旧对象删除,旧会话立即失效)→ 生成新 token + refreshToken 写回用户表——后登录踢掉前登录;
198
- - **login 时直接生成 token + refreshToken 存用户表,并记录登录时间(login_at 列;命名约定:精确时间列统一用 at 后缀)**;
199
- - **refreshToken 过期时间 7 天(可配置,默认 7 天),从登录时间起算**(行业惯例 7~30 天:微信 30 天 / 支付宝 40 天 / Auth0 30 天;支付自建账户体系普遍取 7 天下限)——**过期即失效,必须重新 login**;
200
- - **轮换策略(已决策:不轮换)**:refreshToken 只在 login 时生成、固定不变至过期——刷新只重新生成 token(写 token 列),refreshToken 不换;7 天到期强制重新 login;
201
- - refreshToken **仅刷新接口上送**,业务请求不上送。
202
-
203
- **refresh 接口签名(已决策:refreshToken 派生密钥)**:refresh 的签名密钥由 refreshToken 派生(`HMAC(refreshToken, 固定盐)`)——**能正确签名 == 持有 refreshToken**,签名与凭据一体、每用户独立;不依赖全局初始密钥(初始密钥泄露不波及 refresh)。服务端验签天然同源:按 refreshToken 查账户表的结果即派生密钥输入,零额外查询。
204
-
205
- **单点确认与刷新流程**:
206
-
207
- ```
208
- login 单点:查询用户表已有 token 删除 Redis 中旧 token 对象(旧会话立即失效)
209
- 生成新 token + refreshToken 存用户表(token + refresh_token + login_at 列)+ Redis
210
- 刷新: 上送 refreshToken refreshToken 查账户表定位用户 + 校验登录时间未超 7
211
- → 派生密钥验签(HMAC(refreshToken, 固定盐))
212
- 重新生成 token(新 hash)→ 更新用户表 token + Redis 映射 返回新 token
213
- ```
214
-
215
- **双层 → 单层对照**:
216
-
217
- | 维度 | 现状(签名 + 登录双层) | 目标(token 令牌单层) |
218
- |------|------------------------|----------------------|
219
- | 层数 | HMAC 签名层 + JWT 登录层,独立互不相关 | 一个 token → 一个两态对象 |
220
- | token 形态 | JWT:payload 塞 user 对象,客户端可解析 | **纯随机 hash**:不可解析、无数据 |
221
- | 匿名态 | 只有验签(无对象) | **有对象**:只有 security |
222
- | 登录 | 签发携带身份的新凭证 | **身份附着到已有对象**(两态演进) |
223
- | 验证次数 | 每请求两轮(先验签、再验 token) | 一次还原(验签材料与身份同对象) |
224
- | 密钥管理 | appKey+secret / jwt secret 两套 | security 段统一承载(Redis 随 token 存) |
225
- | 吊销 | blacklist 补丁 | 删 token 即吊销,blacklist 退役;**防重放保留**(签名含 timestamp + nonce) |
226
- | 过期 | JWT exp + blacklist 双轨 | **token 固定 30 分钟**(Redis TTL)+ refreshToken 刷新重新生成 |
227
- | 对象更新 | 旧 token 携带旧数据 | Redis 对象实时 |
228
- | 信息暴露 | 用户信息在客户端可解 | 客户端只见 hash |
229
- | 存储 | 密钥表按需查 + 无身份存储 | **Redistoken { 通讯信息 + 身份信息 }** |
230
-
231
- **与现有 HMAC 体系的关系**:客户端签名行为保留(所有请求都签名),验签所需的签名密钥由 Redis 中对象的 security 段提供——获取 token 后即存在。**获取 token 接口本身也要签名**(推翻现有"唯一例外")。
232
-
233
- #### 初始密钥(bootstrap,已决策:方案 2)
234
-
235
- 获取 token 接口要签名,但客户端此时还没有签名密钥——需要**初始密钥**保障两端一致,两个候选方案:
236
-
237
- **方案 1:deviceId 即初始密钥**
238
-
239
- - 客户端固定自己的 deviceId(native app 读设备标识;**H5 只能是随机数,客户端保存**);
240
- - 获取 token 时同时上送 deviceId,作为初始化签名密钥——服务端按此验证首次签名,随后签发真密钥。
241
-
242
- **方案 2:约定算法推导(固定密钥 + 时间窗口)**
243
-
244
- - 双方内置固定密钥(客户端包内 + 服务端配置);
245
- - 双方约定算法按时间窗口计算一个动态密钥(如 `HMAC(固定密钥, 时间窗口)`)——**两端独立计算,结果一样**,首次请求用它签名。
246
-
247
- | 维度 | 方案 1:deviceId | 方案 2:约定算法 |
248
- |------|-----------------|----------------|
249
- | 两端一致性 | 客户端生成 → 上送服务端(事后一致) | 双方独立计算(事前一致) |
250
- | 服务端可验证性 | 弱:服务端无法预知 deviceId,首次请求本质是"信任上送" | 强:服务端独立验算,无需信任首次请求 |
251
- | 密钥保密性 | 弱:deviceId 是公开标识,非秘密;明文上送 | 强:密钥不经过网络 |
252
- | 防重放 | 无(固定 deviceId 签名可重放) | 时间窗口天然限制,窗口过期失效 |
253
- | 密钥轮换 | 无 | 动态轮换(窗口粒度) |
254
- | 泄露风险 | deviceId 可伪造/复制;H5 随机数清缓存即失、可被拷走 | 固定密钥被逆向提取(native 加固 / H5 混淆缓解有限)→ 全局失效,需服务端可更换 + 客户端更新 |
255
- | 实现复杂度 | 低(无预置密钥管理) | 中(时钟同步、窗口容忍、密钥管理) |
256
-
257
- **风险与缓解**:两方案都需要 TLS 保护传输。方案 1 的安全强度依赖"首次信任",适合低风险通道;方案 2 强度更高(服务端可预验证、密钥动态),但固定密钥泄露是全局性风险,需内置密钥可轮换机制。**已决策:方案 2(约定算法推导:固定密钥 + 时间窗口)**——服务端可独立验算、密钥不经过网络、时间窗口天然防重放;落地约束:时钟同步(服务端容忍 ±1 窗口)、固定密钥服务端可轮换 + 客户端可更新。
258
-
259
- ### 4.3 消费方改造
260
-
261
- | 消费方 | 现状 | 改造后 |
262
- |--------|------|--------|
263
- | `SignatureStrategy`(HMAC 验签) | 验签后查密钥表,`{ id: appKey }` | **并入对象还原**:验签材料取自 Redis 对象 security |
264
- | `JwtStrategy` | `request.user as User` + 可选 blacklist | 退役——token 是 hash 不可解析,按 token 查 Redis 还原对象,查不到 401 |
265
- | 获取 token 接口 | sign/issue 签发 appKey+secret(唯一免签名 @Public 接口) | 生成 token hash + 签名密钥(+可选加密密钥)存 Redis;**接口本身也要签名**(初始密钥方案 2:固定密钥 + 时间窗口) |
266
- | `@Public()` 装饰器 | 标记免签名接口(sign/issue 唯一使用) | **退役——所有接口都必须签名,无豁免**(获取 token 接口用初始密钥签名) |
267
- | `signToken/verifyToken` | `(user: User)` 弱类型 | 退役——无 JWT 签发/验证 |
268
- | 登录服务 | `signToken({ id: String(row.id), type: 'admin' })` 手写 | 身份附着到 Redis 已有对象(机器校验 identity 段字段齐全),返回业务数据(可选) |
269
- | controller handler | `(body, _user: User)` | `(body, token: AdminUserToken)`,按模块类型化 |
270
- | 黑名单 checker | `(user: User)` | 机制退役(删 token 替代) |
271
- | **DTO 字段注入** | `__inject` 适配器 `(body, user) => void`,只映射一个 id | **`from(token)`:DTO 字段从 Token 映射多个属性**,自动标记服务器注入(客户端不传、运行时填充) |
272
- | utils/service/flow 方法参数 | `objectField` 内联(Customer 现状) | `args: { customer: customerToken.fields }` 或直接引用 Token |
273
-
274
- ### 4.4 Customer 落地(本案例的落点,已决策:打散 + 注入)
275
-
276
- 1. `schema/customer.table.ts` 保留——Customer 对应一张用户表;
277
- 2. `customerToken = defineToken({ name: 'Customer', api: bleApi, app: bleWx, security: { ...from(密钥表, [...]) }, identity: { ...from(customer, [id, account, device, channel, phone]) }, key: 'id' })`——POS 服务器映射的客户身份,单一事实来源;
278
- 3. **SubmitCpuRequest.customer 打散**:嵌套对象拆为顶层字段,从 Token 映射——`from(customerToken)` 展开 id/account/device/channel/phone DTO,字段自动标记为服务器注入(客户端不传,运行时由 Token 填充,复用现有 `__inject` 机制、从"一个 id"扩展为"多个属性"):
279
-
280
- ```ts
281
- // dto_schema/ble-wx/ble-charge.dto.ts
282
- export const SubmitCpuRequest = buildInput('SubmitCpuRequest', {
283
- ...from(customerToken, [id, account, device, channel, phone]), // 注入字段
284
- fee: dtoField(intField({ optional: false, label: '充值金额(分)' })),
285
- devId: dtoField(stringField({ maxLength: 32, optional: false, label: '设备号' })),
286
- // ...其余 POS 参数
287
- });
288
- ```
289
-
290
- 4. `ble.utils.ts` 的 `customerParam` 内联删除,`getBsId/getOpId` 参数改引用 Token 字段;
291
- 5. `Customer` 这个独立 buildInput DTO 删除(无消费者,字段已并入 SubmitCpuRequest)。
292
-
293
- ### 4.5 目录与校验
294
-
295
- - 存放:`token_schema/{api.name}/{app.name}/token/{name}.token.ts`——与 service_schema/dao_schema 同布局。
296
- - lint(`loadTokens` + `pylonts lint token`):目录规则、一文件一 Token、**字段列绑定规则(两段字段都必须 `from()` 表列,可跨多张表;禁止内联字段)**、`key` 指向 identity 段中存在的必填字段、security/identity 两段区分校验(identity 段字段不能出现在 security 段)、api+app 归属校验(app ∈ api.apps)。
297
-
298
- ## 5. 落地步骤
299
-
300
- | 步骤 | 内容 | 依赖 |
301
- |------|------|------|
302
- | 1 | `dsl/src/token.ts`:`TokenSchema` + `defineToken`(api+app 归属、security/identity 两段全 from 表列可跨表、`key`)+ 定义期校验 | 无 |
303
- | 2 | `dsl/src/dto.ts`:`from()` 支持 Token 源(展开字段 + 标记服务器注入);typebox-driver 渲染注入元数据 | 1 |
304
- | 3 | `pylon/src/validation.ts`:`InjectFn` `(body, user)` 扩展为 `(body, token)`,支持多属性映射;生成物消费 | 1 |
305
- | 4 | 类型生成:TypeBox schema + TS 类型(前端类型) | 1、2 |
306
- | 5 | Redis 存储:token { security + identity 段 } 映射(TTL 30 分钟 + refreshToken 刷新) | |
307
- | 6 | `pylon-fastify` auth 链**合并改造**:login + refresh 两个接口(未登录浏览场景加获取 token 接口,共 3 个)/ 获取 token 接口生成 token hash + 签名/加密密钥存 Redis(不返回 refreshToken)/ SignatureStrategy 并入对象还原(验签材料取自 security 段,timestamp + nonce 防重放保留;无 token 请求用初始密钥)/ login 按声明读表附着 identity 段 + 单点确认 + 匿名 token 清理 / JwtStrategy + signToken + blacklist + **@Public 退役**(所有接口必须签名,无豁免) | 1、5 |
308
- | 6a | **等保三硬约束落地**:① 双因子登录(口令 + 签名密钥,禁止纯口令);② 登录失败锁定(≤5 次 / 锁 ≥30 分钟);③ Redis 安全加固(签名密钥加密存储、密码认证、访问控制、超时断开);审计日志独立持久化(Redis 不作审计载体) | 6 |
309
- | 7 | 登录链生成(cli admin-login / sign)产出 Token 构造代码 | 4、6 |
310
- | 8 | 存储规则 + lint:`loadTokens` + `pylonts lint token` | 1 |
311
- | 9 | BLE Customer 落地(标准验收用例):customer 表 + CustomerToken + SubmitCpuRequest 打散注入,删除 utils/DTO 重复 | 1、2、8 |
312
-
313
- ## 6. 决策状态
314
-
315
- | # | 决策点 | 状态 |
316
- |---|--------|------|
317
- | 1 | **体系定位** | **已决策:推翻"HMAC 签名 + JWT 登录"双层体系,合并为 token 令牌单层**——签名/加密材料与身份统一由两态对象承载 |
318
- | 2 | Token 字段来源 | **已决策:所有字段全部来源于表,可以是多张表**(禁止内联字段) |
319
- | 3 | **两段结构** | **已决策:`security` 段(签名/加密数据,未登录即有)+ `identity` 段(身份数据,登录后附着)**——两态对象,token 仅对象引用 |
320
- | 4 | **token 形态** | **已决策:纯随机 hash**——只有令牌信息、不可解析、无 payload/exp,JWT 完全退役 |
321
- | 5 | 多来源(jwt/hmac/third) | **已决策:不需要**——Token 就是用户身份,单一概念,归属模块确定 |
322
- | 6 | 身份有效数据确定时机 | **已决策:login 时**——登录时从表读取 identity 段字段附着到 Redis 已有对象;login 返回业务数据(不一定需要) |
323
- | 7 | 安全材料字段 | **已决策:security 段携带签名密钥(必须)、加密密钥(可选)**——获取 token 时建立,验签/加解密消费对象 |
324
- | 8 | **客户端使用规则** | **已决策:token 每次上送(唯一例外:获取 token 接口);所有接口都签名,无豁免——`@Public` 退役**(获取 token 等无 token 请求用**初始密钥方案 2**:固定密钥 + 时间窗口约定算法,两端独立计算) |
325
- | 9 | `SubmitCpuRequest.customer` 处理 | **已决策:打散融入 DTO + 服务端变量注入**(`from(token)` 多属性映射,复用/扩展现有 `__inject` 机制) |
326
- | 10 | **存储选型** | **已决策:Redis**(token → 通讯信息 + 身份信息) |
327
- | 11 | **刷新机制** | **已决策:token 有效期 30 分钟;refreshToken 只在 login 时返回**(获取 token 接口不返回);**标准接口 = login + refresh,未登录浏览场景加获取 token 接口共 3 个**;**token + refreshToken + 登录时间(login_at)都存账户表;login 时单点确认——查用户表旧 token、删除对应 Redis 数据、直接生成新 token + refreshToken 写回(上送匿名 token 则一并删除)**;**refreshToken 有效期 7 天(可配置,默认 7 天),从登录时间起算,过期即失效必须重新 login**;**轮换策略已决策:不轮换**(refreshToken 只在 login 时生成,刷新只换 token) |
1
+ # Token 令牌体系 DSL 扩展提案
2
+
3
+ > 状态:**提案(已定稿,待实现)**
4
+ > 关联代码:`pylon/src/types.ts`(User interface)、`pylon-fastify/src/common/auth/*`(签名/JWT 鉴权策略)、`pylon-sign-db-driver`/`pylon-sign-redis-driver`(密钥解析)、`dsl/src/dto.ts`(DTO)、`dsl/src/service.ts`(service)
5
+ > 背景:BLE 案例 Customer 无处安放 + `@pylonts/core` 的 User 概念过弱
6
+ > **定位(已决策):推翻现有的"HMAC 签名 + JWT 登录"双层鉴权体系,合并为 token 令牌体系**——签名/加密材料与身份数据统一由同一个两态对象承载
7
+
8
+ ## 1. 背景:Customer 的身份问题
9
+
10
+ BLE 案例(business-ble)中,Java 侧 `Customer.java` 是 **POS 服务器端映射对象**——它由服务器上下文注入(请求进来时从卡/设备上下文解析出的客户),不是数据库表、不是 DTO 消息、不是实体行对象。pylon 迁移时它被表达成三份互相独立的重复:
11
+
12
+ | 位置 | 表达方式 | 问题 |
13
+ |------|---------|------|
14
+ | `utils_schema/ble-api/ble-wx/utils/ble.utils.ts` | `objectField({ properties: {...} })` 内联为 `getBsId/getOpId` 的参数 | 内联,无身份、无复用 |
15
+ | `dto_schema/ble-wx/ble-charge.dto.ts` | `Customer` 是一个 `buildInput` DTO | 它根本不是消息契约,是身份对象 |
16
+ | 同上 | `SubmitCpuRequest.customer` 内联 `dtoObjectField` 重复同一份结构 | 与 Customer DTO 结构重复、无单一事实来源 |
17
+
18
+ 三份结构必须手工保持一致,字段变了要改三处。**根本原因:pylon 没有"服务器端映射对象"这个概念**。
19
+
20
+ ## 2. 安全定级依据:等保 2.0 三级
21
+
22
+ 涉及银联/支付宝支付的 app(商城、收单平台、支付小程序),定级逻辑(GB/T 22240-2020):涉及资金交易 + 大规模个人信息,受破坏后"对社会秩序和公共利益造成严重损害" → **第三级(监督保护级)**。金融行业惯例:支付清算核心系统四级;支付类平台/app 三级。银联/支付宝/微信支付的合作准入(收单外包、服务商尽调)均以等保三级为门槛(案例:地铁支付宝小程序、收钱吧)。三级义务:**每年至少一次等级测评 + 渗透测试**,近 300 项要求、73 类测评分类。
23
+
24
+ 三级对应用/会话层的硬性要求(GB/T 22239-2019)与本设计的映射:
25
+
26
+ | 条款 | 要求 | 对 token 令牌体系的意义 |
27
+ |------|------|-------------------------------|
28
+ | 8.1.4.1 身份鉴别 | 身份标识唯一;**两种以上鉴别技术组合**(即双因子);登录失败锁定(≤5 次/≥30 分钟);**会话空闲超时断开** | token 固定 30 分钟过期(Redis TTL)+ refreshToken 刷新;删 token = 终止会话;双因子是登录链要补的能力 |
29
+ | 8.1.4.3 安全审计 | 审计覆盖每个用户和重要操作(支付/退款/核销),记录防篡改,**保存 ≥6 个月** | Token 的 `identity` 段正是审计"谁在什么时间做了什么"的锚点;服务端 Redis 对象比 JWT payload 更便于审计追踪 |
30
+ | 8.1.4.7/8.1.4.8 数据完整性/保密性 | 传输加密(TLS)、**存储加密**(银行卡号等敏感数据) | 卡号/账户字段在 Token、表、DTO 全链路要有敏感字段标记(加密落库/脱敏展示) |
31
+ | 8.1.4.10 个人信息保护 | 仅采集必要信息、去标识化 | Token 字段最小化(identity + 必要业务字段) |
32
+ | 移动互联扩展(附录 A2) | 移动应用加固、会话管理、防逆向 | 客户端只存 token(纯 hash)而非完整身份信息 |
33
+
34
+ 结论对提案的支撑:
35
+
36
+ 1. **token 令牌体系与等保三级同向**:Redis 存对象 + token 纯 hash = 会话可终止、可审计、空闲超时可控、对象实时更新(改密后旧会话即时失效);JWT 反序列化模式在"会话终止"和"审计"上是短板。
37
+ 2. **双因子认证**进入落地范围(登录链生成时考虑口令 + 短信/OTP 组合)。
38
+ 3. **敏感字段标记**(卡号等)是 Token 设计要考虑的维度——等保要求存储加密,字段级敏感标记驱动加解密与脱敏。
39
+
40
+ ### 2.1 token 体系等保三级合规性审查
41
+
42
+ 逐条对照三级要求审查 token 体系是否成立:
43
+
44
+ | 条款 | 要求 | token 体系是否满足 | 结论 |
45
+ |------|------|-------------------|------|
46
+ | 8.1.4.1 身份鉴别 | 身份标识唯一 | token 随机 hash 唯一(Redis key) | ✅ |
47
+ | 8.1.4.1 身份鉴别 | 两种以上鉴别技术组合(双因子) | 登录 = 口令 + 签名密钥(密码技术因子,token 体系已保证客户端持有)——可论证成立;**但落地必须禁止纯口令登录** | ⚠️ 落地约束 |
48
+ | 8.1.4.1 身份鉴别 | 登录失败锁定(≤5 次/≥30 分钟) | 未设计 | ⚠️ 落地必须补 |
49
+ | 8.1.4.1 身份鉴别 | 会话空闲超时断开 / 终止会话 | Redis TTL + 删 token | ✅ |
50
+ | 8.1.4.3 安全审计 | 每用户、重要操作审计,防篡改,≥6 个月 | identity 段作审计锚点;**Redis 不承担审计——审计日志独立持久化** | ⚠️ 边界注明 |
51
+ | 8.1.4.4 入侵防范 | 防重放攻击 | 签名含 timestamp + nonce(**不退役**);token 不可解析防篡改 | ✅ |
52
+ | 8.1.4.7 数据完整性 | 传输完整性保护 | 每请求签名,覆盖 body + token | ✅ |
53
+ | 8.1.4.8 数据保密性 | 传输加密、存储加密 | TLS + 可选加密密钥;**Redis 中的签名密钥需加密存储 + Redis 自身按等保加固**(密码认证、访问控制、超时断开) | ⚠️ 落地约束 |
54
+ | 8.1.4.10 个人信息保护 | 最小化、去标识化 | identity 段字段最小化(身份主键 + 必要业务字段) | ✅ |
55
+ | 附录 A2 移动互联 | 移动应用加固、会话管理 | 客户端只存纯 hash token + 密钥,无身份数据 | ✅ |
56
+
57
+ **审查结论:体系成立**,前提是落地时补四个硬约束:
58
+
59
+ 1. **防重放不退役**:签名机制保留 timestamp + nonce(退役的只是独立密钥解析体系);
60
+ 2. **双因子落地**:登录 = 口令 + 签名密钥,禁止纯口令登录(等保要求两种鉴别技术组合);
61
+ 3. **登录失败锁定**:≤5 次 / 锁 ≥30 分钟;
62
+ 4. **Redis 安全加固**:签名密钥加密存储(不落明文)、Redis 密码认证 + 访问控制 + 超时断开(等保对 Redis 有专项测评)、审计日志独立持久化(Redis 不作审计载体)。
63
+
64
+ ## 3. 现状:签名 + 登录双层体系,两者独立
65
+
66
+ 现有鉴权是**两个互不相关的层**(现状明文:"HMAC 证明'请求来自合法客户端',JWT 证明'用户是谁'——两者独立,互不相关"):
67
+
68
+ | 层 | 机制 | 服务端载体 | 密钥/凭证 | 吊销 |
69
+ |----|------|-----------|----------|------|
70
+ | HMAC 签名层 | 客户端对每请求签名(x-app-key/x-timestamp/x-nonce/x-signature) | `SignatureStrategy` + SignUtils,密钥由 sign-db-driver / sign-redis-driver 按需查表解析 | appKey + secret(sign/issue 签发) | nonce 防重放窗口 |
71
+ | JWT 登录层 | 登录后 getToken() 附 Authorization header | `JwtStrategy`:`jwtVerify()` 后 `request.user as User` 反序列化 payload | jwt secret + payload 对象 | 可选 blacklist 检查 |
72
+
73
+ 双层体系的问题:
74
+
75
+ 1. **身份与通信材料分离**:验签走密钥存储、认证走 token payload,两条路径、两套过期与吊销管理;
76
+ 2. **每请求两轮验证**:先验签、再验 token;
77
+ 3. **User 弱**:`@pylonts/core` 手写 interface `{ id; role?; type? }`,三处各自为政构建(login 手写字面量 / HMAC `{ id: appKey }` / JWT `as User` 强转),无声明、无校验、无生成,controller 全是 `_user: User` 弃用参数;
78
+ 4. **DTO 注入只映射一个 id**:`__inject` 适配器(`InjectFn = (body, user) => void`)已有设计但 User 只有 id,多属性映射无能力;
79
+ 5. **安全状态不达标**:JWT 反序列化在"会话终止/审计"上是短板(§2 等保三级要求),blacklist 是补丁式吊销。
80
+
81
+ ## 4. 提案:TokenSchema —— 用户身份对象
82
+
83
+ **定义**:Token 是服务器端映射的用户身份对象(登录主体、客户身份),由服务器上下文注入,具有**声明式定义、唯一身份、类型生成**三个一等公民属性。**没有多来源**:每个模块(api + app)一份身份,归属确定。
84
+
85
+ **口径约定**:大写 **Token** = 服务端两态对象(本 DSL 声明的对象);小写 **token** = 客户端持有的随机 hash 字符串(纯引用,不可解析)。
86
+
87
+ **与现有概念的边界**:
88
+
89
+ | 概念 | 回答的问题 | 数据来源 |
90
+ |------|-----------|---------|
91
+ | TableSchema | 数据存在哪张表 | 数据库 DDL |
92
+ | DtoMessage | 消息契约长什么样 | 请求/响应体 |
93
+ | EntitySchema | DAO 行对象长什么样 | 表行 |
94
+ | **TokenSchema** | **"我是谁"(用户身份)** | **服务器上下文注入** |
95
+
96
+ ### 4.1 DSL 声明
97
+
98
+ ```ts
99
+ // token_schema/api/admin/token/admin-user.token.ts
100
+ export const adminUserToken = defineToken({
101
+ name: 'AdminUser',
102
+ description: 'admin 后台登录主体',
103
+ api: api, // 归属后端(模块双定:api + app)
104
+ app: admin,
105
+ security: { // 安全材料段:内建字段(未登录即有)
106
+ secret: dtoField(stringField({ minLength: 32, maxLength: 64, label: '签名密钥' })), // 必选:签名
107
+ cipher: dtoField(stringField({ optional: true, label: '加密密钥' })), // 可选:通道加密
108
+ },
109
+ identity: { // 身份段:登录后才有(表必须含 token + refresh_token + login_at 列,硬约束)
110
+ ...from(adminUserTable, [adminUserTable.columns.id, adminUserTable.columns.username]),
111
+ },
112
+ });
113
+ ```
114
+
115
+ **字段承载(已决策:TokenSchema 内部装 dtoField)**:两段都是 `Record<string, DtoField>`,但来源不同——**identity 段字段必须 `from(表, 列)` 投影**(复用 DTO 的 `from()`,返回的正是 DtoField;身份数据都有表落点,`from` 天然继承类型/校验/语义);**security 段是内建字段,不挂钩表**——签名密钥/加密密钥是获取 token 接口生成的随机材料,只存在于 Redis 对象,没有表落点。token 字段因此天然拥有 DTO 字段的全部语义:类型/约束继承,description/optional/pattern 可字段级覆盖。
116
+
117
+ **两段结构(已决策)**:Token 与一般身份对象不同,它承载**两类数据**,且两类数据的**存在时机不同**。两段是**声明期组织**(决定字段何时存在),**运行时对象是平面结构**——所有字段合并为一个平面对象,不分段:
118
+
119
+ | | 内容 | 存在时机 | 作用 |
120
+ |----|------|---------|------|
121
+ | `security` 安全材料段 | **内建字段**:`secret`(签名密钥,必选)+ `cipher`(加密密钥,可选)——服务端生成、不挂钩表 | **未登录即有**(获取 token 时生成) | HMAC 验签、通道加解密 |
122
+ | `identity` 身份段 | 身份数据(id/username/bsId 等),**必须 `from()` 表列** | **登录后才有** | 业务消费"我是谁" |
123
+
124
+ ```
125
+ 未登录态:Token = { secret, cipher } (平面对象,只有安全材料)
126
+ 登录态: Token = { secret, cipher, id, account, ... } (身份字段附着到同一平面对象)
127
+ ```
128
+
129
+ **运行时形态(已决策:平面)**:Redis 存储、注入函数、controller 消费全部按**平面对象**访问——`body.id = token.id`、`token.secret`,不存在 `token.security.xxx` / `token.identity.xxx` 嵌套。两段信息只决定字段何时存在,不决定存储/访问形态。
130
+
131
+ **与 JWT 的本质区别(已决策)**:JWT 是"登录后才签发身份凭证"——无登录无 token;Token 是**两态对象**——未登录就有对象(通信材料),登录只是给对象**附着身份**。token 始终只是对象引用,不携带任何数据。
132
+
133
+ **声明规则**:
134
+
135
+ | 规则 | 含义 |
136
+ |------|------|
137
+ | `api` + `app` 双必填 | Token 归属模块,一个后端一个身份,与 service/dao 的归属规则一致 |
138
+ | **identity 段全部来源于表**(已决策) | identity 段字段必须 `from()` 表列,**可以是多张表**;不允许内联字段——身份数据不是凭空声明的,都有表落点(`from` 天然继承类型/校验/语义) |
139
+ | **security 段内建**(已决策) | security 段固定两个内建字段:`secret`(签名密钥,必选)+ `cipher`(加密密钥,可选)——获取 token 接口生成、存 Redis 对象,**不挂钩表**(签名材料是服务端随机产物,无表落点) |
140
+ | **身份表硬约束**(已决策) | identity 段 from 的表(账户表)**必须包含 `token` + `refresh_token` + `login_at` 三列**——token 体系运行时把会话凭据(token + refreshToken + 登录时间)写账户表(决策 #11),表缺列则体系不成立,定义期报错 |
141
+
142
+ **身份有效数据的确定时机(已决策):login 时**——登录成功时从表读取 identity 段字段,附着到 Redis 已有对象(security 段在获取 token 时已存在);此后身份有效数据以对象为准(不再回查表)。对象失效(删 token)即重新登录。
143
+
144
+ ### 4.2 token 令牌体系(已决策)
145
+
146
+ **token 形态(已决策)**:token 是 **xx 位随机数(hash 值)**——只有令牌信息、无任何其他数据、**不可解析**。它只是一个引用,无 payload、无 exp(过期完全由 Redis 键 TTL 管理),**JWT 完全退役**。
147
+
148
+ **客户端持有(已决策)**:
149
+
150
+ | 材料 | 必须性 | 获取时机 |
151
+ |------|--------|---------|
152
+ | token(随机 hash) | 必须 | 调用"获取 token"接口后 |
153
+ | 签名密钥 | 必须 | 调用"获取 token"接口后 |
154
+ | 加密密钥 | 可选 | 调用"获取 token"接口后 |
155
+ | 业务数据(身份数据) | 不一定需要 | 调用 login |
156
+
157
+ **客户端使用规则(已决策)**:
158
+
159
+ - token **每次请求必须上送**——**唯一例外:获取 token 接口**(首个请求,无 token 可上送);
160
+ - **所有接口都需要签名**(包括获取 token 接口——初始密钥见下方"bootstrap 已决策:方案 2");无 token 的请求(获取 token、未携带 token login)用初始密钥签名;
161
+ - 签名密钥对请求签名(token 在签名范围内),加密密钥用于可选通道加密。
162
+ - **refresh 是客户端内部固定流程(已决策)**:refresh 不暴露为客户端公共 API——api-client(web/wx)内部自动处理(会话过期自动执行 refresh,body 上送 refreshToken + 派生密钥签名);前端不生成 refresh 调用函数。
163
+
164
+ **接口清单(已决策)**:
165
+
166
+ | 接口 | 场景 | 是否上送 token | 签名材料 | 返回 |
167
+ |------|------|--------------|---------|------|
168
+ | sign(获取签名,MVP 2026-08-19) | 未登录浏览(匿名签发 security-only token) | 无(首个请求) | 初始密钥(方案 2) | token + **secret(签名密钥)**(无 refreshToken,决策 #11;cipher 存对象不外发) |
169
+ | login | 所有场景必选(**app 级入口**) | 无(登录前无 token) | 初始密钥(方案 2) | token + refreshToken + **secret(下发密钥)+** 业务数据? |
170
+ | refresh | 所有场景必选 | 否(**请求体上送 refreshToken**) | **refreshToken 派生密钥**(`HMAC(refreshToken, 固定盐)`,两端可算,已决策) | 新 token |
171
+
172
+ **业务请求签名材料(已决策 #15:暂下发密钥)**:login 响应**直接下发 secret**——客户端持有 secret 对业务请求签名,服务端按 `{app_name}.{token}` 还原对象取 secret 验签(与对象内字段一致,零额外查询)。**派生密钥(业务请求签名改由 `HMAC(token, 固定盐)` 等派生,不下发 secret)为后续工作**——当前体系不做,本决策是过渡实现。
173
+
174
+ **签发接口是 app 级的(已决策)**:token 与系统(app)强相关,签发接口不在公共层做——每个 app 定义自己的登录入口(admin 登录接口 / 微信小程序登录接口),login 直接生成 token hash + 安全材料(secret/cipher)+ 附着身份 + 返回 token/refreshToken。**"获取签名"匿名接口(未登录浏览场景)已实现(2026-08-19 MVP,gen-login sign 形态)**——签发 security-only token(无身份、无 refreshToken),客户端拿到 secret 即可签名调业务接口;后续真实登录在此 token 上升级(rotateToken + attachIdentity)。**登录入口 controller 用 `@LoginEntry(app?)` 标记**(login/refresh/sign 入口方法,auth 链据此识别入口 + 按 body 是否携带 refreshToken 区分验证模式);**`@Login` 装饰器语义不变**(业务接口的登录校验装饰器,module 参数照旧);module_name 在各登录入口定义(= 所属 app 名),**兼作 Redis key 前缀**(`{app_name}.{token}`),auth 链校验按 token 的 app 归属(key 前缀)而非 user.type 过滤。
175
+
176
+ **服务端存储(已决策)**:**Redis token 存储平面对象**——两段字段合并(安全材料 + 登录后附着的身份数据),不分段。**Redis key = `{app_name}.{token}`**——token 强绑定 app,不同 app 的 token 命名空间隔离(app 前缀防串)。**token TTL 固定 30 分钟**,过期后经 refreshToken 重新生成。
177
+
178
+ **生命周期**:
179
+
180
+ ```
181
+ 登录: 客户端签名请求(上送凭据;app 级入口)
182
+ 验签(无 token 用初始密钥)
183
+ 凭据校验**单点确认**(查用户表旧 token Redis 旧对象)
184
+ 生成 token(随机 hash)+ 签名密钥(+ 可选加密密钥)+ refreshToken
185
+ 存用户表(token + refresh_token + login_at 列)
186
+ → 按 identity 段声明从表读身份数据
187
+ Redis {app_name}.{token} → { secret, cipher, id, ... }(平面对象)
188
+ 返回客户端 { token, refreshToken, 业务数据? }
189
+ 业务请求:客户端上送 token + 签名 → 服务端按 {app_name}.{token} 查 Redis 一次还原平面对象
190
+ (secret 验签/加解密,身份字段供业务——原两轮验证合成一步)
191
+ → 查不到 = 401(删 token 即吊销,blacklist 退役;防重放仍靠签名 timestamp + nonce)
192
+ 刷新: 请求体上送 refreshToken → 按 refreshToken 查账户表定位用户
193
+ → 校验账号状态(禁用拒绝)→ 校验最近一次登录/刷新未超 7 天
194
+ 查表取 refreshToken 派生密钥验签(HMAC(refreshToken, 固定盐))
195
+ 重新生成 token(新 hash)+ refreshToken(新 hash)→ 更新 Redis 映射 + 账户表 返回新 token + 新 refreshToken
196
+ 退出: Redis 删除 token(服务器端主动失效)
197
+ ```
198
+
199
+ **刷新机制(已决策)**:
200
+
201
+ - **token 有效期 30 分钟**(Redis 键 TTL),过期后可刷新;
202
+ - **refreshToken 只在 login 时返回**(获取 token 接口不返回),用 refreshToken 重新生成 token(延长有效期);
203
+ - **token + refreshToken 都存储于本系统账户表**(账户表 token + refresh_token 列,一用户一行);
204
+ - **login 时单点确认**:查询用户表已有 token → **删除旧 token 对应的数据**(Redis 旧对象删除,旧会话立即失效)→ 生成新 token + refreshToken 写回用户表——后登录踢掉前登录;
205
+ - **login 时直接生成 token + refreshToken 存用户表,并记录登录时间(login_at 列;命名约定:精确时间列统一用 at 后缀)**;
206
+ - **refreshToken 过期时间 7 天(可配置,默认 7 天),滑动窗口**(行业惯例 7~30 天:微信 30 天 / 支付宝 40 天 / Auth0 30 天;支付自建账户体系普遍取 7 天下限)——**login_at 在 login 与每次 refresh 时更新(knex.fn.now()),7 天从最近一次登录/刷新时间起算**;到期即失效,必须重新 login;
207
+ - **轮换策略(已决策:每次刷新轮换)**:refresh 时重新生成 refreshToken(新随机 hash)写账户表——旧 refreshToken 立即作废(不可重放,泄露窗口缩短);token 与 refreshToken 每次 refresh 全部更换,客户端从 refresh 响应提取新会话(login 响应同构);**login_at 随 refresh 更新(7 天滑动窗口,活跃用户持续续期,长期不用 7 天后强制重新 login)**;
208
+ - refreshToken **仅刷新接口上送,且只在请求体中上送**(`{ refreshToken }`)——不经任何 header;login 只上送 username/password,业务请求不上送;
209
+ - **refresh 同样校验账号状态(已决策)**:status 非正常的禁用账号即使 refreshToken 未过期也拒绝刷新——状态门禁 login/refresh 共用;
210
+ - **refresh 查表不取 password(已决策)**:按 refresh_token 查账户表的查询列不含 password(refresh 不做口令校验)、也不含 refresh_token 列(仅作 where 键)——行类型与登录行分离(RefreshRow 无 password / LoginRow 有)。
211
+
212
+ **refresh 接口签名(已决策:refreshToken 派生密钥)**:refresh 的签名密钥由 refreshToken 派生(`HMAC(refreshToken, 固定盐)`)——**能正确签名 == 持有 refreshToken**,签名与凭据一体、每用户独立;不依赖全局初始密钥(初始密钥泄露不波及 refresh)。服务端验签天然同源:按 refreshToken 查账户表的结果即派生密钥输入,零额外查询。
213
+
214
+ **单点确认与刷新流程**:
215
+
216
+ ```
217
+ login 单点:查询用户表已有 token 删除 Redis 中旧 token 对象(旧会话立即失效)
218
+ → 生成新 token + refreshToken → 存用户表(token + refresh_token + login_at 列)+ Redis
219
+ 刷新: 请求体上送 refreshToken refreshToken 查账户表定位用户 + 校验账号状态(禁用拒绝)+ 校验最近一次登录/刷新未超 7
220
+ 派生密钥验签(HMAC(refreshToken, 固定盐))
221
+ 重新生成 token(新 hash)+ refreshToken(新 hash)→ 更新用户表 token + refresh_token + login_at 列 + Redis 映射 → 返回新 token + 新 refreshToken
222
+ ```
223
+
224
+ **双层 单层对照**:
225
+
226
+ | 维度 | 现状(签名 + 登录双层) | 目标(token 令牌单层) |
227
+ |------|------------------------|----------------------|
228
+ | 层数 | HMAC 签名层 + JWT 登录层,独立互不相关 | 一个 token → 一个两态对象 |
229
+ | token 形态 | JWTpayload user 对象,客户端可解析 | **纯随机 hash**:不可解析、无数据 |
230
+ | 匿名态 | 只有验签(无对象) | **有对象**:平面对象只有安全材料(secret/cipher) |
231
+ | 登录 | 签发携带身份的新凭证 | **身份附着到已有对象**(两态演进) |
232
+ | 验证次数 | 每请求两轮(先验签、再验 token) | 一次还原(验签材料与身份同对象) |
233
+ | 密钥管理 | appKey+secret / jwt secret 两套 | secret/cipher 统一承载(Redis 平面对象随 token 存) |
234
+ | 吊销 | blacklist 补丁 | 删 token 即吊销,blacklist 退役;**防重放保留**(签名含 timestamp + nonce) |
235
+ | 过期 | JWT exp + blacklist 双轨 | **token 固定 30 分钟**(Redis TTL)+ refreshToken 刷新重新生成 |
236
+ | 对象更新 | 旧 token 携带旧数据 | Redis 对象实时 |
237
+ | 信息暴露 | 用户信息在客户端可解 | 客户端只见 hash |
238
+ | 存储 | 密钥表按需查 + 无身份存储 | **Redis:token → { 通讯信息 + 身份信息 }** |
239
+
240
+ **与现有 HMAC 体系的关系**:客户端签名行为保留(所有请求都签名),验签所需的签名密钥由 Redis 平面对象的 secret 字段提供——获取 token 后即存在。**获取 token 接口本身也要签名**(推翻现有"唯一例外")。
241
+
242
+ #### 初始密钥(bootstrap,已决策:方案 2
243
+
244
+ 获取 token 接口要签名,但客户端此时还没有签名密钥——需要**初始密钥**保障两端一致,两个候选方案:
245
+
246
+ **方案 1:deviceId 即初始密钥**
247
+
248
+ - 客户端固定自己的 deviceId(native app 读设备标识;**H5 只能是随机数,客户端保存**);
249
+ - 获取 token 时同时上送 deviceId,作为初始化签名密钥——服务端按此验证首次签名,随后签发真密钥。
250
+
251
+ **方案 2:约定算法推导(固定密钥 + 时间窗口)**
252
+
253
+ - 双方内置固定密钥(客户端包内 + 服务端配置);
254
+ - 双方约定算法按时间窗口计算一个动态密钥(如 `HMAC(固定密钥, 时间窗口)`)——**两端独立计算,结果一样**,首次请求用它签名。
255
+
256
+ | 维度 | 方案 1:deviceId | 方案 2:约定算法 |
257
+ |------|-----------------|----------------|
258
+ | 两端一致性 | 客户端生成 → 上送服务端(事后一致) | 双方独立计算(事前一致) |
259
+ | 服务端可验证性 | 弱:服务端无法预知 deviceId,首次请求本质是"信任上送" | 强:服务端独立验算,无需信任首次请求 |
260
+ | 密钥保密性 | 弱:deviceId 是公开标识,非秘密;明文上送 | 强:密钥不经过网络 |
261
+ | 防重放 | 无(固定 deviceId 签名可重放) | 时间窗口天然限制,窗口过期失效 |
262
+ | 密钥轮换 | 无 | 动态轮换(窗口粒度) |
263
+ | 泄露风险 | deviceId 可伪造/复制;H5 随机数清缓存即失、可被拷走 | 固定密钥被逆向提取(native 加固 / H5 混淆缓解有限)→ 全局失效,需服务端可更换 + 客户端更新 |
264
+ | 实现复杂度 | 低(无预置密钥管理) | 中(时钟同步、窗口容忍、密钥管理) |
265
+
266
+ **风险与缓解**:两方案都需要 TLS 保护传输。方案 1 的安全强度依赖"首次信任",适合低风险通道;方案 2 强度更高(服务端可预验证、密钥动态),但固定密钥泄露是全局性风险,需内置密钥可轮换机制。**已决策:方案 2(约定算法推导:固定密钥 + 时间窗口)**——服务端可独立验算、密钥不经过网络、时间窗口天然防重放;落地约束:时钟同步(服务端容忍 ±1 窗口)、固定密钥服务端可轮换 + 客户端可更新。
267
+
268
+ ### 4.3 消费方改造
269
+
270
+ | 消费方 | 现状 | 改造后 |
271
+ |--------|------|--------|
272
+ | `SignatureStrategy`(HMAC 验签) | 验签后查密钥表,`{ id: appKey }` | **并入对象还原**:验签材料取自 Redis 平面对象 secret/cipher 字段 |
273
+ | `JwtStrategy` | `request.user as User` + 可选 blacklist | 退役——token 是 hash 不可解析,按 token 查 Redis 还原对象,查不到 401 |
274
+ | 获取 token 接口 | sign/issue 签发 appKey+secret(唯一免签名 @Public 接口) | 生成 token hash + 签名密钥(+可选加密密钥)存 Redis;**接口本身也要签名**(初始密钥方案 2:固定密钥 + 时间窗口) |
275
+ | 登录入口 controller(login/refresh) | 无专门标记;login 走 JWT 签发 | **`@LoginEntry(app?)` 标记** login/refresh 入口方法——auth 链按入口 + body 是否携带 refreshToken 区分验证模式(业务 x-token / login 初始密钥 / refresh 派生密钥),refreshToken 在 refresh 请求体中上送 |
276
+ | `@Public()` 装饰器 | 标记免签名接口(sign/issue 唯一使用) | **保留——原语义不变**:标记的接口不检查签名 |
277
+ | `signToken/verifyToken` | `(user: User)` 弱类型 | 退役——无 JWT 签发/验证 |
278
+ | 登录服务 | `signToken({ id: String(row.id), type: 'admin' })` 手写 | 身份附着到 Redis 已有对象(机器校验 identity 段字段齐全),返回业务数据(可选) |
279
+ | controller handler | `(body, _user: User)` | `(body, token: AdminUserToken)`,按模块类型化 |
280
+ | 黑名单 checker | `(user: User)` | 机制退役(删 token 替代) |
281
+ | **DTO 字段注入** | `__inject` 适配器 `(body, user) => void`,只映射一个 id | **`fromToken(token, [...]): DTO 字段引用 Token 字段(ref 机制),自动标记服务器注入**(客户端不传、运行时填充)。与 `from(表)` 共享 Field 实例不同,`fromToken` 为每个字段**新建 DtoField 并 `setRef(token 字段)`**——复用 token 字段的类型/约束,字段实例独立(buildMessage 反写 name/schema 不污染 token 字段),ref 链渲染时递归展开(防环) |
282
+ | utils/service/flow 方法参数 | `objectField` 内联(Customer 现状) | `args: { customer: customerToken.fields }` 或直接引用 Token |
283
+
284
+ ### 4.4 Customer 落地(本案例的落点,已决策:打散 + 注入)
285
+
286
+ 1. `schema/customer.table.ts` 保留——Customer 对应一张用户表;
287
+ 2. `customerToken = defineToken({ name: 'Customer', api: bleApi, app: bleWx, security: { secret: dtoField(stringField(...)), cipher: dtoField(stringField({ optional: true })) }, identity: { ...from(customer, [id, account, device, channel, phone]) } })`——POS 服务器映射的客户身份,单一事实来源;
288
+ 3. **SubmitCpuRequest.customer 打散**:嵌套对象拆为顶层字段,从 Token 映射——`fromToken(customerToken, [...])` 展开 id/account/device/channel/phone 进 DTO,字段自动标记为服务器注入(客户端不传,运行时由 Token 填充,复用现有 `__inject` 机制、从"一个 id"扩展为"多个属性")。**引用方式是 ref 而非共享实例**:每个字段 = 新建 `dtoField(...).setRef(customerToken.fields.xxx)`,DTO 层可再覆盖 optional/description,类型/约束继承 token 字段:
289
+ ```ts
290
+ // dto_schema/ble-wx/ble-charge.dto.ts
291
+ export const SubmitCpuRequest = buildInput('SubmitCpuRequest', {
292
+ ...fromToken(customerToken, [id, account, device, channel, phone]), // 注入字段
293
+ fee: dtoField(intField({ optional: false, label: '充值金额(分)' })),
294
+ devId: dtoField(stringField({ maxLength: 32, optional: false, label: '设备号' })),
295
+ // ...其余 POS 参数
296
+ });
297
+ ```
298
+
299
+ 4. `ble.utils.ts` 的 `customerParam` 内联删除,`getBsId/getOpId` 参数改引用 Token 字段;
300
+ 5. `Customer` 这个独立 buildInput DTO 删除(无消费者,字段已并入 SubmitCpuRequest)。
301
+
302
+ ### 4.5 目录与校验
303
+
304
+ - 存放:`token_schema/{api.name}/{app.name}/token/{name}.token.ts`——与 service_schema/dao_schema 同布局。
305
+ - **生成物(`pylonts gen token`)**:`{api.name}/src/modules/{app.name}/token/{name}Token.ts`——TypeBox schema + `Static` 类型(平面结构,identity 字段全 Optional),落 api 侧模块目录(auth 链 / 登录链生成器 import 它);前端只持有 hash,不需要 token 类型。
306
+ - lint(`loadTokens` + `pylonts lint token`):目录规则、一文件一 Token、**字段承载规则(两段字段必须是 dtoField;identity 段必须全部 `from()` 表列,可跨多张表,禁止内联字段;security 段固定内建 `secret`/`cipher`)**、**身份表硬约束(identity 段 from 的表必须含 `token` + `refresh_token` + `login_at` 列)**、security/identity 两段区分校验(identity 段字段不能出现在 security 段)、api+app 归属校验(app ∈ api.apps)。
307
+
308
+ ## 5. 落地步骤
309
+
310
+ | 步骤 | 内容 | 依赖 |
311
+ |------|------|------|
312
+ | 1 | `dsl/src/token.ts`:`TokenSchema` + `defineToken`(api+app 归属、identity 段 `Record<string, DtoField>` 全 from 表列可跨表、security 段内建 secret/cipher、**身份表硬约束:identity 段 from 的表必须含 `token` + `refresh_token` + `login_at` 列**)+ 定义期校验 | 无 |
313
+ | 2 | `dsl/src/dto.ts`:**新增 `fromToken(token, fields)`**(不复用 `from`——机制不同:共享实例 vs ref 引用;每个字段新建 DtoField + `setRef(token 字段)` + 标记服务器注入);`typebox-driver` 补 ref 渲染(沿 ref 链递归展开类型/约束,防环)+ 渲染注入元数据 | 1 |
314
+ | 3 | `pylon/src/validation.ts`:`InjectFn` 从 `(body, user)` 扩展为 `(body, token)`,支持多属性映射;生成物消费 | 1 |
315
+ | 4 | 类型生成:TypeBox schema + TS 类型——生成物落 **api 侧** `{api}/src/modules/{app}/token/{name}Token.ts`(`pylonts gen token`,平面结构:security 按声明、identity 全 Optional;auth 链/登录链消费;前端只持有 hash,不需要 token 类型) | 1、2 |
316
+ | 5 | Redis 存储:token → **平面对象**(**key = `{app_name}.{token}`** app 级命名空间,两段字段合并,TTL 30 分钟 + refreshToken 刷新) | 无 |
317
+ | 6 | `pylon-fastify` auth 链**合并改造**:**app 级登录入口**(admin 登录 / 微信登录,入口即登录:login 生成 hash + secret/cipher + 身份附着,无公共签发接口)/ **`@LoginEntry(app?)` 标记登录入口 controller**(login/refresh 方法),auth 链按入口 + body 是否携带 refreshToken 区分验证模式(业务 x-token + Redis secret / login 初始密钥 / refresh 派生密钥)/ **`@Login` 装饰器不变**,module_name 在登录入口定义(= app 名),auth 链校验按 token 的 app 归属(key 前缀)/ SignatureStrategy 并入对象还原(验签材料取自平面对象 secret/cipher,timestamp + nonce 防重放保留;无 token 请求用初始密钥)/ login 按声明读表附着身份字段 + 单点确认 / refresh 接口(body 上送 refreshToken,派生密钥验签)/ JwtStrategy + signToken + blacklist 退役;**`@Public` 保留**(原语义:标记的接口不检查签名) | 1、5 |
318
+ | 6a | **等保三硬约束落地**:① 双因子登录(口令 + 签名密钥,禁止纯口令);② 登录失败锁定(≤5 次 / 锁 ≥30 分钟);③ Redis 安全加固(签名密钥加密存储、密码认证、访问控制、超时断开);审计日志独立持久化(Redis 不作审计载体) | 6 |
319
+ | 7 | 登录链生成(cli admin-login / sign)产出 Token 构造代码 | 4、6 |
320
+ | 8 | 存储规则 + lint:`loadTokens` + `pylonts lint token` | 1 |
321
+ | 9 | BLE Customer 落地(标准验收用例):customer 表 + CustomerToken + SubmitCpuRequest 打散注入,删除 utils/DTO 重复 | 1、2、8 |
322
+
323
+ ## 6. 决策状态
324
+
325
+ | # | 决策点 | 状态 |
326
+ |---|--------|------|
327
+ | 1 | **体系定位** | **已决策:推翻"HMAC 签名 + JWT 登录"双层体系,合并为 token 令牌单层**——签名/加密材料与身份统一由两态对象承载 |
328
+ | 2 | Token 字段来源 | **已决策:identity 段全部来源于表,可以是多张表**(禁止内联字段);**security 段内建固定字段(不挂钩表)**——`secret`(签名密钥,必选)+ `cipher`(加密密钥,可选),获取 token 接口生成的随机材料,存 Redis 对象无表落点 |
329
+ | 3 | **两段结构** | **已决策:`security` 段(签名/加密数据,未登录即有)+ `identity` 段(身份数据,登录后附着)**——两态对象,token 仅对象引用;**两段是声明期组织,运行时对象为平面结构**(所有字段合并,`token.secret` / `token.id` 直接访问,无嵌套) |
330
+ | 4 | **token 形态** | **已决策:纯随机 hash**——只有令牌信息、不可解析、无 payload/exp,JWT 完全退役 |
331
+ | 5 | 多来源(jwt/hmac/third) | **已决策:不需要**——Token 就是用户身份,单一概念,归属模块确定 |
332
+ | 6 | 身份有效数据确定时机 | **已决策:login 时**——登录时从表读取 identity 段字段附着到 Redis 已有对象;login 返回业务数据(不一定需要) |
333
+ | 7 | 安全材料字段 | **已决策:security 段内建 `secret`(签名密钥,必须)+ `cipher`(加密密钥,可选),不挂钩表**——获取 token 时生成(随机材料,存 Redis 对象),验签/加解密消费对象 |
334
+ | 8 | **客户端使用规则** | **已决策:token 每次上送(唯一例外:获取 token 接口);业务接口默认都签名,`@Public` 保留**(原语义:标记的接口不检查签名;获取 token 等无 token 请求用**初始密钥方案 2**:固定密钥 + 时间窗口约定算法,两端独立计算) |
335
+ | 9 | `SubmitCpuRequest.customer` 处理 | **已决策:打散融入 DTO + 服务端变量注入**(`fromToken(token, [...])` 多属性映射,复用/扩展现有 `__inject` 机制) |
336
+ | 10 | **存储选型** | **已决策:Redis**(token → 通讯信息 + 身份信息) |
337
+ | 11 | **刷新机制** | **已决策:token 有效期 30 分钟;refreshToken 只在 login 时返回**(获取 token 接口不返回);**标准接口 = login + refresh,未登录浏览场景加获取 token 接口共 3 个**;**token + refreshToken + 登录时间(login_at)都存账户表;login 时单点确认——查用户表旧 token、删除对应 Redis 数据、直接生成新 token + refreshToken 写回(上送匿名 token 则一并删除)**;**refreshToken 有效期 7 天(可配置,默认 7 天),滑动窗口——login_at 在 login 与每次 refresh 时更新,7 天从最近一次登录/刷新起算,过期即失效必须重新 login**;**轮换策略已决策:每次刷新轮换**——refresh 重新生成 token + refreshToken 写回账户表(旧 refreshToken 立即作废),客户端以 refresh 响应更新会话;refresh 同时更新 login_at(7 天滑动窗口);**refreshToken 上送载体 = refresh 请求体**(`{ refreshToken }`,不经 header,login 只上送 username/password);**refresh 校验账号状态(禁用拒绝)+ 查表不取 password**(RefreshRow 与 LoginRow 分离);**客户端 refresh 为内部固定流程**——不暴露公共 refresh 方法、不生成前端 refresh 函数(api-client 内部自动刷新) |
338
+ | 12 | **Token 字段承载与 DTO 引用方式** | **已决策:TokenSchema 内部装 dtoField**——两段 `Record<string, DtoField>`;**identity 段用 `from(表, 列)` 投影**(禁止裸内联 Field),**security 段内建 `secret`/`cipher` 不挂钩表**;**DTO 引用 token 字段走 `fromToken(token, [...])`(ref 机制)**——为每个字段新建 DtoField + `setRef(token 字段)`(复用类型/约束、实例独立,buildMessage 反写不污染 token 字段),typebox-driver 沿 ref 链递归展开渲染(防环),字段自动标记服务器注入(复用/扩展 `__inject`);**注入访问走平面对象**——`body.id = token.id`,两段字段同一平面,不分段访问 |
339
+ | 13 | **身份表硬约束** | **已决策:identity 段 from 的表(账户表)必须包含 `token` + `refresh_token` + `login_at` 三列(硬约束,定义期报错)**——token 体系运行时把会话凭据(token + refreshToken + 登录时间)写账户表,表缺列则体系不成立;`login_at` 是 refreshToken 过期(7 天)起算的运行时依赖;与决策 #11 配套 |
340
+ | 14 | **签发范围与 Redis 键** | **已决策:token 与 app 强相关,签发接口 app 级**——每个 app 定义自己的登录入口(admin/微信入口即登录),login 直接生成 hash + 安全材料 + 身份附着;**匿名"获取签名"接口已实现(2026-08-19 MVP,gen-login sign 形态)**——签发无身份 token(security-only,无 refreshToken,响应只外发 secret),未登录浏览场景;后续真实登录升级此 token(rotateToken + attachIdentity);**Redis key = `{app_name}.{token}`**(app 命名空间隔离,防不同 app token 串扰);**登录入口 controller 用 `@LoginEntry(app?)` 标记**(login/refresh/sign 入口方法);**`@Login` 装饰器语义不变**(业务接口登录校验),module_name 在各登录入口定义(= app 名),兼作 Redis key 前缀 |
341
+ | 15 | **业务请求签名材料** | **已决策(过渡):login 响应下发 secret**——客户端持 secret 签名业务请求,服务端按 `{app_name}.{token}` 还原对象取 secret 验签;**派生密钥(HMAC(token, 固定盐) 等,不下发 secret)为后续工作**,当前体系不做 |