@h-ai/iam 0.1.0-alpha9 → 1.0.0-beta.1

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/README.md CHANGED
@@ -1,101 +1,228 @@
1
1
  # @h-ai/iam
2
2
 
3
+ [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
+
5
+ [框架首页](../../README.md) · 相关模块:[@h-ai/audit](../audit/README.md) · [@h-ai/cache](../cache/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/crypto](../crypto/README.md) · [@h-ai/reldb](../reldb/README.md) · [@h-ai/reach](../reach/README.md)
6
+
7
+ ## 简介
8
+
3
9
  身份与访问管理模块,提供统一的 `iam` 对象实现认证、会话、授权与用户管理。
4
10
 
5
- ## 功能特性
11
+ ## 核心功能清单
6
12
 
7
- | 功能 | 说明 |
8
- | -------------- | ------------------------------------------------------ |
9
- | **认证** | 密码、OTP(邮箱/短信验证码)、LDAP、API Key 多策略认证 |
10
- | **会话** | 有状态会话(随机访问令牌 + 缓存,滑动续期可选) |
11
- | **授权** | RBAC 角色与权限管理(DB + 缓存,通配符权限匹配) |
12
- | **用户管理** | 注册、查询、更新、密码重置、管理员重置密码 |
13
- | **API Key** | API Key 创建、吊销、验证,支持 scope 与过期时间 |
14
- | **前端客户端** | HTTP API 契约定义,支持独立前端使用 |
13
+ | 功能 | 说明 |
14
+ | -------------- | -------------------------------------------------------------------------------------- |
15
+ | **认证** | 密码、OTP(邮箱/短信验证码)、LDAP、API Key 多策略认证 |
16
+ | **会话** | 有状态会话(随机访问令牌 + 缓存,滑动续期可选) |
17
+ | **授权** | RBAC 角色与权限管理(DB 批量读取,通配符权限匹配) |
18
+ | **用户管理** | 注册、查询、更新、密码重置、管理员重置密码 |
19
+ | **API Key** | API Key 创建、吊销、验证,支持 scope 与过期时间 |
20
+ | **一次性票据** | 短期、一次性能力票据(签发 / 原子消费),用于 WebSocket 等无法携带 Bearer Token 的通道 |
21
+ | **前端客户端** | HTTP API 契约定义,支持独立前端使用 |
15
22
 
16
- ## 安装
23
+ ## 使用入门
24
+
25
+ ### 安装
17
26
 
18
27
  ```bash
19
28
  pnpm add @h-ai/iam
20
29
  ```
21
30
 
22
- ## 依赖
31
+ ### 依赖
23
32
 
24
33
  - `@h-ai/reldb` — 数据库(用户/角色/权限持久化),**需在 iam.init() 前初始化**
25
- - `@h-ai/cache` — 缓存(会话/OTP/重置令牌/权限缓存),**需在 iam.init() 前初始化**
34
+ - `@h-ai/cache` — 缓存(会话/OTP/一次性票据),**需在 iam.init() 前初始化**
26
35
  - `@h-ai/crypto` — 密码哈希(内部使用,自动初始化)
27
- - `@h-ai/audit` — 审计日志(RBAC 关键操作记录)
36
+ - `@h-ai/audit` — 宿主初始化后自动记录 IAM 安全事件;未选用时不调用采集器。启用后的审计写失败仍记录错误,外部事务应回滚。
37
+
38
+ - `@h-ai/reach` — **可选**,应用安装、初始化并注入后可使用标准邮件/短信模板;自定义发送回调无需 Reach。
39
+ - `ldapts` — **可选**,仅在显式启用 LDAP 且没有注入 `ldapClientFactory` 时加载。
40
+
41
+ ### 可选 LDAP 与通知接入
28
42
 
29
- ## 快速开始
43
+ 只使用密码、用户管理和 RBAC 时,无需安装或初始化 LDAP/Reach。两者均为可选 peer dependency,应用只安装选用的集成。下列发送、LDAP 片段位于应用启动/认证函数中,reldb/cache 必须先成功初始化;`username/password` 来自已校验输入。
44
+
45
+ #### LDAP / Active Directory
46
+
47
+ 使用默认客户端时执行 `pnpm add ldapts`,再显式设置 `login.ldap: true` 和 `ldap` 连接配置。只提供连接配置不会启用 LDAP。启用默认客户端却缺少 SDK 时,`iam.init()` 返回配置错误。
48
+
49
+ ```yaml
50
+ # config/_iam.yml(启动前设置 HAI_IAM_LDAP_BINDPASSWORD)
51
+ login:
52
+ ldap: true
53
+ ldap:
54
+ url: ldaps://ldap.example.com:636
55
+ bindDn: cn=service,dc=example,dc=com
56
+ bindPassword: '${HAI_IAM_LDAP_BINDPASSWORD}'
57
+ searchBase: ou=users,dc=example,dc=com
58
+ searchFilter: '(uid={{username}})'
59
+ usernameAttribute: uid
60
+ emailAttribute: mail
61
+ displayNameAttribute: cn
62
+ connectTimeout: 5000
63
+ ```
64
+
65
+ `ldaps://` 使用 TLS;`ldap://` 加 `useTls: true` 使用 StartTLS。Active Directory 可用 `searchFilter: "(sAMAccountName={{username}})"` 和 `usernameAttribute: sAMAccountName`;占位符会转义后搜索。首次登录默认同步本地用户并分配默认角色,账户禁用与锁定仍有效。`ldapSyncUser: false` 要求已有且处于激活状态的本地用户以绑定数据库凭据代际;不创建虚拟 DN 用户。可传自定义 `ldapClientFactory`;工厂返回具备 `bind/search/unbind` 的客户端,结果均为 HaiResult。注入工厂时无需安装 ldapts。
30
66
 
31
67
  ```ts
32
- import { cache } from '@h-ai/cache'
68
+ import type { IamConfigSettingsInput } from '@h-ai/iam'
69
+ import { core } from '@h-ai/core'
33
70
  import { iam } from '@h-ai/iam'
34
- import { reldb } from '@h-ai/reldb'
35
71
 
36
- // 1. 初始化依赖
37
- await reldb.init({ type: 'sqlite', database: './data.db' })
38
- await cache.init({ type: 'memory' })
72
+ // core 已加载 config/,reldb 与 cache 已成功初始化。
73
+ const initialized = await iam.init(core.config.getOrThrow<IamConfigSettingsInput>('iam'))
74
+ if (!initialized.success)
75
+ return initialized
76
+ const result = await iam.auth.loginWithLdap({ username, password })
77
+ if (!result.success)
78
+ return result
79
+ return result
80
+ ```
39
81
 
40
- // 2. 初始化 IAM(自动使用已初始化的 reldb 和 cache 单例)
41
- await iam.init({
42
- session: { maxAge: 86400, sliding: true },
43
- // OTP 回调(启用 OTP 登录时注入发送逻辑)
44
- onOtpSendEmail: async (email, code) => {
45
- await sendEmail(email, `验证码: ${code}`)
46
- },
47
- // 密码重置回调(启用密码重置时注入通知逻辑)
48
- onPasswordResetRequest: async (user, token, expiresAt) => {
49
- await sendEmail(user.email!, `重置链接: https://example.com/reset?token=${token}`)
50
- },
51
- })
82
+ #### 使用可选 Reach 自动发送
83
+
84
+ 执行 `pnpm add @h-ai/reach`,应用负责 Reach 的配置、初始化和关闭,IAM 只使用传入的实例。以下 SMTP/HTTP Provider 为示例,SMS 网关按 [Reach 接口](../reach/README.md) 接收通知。Console 只记录元数据,不能用于实际收信。
85
+
86
+ ```yaml
87
+ # config/_reach.yml;环境变量仅在服务端设置。
88
+ providers:
89
+ - name: email
90
+ type: smtp
91
+ host: '${SMTP_HOST}'
92
+ port: 465
93
+ secure: true
94
+ user: '${SMTP_USER}'
95
+ pass: '${SMTP_PASSWORD}'
96
+ from: '${MAIL_FROM}'
97
+ - name: sms
98
+ type: api
99
+ url: '${SMS_GATEWAY_URL}'
100
+ templates:
101
+ - name: otp_email
102
+ provider: email
103
+ subject: Verification code
104
+ body: 'Your code is {code}.'
105
+ - name: otp_sms
106
+ provider: sms
107
+ body: 'Your code is {code}.'
108
+ - name: password_reset
109
+ provider: email
110
+ subject: Password reset
111
+ body: 'Reset: {resetUrl}. Expires: {expiresAt}.'
112
+ - name: password_reset_sms
113
+ provider: sms
114
+ body: 'Reset: {resetUrl}. Expires: {expiresAt}.'
115
+ ```
52
116
 
53
- // 2. 注册
54
- const userResult = await iam.user.register({
55
- username: 'admin',
56
- email: 'admin@example.com',
57
- password: 'Password123',
58
- })
117
+ 标准模板及变量:`otp_email / otp_sms` 使用 `{code}`;`password_reset / password_reset_sms` 使用 `{token}`、`{expiresAt}`、`{resetUrl}`。`passwordReset.mode: phone` 选择短信重置。必须在 `iam.init()` 前配置模板;缺少通道时公开配置隐藏对应入口。
59
118
 
60
- // 3. 登录
61
- const loginResult = await iam.auth.login({
62
- identifier: 'admin',
63
- password: 'Password123',
119
+ ```ts
120
+ import type { ReachConfigInput } from '@h-ai/reach'
121
+ import { core } from '@h-ai/core'
122
+ import { iam } from '@h-ai/iam'
123
+ import { reach } from '@h-ai/reach'
124
+
125
+ // core、reldb、cache 已初始化;按应用生命周期反序关闭 IAM 与 Reach。
126
+ const notification = await reach.init(core.config.getOrThrow<ReachConfigInput>('reach'))
127
+ if (!notification.success)
128
+ return notification
129
+ const initialized = await iam.init({
130
+ reach,
131
+ login: { otp: true },
132
+ otp: { email: true, sms: true },
133
+ passwordReset: { mode: 'email', resetUrl: 'https://app.example.com/auth/reset-password' },
64
134
  })
65
- if (loginResult.success) {
66
- const { user, tokens } = loginResult.data
67
- }
135
+ if (!initialized.success)
136
+ return initialized
137
+ return iam.auth.sendOtp('user@example.com')
138
+ ```
139
+
140
+ #### 应用自定义短信 / 邮件发送
68
141
 
69
- // 4. 验证令牌
70
- const session = await iam.auth.verifyToken(loginResult.data.tokens.accessToken)
142
+ 注入 `onOtpSendEmail`、`onOtpSendSms` 和 `onPasswordResetRequest` 即可接入现有发送服务;可只实现需要的回调,也可与 Reach 混用,显式回调优先。以下服务为应用实现,示例假定其 Promise 仅在投递成功时完成;若服务返回 HaiResult,须检查 `success`,失败时在回调内抛错。IAM 捕获回调异常并返回业务错误,不将验证码、令牌或内部投递详情写入用户提示。
71
143
 
72
- // 5. OTP 验证码登录
73
- await iam.auth.sendOtp('user@example.com')
74
- const otpResult = await iam.auth.loginWithOtp({
75
- identifier: 'user@example.com',
76
- code: '123456',
144
+ ```ts
145
+ import { iam } from '@h-ai/iam'
146
+ import { mailService, smsService } from './notifications.js'
147
+
148
+ // mailService/smsService 为应用已有的发送服务;不需要安装或初始化 Reach。
149
+ // 调用方须检查服务投递结果,失败时拒绝 Promise,不能吞掉错误。
150
+ const initialized = await iam.init({
151
+ otp: { email: true, sms: true },
152
+ onOtpSendEmail: async (email, code, context) => {
153
+ await mailService.send({ to: email, subject: 'Verification code', text: code, idempotencyKey: context.idempotencyKey })
154
+ },
155
+ onOtpSendSms: async (phone, code, context) => {
156
+ await smsService.send({ to: phone, code, idempotencyKey: context.idempotencyKey })
157
+ },
158
+ passwordReset: { mode: 'email' },
159
+ onPasswordResetRequest: async (user, token, context) => {
160
+ if (!user.email)
161
+ throw new Error('Reset recipient unavailable')
162
+ const url = new URL('https://app.example.com/auth/reset-password')
163
+ url.searchParams.set('token', token)
164
+ await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt: context.expiresAt, idempotencyKey: context.idempotencyKey })
165
+ },
77
166
  })
167
+ if (!initialized.success)
168
+ return initialized
169
+ ```
170
+
171
+ ### 快速开始
172
+
173
+ ```ts
174
+ import { cache } from '@h-ai/cache'
175
+ import { core } from '@h-ai/core'
176
+ import { iam } from '@h-ai/iam'
177
+ import { reldb } from '@h-ai/reldb'
78
178
 
79
- // 6. 检查权限
80
- const hasPermission = await iam.authz.checkPermission(
81
- loginResult.data.user.id,
82
- 'user:read',
83
- )
179
+ // 基础密码认证不需要安装 Reach 或 ldapts。
180
+ async function example() {
181
+ try {
182
+ const db = await reldb.init({ type: 'sqlite', database: './data.db' })
183
+ if (!db.success)
184
+ return db
185
+ const cached = await cache.init({ type: 'memory' })
186
+ if (!cached.success)
187
+ return cached
188
+ const initialized = await iam.init({})
189
+ if (!initialized.success)
190
+ return initialized
191
+ const registered = await iam.user.register({ username: 'demo', password: 'Password123' })
192
+ if (!registered.success)
193
+ return registered
194
+ const login = await iam.auth.login({ identifier: 'demo', password: 'Password123' })
195
+ if (!login.success)
196
+ return login
197
+ return await iam.auth.verifyToken(login.data.tokens.accessToken)
198
+ }
199
+ finally {
200
+ // 应用关闭时按依赖的反向顺序释放。
201
+ await iam.close()
202
+ await cache.close()
203
+ await reldb.close()
204
+ }
205
+ }
84
206
 
85
- // 7. 关闭
86
- await iam.close()
207
+ const result = await example()
208
+ if (!result.success)
209
+ core.logger.error('Authentication example failed', { code: result.error.code })
87
210
  ```
88
211
 
89
- ## 设计说明
212
+ 默认 LDAP 关闭;OTP 需要 `otp` 配置和可用发送回调,重置需要可用通知回调,才能在 `iam.publicConfig` 中启用。
90
213
 
91
- ### 架构概览
214
+ `iam.config` 返回的是**脱敏后的配置快照**:LDAP `url` / `bindPassword` 等敏感配置会自动隐藏,便于状态检查但不能代替原始配置源。
215
+
216
+ ### 设计说明
217
+
218
+ #### 架构概览
92
219
 
93
220
  IAM 是**生命周期单例**模块,通过 `iam.init()` / `iam.close()` 管理运行时状态。内部由 5 个子模块构成,按依赖顺序初始化:
94
221
 
95
222
  ```
96
223
  iam.init()
97
- ├─ session (缓存会话管理,无 DB 依赖)
98
- ├─ authz (RBAC 角色/权限,DB + 缓存)
224
+ ├─ session (缓存会话管理,当前凭据状态共享缓存/DB 回源)
225
+ ├─ authz (RBAC 角色/权限,DB 批量读取)
99
226
  ├─ authn (认证策略,依赖 session + authz)
100
227
  ├─ user (用户管理,依赖 session + authz + passwordStrategy)
101
228
  └─ seed (种子数据,依赖 authz)
@@ -105,29 +232,29 @@ iam.init()
105
232
 
106
233
  初始化前访问任何子模块方法,均返回 `NOT_INITIALIZED` 错误(基于 `NotInitializedKit` Proxy)。
107
234
 
108
- ### 数据存储分布
235
+ #### 数据存储分布
109
236
 
110
- | 数据类型 | 存储位置 | 说明 |
111
- | ------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- |
112
- | 用户、角色、权限、关联关系、API Key | DB(6 张表) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
113
- | 会话(accessToken → Session) | 缓存 | 有 TTL,滑动续期可选 |
114
- | Token 映射(refreshToken → userId + accessToken) | 缓存 | 独立 key,refresh 后旧 token 立即失效 |
115
- | OTP 验证码 | 缓存 | 有 TTL + 尝试次数限制 |
116
- | 密码重置令牌 | 缓存 | 有 TTL + 最大验证次数限制 |
117
- | API Key | DB | `hai_iam_api_keys`,明文不落库,仅存 hash;前缀用于候选检索 |
237
+ | 数据类型 | 存储位置 | 说明 |
238
+ | ----------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
239
+ | 用户、角色、权限、关联关系、API Key | DB(基础 5 张,可选 API Key 1 张) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
240
+ | 会话(accessToken → Session) | 缓存 | 有 TTL,滑动续期可选 |
241
+ | Refresh 消费状态 | 缓存的用户分区 | 与 session/索引在单键 CAS 中提交 |
242
+ | OTP 验证码 | 缓存 | 有 TTL + 尝试次数限制 |
243
+ | 密码重置令牌 | 用户数据库行 | SM3 摘要、数据库有效期、尝试次数及最终条件提交 |
244
+ | API Key | DB | `hai_iam_api_keys`,明文不落库,仅存 hash;前缀用于候选检索 |
118
245
 
119
246
  ---
120
247
 
121
- ### 子模块:认证(authn)— `iam.auth`
248
+ #### 子模块:认证(authn)— `iam.auth`
122
249
 
123
- #### 关键对象
250
+ ##### 关键对象
124
251
 
125
252
  - **`AuthnOperations`** — 认证操作接口,包含 `login` / `loginWithOtp` / `loginWithLdap` / `loginWithApiKey` / `logout` / `verifyToken` / `sendOtp` / `registerAndLogin`
126
253
  - **`AuthStrategy`** — 认证策略接口(`type` + `authenticate` + `challenge?`),4 种实现:密码、OTP、LDAP、API Key
127
254
  - **`AuthResult`** — 登录结果,含用户信息、`TokenPair`(accessToken + refreshToken)、角色/权限列表
128
255
  - **`Credentials`** — 统一凭证联合类型(`{ type: 'password' | 'otp' | 'ldap' | 'apikey' } & 具体凭证`)
129
256
 
130
- #### 关键流程
257
+ ##### 关键流程
131
258
 
132
259
  **密码登录流程:**
133
260
 
@@ -151,122 +278,157 @@ login(credentials)
151
278
  ```
152
279
  verifyToken(accessToken)
153
280
  → session.get(accessToken) → 缓存查询
281
+ → 读取共享缓存中的当前用户/凭据状态,检查启用状态、期限与会话绑定的 credentialVersion;不可信时回源 DB
154
282
  → 滑动续期模式下自动延长 TTL
155
- → 返回 Session 对象(含 userId、roles、permissions)
283
+ → 复用上述用户资料,读取共享代际缓存中的当前角色/权限;冷读使用单条 JOIN
284
+ → 必要状态读取失败、用户缺失/停用/到期或凭据代不匹配时拒绝;API Key 会话再次收敛 scope
285
+ → 返回当前 Session 对象(含 userId、roles、permissions)
156
286
  ```
157
287
 
158
- #### 使用方式
288
+ ##### 使用方式
159
289
 
160
- ```ts
161
- // 密码登录
162
- const result = await iam.auth.login({ identifier: 'admin', password: 'Password123' })
290
+ 以下示例运行于服务端,reldb/cache/IAM 已成功初始化,参数来自校验后的请求。密码登录默认可用;OTP 需 `login.otp: true`、`otp` 通道和发送回调/Reach 模板;LDAP 需 `login.ldap: true`(配置见前文);API Key 需 `login.apikey: true`(完整流程见下文)。登录返回 `AuthResult`,注册并登录返回相同认证结构;`register.enabled` 与密码登录开关分别控制。
163
291
 
164
- // OTP 登录
165
- await iam.auth.sendOtp('user@example.com')
166
- const result = await iam.auth.loginWithOtp({ identifier: 'user@example.com', code: '123456' })
292
+ OTP 是两次交互:先发送,用户取得验证码后再登录;发送/登录失败均返回 HaiResult。LDAP 使用 `iam.auth.loginWithLdap({ username, password })`,API Key 使用 `loginWithApiKey({ key })`,不要用密码登录替代。所有示例先检查 `success` 再访问 `data`。
167
293
 
168
- // LDAP 登录(需在 init 时提供 ldapClientFactory)
169
- const result = await iam.auth.loginWithLdap({ username: 'jdoe', password: 'pass' })
294
+ ```ts
295
+ import { iam } from '@h-ai/iam'
170
296
 
171
- // API Key 登录(需启用 login.apikey: true)
172
- const result = await iam.auth.loginWithApiKey({ key: 'hai_xxxx...' })
297
+ async function passwordLogin(identifier: string, password: string) {
298
+ const result = await iam.auth.login({ identifier, password })
299
+ if (!result.success)
300
+ return result
301
+ // 成功后返回 user、tokens、roles、permissions;令牌只交给安全存储。
302
+ return result
303
+ }
173
304
 
174
- // 验证令牌
175
- const session = await iam.auth.verifyToken(accessToken)
305
+ async function requestLoginCode(identifier: string) {
306
+ // identifier 为启用通道对应的邮箱/手机号,返回 expiresAt。
307
+ return iam.auth.sendOtp(identifier)
308
+ }
309
+
310
+ async function otpLogin(identifier: string, code: string) {
311
+ // 用户收到验证码并输入后调用;发送失败时不能伪报成功。
312
+ return iam.auth.loginWithOtp({ identifier, code })
313
+ }
176
314
 
177
- // 注册并登录(一站式)
178
- const result = await iam.auth.registerAndLogin({ username: 'new', password: 'Pass123' })
315
+ async function registerWithSession(username: string, password: string) {
316
+ return iam.auth.registerAndLogin({ username, password })
317
+ }
179
318
  ```
180
319
 
181
- #### 特别关注
320
+ ##### 特别关注
182
321
 
183
322
  - **Timing attack 防护**:用户不存在时执行 dummy password hash,保持响应时间一致
184
323
  - **账户锁定**:连续登录失败 `maxLoginAttempts` 次后锁定 `lockoutDuration` 秒
185
324
  - **认证策略可独立启用/禁用**:通过 `login.password` / `login.otp` / `login.ldap` / `login.apikey` 控制
186
325
  - **OTP 安全**:rejection sampling 消除模偏差,常量时间比较防 timing attack,频率限制(`resendInterval`)
187
326
 
327
+ `iam.auth.logout(accessToken)` 直接按完整不透明令牌在用户分区执行一次 CAS,原子撤销 access 与关联 refresh;access 已到期也不能跳过撤销。非法/不存在的令牌幂等成功,缓存读取或提交故障返回失败,恢复后可用同一令牌重试。HTTP 登出只允许撤销所持令牌,不以 userId 定位他人会话,也不要求该 access 仍通过认证。调用方必须检查 `success`;本地 Cookie/安全存储清理不代表服务端撤销已经确认,失败应显示提示。
328
+
188
329
  ---
189
330
 
190
- ### 子模块:会话(session)— `iam.session`
331
+ #### 子模块:会话(session)— `iam.session`
191
332
 
192
- #### 关键对象
333
+ ##### 关键对象
193
334
 
194
335
  - **`SessionOperations`** — 会话操作接口:`create` / `get` / `verifyToken` / `update` / `delete` / `deleteByUserId` / `refresh` / `revokeRefresh` / `patchUserSessions`
195
336
  - **`Session`** — 会话实体,含 userId、roles、permissions、accessToken、过期时间等
196
337
  - **`TokenPair`** — 令牌对:`{ accessToken, refreshToken, expiresIn, tokenType: 'Bearer' }`
197
338
 
198
- #### 关键流程
339
+ ##### 关键流程
199
340
 
200
341
  **Token 刷新(Rotation 策略):**
201
342
 
202
343
  ```
203
344
  refresh(refreshToken)
204
- → 从缓存读取 refreshToken → { userId, accessToken } 映射
205
- → 获取旧 session(通过 accessToken)
206
- → 删除旧 refreshToken(缓存 key 立即失效 → 防重放攻击)
207
- → 删除旧 accessToken session
208
- → 创建全新 session(复用旧 session 的用户上下文)
209
- → 返回新的 TokenPair
345
+ → 读取用户分区中的旧会话和版本,验证授权
346
+ → 构造新令牌候选(尚未生效)
347
+ → 单键 CAS 再核验版本和 refresh,原子撤销旧会话并发布唯一后继
348
+ → 仅提交成功后返回 TokenPair
210
349
  ```
211
350
 
212
351
  **单设备登录:**
213
352
 
214
353
  ```
215
354
  create(options) [singleDevice=true]
216
- → 清除该用户所有已有令牌(遍历 user→tokens 映射)
217
- → 生成新的 accessToken + refreshToken
218
- → 存储 session 到缓存
355
+ → 构造新 accessToken + refreshToken
356
+ → 单键 CAS 同时替换该用户全部旧会话与索引
357
+ → 提交失败不踢出原会话
219
358
  ```
220
359
 
221
- #### 使用方式
360
+ ##### 使用方式
361
+
362
+ 登录成功后通过 `tokens.accessToken` 认证请求,通过 `tokens.refreshToken` 换取新令牌;`expiresIn` 单位为秒。独立 API 使用 `Authorization: Bearer <accessToken>`;SvelteKit Cookie 入口使用 kit 的标准认证集成,详见下方前端调用。
363
+
364
+ 受保护请求使用 `iam.auth.verifyToken`,返回当前用户资料、角色和权限;`iam.session.get/verifyToken` 是低层会话快照入口,不能替代当前授权。`session.create` 只用于可信服务端集成,不接受客户端自报 roles/permissions 或 API Key 绑定。
222
365
 
223
366
  ```ts
224
- // 创建会话(通常由 login 内部调用)
225
- const session = await iam.session.create({
226
- userId: 'user-id',
227
- username: 'admin',
228
- roles: ['admin'],
229
- permissions: ['user:read', 'user:write'],
230
- })
367
+ import { iam } from '@h-ai/iam'
231
368
 
232
- // 验证令牌
233
- const session = await iam.session.verifyToken(accessToken)
369
+ async function authenticateRequest(accessToken: string) {
370
+ // 受保护请求读取当前用户状态及角色/权限,失败时停止业务操作。
371
+ const verified = await iam.auth.verifyToken(accessToken)
372
+ if (!verified.success)
373
+ return verified
374
+ return verified
375
+ }
376
+
377
+ async function rotateTokens(refreshToken: string) {
378
+ const refreshed = await iam.session.refresh(refreshToken)
379
+ if (!refreshed.success)
380
+ return refreshed
381
+ // 安全存储替换两个令牌;下一次请求仍走 auth.verifyToken。
382
+ return refreshed
383
+ }
234
384
 
235
- // 刷新令牌
236
- const newTokens = await iam.session.refresh(refreshToken)
385
+ async function signOut(accessToken: string) {
386
+ return iam.auth.logout(accessToken)
387
+ }
237
388
 
238
- // 强制下线
239
- await iam.session.deleteByUserId(userId)
389
+ async function forceSignOut(userId: string) {
390
+ // 调用前已验证管理员权限,返回撤销的会话数。
391
+ return iam.session.deleteByUserId(userId)
392
+ }
240
393
  ```
241
394
 
242
- #### 特别关注
395
+ ##### 特别关注
243
396
 
244
- - **纯缓存存储**:不落库,重启后所有会话失效(符合安全最佳实践)
245
- - **滑动续期**:`sliding: true` 时每次 `get()` 自动延长 TTL
246
- - **Refresh Rotation**:旧 refreshToken 单次使用后立即失效,检测到重放即说明 token 泄漏
397
+ - **纯缓存存储**:不落库;Memory 重启后丢失,共享 Redis 可跨 IAM 服务重启保留会话直至 TTL 或撤销
398
+ - **滑动续期**:`sliding: true` 时每次 `get()` 单调延长 access 期限;分区保留期限另由 access/refresh 最大期限确定
399
+ - **Refresh Rotation**:旧 refresh 仅能成功消费一次;重复请求也可能来自合法并发或响应丢失,不能仅据此断言凭据泄露。
247
400
  - **Token 强度**:256-bit crypto random(`crypto.getRandomValues(new Uint8Array(32))`),base64url 编码
248
401
 
402
+ `create({ maxAge })` 可覆盖配置默认时长(正安全整数秒),该值持久化为 `Session.maxAge`;签发和刷新返回的 `expiresIn` 均使用它。滑动续期按该时长延长 access,返回已成功提交的 `lastActiveAt/expiresAt`;迟到请求只延长时间,不缩短较新续期。显式 `update` 的有效日期补丁完整生效并产生新内容版本。客户端根据本次返回的 `expiresIn` 安排刷新,不能硬编码配置默认值。
403
+
404
+ 未启用额外闲置/绝对期限时,access 与 refresh 独立到期:用户分区保留至所有 access/refresh 的最晚有效期,但 access 认证检查自己的 `expiresAt`。启用额外会话策略时,access/refresh 还必须满足相同的闲置与绝对期限。access 过期仍可凭未过期、未撤销的 refresh 轮换;每次轮换从新签发时刻重新计算 `refreshTokenMaxAge`,旧 access/refresh 同时失效。删除 access、logoutAll、单设备替换均撤销待刷新记录;仅 revokeRefresh 保留仍有效的 access。刷新路由只校验 refresh,不能先要求旧 access 认证成功。
405
+
406
+ 会话仓库将缓存读取/提交失败返回为失败 HaiResult,不解释为会话不存在或过期,也不执行清理补偿。用户会话及索引共用一个文档,批量授权更新不再逐 token 调用 get/ttl 或维护第二份索引;只按已确认的绝对期限清理。故障恢复后可重新读取并重试更新/退出,logoutAll 仍覆盖原有全部记录。结构损坏同样失败关闭并保留原值,交由运维处理,禁止用空文档覆盖未知授权状态。
407
+
408
+ Session.credentialVersion 绑定创建时的数据库凭据代际;CreateSessionOptions.expectedCredentialVersion 仅供已验证策略传入,旧代拒绝。省略它的受信低层 create 仍读取真实、激活且未到期的本地用户并绑定当前代,不能伪造缺少数据库记录的身份。
409
+
249
410
  ---
250
411
 
251
- ### 子模块:授权(authz)— `iam.authz`
412
+ #### 子模块:授权(authz)— `iam.authz`
252
413
 
253
- #### 关键对象
414
+ 角色列表通过 `iam.authz.getRoleUserCounts(roleIds)` 一次聚合当前页角色的真实成员数,返回 `HaiResult<Map<string, number>>`。无关联为 0;查询失败显示加载错误,不生成零统计。
415
+
416
+ ##### 关键对象
254
417
 
255
418
  - **`AuthzOperations`** — 授权操作接口,覆盖角色 CRUD、权限 CRUD、角色-权限/用户-角色分配、权限检查
256
419
  - **`Role`** — 角色实体:`{ id, code, name, description, isSystem }`
257
420
  - **`Permission`** — 权限实体:`{ id, code, name, type, resource, action }`,type 可为 `menu` / `api` / `button`
258
421
  - **`PermissionType`** — 权限类型枚举
259
422
 
260
- #### 关键流程
423
+ ##### 关键流程
261
424
 
262
425
  **权限检查(`checkPermission`):**
263
426
 
264
427
  ```
265
428
  checkPermission(userId, permission)
266
429
  → RBAC 未启用 → 直接返回 true
267
- → 查询用户角色列表(DB)
268
- → 检查是否包含超管角色(缓存 superAdminRoleId)→ 是则直接返回 true
269
- → 逐角色查询权限代码列表(缓存优先,miss 时查 DB 并写入缓存)
430
+ → 从共享代际缓存读取当前角色与权限,冷读使用单条 JOIN
431
+ → 包含超管角色则返回 true;必要状态读取失败返回错误
270
432
  → 逐权限匹配:精确匹配 或 通配符匹配(`admin:*` 匹配 `admin:read`)
271
433
  → 返回 true / false
272
434
  ```
@@ -276,57 +438,79 @@ checkPermission(userId, permission)
276
438
  ```
277
439
  assignPermissionToRole(roleId, permId)
278
440
  → DB 写入关联关系
279
- → 清除角色权限缓存
280
441
  → 查询该角色下所有用户 → 逐用户重新解析权限 → 更新活跃 session
281
442
  (best-effort:同步失败仅 log.error,不影响权限分配结果)
282
443
  ```
283
444
 
284
- #### 使用方式
445
+ ##### 使用方式
446
+
447
+ 先创建权限,使用权限 **ID** 保存角色的 `permissionIds`,再使用角色 **ID** 分配用户。会话中的 `roles`/`permissions` 和守卫使用稳定 **code**;显示 `name` 可独立修改,不能代替 ID/code。示例默认 RBAC 已启用,管理操作须由应用先校验调用者权限和资源范围。
448
+
449
+ `createRole/updateRole` 将角色资料与完整 `permissionIds` 放在同一事务:省略保持、空数组清空、重复去重、未知权限失败;并发以最后提交的完整集合为准。使用自管事务时提交后同步会话;使用外部事务时调用方须在提交后完成同步。
450
+
451
+ `checkPermission(userId, code)` 查询主体的 RBAC 权限,不识别请求所属 API Key 的 scopes;`getUserAuthorization` 也是主体授权读取。API Key 请求必须使用 `iam.auth.verifyToken` 返回的已收敛权限(下文完整示例),不能只拿 userId 重查并据此放行。serv 可用 `.permission('record:read')` 声明精确权限守卫;菜单/按钮隐藏只是体验控制,业务 API 仍须授权。
285
452
 
286
453
  ```ts
287
- // 角色管理
288
- const role = await iam.authz.createRole({ code: 'editor', name: '编辑' })
289
- const roles = await iam.authz.getAllRoles({ page: 1, pageSize: 20 })
290
-
291
- // 权限管理
292
- const perm = await iam.authz.createPermission({
293
- code: 'article:publish',
294
- name: '发布文章',
295
- type: 'api',
296
- resource: 'article',
297
- action: 'publish',
298
- })
454
+ import { iam } from '@h-ai/iam'
299
455
 
300
- // 分配与检查
301
- await iam.authz.assignRole(userId, role.data.id)
302
- await iam.authz.assignPermissionToRole(role.data.id, perm.data.id)
303
- const allowed = await iam.authz.checkPermission(userId, 'article:publish')
456
+ async function provisionReader(userId: string) {
457
+ // 示例初始化权限/角色;生产种子应先按 code 查询,保证幂等。
458
+ const permission = await iam.authz.createPermission({
459
+ code: 'record:read',
460
+ name: '读取记录',
461
+ type: 'api',
462
+ resource: 'record',
463
+ action: 'read',
464
+ })
465
+ if (!permission.success)
466
+ return permission
467
+ const role = await iam.authz.createRole({
468
+ code: 'record_reader',
469
+ name: '记录读取者',
470
+ permissionIds: [permission.data.id],
471
+ })
472
+ if (!role.success)
473
+ return role
474
+ return iam.authz.assignRole(userId, role.data.id)
475
+ }
476
+
477
+ async function replaceRolePermissions(roleId: string, permissionIds: string[]) {
478
+ // 完整替换:[] 清空;省略 permissionIds 则保持已有权限。
479
+ return iam.authz.updateRole(roleId, { permissionIds })
480
+ }
304
481
 
305
- // 批量同步角色
306
- await iam.authz.syncRoles(userId, [roleId1, roleId2])
482
+ async function replaceUserRoles(userId: string, roleIds: string[]) {
483
+ // 完整替换用户的角色集合;[] 移除全部角色。
484
+ return iam.authz.syncRoles(userId, roleIds)
485
+ }
486
+
487
+ async function currentAuthorization(userId: string) {
488
+ // 返回 roles/permissions 的 code 数组,不接受 ID 或显示名称代替 code。
489
+ return iam.authz.getUserAuthorization(userId)
490
+ }
307
491
  ```
308
492
 
309
- #### 特别关注
493
+ ##### 特别关注
310
494
 
311
495
  - **通配符匹配**:`admin:*` 匹配 `admin:read`、`admin:write:detail` 等(单层 `*` 匹配冒号后全部)
312
496
  - **超管角色**:配置的 `rbac.superAdminRole`(默认 `super_admin`)自动拥有所有权限
313
497
  - **事务保护**:`deleteRole` / `deletePermission` 使用 DB 事务,先清关联再删实体
314
- - **会话同步延迟**:权限变更后活跃 session 中的权限列表会被同步更新,但为 best-effort(大量用户场景可能有延迟)
498
+ - **会话同步**:缓存更新为 best-effort;受保护请求使用 `iam.auth.verifyToken`,使用共享代际缓存校验当前用户状态、期限及会话绑定的凭据代,再读取当前授权;缓存不可信时回源,必要读取失败即拒绝。`iam.session.get/verifyToken` 复用同一当前用户/凭据读取并检查启用状态与 `credentialVersion`,但角色/权限字段仍是会话快照,不能替代当前授权。`iam.authz.getUserAuthorization(userId)` 复用单次 JOIN 冷读与共享缓存。
315
499
  - **种子数据**:`seedDefaultData: true` 时自动创建 admin/user/guest 三个默认角色及基础权限,幂等执行
316
500
 
317
501
  ---
318
502
 
319
- ### 子模块:用户管理(user)— `iam.user`
503
+ #### 子模块:用户管理(user)— `iam.user`
320
504
 
321
- #### 关键对象
505
+ ##### 关键对象
322
506
 
323
507
  - **`UserOperations`** — 用户操作接口:注册、查询、更新、删除、密码修改与重置、密码强度验证
324
- - **`User`** — 用户实体(公开字段):`{ id, username, email, phone, displayName, avatarUrl, enabled, roles? }`
508
+ - **`User`** — 用户实体(公开字段):`{ id, username, email, phone, displayName, avatarUrl, accountType?, accountStatus, accountExpiresAt?, roles? }`
325
509
  - **`StoredUser`** — 内部存储用户(含 `passwordHash`、`loginFailedCount`、`lockedUntil` 等敏感字段)
326
510
  - **`RegisterOptions`** / **`RegisterResult`** — 注册输入与输出
327
- - **`UpdateCurrentUserInput`** — 当前用户可修改的白名单字段(`displayName` / `avatarUrl` / `phone` / `metadata`)
511
+ - **`UpdateCurrentUserInput`** — 当前用户可修改的白名单字段(`username` / `email` / `displayName` / `avatarUrl` / `phone` / `metadata`)
328
512
 
329
- #### 关键流程
513
+ ##### 关键流程
330
514
 
331
515
  **用户注册流程:**
332
516
 
@@ -336,7 +520,7 @@ register(options)
336
520
  → 密码强度验证(Zod schema + 自定义规则)
337
521
  → 密码哈希(@h-ai/crypto)
338
522
  → DB 事务:创建用户 + 查询创建结果
339
- → 分配默认角色(rbac.defaultRole,事务外执行,失败仅 log)
523
+ → 分配默认角色(rbac.defaultRole;失败则撤销新账号并返回失败)
340
524
  → 返回 RegisterResult { user, agreements? }
341
525
  ```
342
526
 
@@ -346,70 +530,103 @@ register(options)
346
530
  requestPasswordReset(email)
347
531
  → 按邮箱查找用户(不存在时静默返回 ok → 防枚举攻击)
348
532
  → 生成 256-bit 随机令牌
349
- → 缓存存储 SHA-256(token) → userId 映射(有 TTL + maxAttempts)
533
+ → 用户数据库行保存令牌摘要、期限、尝试次数和 resetVersion
350
534
  → 调用 onPasswordResetRequest 回调(业务层负责发送邮件/短信)
351
535
 
352
536
  confirmPasswordReset(token, newPassword)
353
- → 缓存查询 SHA-256(token) → 校验有效期和尝试次数
537
+ → 读取数据库令牌摘要 → 校验有效期、尝试次数和 resetVersion
354
538
  → 密码强度验证 → 哈希 → 更新 DB
355
539
  → 清除该用户所有活跃会话(强制重新登录)
356
- → 删除已使用的重置令牌
540
+ → 条件 SQL 同时消费令牌、更新密码并推进凭据代际
357
541
  ```
358
542
 
359
- #### 使用方式
543
+ ##### 使用方式
544
+
545
+ `register` 返回用户但不登录;需要同时登录时使用 `auth.registerAndLogin`。`createUser` 是管理端入口,关闭自助注册不阻止管理员建用户。当前用户资料用 `getCurrentUser/updateCurrentUser`,允许 username/email/displayName/avatarUrl/phone/metadata,安全字段和角色使用经授权的管理入口。
546
+
547
+ 分页返回统一 `PaginatedResult<User>`;角色过滤使用 `roleCode`,角色关联按需 `include: ['roles']`。批量查询保留去重后的输入顺序,缺失项省略,不能假定第 n 个结果必对应第 n 个输入;按 id 映射关联。各方法返回公开用户结构,管理查询仍需数据范围和字段披露控制。
548
+
549
+ `getUser(id)` 成功但不存在时返回 `null`;`getCurrentUser(token)` 缺失/会话无效时失败。不要把 DB 读取失败解释成空列表。`validatePassword` 为同步 HaiResult 检查;`deleteUser`、停用、密码修改/重置会清除受影响会话。
360
550
 
361
551
  ```ts
362
- // 注册
363
- const result = await iam.user.register({
364
- username: 'alice',
365
- email: 'alice@example.com',
366
- password: 'Password123',
367
- })
552
+ import { iam } from '@h-ai/iam'
368
553
 
369
- // 当前用户操作
370
- const me = await iam.user.getCurrentUser(accessToken)
371
- await iam.user.updateCurrentUser(accessToken, { displayName: '新名称' })
372
- await iam.user.changeCurrentUserPassword(accessToken, 'oldPass', 'newPass')
554
+ async function registerUser(username: string, email: string, password: string) {
555
+ // 返回 RegisterResult,不自动建立会话。
556
+ return iam.user.register({ username, email, password })
557
+ }
558
+
559
+ async function createManagedUser(username: string, password: string) {
560
+ // 先验证调用者管理权限;不受 register.enabled 开关限制。
561
+ return iam.user.createUser({ username, password })
562
+ }
373
563
 
374
- // 管理操作
375
- const users = await iam.user.listUsers({ page: 1, pageSize: 20, search: 'alice', include: ['roles'] })
376
- await iam.user.updateUser(userId, { enabled: false }) // 禁用用户 → 自动清除会话
377
- await iam.user.deleteUser(userId) // 删除用户 → 事务清除角色关联
564
+ async function updateMyProfile(accessToken: string, displayName: string) {
565
+ return iam.user.updateCurrentUser(accessToken, { displayName })
566
+ }
378
567
 
379
- // 密码重置
380
- await iam.user.requestPasswordReset('alice@example.com')
381
- await iam.user.confirmPasswordReset(token, 'NewPassword456')
382
- await iam.user.adminResetPassword(userId, 'TempPassword123')
568
+ async function changeMyPassword(accessToken: string, oldPassword: string, newPassword: string) {
569
+ // 成功后撤销该用户全部会话,客户端回到登录入口。
570
+ return iam.user.changeCurrentUserPassword(accessToken, oldPassword, newPassword)
571
+ }
383
572
 
384
- // 密码强度验证(同步方法)
385
- const valid = iam.user.validatePassword('weak')
573
+ async function listReaders(page: number) {
574
+ const result = await iam.user.listUsers({
575
+ page,
576
+ pageSize: 20,
577
+ roleCode: 'record_reader',
578
+ search: 'alice',
579
+ accountStatus: 'active',
580
+ include: ['roles'],
581
+ sortBy: 'createdAt',
582
+ sortDirection: 'desc',
583
+ })
584
+ if (!result.success)
585
+ return result
586
+ // 直接使用 items/total/page/pageSize,不对当前页再次过滤。
587
+ return result
588
+ }
589
+
590
+ async function loadUsers(userIds: readonly string[]) {
591
+ // 最多 1000 个输入 ID;去重保留输入顺序,缺失 ID 省略。
592
+ return iam.user.getUsers(userIds, { include: ['roles'] })
593
+ }
594
+
595
+ async function makeUserDormant(userId: string) {
596
+ // 调用前已验证管理权限;成功后自动清除该用户会话。
597
+ return iam.user.updateUser(userId, { accountStatus: 'dormant' })
598
+ }
386
599
  ```
387
600
 
388
- #### 特别关注
601
+ ##### 特别关注
389
602
 
390
603
  - **`toUser()` 字段脱敏**:所有对外返回的用户对象均剥离 `passwordHash`、`loginFailedCount` 等敏感字段
391
- - **会话联动**:`deleteUser` / `updateUser({ enabled: false })` / `changePassword` / `confirmPasswordReset` 自动清除受影响用户的所有活跃会话
604
+ - **会话联动**:`deleteUser` / `updateUser({ accountStatus: 'dormant' })` / `changePassword` / `confirmPasswordReset` 自动清除受影响用户的所有活跃会话
392
605
  - **防枚举攻击**:`requestPasswordReset` 对不存在的邮箱返回 `ok(undefined)` 而非错误
393
606
  - **validatePassword 是同步方法**:这是 `UserOperations` 中唯一的同步方法,其余均为异步
394
607
  - **搜索模糊匹配**:`listUsers` 支持按用户名、邮箱、手机号、显示名称模糊搜索,LIKE 通配符已转义
608
+ - **服务端排序**:`listUsers` 仅允许 `username`、`email`、`displayName`、`accountStatus`、`createdAt` 排序;字段经白名单映射,未指定时默认按创建时间倒序
395
609
 
396
610
  ---
397
611
 
398
- ### 子模块:API Key — `iam.apiKey`
612
+ #### 子模块:API Key — `iam.apiKey`
613
+
614
+ `scopes` 与主体权限取交集,支持 `*` 和命名空间 `:*`;默认空数组只保留身份,不授予操作权限。受限 key 的会话 `roles` 为空,通过 permission 守卫访问,显式 `*` 也不能超出主体授权。API Key 登录绑定 key ID,派生会话的 get/verifyToken/refresh 每次检查持久化 key,撤销、禁用、过期或 DB 读取失败即拒绝。单独 verifyApiKey 仅返回验证后的 key 元数据,仍需主体与操作授权。
399
615
 
400
- #### 关键对象
616
+ ##### 关键对象
401
617
 
402
618
  - **`ApiKeyOperations`** — API Key 操作接口:`createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`
403
619
  - **`ApiKey`** — API Key 实体(公开字段):`{ id, userId, name, keyPrefix, enabled, expiresAt, scopes }`
404
620
  - **`CreateApiKeyResult`** — 创建结果:`{ apiKey, rawKey }`(明文密钥仅返回一次)
405
621
 
406
- #### 关键流程
622
+ ##### 关键流程
407
623
 
408
624
  **API Key 验证:**
409
625
 
410
626
  ```
411
627
  verifyApiKey(rawKey)
412
- → 从 rawKey 提取前缀(prefix + 前 8 字符)
628
+ → 以 SHA-256(rawKey) 指纹读取共享代际缓存,命中时校验有效期并返回
629
+ → 冷读从 rawKey 提取前缀(prefix + 前 8 字符)
413
630
  → 按前缀从 DB 检索候选 API Key 列表(缩小范围)
414
631
  → 逐候选项进行 hash 验证(同步操作)
415
632
  → 验证通过 → 检查是否过期/禁用
@@ -417,25 +634,93 @@ verifyApiKey(rawKey)
417
634
  → 返回 ApiKey 实体
418
635
  ```
419
636
 
420
- #### 使用方式
637
+ ##### 使用方式
638
+
639
+ 先在初始化配置中启用 API Key。它是用户的受限凭据,不能独立授予用户未拥有的权限:**有效权限 = 当前主体权限 ∩ scopes**。scopes 是与权限 code 相同的字符串,不是角色名、菜单名称或资源 ID;不会创建权限,也不会替代租户/记录归属校验。
640
+
641
+ ```yaml
642
+ # config/_iam.yml
643
+ login:
644
+ apikey: true
645
+ apikey:
646
+ maxKeysPerUser: 10
647
+ defaultExpirationDays: 30
648
+ ```
649
+
650
+ 假设主体拥有 `record:read`、`record:write`、`user:read`,不同 scopes 的结果如下(主体授权改变后重新计算):
651
+
652
+ | scopes | 有效权限 | roles |
653
+ | ------------------------------ | ----------------------------- | ------------ |
654
+ | 未传或 `[]` | 无操作权限,仅可识别身份 | `[]` |
655
+ | `['record:read']` | `record:read` | `[]` |
656
+ | `['record:*']` | `record:read`、`record:write` | `[]` |
657
+ | `['record:read', 'user:read']` | 两个指定权限 | `[]` |
658
+ | `['missing:read']` | 主体未拥有,因此无权限 | `[]` |
659
+ | `['*']` | 主体当前拥有的全部权限 | 保留主体角色 |
660
+
661
+ `record:*` 匹配冒号后的全部层级,不支持任意正则或 `record:r*` 等中间通配符。主体仅拥有 `record:read` 时,`['record:*']` 仍只得到读取权限;主体持有通配符时,交集保留较窄范围。受限 Key 没有角色入口,使用 permission 守卫;显式 `*` 是宽授权,按实际需要选择。
662
+
663
+ 以下为创建 → Key 登录 → Bearer 会话验证与操作授权 → 列表 → 撤销的服务端示例。先检查所有 HaiResult;每个管理入口还要校验本人归属或管理员权限,IAM 不知道业务的调用者身份。
421
664
 
422
665
  ```ts
423
- // 创建 API Key
424
- const result = await iam.apiKey.createApiKey(userId, {
425
- name: 'CI/CD',
426
- expirationDays: 90,
427
- scopes: ['api:read'],
428
- })
429
- // result.data.rawKey → 明文密钥(仅此一次展示)
666
+ import { err, HaiCommonError, ok } from '@h-ai/core'
667
+ import { iam } from '@h-ai/iam'
668
+
669
+ async function createReadKey(userId: string) {
670
+ // 调用前校验管理权限或本人归属;scopes 使用权限 code。
671
+ const created = await iam.apiKey.createApiKey(userId, {
672
+ name: 'Read client',
673
+ expirationDays: 30,
674
+ scopes: ['record:read'],
675
+ })
676
+ if (!created.success)
677
+ return created
678
+ // rawKey 仅本次返回;通过安全渠道展示一次,不写日志。
679
+ return created
680
+ }
681
+
682
+ async function apiKeyLogin(rawKey: string) {
683
+ // 通常由客户端取得 Key 后调用;服务器内部验证并绑定 Key。
684
+ return iam.auth.loginWithApiKey({ key: rawKey })
685
+ }
686
+
687
+ async function requireRecordRead(accessToken: string, deniedMessage: string) {
688
+ // deniedMessage 由应用 i18n 提供;先读取当前且已收敛 scope 的会话。
689
+ const verified = await iam.auth.verifyToken(accessToken)
690
+ if (!verified.success)
691
+ return verified
692
+ const allowed = verified.data.permissions.some(code =>
693
+ code === '*' || code === 'record:read'
694
+ || (code.endsWith(':*') && 'record:read'.startsWith(code.slice(0, -1))),
695
+ )
696
+ if (!allowed)
697
+ return err(HaiCommonError.FORBIDDEN, deniedMessage)
698
+ // 这里只确认操作权限;业务层还须检查具体记录的数据归属。
699
+ return ok({ userId: verified.data.userId })
700
+ }
430
701
 
431
- // 列出用户的 API Key
432
- const keys = await iam.apiKey.listApiKeys(userId)
702
+ async function listMyKeys(userId: string) {
703
+ return iam.apiKey.listApiKeys(userId, {
704
+ page: 1,
705
+ pageSize: 20,
706
+ search: 'Read',
707
+ enabled: true,
708
+ })
709
+ }
433
710
 
434
- // 吊销
435
- await iam.apiKey.revokeApiKey(keyId)
711
+ async function revokeKey(keyId: string) {
712
+ // 调用前校验本人归属或管理权限。
713
+ return iam.apiKey.revokeApiKey(keyId)
714
+ }
436
715
  ```
437
716
 
438
- #### 特别关注
717
+ 创建后只有 `rawKey` 能用于 Key 登录;`apiKey.id` 是管理 ID,`keyPrefix` 仅用于显示/检索,均不能代替 rawKey。`verifyApiKey(rawKey)` 仅验证 Key 并返回元数据,不能代替用户状态、scopes 与操作授权。
718
+
719
+ Key 登录会话绑定 Key ID。刷新使用普通 `iam.session.refresh`,不会丢失该绑定或扩大 scopes。撤销成功后,已有 accessToken 的 get/verify 以及 refreshToken 的 refresh 都会失败;过期、禁用及持久化读取失败同样拒绝。角色撤权由 `iam.auth.verifyToken` 读取当前授权生效。改 scope 使用创建新 Key 后撤销旧 Key,当前 API 不提供 scope 更新操作。
720
+
721
+ `expirationDays: 0` 表示不设过期时间,省略时使用 `apikey.defaultExpirationDays`;需要限时访问时显式设置。明文 Key 和 Token 不进入 URL、日志、公开配置或普通 localStorage。
722
+
723
+ ##### 特别关注
439
724
 
440
725
  - **明文不落库**:数据库仅存储 hash,创建时返回的 `rawKey` 是唯一获取机会
441
726
  - **前缀检索**:使用 `keyPrefix` 缩小候选集,避免全表扫描
@@ -444,50 +729,150 @@ await iam.apiKey.revokeApiKey(keyId)
444
729
 
445
730
  ---
446
731
 
447
- ## 更多用法
732
+ ### 更多用法
448
733
 
449
734
  详细 API 参数、错误码及集成模式请参考 Skill 模板(`packages/cli/templates/skills/hai-iam/SKILL.md`)。
450
735
 
451
- ## 前端 API 契约
736
+ ### 前端 API 调用
452
737
 
453
- 前端通过 `@h-ai/iam/api` 导出的契约定义(`iamEndpoints`)与 `@h-ai/api-client` 配合调用:
738
+ 公共 HTTP API 由 `@h-ai/api-contract` 定义,并通过 `@h-ai/api-client` 生成 typed client 调用:
454
739
 
455
740
  ```ts
456
- import { api } from '@h-ai/api-client'
457
- import { iamEndpoints } from '@h-ai/iam/api'
741
+ import { apiClient } from '@h-ai/api-client'
458
742
 
459
- await api.init({ baseUrl: '/api/iam' })
743
+ await apiClient.init({ baseUrl: '/api/v1' })
460
744
 
461
745
  // 登录
462
- const result = await api.call(iamEndpoints.login, {
746
+ const result = await apiClient.iam.auth.login({
463
747
  identifier: 'admin',
464
748
  password: 'Password123',
465
749
  })
466
750
 
467
751
  // 获取当前用户
468
- const user = await api.call(iamEndpoints.currentUser, {})
752
+ const user = await apiClient.iam.auth.currentUser()
469
753
 
470
754
  // 修改密码
471
- await api.call(iamEndpoints.changePassword, {
755
+ await apiClient.iam.auth.changePassword({
472
756
  oldPassword: 'Password123',
473
757
  newPassword: 'NewPassword456',
474
758
  })
475
759
  ```
476
760
 
477
- ## 密码重置
761
+ 浏览器条件入口仅导出配置 Schema、类型和 HaiIamError,不包含服务端 `iam` 实例;认证通过 HTTP/typed client 调用服务端。
762
+
763
+ ### 密码重置
764
+
765
+ 重置需要启用 `passwordReset.enabled` 和可用的通知回调/Reach 模板。申请成功仅表示请求已处理,不证明账户存在或用户已收信;发送或内部处理失败仍须处理失败 HaiResult。令牌只允许一次成功消费且有有效期/尝试限制,成功后撤销旧会话。管理员重置无需旧密码,但须由应用验证管理权限。
478
766
 
479
767
  ```ts
480
- // 请求重置(即使用户不存在也返回 ok,防止枚举)
481
- await iam.user.requestPasswordReset('admin@example.com')
768
+ import { iam } from '@h-ai/iam'
769
+
770
+ async function requestReset(identifier: string) {
771
+ // 缺失用户与有效用户使用相同成功响应,避免枚举账户。
772
+ return iam.user.requestPasswordReset(identifier)
773
+ }
774
+
775
+ async function confirmReset(token: string, newPassword: string) {
776
+ // token 来自通知中的重置链接;成功后重新登录。
777
+ return iam.user.confirmPasswordReset(token, newPassword)
778
+ }
779
+
780
+ async function resetByAdministrator(userId: string, newPassword: string) {
781
+ // 管理权限校验在服务端入口完成;不接受未经校验的 userId。
782
+ return iam.user.adminResetPassword(userId, newPassword)
783
+ }
784
+ ```
785
+
786
+ 缓存清理失败可能返回失败,但已提交的新密码与凭据代际不会回滚;重新登录,不用旧凭据自动重试。
787
+
788
+ ### 一次性票据 — `iam.ticket`
789
+
790
+ 短期、一次性能力票据。用于无法携带 Bearer Token 的通道(如 WebSocket URL):由已认证的 HTTP 请求签发一个绑定主体、用途与操作的票据,服务端在首次校验时**原子消费**(单次有效)。
791
+
792
+ ```ts
793
+ import type { Session } from '@h-ai/iam'
794
+ import { iam } from '@h-ai/iam'
795
+
796
+ async function issueAudioTicket(session: Session) {
797
+ // session 已通过当前授权校验,签发前还须验证该操作及资源范围。
798
+ const issued = await iam.ticket.issue({
799
+ subjectId: session.userId,
800
+ purpose: 'ai-audio',
801
+ grant: { operation: 'transcribe', model: 'whisper-1' },
802
+ ttlMs: 30_000,
803
+ })
804
+ if (!issued.success)
805
+ return issued
806
+ // 仅将短期票据交给客户端,不把长期 accessToken 放进 URL。
807
+ return issued
808
+ }
809
+
810
+ async function consumeAudioTicket(ticket: string) {
811
+ const consumed = await iam.ticket.consume(ticket, { purpose: 'ai-audio' })
812
+ if (!consumed.success)
813
+ return consumed
814
+ // 按 grant 校验本次建连操作/资源;票据本身不自动读取当前 RBAC。
815
+ return consumed
816
+ }
817
+ ```
818
+
819
+ 特性:密码学安全随机值、TTL、原子单次消费(并发消费仅一个成功)、用途绑定(防跨用途重放)。
820
+
821
+ ### 配置驱动认证页面
822
+
823
+ 密码登录只提交 `identifier` 和 `password`;`AuthPanel` 将 `LoginForm` 的 `username` 映射为 `identifier`。会话有效期由服务端 `session.maxAge` / `session.refreshTokenMaxAge` 配置决定,登录表单不提供按次登录的持久会话选项。
824
+
825
+ 服务端通过 `iam.publicConfig` 提供白名单配置,或通过客户端的 `client.iam.auth.publicConfig()` 标准 HTTP contract 获取。只展示已开启且具备依赖的登录方式;LDAP 接入见上方可选配置,默认关闭;只有选用默认客户端时才安装 ldapts。
826
+
827
+ | 配置 | 用途 |
828
+ | ------------------------------------------------------------------------------------ | ----------------------------------------------------- |
829
+ | `login.password / otp / ldap` | 独立启用密码、验证码、企业账号 |
830
+ | `otp.email / sms` | 独立启用邮件、短信 |
831
+ | `register.enabled / defaultAccountStatus` | 自助注册及新账户状态 |
832
+ | `passwordReset.enabled / mode / resetUrl` | 启用重置、选择 email/phone 渠道、配置重置页面绝对地址 |
833
+ | `agreements.showOnRegister / showOnLogin` | 注册/登录协议弹窗显示;链接只接受 HTTP(S) |
834
+ | `ui.variant / title / subtitle / brandTitle / brandText / logoUrl / illustrationUrl` | 卡片/分栏布局、标题、品牌、Logo 和配图 |
835
+
836
+ 选用 Reach 时,在 reldb/cache/Reach 初始化成功后使用 `iam.init({ ...settings, reach })` 自动接入 Reach 的 `otp_email`、`otp_sms`、`password_reset`、`password_reset_sms` 模板。模板绑定对应邮件/短信 Provider;显式发送回调可覆盖默认行为。Console Provider 只记录元数据,实际收信需配置 SMTP、短信或 HTTP 通知服务。发送失败返回错误,不会伪报已发送。
482
837
 
483
- // 确认重置(校验令牌有效期和尝试次数 → 更新密码 → 清除所有会话)
484
- const result = await iam.user.confirmPasswordReset(token, 'NewPassword456')
838
+ SvelteKit 使用 `kit.auth.createApi(iam)` 导出标准 handler,匿名端点白名单使用 `kit.auth.publicPaths`。客户端通过 `kit.client.createAuthActions()` 连接 Cookie 端点;独立 HTTP 客户端通过 `apiClient.createAuthActions(client)` 连接 IAM contract 并自动保存 Token 到已配置安全存储。将配置及动作直接传入 `AuthPanel`,完成登录、注册、找回及重置;单独使用 `LoginForm` 可传 `methods`、`onsendotp`、`resendInterval`,密码表单可传完整 `passwordPolicy`。
485
839
 
486
- // 管理员重置(无需旧密码)
487
- await iam.user.adminResetPassword(userId, 'TempPassword123')
840
+ 管理员使用 `iam.user.createUser()` 建用户,调用端必须校验管理员权限;关闭自助注册不影响管理端。注册与密码登录开关独立,`registerAndLogin` 在注册完成后创建会话;默认休眠账号不会创建会话;`defaultAccountStatus` 默认 active,可设为 dormant 等待管理员激活。
841
+
842
+ 协议在 Modal 中打开,关闭后保留表单输入。使用 URL 时,协议页面须允许 iframe 嵌入;禁止脚本执行,并提供新窗口查看入口。`LoginForm/RegisterForm` 提供 `header/extraFields/footer` snippet,`AuthPanel` 透传 `loginHeader/loginFields/loginFooter` 和 `registerHeader/registerFields/registerFooter`;自定义字段由应用绑定状态并在动作中处理。示例见 [UI 用法](../ui/README.md#配置驱动认证页面)。
843
+
844
+ #### Markdown 协议内容
845
+
846
+ 协议支持 `agreements.userAgreementMarkdown` 与 `privacyPolicyMarkdown` 直接从配置加载;YAML 的 `|` 保留 Markdown 段落和换行。弹窗复用安全 MarkdownRenderer,关闭原始 HTML、脚本和代码复制工具。同主题同时提供正文与 URL 时优先正文,URL 可作为额外查看入口;只有 URL 时使用隔离 iframe。
847
+
848
+ ```yaml
849
+ agreements:
850
+ showOnRegister: true
851
+ showOnLogin: false
852
+ userAgreementMarkdown: |
853
+ # 用户协议
854
+
855
+ **账户使用条款**
856
+
857
+ - 请妥善保管账户凭据。
858
+ privacyPolicyMarkdown: |
859
+ # 隐私政策
860
+
861
+ 注册资料用于账户管理,会话信息用于身份认证。
862
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
863
+ # userAgreementUrl: https://example.com/terms
864
+ # privacyPolicyUrl: https://example.com/privacy
488
865
  ```
489
866
 
490
- ## 错误处理
867
+ #### 分布式安全与边界
868
+
869
+ 所有节点共享同一数据库和 Redis;Memory 仅用于单进程测试。OTP 的验证码、尝试次数和冷却期保存在同一身份 key,通过 CAS 发布和消费;冲突拒绝旧代,消费/耗尽保留冷却期。密码重置的摘要、有效期、尝试次数和 resetVersion 保存在用户数据库行,策略失败计次,最终一条条件 SQL 同时改密、消费令牌并推进 credentialVersion;新令牌、后续尝试、停用或到期都会阻止迟到提交。发送重置请求只推进 resetVersion,不撤销现有会话;成功改密才推进 credentialVersion。认证策略保存已验证的凭据代际,建立会话/认证/刷新均校验共享代际缓存或安全回源的当前状态;缓存批量清理失败也不能使旧会话有效。通知只在成功发布后调用,第三参数为 { expiresAt, idempotencyKey },应用及 Reach Provider 必须用该稳定键持久化去重。异常/超时可能已送达,返回结果未知,不自动重发或回滚已发布凭据;没有接收端幂等保障时不能承诺外部副作用恰好一次。
870
+
871
+ 可选字段(如 `ldap`)若尚未写入 YAML,请先声明该字段,或通过 `HAI_IAM` 的 JSON 值整体覆盖模块配置;已有字段支持 `HAI_IAM_LOGIN_OTP` 等环境变量覆盖。
872
+
873
+ 标准 HTTP 入口:`GET /auth/config`、`POST /auth/login/ldap`、`POST /auth/password-reset/request`、`POST /auth/password-reset/confirm`;验证码继续使用 `/auth/otp/send` 与 `/auth/login/otp`。安全错误:`SECURITY_BUSY (hai:iam:023)`、`PASSWORD_RESET_DISABLED (hai:iam:024)`。
874
+
875
+ ### 错误处理
491
876
 
492
877
  所有操作返回 `HaiResult<T>`,通过 `HaiIamError` 做分支判断:
493
878
 
@@ -502,10 +887,86 @@ if (!result.success) {
502
887
  }
503
888
  ```
504
889
 
505
- ## 测试
890
+ 会话与 refresh 消费记录统一保存在 `hai:iam:sessions:{SHA256(userId)}` 版本化文档,不再使用独立 token 映射/索引。Bearer token 携带不可作为凭据的分区提示和 256 位随机值;客户端必须视为不透明字符串。`Session.version` 用于轮换提交校验。Memory 仅用于单进程测试,生产所有节点共享 Redis(含 Cluster)。并发冲突从最新文档重试,耗尽返回 CONFLICT;旧 refresh 不会产生第二个后继。若响应丢失后结果不明,调用方按失败关闭并重新登录,不假定旧凭据仍有效;全部会话仍受同一用户状态索引管理。
891
+
892
+ `iam.session.update(token, patch, expectedVersion)` 必须传入读取到的 Session.version。旧版本返回 CONFLICT,已撤销 token 返回 SESSION_NOT_FOUND;不会将旧内容重新写回或覆盖新会话。仅滑动时间戳更新不改变内容版本,允许正常并行请求。批量权限更新绑定首次读取的 token/版本集合,不将迟到数据应用到后续登录;遇到较新内容版本失败,调用方重新查询授权后再提交。
893
+
894
+ ### 共享鉴权缓存
895
+
896
+ 验签所需账号/凭据状态、角色权限、敏感操作和已验证 API Key 使用 Redis 共享缓存(60 秒 DTO TTL),缓存值不保存数据库中的密码/API Key 验证哈希、明文密钥或重置令牌;API Key 缓存地址使用明文 Key 的单向 SHA-256 指纹;账号/密码/Key 到期时间每次按当前时间核验。权限冷读使用单次 JOIN。所有 IAM 写入口在数据库变更前建立共享脏标记,提交/回滚后推进代际,外部事务同样等到完成才允许回填;冷读与代际变化并发时不发布旧缓存。正常热路径不查 PG,写入中、失效未确认或缓存读失败时回源;会话 Redis 本身不可用仍拒绝验签。
897
+
898
+ 写前无法登记失效时拒绝变更;已提交后失效失败不会伪装回滚,持久脏标记使后续授权继续读数据库。进程崩溃或提交结果未知时不通过 TTL 擅自清除此标记;应告警并在确认没有 IAM 写事务的维护窗口修复缓存状态。多节点必须同时升级并通过 IAM API 写权限/账号;直接 SQL、旧版本写节点或独立系统改表不触发缓存失效,需停写并使 IAM 缓存整体失效后再恢复,不能与在线缓存写入混用。API Key 明文验证命中缓存后避免重复昂贵哈希,lastUsedAt 在冷验证时采样更新(最多约一分钟延迟)。
899
+
900
+ ## 自托管交互挑战
901
+
902
+ `captcha.enabled` 默认关闭。开启后使用 ALTCHA 自托管计算挑战,配置 `ttlSeconds`(默认120,1–600秒)、`cost`(默认5000,100–50000)和 `maxCounter`(默认100,1–2000)控制期限与工作量。不需要外部验证码账号,HMAC 秘密与单次消费状态只保存在既有共享 Cache;生产仍须使用共享后端。计算挑战增加自动化尝试成本,不保证请求来自真人。
903
+
904
+ `iam.auth.createCaptcha({operation})` 返回 `{id,challenge}`;operation 为 login/register/sendOtp/passwordReset/renewPassword。调用者用官方 `altcha/lib` 的 solveChallenge/pbkdf2 求解,将 `JSON.stringify({id,solution})` 作为 captcha 字段交给对应操作。密码/OTP/LDAP 登录、公开注册、匿名原密码续期都在服务端强制检查;sendOtp(identifier,{captcha}) 和 requestPasswordReset(identifier,{captcha}) 接收可选第二参数。证明用途绑定、短期单用,验证成功后原子删除;密码错误也会消费证明,重试需要新挑战。Cache 故障不降级放行;有效期由 Cache TTL 决定,重初始化不续期。管理员创建/邀请、已登录改密和 API Key 机器鉴别沿用各自授权与凭据,不额外加入交互挑战。
905
+
906
+ 标准 Kit/Serv 暴露 POST /auth/captcha(Kit 同源前缀为 /api),并保留 captcha 字段。`iam.publicConfig.captcha` 只公开 enabled。框架 AuthPanel 与 createAuthActions 在启用后提交时自动生成、异步求解并提交新证明,关闭时不加载求解器;自定义表单可复用同一协议。UI 配置启用但缺少 createCaptcha 动作时拒绝提交,取消/卸载会停止尚未提交的鉴别请求。captcha 证明按凭据规则始终脱敏,不写浏览器持久存储。030/031/032/033 分别表示需要验证、证明无效/已用/过期、未启用、服务不可用。
907
+
908
+ ## 可选密码与登录保护
909
+
910
+ 公开登录与 `renewPassword` 对不存在、无本地口令、休眠、注销、到期及锁定账号统一返回 `INVALID_CREDENTIALS`(“用户名或密码不正确”)。受限账号不继续校验猜测的密码,正确与错误密码得到相同反馈且不改变原有锁定计数;已认证管理入口仍可查看具体状态。密码计算或存储故障保留为基础设施错误,不能计为用户输错密码。
911
+
912
+ 当前密码复用返回 `HaiIamError.PASSWORD_REUSE_NOT_ALLOWED`(`hai:iam:034`),复杂度不符仍为 `PASSWORD_POLICY_VIOLATION`(`008`);前端应按错误码映射友好提示,不能直接显示未知异常。首次改密失败不签发普通会话,成功后使用新密码重新登录。
913
+
914
+ `password.minLength/maxLength` 控制长度,各 `require*` 指定必需类别;`minCharacterClasses` 支持大小写、数字、特殊字符中任意 N 类,0不额外限制。CLI/admin 示例默认8–128位、不额外要求字符类别;需要任意3类时显式设置 `minCharacterClasses: 3`。所有服务端创建、改密和重置入口共用校验。
915
+
916
+ `expirationDays: 0` 关闭定期改密,正数为天数;`preventSamePassword` 可独立开启。启用期限或首次强改时,新旧密码必须不同。`requireInitialChange` 作用于管理端创建/重置的密码,自助注册设密不重复强改。首次/过期密码不能授权普通会话或刷新旧会话;用户通过 `iam.user.renewPassword({ identifier, password, newPassword })` 验证原密码并更换后重新登录,Kit/Serv 与 AuthPanel 提供完成流程。
917
+
918
+ `security.lockoutEnabled` 的框架 Schema 默认开启,CLI/admin 简化示例显式关闭,按需启用;`failureWindow` 默认86400秒,从首次失败起按固定窗口计数,`maxLoginAttempts` 为阈值,`lockoutDuration` 为锁定秒数。示例锁定1200秒。关闭时忽略历史锁;计数与阈值在共享数据库中原子处理。
919
+
920
+ `initialCredentials.enabled` 开启管理端 `iam.user.inviteUser()`:内部生成独立随机秘密,仅发送短期单次设密邀请,不返回/发送初始秘密。沿用 `passwordReset` 的模式、期限、尝试次数和敏感通知通道,接收人通过重置页面设密。关闭重置或缺少通道时不创建账号。投递未知返回失败并保留已创建账号及邀请,核对后再请求重置通知,不自动回滚或重发。
921
+
922
+ `loginAccess.enabled` 开启登录限制;`allowIps/denyIps` 支持 IPv4/IPv6/CIDR,拒绝优先,空列表不限制对应维度。`windows` 用 `days`(周一1至周日7)、`start/end`(HH:mm,含起点不含终点,支持跨午夜)和 IANA `timezone`。登录方法第二参为可信 `AuthRequestContext`:Kit 使用平台 `getClientAddress()`,Serv 使用可信代理策略解析的 `context.ip`。不得复制任意转发头或凭据 JSON 的 IP;地址限制启用但无可信地址时拒绝。注册后自动登录、API Key 登录同样执行。
923
+
924
+ `reauthentication.enabled/operations` 选择敏感操作,`ttlSeconds` 为证明期限。`iam.auth.reauthenticate(token, password, operation)` 验证原密码并签发单次票据;操作入口完成权限/资源检查后用 `checkSensitiveOperation(token, operation, ticket)` 消费。证明绑定主体、当前会话和操作,不提升权限。Serv 标准 IAM 写操作与 admin 守卫读取 `x-hai-reauthentication`;Kit 业务入口用 `kit.guard.requireSensitive(iam, event, operation)`,业务操作 ID 由项目定义。票据只短暂保存在内存,公开配置仅暴露开关/操作清单。admin/fullstack 标准界面在已登录用户点击敏感动作时弹出当前账号密码验证窗口;取消不提交、密码错误不继续,正确后才执行原操作,不创建另一个登录会话。
925
+
926
+ RBAC 权限支持 `sensitiveOperations: string[]`(持久字段 `sensitive_operations`)。可信管理端可调用 `iam.authz.setPermissionSensitiveOperations(permissionId, operations)`;检查结果的 `success`,空列表仅移除该权限的附加规则。开启 `reauthentication.enabled` 后,所有权限记录的规则与文件 `operations` 取并集,服务端实时执行。规则 ID 必须对应实际业务守卫,不能替代原权限检查。异步 `iam.getPublicConfig()` 返回合并后的公开策略,DB 读取失败返回失败;同步 `iam.publicConfig` 只保留文件快照。RBAC 关闭时忽略数据库规则。权限表迁移只添加可空 JSON 字段,保留既有角色/权限关联。
927
+
928
+ ## 账号类型和生命周期
929
+
930
+ 用户默认是 `accountType: 'permanent'`、`accountStatus: 'active'`。管理入口可创建 `temporary` 账号并提供 `accountExpiresAt`,或通过 `updateUser` 将账号转为 `dormant`、`closed`。状态与凭据代际在同一事务更新;账号到期或非激活时,所有登录方式、旧 access 和 refresh 均被拒绝。管理员明确恢复为 `active` 时仍需重新登录,原 token 不会复活;账号仅使用 `accountStatus` 表达生命周期,不再提供独立的启用/停用字段。`closed` 保留账号记录,物理删除仍由 `deleteUser` 完成,项目决定个人数据的保留与注销流程。
931
+
932
+ 升级到 beta.1 前停止旧版本的写入并备份数据库。迁移 v6 将旧 `enabled=false`(含不明 NULL)且仍为 active 的账号转为 dormant,保留原有 dormant/closed,再删除 `enabled` 列;相关凭据同时失效。新接口、过滤和排序均只使用 `accountStatus`。这是一项数据库契约变更,容器镜像回滚不会恢复旧列,恢复旧版必须同时恢复经过核对的数据库备份。管理员可重置任何状态账号的密码,重置不改变状态;是否要求初始密码更换由 `password.requireInitialChange` 决定。
933
+
934
+ `accounts.dormantAfterDays` 默认 `0`,关闭自动休眠。设为 `90` 时,IAM 每隔 `accounts.scanIntervalSeconds`(默认3600秒)扫描最近90天没有成功登录的激活账号,未登录账号从创建时间起算。`lastLoginAt` 只在成功鉴别时更新。扫描是数据库条件更新,多节点重复执行不会重复变更;`iam.user.scanInactiveAccounts()` 也可由可信后台或既有调度器调用。`close()` 停止定时器并等待本轮扫描完成。公开注册接口不应允许用户自行设置这些管理字段。
935
+
936
+ ## 可选会话安全策略
937
+
938
+ 在 `session` 中设置 `idleTimeout: 1800` 可要求30分钟没有成功认证请求后重新登录;`0` 关闭。该策略在 access 读取、活动提交和 refresh 轮换时检查,不能通过长期 refresh 恢复已闲置的会话。自动 refresh 只换票,不延长闲置期限;后台业务轮询仍会产生认证活动,应用应按实际用户操作设计轮询行为。
939
+
940
+ `absoluteTimeout` 是从首次登录起的秒数上限,刷新不重置起点,`0` 关闭。`maxSessions` 限制每个用户的有效会话数,包含 access 已过期但仍可刷新的记录,`0` 不限制;并发登录在同一共享状态 CAS 中检查配额。`singleDevice: true` 默认顶旧保新;配合 `singleDeviceMode: 'reject'` 可保留旧会话并拒绝重复登录。显式退出撤销 access 和 refresh,关闭浏览器不能替代服务端期限。
941
+
942
+ 未启用上述期限时,保留 access 与 refresh 独立到期的行为。每次成功登录始终生成新随机凭据,不复用客户端会话ID。新增的 `SESSION_LIMIT_EXCEEDED` 表示登录达到已配置的会话上限。
943
+
944
+ ## 持久结构与凭据通知
945
+
946
+ `migrateIam({ dryRun: true })` 在 RelDB 初始化后只读预检;iam.init 自动补齐支持版本的凭据/重置字段,保留用户与密码,不修改业务权限。升级前备份停旧写入;没有安全代次的旧会话需重新登录,不能回退数据库安全代次来恢复授权。默认 Reach 通知使用 sensitive/expiresAt/idempotencyKey,不落明文、不延迟重投;自定义回调同样须遵守有效期与凭据保护。
947
+
948
+ 审计模块已初始化且 capture.enabled 开启时,IAM 自动记录关键账号、鉴别、密码、授权、API Key 和会话事件,包含失败/拒绝与安全原因码。先初始化 audit 再初始化 IAM,关闭时反序。不会记录密码、一次性票据或令牌;成功登录才确认主体,可信服务/CLI 调用应以 audit.withContext 提供操作者和关联信息。外部授权事务的事件随该事务提交或回滚,回滚失败尝试由调用入口在事务结束后记录。审计写失败输出诊断,不把已生效业务改成可盲目重试的失败。
949
+
950
+ `session.terminalLimits: { web: 2, app: 1 }` 设置分终端会话数,默认 `{}` 关闭。非空时未列出的 `desktop/api` 等类型不能新登录;旧会话按 `web` 处理。`terminalLimitMode: reject` 为默认,`replace` 仅替换同类最早会话。全局 `maxSessions` 和 `singleDevice` 继续约束所有终端,配额同一 CAS 原子提交。服务端登录上下文的 `terminalType` 和标准客户端 `auth.terminalType` 选择分组;类型不是硬件身份或授权凭据。
951
+
952
+ Web 原地恢复使用 UI 的 `createSessionRecovery` / `SessionRecoveryDialog` 与 SDK 的 `recoverSession`,复用正常登录策略。`expectedUserId` 在密码、OTP、LDAP 登录签发会话前限制原主体;验证失败不签发恢复会话。仅重发明确未认证拒绝的一笔请求,网络结果不明时不重发。页面刷新/关闭后的内存草稿不在恢复范围内。
953
+
954
+ ## 页面离开会话策略
955
+
956
+ `session.endOnPageHide` 默认 `false`。启用后宿主调用 `actions.bindPageExit({ enabled: config.session.endOnPageHide })` 并在卸载时调用返回的清理函数;标准 admin/H5/fullstack 模板已经接入。非 BFCache 的 `pagehide`(也包括刷新、整页导航)通过 POST `/auth/session/close` 尽力撤销当前 access 及关联 refresh,沿用 CSRF/传输加密,使用 `keepalive` 且不自动刷新会话。该响应不清 Cookie/本地凭据,避免旧响应覆盖下一次登录。显式退出仍使用正常 logout,可靠撤销并清理当前凭据。浏览器可能不发送页面离开请求,因此必须按业务配合服务端 `idleTimeout`/`absoluteTimeout`;不能据此保证关闭即撤销。
957
+
958
+ 标准 `/auth/me` 也检查 `iam.profile.update` / `iam.profile.avatar`;混合修改优先选取已启用的资料用途,否则使用头像用途。`authz.getSensitiveOperations(operation?)` 可只查询一个操作;未传则列出全部有效数据库规则。超管身份每次从共享角色读取,默认种子允许并发创建冲突后重读,并传播权限分配失败。
959
+
960
+ ## 开发测试
961
+
962
+ 公开示例从 `@h-ai/iam` 导入,区分可省略默认值的 `IamConfigInput` 与 Schema 解析后的 `IamConfig`。服务端依赖先初始化;`HaiResult` 先检查 `success`,会话修改使用读取到的 `version`,通知复用持久幂等键。内部策略/仓储示例标记为“模块内部上下文”,不作为应用的深层导入入口。检查器只验证示例类型;真实 Redis/数据库、LDAP 和通知投递仍需对应集成测试。见[模块 JSDoc 约定](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证)。
506
963
 
507
964
  ```bash
508
- pnpm test
965
+ pnpm --filter @h-ai/iam typecheck
966
+ pnpm --filter @h-ai/iam lint
967
+ pnpm --filter @h-ai/iam build
968
+ pnpm --filter @h-ai/iam test
969
+ node scripts/check-module-jsdoc.mjs --package iam
509
970
  ```
510
971
 
511
972
  ## 许可证