@h-ai/iam 0.1.0-alpha.55 → 0.1.0-alpha.57
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.en.md +556 -0
- package/README.ja.md +556 -0
- package/README.md +4 -0
- package/package.json +6 -6
package/README.en.md
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# @h-ai/iam
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/audit](../audit/README.en.md) · [@h-ai/cache](../cache/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/crypto](../crypto/README.en.md) · [@h-ai/reldb](../reldb/README.en.md)
|
|
6
|
+
|
|
7
|
+
Identity and access management through a unified `iam` object for authentication, sessions, authorization, and user management.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
| Feature | Description |
|
|
12
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| **Authentication** | Password, OTP (email/SMS codes), LDAP, and API Key strategies |
|
|
14
|
+
| **Sessions** | Stateful sessions (random access tokens + cache, optional sliding renewal) |
|
|
15
|
+
| **Authorization** | RBAC roles and permissions (DB + cache, wildcard matching) |
|
|
16
|
+
| **User management** | Registration, queries, updates, password reset, and admin password reset |
|
|
17
|
+
| **API Key** | Create, revoke, and verify API keys with scopes and expiration |
|
|
18
|
+
| **One-time tickets** | Short-lived capability tickets (issue / atomic consume) for channels such as WebSocket that cannot carry Bearer tokens |
|
|
19
|
+
| **Frontend client** | HTTP API contracts for independent frontends |
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add @h-ai/iam
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Dependencies
|
|
28
|
+
|
|
29
|
+
- `@h-ai/reldb`: persistence for users/roles/permissions; **initialize before iam.init()**.
|
|
30
|
+
- `@h-ai/cache`: sessions/OTP/reset tokens/permission cache; **initialize before iam.init()**.
|
|
31
|
+
- `@h-ai/crypto`: password hashing, used internally and initialized automatically.
|
|
32
|
+
- `@h-ai/audit`: audit records for key RBAC operations.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { cache } from '@h-ai/cache'
|
|
38
|
+
import { iam } from '@h-ai/iam'
|
|
39
|
+
import { reldb } from '@h-ai/reldb'
|
|
40
|
+
|
|
41
|
+
// 1. 初始化依赖
|
|
42
|
+
await reldb.init({ type: 'sqlite', database: './data.db' })
|
|
43
|
+
await cache.init({ type: 'memory' })
|
|
44
|
+
|
|
45
|
+
// 2. 初始化 IAM(自动使用已初始化的 reldb 和 cache 单例)
|
|
46
|
+
await iam.init({
|
|
47
|
+
session: { maxAge: 86400, sliding: true },
|
|
48
|
+
// OTP 回调(启用 OTP 登录时注入发送逻辑)
|
|
49
|
+
onOtpSendEmail: async (email, code) => {
|
|
50
|
+
await sendEmail(email, `验证码: ${code}`)
|
|
51
|
+
},
|
|
52
|
+
// 密码重置回调(启用密码重置时注入通知逻辑)
|
|
53
|
+
onPasswordResetRequest: async (user, token, expiresAt) => {
|
|
54
|
+
await sendEmail(user.email!, `重置链接: https://example.com/reset?token=${token}`)
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// 2. 注册
|
|
59
|
+
const userResult = await iam.user.register({
|
|
60
|
+
username: 'admin',
|
|
61
|
+
email: 'admin@example.com',
|
|
62
|
+
password: 'Password123',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// 3. 登录
|
|
66
|
+
const loginResult = await iam.auth.login({
|
|
67
|
+
identifier: 'admin',
|
|
68
|
+
password: 'Password123',
|
|
69
|
+
})
|
|
70
|
+
if (loginResult.success) {
|
|
71
|
+
const { user, tokens } = loginResult.data
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. 验证令牌
|
|
75
|
+
const session = await iam.auth.verifyToken(loginResult.data.tokens.accessToken)
|
|
76
|
+
|
|
77
|
+
// 5. OTP 验证码登录
|
|
78
|
+
await iam.auth.sendOtp('user@example.com')
|
|
79
|
+
const otpResult = await iam.auth.loginWithOtp({
|
|
80
|
+
identifier: 'user@example.com',
|
|
81
|
+
code: '123456',
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// 6. 检查权限
|
|
85
|
+
const hasPermission = await iam.authz.checkPermission(
|
|
86
|
+
loginResult.data.user.id,
|
|
87
|
+
'user:read',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// 7. 关闭
|
|
91
|
+
await iam.close()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`iam.config` returns a **redacted configuration snapshot**. Sensitive LDAP `url` / `bindPassword` and similar fields are hidden automatically. Use it to inspect state, not as the original configuration source.
|
|
95
|
+
|
|
96
|
+
## Design
|
|
97
|
+
|
|
98
|
+
### Architecture
|
|
99
|
+
|
|
100
|
+
IAM is a **lifecycle singleton** managed through `iam.init()` / `iam.close()`. Five internal subsystems initialize in dependency order:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
iam.init()
|
|
104
|
+
├─ session (cached sessions; no DB dependency)
|
|
105
|
+
├─ authz (RBAC roles/permissions; DB + cache)
|
|
106
|
+
├─ authn (authentication; depends on session + authz)
|
|
107
|
+
├─ user (users; depends on session + authz + passwordStrategy)
|
|
108
|
+
└─ seed (seed data; depends on authz)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
All subsystems use **factories + closures** (for example `createSessionOperations(deps)`), not classes. Getters expose them as `iam.auth` / `iam.user` / `iam.authz` / `iam.session` / `iam.apiKey`.
|
|
112
|
+
|
|
113
|
+
Before initialization, every subsystem method returns `NOT_INITIALIZED` through a `NotInitializedKit` proxy.
|
|
114
|
+
|
|
115
|
+
### Data storage
|
|
116
|
+
|
|
117
|
+
| Data | Storage | Description |
|
|
118
|
+
| --------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
119
|
+
| Users, roles, permissions, associations, API keys | DB (6 tables) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
|
|
120
|
+
| Sessions (accessToken → Session) | Cache | TTL, optional sliding renewal |
|
|
121
|
+
| Token mapping (refreshToken → userId + accessToken) | Cache | Separate keys; old tokens expire immediately after refresh |
|
|
122
|
+
| OTP codes | Cache | TTL + attempt limit |
|
|
123
|
+
| Password reset tokens | Cache | TTL + maximum verification attempts |
|
|
124
|
+
| API Key | DB | `hai_iam_api_keys`; store hashes only, never plaintext; prefix narrows candidates |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### Authentication (authn) — `iam.auth`
|
|
129
|
+
|
|
130
|
+
#### Key types
|
|
131
|
+
|
|
132
|
+
- **`AuthnOperations`**: `login` / `loginWithOtp` / `loginWithLdap` / `loginWithApiKey` / `logout` / `verifyToken` / `sendOtp` / `registerAndLogin`.
|
|
133
|
+
- **`AuthStrategy`**: strategy interface (`type` + `authenticate` + `challenge?`), implemented for password, OTP, LDAP, and API Key.
|
|
134
|
+
- **`AuthResult`**: login result with user details, `TokenPair` (accessToken + refreshToken), and roles/permissions.
|
|
135
|
+
- **`Credentials`**: a credential union combining `{ type: 'password' | 'otp' | 'ldap' | 'apikey' }` with strategy-specific credentials.
|
|
136
|
+
|
|
137
|
+
#### Key workflows
|
|
138
|
+
|
|
139
|
+
**Password login:**
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
login(credentials)
|
|
143
|
+
→ Check password login is enabled
|
|
144
|
+
→ Find user by identifier (username / email / phone)
|
|
145
|
+
→ Dummy hash for missing users (fixed timing to mitigate timing attacks)
|
|
146
|
+
→ Check account lock (loginFailedCount >= maxLoginAttempts within lockoutDuration)
|
|
147
|
+
→ Verify password hash (@h-ai/crypto)
|
|
148
|
+
→ Failure: record attempt; lock at the limit
|
|
149
|
+
→ Success: reset failure count
|
|
150
|
+
→ Check password expiration (passwordUpdatedAt when expirationDays > 0)
|
|
151
|
+
→ Query user roles + permissions
|
|
152
|
+
→ Create session (session.create → TokenPair)
|
|
153
|
+
→ Return AuthResult { user, tokens, roles, permissions, agreements? }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Token verification:**
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
verifyToken(accessToken)
|
|
160
|
+
→ session.get(accessToken) → cache lookup
|
|
161
|
+
→ Extend TTL automatically when sliding renewal is enabled
|
|
162
|
+
→ Return Session (userId, roles, permissions)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### Usage
|
|
166
|
+
|
|
167
|
+
`authz.createRole({ ..., permissionIds })` and `authz.updateRole(id, { ..., permissionIds })` save role data and the complete permission set in one transaction; any failure rolls back. `permissionIds: []` clears permissions, omission preserves them, duplicate IDs are deduplicated, and unknown permissions fail. Concurrent updates use the last committed complete set without merging. Self-managed commits synchronize user sessions; with an external transaction, the caller must synchronize after commit. Admin Console uses this API directly, without split updates or compensating role deletion.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// 密码登录
|
|
171
|
+
const result = await iam.auth.login({ identifier: 'admin', password: 'Password123' })
|
|
172
|
+
|
|
173
|
+
// OTP 登录
|
|
174
|
+
await iam.auth.sendOtp('user@example.com')
|
|
175
|
+
const result = await iam.auth.loginWithOtp({ identifier: 'user@example.com', code: '123456' })
|
|
176
|
+
|
|
177
|
+
// LDAP 登录(需在 init 时提供 ldapClientFactory)
|
|
178
|
+
const result = await iam.auth.loginWithLdap({ username: 'jdoe', password: 'pass' })
|
|
179
|
+
|
|
180
|
+
// API Key 登录(需启用 login.apikey: true)
|
|
181
|
+
const result = await iam.auth.loginWithApiKey({ key: 'hai_xxxx...' })
|
|
182
|
+
|
|
183
|
+
// 验证令牌
|
|
184
|
+
const session = await iam.auth.verifyToken(accessToken)
|
|
185
|
+
|
|
186
|
+
// 注册并登录(一站式)
|
|
187
|
+
const result = await iam.auth.registerAndLogin({ username: 'new', password: 'Pass123' })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### Considerations
|
|
191
|
+
|
|
192
|
+
- **Timing attack mitigation**: perform a dummy password hash for nonexistent users to keep response timing consistent.
|
|
193
|
+
- **Account lockout**: lock for `lockoutDuration` seconds after `maxLoginAttempts` consecutive failures.
|
|
194
|
+
- **Independent strategy toggles**: `login.password` / `login.otp` / `login.ldap` / `login.apikey`.
|
|
195
|
+
- **OTP security**: rejection sampling avoids modulo bias, constant-time comparison mitigates timing attacks, and `resendInterval` limits frequency.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### Sessions (session) — `iam.session`
|
|
200
|
+
|
|
201
|
+
#### Key types
|
|
202
|
+
|
|
203
|
+
- **`SessionOperations`**: `create` / `get` / `verifyToken` / `update` / `delete` / `deleteByUserId` / `refresh` / `revokeRefresh` / `patchUserSessions`.
|
|
204
|
+
- **`Session`**: userId, roles, permissions, accessToken, expiration, and other session data.
|
|
205
|
+
- **`TokenPair`**: `{ accessToken, refreshToken, expiresIn, tokenType: 'Bearer' }`.
|
|
206
|
+
|
|
207
|
+
#### Key workflows
|
|
208
|
+
|
|
209
|
+
**Token refresh (rotation):**
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
refresh(refreshToken)
|
|
213
|
+
→ Read refreshToken → { userId, accessToken } from cache
|
|
214
|
+
→ Get old session via accessToken
|
|
215
|
+
→ Delete old refreshToken (expire key immediately to prevent replay)
|
|
216
|
+
→ Delete old accessToken session
|
|
217
|
+
→ Create a new session with the old user context
|
|
218
|
+
→ Return new TokenPair
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Single-device login:**
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
create(options) [singleDevice=true]
|
|
225
|
+
→ Clear all existing user tokens via user→tokens mapping
|
|
226
|
+
→ Generate new accessToken + refreshToken
|
|
227
|
+
→ Store session in cache
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### Usage
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// 创建会话(通常由 login 内部调用)
|
|
234
|
+
const session = await iam.session.create({
|
|
235
|
+
userId: 'user-id',
|
|
236
|
+
username: 'admin',
|
|
237
|
+
roles: ['admin'],
|
|
238
|
+
permissions: ['user:read', 'user:write'],
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// 验证令牌
|
|
242
|
+
const session = await iam.session.verifyToken(accessToken)
|
|
243
|
+
|
|
244
|
+
// 刷新令牌
|
|
245
|
+
const newTokens = await iam.session.refresh(refreshToken)
|
|
246
|
+
|
|
247
|
+
// 强制下线
|
|
248
|
+
await iam.session.deleteByUserId(userId)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
#### Considerations
|
|
252
|
+
|
|
253
|
+
- **Cache-only storage**: no database persistence; all sessions expire after restart, following the security design.
|
|
254
|
+
- **Sliding renewal**: with `sliding: true`, each `get()` extends TTL automatically.
|
|
255
|
+
- **Refresh rotation**: old refreshToken expires after one use; replay detection indicates token leakage.
|
|
256
|
+
- **Token strength**: 256-bit cryptographic randomness (`crypto.getRandomValues(new Uint8Array(32))`), base64url encoded.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### Authorization (authz) — `iam.authz`
|
|
261
|
+
|
|
262
|
+
#### Key types
|
|
263
|
+
|
|
264
|
+
- **`AuthzOperations`**: role/permission CRUD, role-permission/user-role assignments, and permission checks.
|
|
265
|
+
- **`Role`**: `{ id, code, name, description, isSystem }`.
|
|
266
|
+
- **`Permission`**: `{ id, code, name, type, resource, action }`; type is `menu` / `api` / `button`.
|
|
267
|
+
- **`PermissionType`**: permission type enum.
|
|
268
|
+
|
|
269
|
+
#### Key workflows
|
|
270
|
+
|
|
271
|
+
**Permission checks (`checkPermission`):**
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
checkPermission(userId, permission)
|
|
275
|
+
→ Return true if RBAC is disabled
|
|
276
|
+
→ Query user roles (DB)
|
|
277
|
+
→ Check super-admin role (cached superAdminRoleId); return true if present
|
|
278
|
+
→ Get each role's permission codes (cache first; DB on miss, then cache)
|
|
279
|
+
→ Match each permission exactly or by wildcard (`admin:*` matches `admin:read`)
|
|
280
|
+
→ Return true / false
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Session synchronization after role/permission changes:**
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
assignPermissionToRole(roleId, permId)
|
|
287
|
+
→ Write association to DB
|
|
288
|
+
→ Clear role permission cache
|
|
289
|
+
→ Find role users → resolve permissions per user → update active sessions
|
|
290
|
+
(best-effort: failures only log.error; permission assignment still succeeds)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
#### Usage
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
// 角色管理
|
|
297
|
+
const role = await iam.authz.createRole({ code: 'editor', name: '编辑' })
|
|
298
|
+
const roles = await iam.authz.getAllRoles({ page: 1, pageSize: 20 })
|
|
299
|
+
|
|
300
|
+
// 权限管理
|
|
301
|
+
const perm = await iam.authz.createPermission({
|
|
302
|
+
code: 'article:publish',
|
|
303
|
+
name: '发布文章',
|
|
304
|
+
type: 'api',
|
|
305
|
+
resource: 'article',
|
|
306
|
+
action: 'publish',
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// 分配与检查
|
|
310
|
+
await iam.authz.assignRole(userId, role.data.id)
|
|
311
|
+
await iam.authz.assignPermissionToRole(role.data.id, perm.data.id)
|
|
312
|
+
const allowed = await iam.authz.checkPermission(userId, 'article:publish')
|
|
313
|
+
|
|
314
|
+
// 批量同步角色
|
|
315
|
+
await iam.authz.syncRoles(userId, [roleId1, roleId2])
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### Considerations
|
|
319
|
+
|
|
320
|
+
- **Wildcard matching**: `admin:*` matches `admin:read`, `admin:write:detail`, etc.; one `*` matches everything after the colon.
|
|
321
|
+
- **Super-admin role**: `rbac.superAdminRole` (default `super_admin`) automatically grants all permissions.
|
|
322
|
+
- **Transactions**: `deleteRole` / `deletePermission` clear associations before deleting entities in a DB transaction.
|
|
323
|
+
- **Session synchronization latency**: permission changes update active session permissions on a best-effort basis; many users may cause delay.
|
|
324
|
+
- **Seed data**: `seedDefaultData: true` idempotently creates admin/user/guest roles and basic permissions.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
### Users (user) — `iam.user`
|
|
329
|
+
|
|
330
|
+
#### Key types
|
|
331
|
+
|
|
332
|
+
- **`UserOperations`**: registration, queries, updates, deletion, password changes/resets, and password strength validation.
|
|
333
|
+
- **`User`** (public fields): `{ id, username, email, phone, displayName, avatarUrl, enabled, roles? }`.
|
|
334
|
+
- **`StoredUser`**: internal stored user, including sensitive `passwordHash`, `loginFailedCount`, `lockedUntil`, etc.
|
|
335
|
+
- **`RegisterOptions`** / **`RegisterResult`**: registration input/output.
|
|
336
|
+
- **`UpdateCurrentUserInput`**: fields the current user may change (`displayName` / `avatarUrl` / `phone` / `metadata`).
|
|
337
|
+
|
|
338
|
+
#### Key workflows
|
|
339
|
+
|
|
340
|
+
**User registration:**
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
register(options)
|
|
344
|
+
→ Check registration is enabled
|
|
345
|
+
→ Validate password strength (Zod schema + custom rules)
|
|
346
|
+
→ Hash password (@h-ai/crypto)
|
|
347
|
+
→ DB transaction: create user + query result
|
|
348
|
+
→ Assign default role (rbac.defaultRole, outside transaction; failures only log)
|
|
349
|
+
→ Return RegisterResult { user, agreements? }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Password reset:**
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
requestPasswordReset(email)
|
|
356
|
+
→ Find user by email (missing users return ok silently to prevent enumeration)
|
|
357
|
+
→ Generate a random 256-bit token
|
|
358
|
+
→ Cache SHA-256(token) → userId (TTL + maxAttempts)
|
|
359
|
+
→ Call onPasswordResetRequest (application sends email/SMS)
|
|
360
|
+
|
|
361
|
+
confirmPasswordReset(token, newPassword)
|
|
362
|
+
→ Look up SHA-256(token) and verify expiration/attempts
|
|
363
|
+
→ Validate password strength → hash → update DB
|
|
364
|
+
→ Clear all active user sessions to force login
|
|
365
|
+
→ Delete consumed reset token
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
#### Usage
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
// 注册
|
|
372
|
+
const result = await iam.user.register({
|
|
373
|
+
username: 'alice',
|
|
374
|
+
email: 'alice@example.com',
|
|
375
|
+
password: 'Password123',
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// 当前用户操作
|
|
379
|
+
const me = await iam.user.getCurrentUser(accessToken)
|
|
380
|
+
await iam.user.updateCurrentUser(accessToken, { displayName: '新名称' })
|
|
381
|
+
await iam.user.changeCurrentUserPassword(accessToken, 'oldPass', 'newPass')
|
|
382
|
+
|
|
383
|
+
// 管理操作
|
|
384
|
+
const users = await iam.user.listUsers({
|
|
385
|
+
page: 1,
|
|
386
|
+
pageSize: 20,
|
|
387
|
+
search: 'alice',
|
|
388
|
+
sortBy: 'createdAt',
|
|
389
|
+
sortDirection: 'desc',
|
|
390
|
+
include: ['roles'],
|
|
391
|
+
})
|
|
392
|
+
await iam.user.updateUser(userId, { enabled: false }) // 禁用用户 → 自动清除会话
|
|
393
|
+
await iam.user.deleteUser(userId) // 删除用户 → 事务清除角色关联
|
|
394
|
+
|
|
395
|
+
// 密码重置
|
|
396
|
+
await iam.user.requestPasswordReset('alice@example.com')
|
|
397
|
+
await iam.user.confirmPasswordReset(token, 'NewPassword456')
|
|
398
|
+
await iam.user.adminResetPassword(userId, 'TempPassword123')
|
|
399
|
+
|
|
400
|
+
// 密码强度验证(同步方法)
|
|
401
|
+
const valid = iam.user.validatePassword('weak')
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
#### Considerations
|
|
405
|
+
|
|
406
|
+
- **`toUser()` redaction**: public users omit sensitive fields such as `passwordHash` and `loginFailedCount`.
|
|
407
|
+
- **Session invalidation**: `deleteUser` / `updateUser({ enabled: false })` / `changePassword` / `confirmPasswordReset` clear all active sessions for affected users.
|
|
408
|
+
- **Enumeration protection**: `requestPasswordReset` returns `ok(undefined)` for nonexistent emails instead of an error.
|
|
409
|
+
- **Synchronous validatePassword**: the only synchronous method in `UserOperations`; all others are asynchronous.
|
|
410
|
+
- **Fuzzy search**: `listUsers` searches username, email, phone, and display name with escaped LIKE wildcards.
|
|
411
|
+
- **Server sorting**: `listUsers` allows only `username`, `email`, `displayName`, `enabled`, and `createdAt`, mapped through an allowlist; defaults to newest creation first.
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
### API keys — `iam.apiKey`
|
|
416
|
+
|
|
417
|
+
#### Key types
|
|
418
|
+
|
|
419
|
+
- **`ApiKeyOperations`**: `createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`.
|
|
420
|
+
- **`ApiKey`** (public fields): `{ id, userId, name, keyPrefix, enabled, expiresAt, scopes }`.
|
|
421
|
+
- **`CreateApiKeyResult`**: `{ apiKey, rawKey }`; the plaintext key is returned only once.
|
|
422
|
+
|
|
423
|
+
#### Key workflows
|
|
424
|
+
|
|
425
|
+
**API key verification:**
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
verifyApiKey(rawKey)
|
|
429
|
+
→ Extract prefix from rawKey (prefix + first 8 characters)
|
|
430
|
+
→ Query candidate keys by prefix from DB
|
|
431
|
+
→ Verify each hash synchronously
|
|
432
|
+
→ If valid, check expiration/disabled state
|
|
433
|
+
→ Update lastUsedAt asynchronously without blocking return
|
|
434
|
+
→ Return ApiKey
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### Usage
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// 创建 API Key
|
|
441
|
+
const result = await iam.apiKey.createApiKey(userId, {
|
|
442
|
+
name: 'CI/CD',
|
|
443
|
+
expirationDays: 90,
|
|
444
|
+
scopes: ['api:read'],
|
|
445
|
+
})
|
|
446
|
+
// result.data.rawKey → 明文密钥(仅此一次展示)
|
|
447
|
+
|
|
448
|
+
// 列出用户的 API Key(服务端分页)
|
|
449
|
+
const keys = await iam.apiKey.listApiKeys(userId, { page: 1, pageSize: 20, search: 'ci' })
|
|
450
|
+
// keys.data → { items, total, page, pageSize }
|
|
451
|
+
|
|
452
|
+
// 吊销
|
|
453
|
+
await iam.apiKey.revokeApiKey(keyId)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
#### Considerations
|
|
457
|
+
|
|
458
|
+
- **No plaintext persistence**: only hashes are stored; `rawKey` at creation is the sole opportunity to retrieve it.
|
|
459
|
+
- **Prefix lookup**: `keyPrefix` narrows candidates without a full table scan.
|
|
460
|
+
- **Count limit**: at most `maxKeysPerUser` (default 10) keys per user.
|
|
461
|
+
- **Explicit enablement**: API keys initialize only with `login.apikey: true`.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## More usage
|
|
466
|
+
|
|
467
|
+
See the skill template (Chinese) for detailed API parameters, codes, and integration patterns: `packages/cli/templates/skills/hai-iam/SKILL.md`.
|
|
468
|
+
|
|
469
|
+
## Frontend API calls
|
|
470
|
+
|
|
471
|
+
Public HTTP APIs are defined by `@h-ai/api-contract` and called through the `@h-ai/api-client` typed client:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { apiClient } from '@h-ai/api-client'
|
|
475
|
+
|
|
476
|
+
await apiClient.init({ baseUrl: '/api/v1' })
|
|
477
|
+
|
|
478
|
+
// 登录
|
|
479
|
+
const result = await apiClient.iam.auth.login({
|
|
480
|
+
identifier: 'admin',
|
|
481
|
+
password: 'Password123',
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// 获取当前用户
|
|
485
|
+
const user = await apiClient.iam.auth.currentUser()
|
|
486
|
+
|
|
487
|
+
// 修改密码
|
|
488
|
+
await apiClient.iam.auth.changePassword({
|
|
489
|
+
oldPassword: 'Password123',
|
|
490
|
+
newPassword: 'NewPassword456',
|
|
491
|
+
})
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## Password reset
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
// 请求重置(即使用户不存在也返回 ok,防止枚举)
|
|
498
|
+
await iam.user.requestPasswordReset('admin@example.com')
|
|
499
|
+
|
|
500
|
+
// 确认重置(校验令牌有效期和尝试次数 → 更新密码 → 清除所有会话)
|
|
501
|
+
const result = await iam.user.confirmPasswordReset(token, 'NewPassword456')
|
|
502
|
+
|
|
503
|
+
// 管理员重置(无需旧密码)
|
|
504
|
+
await iam.user.adminResetPassword(userId, 'TempPassword123')
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
## One-time tickets — `iam.ticket`
|
|
508
|
+
|
|
509
|
+
Short-lived capability tickets for channels that cannot carry Bearer tokens, such as WebSocket URLs. An authenticated HTTP request issues a ticket bound to a subject, purpose, and operation. The server **atomically consumes** it on first validation, making it valid only once.
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
// 签发(已登录 HTTP 请求内):绑定主体、用途与本次操作,30 秒过期
|
|
513
|
+
const issued = await iam.ticket.issue({
|
|
514
|
+
subjectId: session.userId,
|
|
515
|
+
purpose: 'ai-audio',
|
|
516
|
+
grant: { operation: 'transcribe', model: 'whisper-1' },
|
|
517
|
+
ttlMs: 30_000,
|
|
518
|
+
})
|
|
519
|
+
// issued.data.ticket 交给客户端,用于建立 WebSocket 连接
|
|
520
|
+
|
|
521
|
+
// 消费(服务端建连时):原子单次,返回主体 + 用途 + grant
|
|
522
|
+
const consumed = await iam.ticket.consume(ticket, { purpose: 'ai-audio' })
|
|
523
|
+
if (consumed.success) {
|
|
524
|
+
const { subjectId, grant } = consumed.data
|
|
525
|
+
// 用 grant.operation / grant.model 交叉校验客户端请求
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Features: cryptographically secure randomness, TTL, atomic single use (only one concurrent consumer succeeds), and purpose binding to prevent cross-purpose replay.
|
|
530
|
+
|
|
531
|
+
## Error handling
|
|
532
|
+
|
|
533
|
+
All operations return `HaiResult<T>`; branch on `HaiIamError`:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
import { HaiIamError, iam } from '@h-ai/iam'
|
|
537
|
+
|
|
538
|
+
const result = await iam.auth.login({ identifier: 'admin', password: 'wrong' })
|
|
539
|
+
if (!result.success) {
|
|
540
|
+
if (result.error.code === HaiIamError.INVALID_CREDENTIALS.code) {
|
|
541
|
+
// 用户名或密码错误
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## Tests
|
|
547
|
+
|
|
548
|
+
```bash
|
|
549
|
+
pnpm test
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## License
|
|
553
|
+
|
|
554
|
+
Apache-2.0
|
|
555
|
+
|
|
556
|
+
Role lists aggregate actual member counts for the current page with one `iam.authz.getRoleUserCounts(roleIds)` call, returning `HaiResult<Map<string, number>>`. Unassigned roles get 0; failed queries show loading errors rather than fabricated zero counts.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# @h-ai/iam
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/audit](../audit/README.ja.md) · [@h-ai/cache](../cache/README.ja.md) · [@h-ai/core](../core/README.ja.md) · [@h-ai/crypto](../crypto/README.ja.md) · [@h-ai/reldb](../reldb/README.ja.md)
|
|
6
|
+
|
|
7
|
+
共通の `iam` オブジェクトで認証、セッション、認可、ユーザー管理を提供する Identity and Access Management モジュールです。
|
|
8
|
+
|
|
9
|
+
## 機能
|
|
10
|
+
|
|
11
|
+
| 機能 | 説明 |
|
|
12
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------- |
|
|
13
|
+
| **認証** | パスワード、OTP(メール・SMS コード)、LDAP、API Key の認証方式 |
|
|
14
|
+
| **セッション** | 状態を持つセッション(ランダム access token + キャッシュ、任意の自動延長) |
|
|
15
|
+
| **認可** | RBAC のロール・権限管理(DB + キャッシュ、ワイルドカード照合) |
|
|
16
|
+
| **ユーザー管理** | 登録、検索、更新、パスワードリセット、管理者によるリセット |
|
|
17
|
+
| **API Key** | scope・期限付き API Key の作成、失効、検証 |
|
|
18
|
+
| **一度限りのチケット** | Bearer Token を付けられない WebSocket などのための短期チケット(発行 / アトミックな消費) |
|
|
19
|
+
| **フロントエンドクライアント** | 独立フロントエンド向けの HTTP API 契約 |
|
|
20
|
+
|
|
21
|
+
## インストール
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add @h-ai/iam
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 依存関係
|
|
28
|
+
|
|
29
|
+
- `@h-ai/reldb`:ユーザー・ロール・権限の永続化。**iam.init() より先に初期化**。
|
|
30
|
+
- `@h-ai/cache`:セッション・OTP・リセット Token・権限のキャッシュ。**iam.init() より先に初期化**。
|
|
31
|
+
- `@h-ai/crypto`:内部で使用するパスワードハッシュ。自動初期化。
|
|
32
|
+
- `@h-ai/audit`:主要な RBAC 操作の監査記録。
|
|
33
|
+
|
|
34
|
+
## クイックスタート
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { cache } from '@h-ai/cache'
|
|
38
|
+
import { iam } from '@h-ai/iam'
|
|
39
|
+
import { reldb } from '@h-ai/reldb'
|
|
40
|
+
|
|
41
|
+
// 1. 初始化依赖
|
|
42
|
+
await reldb.init({ type: 'sqlite', database: './data.db' })
|
|
43
|
+
await cache.init({ type: 'memory' })
|
|
44
|
+
|
|
45
|
+
// 2. 初始化 IAM(自动使用已初始化的 reldb 和 cache 单例)
|
|
46
|
+
await iam.init({
|
|
47
|
+
session: { maxAge: 86400, sliding: true },
|
|
48
|
+
// OTP 回调(启用 OTP 登录时注入发送逻辑)
|
|
49
|
+
onOtpSendEmail: async (email, code) => {
|
|
50
|
+
await sendEmail(email, `验证码: ${code}`)
|
|
51
|
+
},
|
|
52
|
+
// 密码重置回调(启用密码重置时注入通知逻辑)
|
|
53
|
+
onPasswordResetRequest: async (user, token, expiresAt) => {
|
|
54
|
+
await sendEmail(user.email!, `重置链接: https://example.com/reset?token=${token}`)
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// 2. 注册
|
|
59
|
+
const userResult = await iam.user.register({
|
|
60
|
+
username: 'admin',
|
|
61
|
+
email: 'admin@example.com',
|
|
62
|
+
password: 'Password123',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// 3. 登录
|
|
66
|
+
const loginResult = await iam.auth.login({
|
|
67
|
+
identifier: 'admin',
|
|
68
|
+
password: 'Password123',
|
|
69
|
+
})
|
|
70
|
+
if (loginResult.success) {
|
|
71
|
+
const { user, tokens } = loginResult.data
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. 验证令牌
|
|
75
|
+
const session = await iam.auth.verifyToken(loginResult.data.tokens.accessToken)
|
|
76
|
+
|
|
77
|
+
// 5. OTP 验证码登录
|
|
78
|
+
await iam.auth.sendOtp('user@example.com')
|
|
79
|
+
const otpResult = await iam.auth.loginWithOtp({
|
|
80
|
+
identifier: 'user@example.com',
|
|
81
|
+
code: '123456',
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// 6. 检查权限
|
|
85
|
+
const hasPermission = await iam.authz.checkPermission(
|
|
86
|
+
loginResult.data.user.id,
|
|
87
|
+
'user:read',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// 7. 关闭
|
|
91
|
+
await iam.close()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`iam.config` は**機密情報をマスクした設定スナップショット**を返します。LDAP の `url` / `bindPassword` などは自動的に非表示になります。状態確認用で、元の設定の代わりにはなりません。
|
|
95
|
+
|
|
96
|
+
## 設計
|
|
97
|
+
|
|
98
|
+
### アーキテクチャ
|
|
99
|
+
|
|
100
|
+
IAM は `iam.init()` / `iam.close()` で状態を管理する**ライフサイクル付きシングルトン**です。内部の 5 つのサブモジュールを依存順に初期化します:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
iam.init()
|
|
104
|
+
├─ session (キャッシュによるセッション、DB 依存なし)
|
|
105
|
+
├─ authz (RBAC ロール・権限、DB + キャッシュ)
|
|
106
|
+
├─ authn (認証、session + authz に依存)
|
|
107
|
+
├─ user (ユーザー管理、session + authz + passwordStrategy に依存)
|
|
108
|
+
└─ seed (初期データ、authz に依存)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
各サブモジュールは class ではなく**ファクトリー + クロージャー**(`createSessionOperations(deps)` など)で作成し、getter で `iam.auth` / `iam.user` / `iam.authz` / `iam.session` / `iam.apiKey` として公開します。
|
|
112
|
+
|
|
113
|
+
初期化前のサブモジュール呼び出しは `NotInitializedKit` Proxy により `NOT_INITIALIZED` を返します。
|
|
114
|
+
|
|
115
|
+
### データの保存先
|
|
116
|
+
|
|
117
|
+
| データ種別 | 保存先 | 説明 |
|
|
118
|
+
| ------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
119
|
+
| ユーザー、ロール、権限、関連付け、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` |
|
|
120
|
+
| セッション(accessToken → Session) | キャッシュ | TTL 付き、任意の自動延長 |
|
|
121
|
+
| Token 対応(refreshToken → userId + accessToken) | キャッシュ | 独立 key、更新後に旧 Token を即時無効化 |
|
|
122
|
+
| OTP コード | キャッシュ | TTL + 試行回数制限 |
|
|
123
|
+
| パスワードリセット Token | キャッシュ | TTL + 最大検証回数 |
|
|
124
|
+
| API Key | DB | `hai_iam_api_keys`。平文は保存せず hash のみ。接頭辞で候補検索 |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### 認証(authn)— `iam.auth`
|
|
129
|
+
|
|
130
|
+
#### 主要な型
|
|
131
|
+
|
|
132
|
+
- **`AuthnOperations`**:認証操作。`login` / `loginWithOtp` / `loginWithLdap` / `loginWithApiKey` / `logout` / `verifyToken` / `sendOtp` / `registerAndLogin`。
|
|
133
|
+
- **`AuthStrategy`**:認証方式のインターフェース(`type` + `authenticate` + `challenge?`)。パスワード・OTP・LDAP・API Key の 4 実装。
|
|
134
|
+
- **`AuthResult`**:ユーザー情報、`TokenPair`(accessToken + refreshToken)、ロール・権限を含むログイン結果。
|
|
135
|
+
- **`Credentials`**:`{ type: 'password' | 'otp' | 'ldap' | 'apikey' }` と各方式の認証情報を組み合わせた共通の共用体型。
|
|
136
|
+
|
|
137
|
+
#### 主要な処理
|
|
138
|
+
|
|
139
|
+
**パスワードによるログイン:**
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
login(credentials)
|
|
143
|
+
→ パスワードログインの有効化を確認
|
|
144
|
+
→ identifier でユーザー検索(username / email / phone)
|
|
145
|
+
→ 未存在なら dummy hash(固定時間でタイミング攻撃を防止)
|
|
146
|
+
→ アカウントロックを確認(lockoutDuration 内で loginFailedCount >= maxLoginAttempts)
|
|
147
|
+
→ パスワードハッシュ検証(@h-ai/crypto)
|
|
148
|
+
→ 失敗:回数を記録し、上限でロック
|
|
149
|
+
→ 成功:失敗回数をリセット
|
|
150
|
+
→ 期限を確認(expirationDays > 0 なら passwordUpdatedAt)
|
|
151
|
+
→ ロール + 権限を検索
|
|
152
|
+
→ セッション作成(session.create → TokenPair)
|
|
153
|
+
→ AuthResult { user, tokens, roles, permissions, agreements? } を返す
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Token の検証:**
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
verifyToken(accessToken)
|
|
160
|
+
→ session.get(accessToken) → キャッシュ検索
|
|
161
|
+
→ 自動延長モードなら TTL を延長
|
|
162
|
+
→ Session を返す(userId、roles、permissions)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### 使用方法
|
|
166
|
+
|
|
167
|
+
`authz.createRole({ ..., permissionIds })` と `authz.updateRole(id, { ..., permissionIds })` は、同じトランザクションでロールと権限集合全体を保存し、途中の失敗はすべてロールバックします。`permissionIds: []` は消去、省略は維持、重複 ID は除去、不明な権限は失敗です。並行更新は最後にコミットした完全な集合を採用し、集合を合成しません。自動管理のコミット後はユーザーセッションを同期し、外部トランザクションでは呼び出し元がコミット後に同期します。Admin Console も直接この API を使い、分割更新や補償削除は行いません。
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// 密码登录
|
|
171
|
+
const result = await iam.auth.login({ identifier: 'admin', password: 'Password123' })
|
|
172
|
+
|
|
173
|
+
// OTP 登录
|
|
174
|
+
await iam.auth.sendOtp('user@example.com')
|
|
175
|
+
const result = await iam.auth.loginWithOtp({ identifier: 'user@example.com', code: '123456' })
|
|
176
|
+
|
|
177
|
+
// LDAP 登录(需在 init 时提供 ldapClientFactory)
|
|
178
|
+
const result = await iam.auth.loginWithLdap({ username: 'jdoe', password: 'pass' })
|
|
179
|
+
|
|
180
|
+
// API Key 登录(需启用 login.apikey: true)
|
|
181
|
+
const result = await iam.auth.loginWithApiKey({ key: 'hai_xxxx...' })
|
|
182
|
+
|
|
183
|
+
// 验证令牌
|
|
184
|
+
const session = await iam.auth.verifyToken(accessToken)
|
|
185
|
+
|
|
186
|
+
// 注册并登录(一站式)
|
|
187
|
+
const result = await iam.auth.registerAndLogin({ username: 'new', password: 'Pass123' })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### 注意点
|
|
191
|
+
|
|
192
|
+
- **タイミング攻撃対策**:未存在ユーザーにも dummy password hash を実行し、応答時間をそろえます。
|
|
193
|
+
- **アカウントロック**:連続 `maxLoginAttempts` 回の失敗で `lockoutDuration` 秒ロック。
|
|
194
|
+
- **方式ごとの有効化**:`login.password` / `login.otp` / `login.ldap` / `login.apikey` で制御。
|
|
195
|
+
- **OTP の安全性**:rejection sampling で剰余偏りを除去、定時間比較でタイミング攻撃を防ぎ、`resendInterval` で頻度制限。
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### セッション(session)— `iam.session`
|
|
200
|
+
|
|
201
|
+
#### 主要な型
|
|
202
|
+
|
|
203
|
+
- **`SessionOperations`**:`create` / `get` / `verifyToken` / `update` / `delete` / `deleteByUserId` / `refresh` / `revokeRefresh` / `patchUserSessions`。
|
|
204
|
+
- **`Session`**:userId、roles、permissions、accessToken、期限などを含むセッション。
|
|
205
|
+
- **`TokenPair`**:Token のペア。`{ accessToken, refreshToken, expiresIn, tokenType: 'Bearer' }`。
|
|
206
|
+
|
|
207
|
+
#### 主要な処理
|
|
208
|
+
|
|
209
|
+
**Token 更新(Rotation):**
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
refresh(refreshToken)
|
|
213
|
+
→ キャッシュから refreshToken → { userId, accessToken } を取得
|
|
214
|
+
→ accessToken で旧 session を取得
|
|
215
|
+
→ 旧 refreshToken を削除(即時無効化で再使用防止)
|
|
216
|
+
→ 旧 accessToken の session を削除
|
|
217
|
+
→ 旧ユーザー context で新 session を作成
|
|
218
|
+
→ 新 TokenPair を返す
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**単一端末のログイン:**
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
create(options) [singleDevice=true]
|
|
225
|
+
→ user→tokens の対応から既存の全 Token を削除
|
|
226
|
+
→ 新 accessToken + refreshToken を生成
|
|
227
|
+
→ session をキャッシュへ保存
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### 使用方法
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// 创建会话(通常由 login 内部调用)
|
|
234
|
+
const session = await iam.session.create({
|
|
235
|
+
userId: 'user-id',
|
|
236
|
+
username: 'admin',
|
|
237
|
+
roles: ['admin'],
|
|
238
|
+
permissions: ['user:read', 'user:write'],
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// 验证令牌
|
|
242
|
+
const session = await iam.session.verifyToken(accessToken)
|
|
243
|
+
|
|
244
|
+
// 刷新令牌
|
|
245
|
+
const newTokens = await iam.session.refresh(refreshToken)
|
|
246
|
+
|
|
247
|
+
// 强制下线
|
|
248
|
+
await iam.session.deleteByUserId(userId)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
#### 注意点
|
|
252
|
+
|
|
253
|
+
- **キャッシュのみの保存**:DB に保存せず、再起動後はすべてのセッションが無効になります(安全性の設計方針)。
|
|
254
|
+
- **自動延長**:`sliding: true` なら各 `get()` で TTL を延長。
|
|
255
|
+
- **Refresh Rotation**:旧 refreshToken は一度の使用で無効化。再使用は漏洩の兆候。
|
|
256
|
+
- **Token 強度**:256-bit の暗号学的乱数(`crypto.getRandomValues(new Uint8Array(32))`)、base64url 形式。
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
### 認可(authz)— `iam.authz`
|
|
261
|
+
|
|
262
|
+
#### 主要な型
|
|
263
|
+
|
|
264
|
+
- **`AuthzOperations`**:ロール・権限 CRUD、ロールと権限・ユーザーとロールの割り当て、権限確認。
|
|
265
|
+
- **`Role`**:ロール。`{ id, code, name, description, isSystem }`。
|
|
266
|
+
- **`Permission`**:権限。`{ id, code, name, type, resource, action }`、type は `menu` / `api` / `button`。
|
|
267
|
+
- **`PermissionType`**:権限種別の enum。
|
|
268
|
+
|
|
269
|
+
#### 主要な処理
|
|
270
|
+
|
|
271
|
+
**権限確認(`checkPermission`):**
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
checkPermission(userId, permission)
|
|
275
|
+
→ RBAC 無効なら true
|
|
276
|
+
→ ユーザーのロール一覧を DB から取得
|
|
277
|
+
→ 最高管理者を確認(キャッシュの superAdminRoleId)、あれば true
|
|
278
|
+
→ 各ロールの権限を取得(キャッシュ優先、miss は DB から取得して保存)
|
|
279
|
+
→ 厳密・ワイルドカード照合(`admin:*` は `admin:read` に一致)
|
|
280
|
+
→ true / false を返す
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**ロール・権限変更後のセッション同期:**
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
assignPermissionToRole(roleId, permId)
|
|
287
|
+
→ 関連を DB に保存
|
|
288
|
+
→ ロール権限のキャッシュを消去
|
|
289
|
+
→ 所属ユーザー検索 → 各権限を再解決 → 活動中 session を更新
|
|
290
|
+
(best-effort:失敗は log.error のみで割り当て結果に影響しない)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
#### 使用方法
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
// 角色管理
|
|
297
|
+
const role = await iam.authz.createRole({ code: 'editor', name: '编辑' })
|
|
298
|
+
const roles = await iam.authz.getAllRoles({ page: 1, pageSize: 20 })
|
|
299
|
+
|
|
300
|
+
// 权限管理
|
|
301
|
+
const perm = await iam.authz.createPermission({
|
|
302
|
+
code: 'article:publish',
|
|
303
|
+
name: '发布文章',
|
|
304
|
+
type: 'api',
|
|
305
|
+
resource: 'article',
|
|
306
|
+
action: 'publish',
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// 分配与检查
|
|
310
|
+
await iam.authz.assignRole(userId, role.data.id)
|
|
311
|
+
await iam.authz.assignPermissionToRole(role.data.id, perm.data.id)
|
|
312
|
+
const allowed = await iam.authz.checkPermission(userId, 'article:publish')
|
|
313
|
+
|
|
314
|
+
// 批量同步角色
|
|
315
|
+
await iam.authz.syncRoles(userId, [roleId1, roleId2])
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### 注意点
|
|
319
|
+
|
|
320
|
+
- **ワイルドカード**:`admin:*` は `admin:read`、`admin:write:detail` などに一致。1 つの `*` がコロン以降全体に一致します。
|
|
321
|
+
- **最高管理者**:`rbac.superAdminRole`(既定 `super_admin`)はすべての権限を持ちます。
|
|
322
|
+
- **トランザクション**:`deleteRole` / `deletePermission` は DB トランザクションで関連を削除してから本体を削除。
|
|
323
|
+
- **同期の遅延**:権限変更は稼働中のセッションへ best-effort で反映し、多数のユーザーでは遅延する場合があります。
|
|
324
|
+
- **初期データ**:`seedDefaultData: true` で admin/user/guest と基本権限を冪等に作成。
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
### ユーザー管理(user)— `iam.user`
|
|
329
|
+
|
|
330
|
+
#### 主要な型
|
|
331
|
+
|
|
332
|
+
- **`UserOperations`**:登録、検索、更新、削除、パスワード変更・リセット、強度検証。
|
|
333
|
+
- **`User`**(公開フィールド):`{ id, username, email, phone, displayName, avatarUrl, enabled, roles? }`。
|
|
334
|
+
- **`StoredUser`**:内部保存用ユーザー。`passwordHash`、`loginFailedCount`、`lockedUntil` などの機密フィールドを含みます。
|
|
335
|
+
- **`RegisterOptions`** / **`RegisterResult`**:登録の入力・出力。
|
|
336
|
+
- **`UpdateCurrentUserInput`**:現在のユーザーが変更できるフィールド(`displayName` / `avatarUrl` / `phone` / `metadata`)。
|
|
337
|
+
|
|
338
|
+
#### 主要な処理
|
|
339
|
+
|
|
340
|
+
**ユーザー登録:**
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
register(options)
|
|
344
|
+
→ 登録の有効化を確認
|
|
345
|
+
→ 強度検証(Zod schema + 独自ルール)
|
|
346
|
+
→ パスワードハッシュ(@h-ai/crypto)
|
|
347
|
+
→ DB トランザクション:作成 + 結果取得
|
|
348
|
+
→ 既定ロール付与(rbac.defaultRole、トランザクション外、失敗は log のみ)
|
|
349
|
+
→ RegisterResult { user, agreements? } を返す
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**パスワードリセット:**
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
requestPasswordReset(email)
|
|
356
|
+
→ メールで検索(未存在は静かに ok、列挙攻撃を防止)
|
|
357
|
+
→ 256-bit ランダム Token を生成
|
|
358
|
+
→ SHA-256(token) → userId をキャッシュ(TTL + maxAttempts)
|
|
359
|
+
→ onPasswordResetRequest を呼ぶ(アプリがメール・SMS 送信)
|
|
360
|
+
|
|
361
|
+
confirmPasswordReset(token, newPassword)
|
|
362
|
+
→ SHA-256(token) を取得して期限・試行回数を検証
|
|
363
|
+
→ 強度検証 → ハッシュ → DB 更新
|
|
364
|
+
→ 全活動セッションを削除して再ログインを要求
|
|
365
|
+
→ 使用済みリセット Token を削除
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
#### 使用方法
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
// 注册
|
|
372
|
+
const result = await iam.user.register({
|
|
373
|
+
username: 'alice',
|
|
374
|
+
email: 'alice@example.com',
|
|
375
|
+
password: 'Password123',
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// 当前用户操作
|
|
379
|
+
const me = await iam.user.getCurrentUser(accessToken)
|
|
380
|
+
await iam.user.updateCurrentUser(accessToken, { displayName: '新名称' })
|
|
381
|
+
await iam.user.changeCurrentUserPassword(accessToken, 'oldPass', 'newPass')
|
|
382
|
+
|
|
383
|
+
// 管理操作
|
|
384
|
+
const users = await iam.user.listUsers({
|
|
385
|
+
page: 1,
|
|
386
|
+
pageSize: 20,
|
|
387
|
+
search: 'alice',
|
|
388
|
+
sortBy: 'createdAt',
|
|
389
|
+
sortDirection: 'desc',
|
|
390
|
+
include: ['roles'],
|
|
391
|
+
})
|
|
392
|
+
await iam.user.updateUser(userId, { enabled: false }) // 禁用用户 → 自动清除会话
|
|
393
|
+
await iam.user.deleteUser(userId) // 删除用户 → 事务清除角色关联
|
|
394
|
+
|
|
395
|
+
// 密码重置
|
|
396
|
+
await iam.user.requestPasswordReset('alice@example.com')
|
|
397
|
+
await iam.user.confirmPasswordReset(token, 'NewPassword456')
|
|
398
|
+
await iam.user.adminResetPassword(userId, 'TempPassword123')
|
|
399
|
+
|
|
400
|
+
// 密码强度验证(同步方法)
|
|
401
|
+
const valid = iam.user.validatePassword('weak')
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
#### 注意点
|
|
405
|
+
|
|
406
|
+
- **`toUser()` の機密除去**:公開ユーザー情報から `passwordHash`、`loginFailedCount` などを除外。
|
|
407
|
+
- **セッション連動**:`deleteUser` / `updateUser({ enabled: false })` / `changePassword` / `confirmPasswordReset` は対象ユーザーの全セッションを削除。
|
|
408
|
+
- **列挙攻撃対策**:未登録メールへの `requestPasswordReset` はエラーでなく `ok(undefined)`。
|
|
409
|
+
- **validatePassword は同期**:`UserOperations` の唯一の同期メソッドで、ほかは非同期。
|
|
410
|
+
- **部分一致検索**:`listUsers` はユーザー名・メール・電話・表示名を検索し、LIKE のワイルドカードはエスケープ済み。
|
|
411
|
+
- **サーバー側の並び順**:`listUsers` は `username`、`email`、`displayName`、`enabled`、`createdAt` のみを許可リストで対応付け、既定は作成日時の降順。
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
### API Key — `iam.apiKey`
|
|
416
|
+
|
|
417
|
+
#### 主要な型
|
|
418
|
+
|
|
419
|
+
- **`ApiKeyOperations`**:`createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`。
|
|
420
|
+
- **`ApiKey`**(公開フィールド):`{ id, userId, name, keyPrefix, enabled, expiresAt, scopes }`。
|
|
421
|
+
- **`CreateApiKeyResult`**:`{ apiKey, rawKey }`。平文の鍵を返すのは一度のみ。
|
|
422
|
+
|
|
423
|
+
#### 主要な処理
|
|
424
|
+
|
|
425
|
+
**API Key の検証:**
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
verifyApiKey(rawKey)
|
|
429
|
+
→ rawKey から接頭辞取得(prefix + 最初の 8 文字)
|
|
430
|
+
→ DB で接頭辞による候補検索
|
|
431
|
+
→ 各 hash を同期検証
|
|
432
|
+
→ 成功後に期限・無効化を確認
|
|
433
|
+
→ lastUsedAt を非同期更新(応答をブロックしない)
|
|
434
|
+
→ ApiKey を返す
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### 使用方法
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// 创建 API Key
|
|
441
|
+
const result = await iam.apiKey.createApiKey(userId, {
|
|
442
|
+
name: 'CI/CD',
|
|
443
|
+
expirationDays: 90,
|
|
444
|
+
scopes: ['api:read'],
|
|
445
|
+
})
|
|
446
|
+
// result.data.rawKey → 明文密钥(仅此一次展示)
|
|
447
|
+
|
|
448
|
+
// 列出用户的 API Key(服务端分页)
|
|
449
|
+
const keys = await iam.apiKey.listApiKeys(userId, { page: 1, pageSize: 20, search: 'ci' })
|
|
450
|
+
// keys.data → { items, total, page, pageSize }
|
|
451
|
+
|
|
452
|
+
// 吊销
|
|
453
|
+
await iam.apiKey.revokeApiKey(keyId)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
#### 注意点
|
|
457
|
+
|
|
458
|
+
- **平文は保存しない**:DB は hash のみを保存し、作成時の `rawKey` が唯一の取得機会。
|
|
459
|
+
- **接頭辞検索**:`keyPrefix` で候補を絞り、全テーブル走査を避けます。
|
|
460
|
+
- **件数制限**:ユーザーあたり最大 `maxKeysPerUser`(既定 10)件。
|
|
461
|
+
- **明示的有効化**:`login.apikey: true` の場合のみ初期化。
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## その他の使い方
|
|
466
|
+
|
|
467
|
+
API 引数・エラーコード・統合パターンの詳細は中国語の Skill テンプレート(`packages/cli/templates/skills/hai-iam/SKILL.md`)を参照してください。
|
|
468
|
+
|
|
469
|
+
## フロントエンドの API 呼び出し
|
|
470
|
+
|
|
471
|
+
公開 HTTP API は `@h-ai/api-contract` で定義し、`@h-ai/api-client` の型付きクライアントで呼び出します:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { apiClient } from '@h-ai/api-client'
|
|
475
|
+
|
|
476
|
+
await apiClient.init({ baseUrl: '/api/v1' })
|
|
477
|
+
|
|
478
|
+
// 登录
|
|
479
|
+
const result = await apiClient.iam.auth.login({
|
|
480
|
+
identifier: 'admin',
|
|
481
|
+
password: 'Password123',
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// 获取当前用户
|
|
485
|
+
const user = await apiClient.iam.auth.currentUser()
|
|
486
|
+
|
|
487
|
+
// 修改密码
|
|
488
|
+
await apiClient.iam.auth.changePassword({
|
|
489
|
+
oldPassword: 'Password123',
|
|
490
|
+
newPassword: 'NewPassword456',
|
|
491
|
+
})
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## パスワードリセット
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
// 请求重置(即使用户不存在也返回 ok,防止枚举)
|
|
498
|
+
await iam.user.requestPasswordReset('admin@example.com')
|
|
499
|
+
|
|
500
|
+
// 确认重置(校验令牌有效期和尝试次数 → 更新密码 → 清除所有会话)
|
|
501
|
+
const result = await iam.user.confirmPasswordReset(token, 'NewPassword456')
|
|
502
|
+
|
|
503
|
+
// 管理员重置(无需旧密码)
|
|
504
|
+
await iam.user.adminResetPassword(userId, 'TempPassword123')
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
## 一度限りのチケット — `iam.ticket`
|
|
508
|
+
|
|
509
|
+
WebSocket URL など Bearer Token を付けられない経路向けの短期チケットです。認証済み HTTP リクエストで、主体・用途・操作に結び付けて発行します。サーバーは初回検証時に**アトミックに消費**し、一度のみ有効です。
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
// 签发(已登录 HTTP 请求内):绑定主体、用途与本次操作,30 秒过期
|
|
513
|
+
const issued = await iam.ticket.issue({
|
|
514
|
+
subjectId: session.userId,
|
|
515
|
+
purpose: 'ai-audio',
|
|
516
|
+
grant: { operation: 'transcribe', model: 'whisper-1' },
|
|
517
|
+
ttlMs: 30_000,
|
|
518
|
+
})
|
|
519
|
+
// issued.data.ticket 交给客户端,用于建立 WebSocket 连接
|
|
520
|
+
|
|
521
|
+
// 消费(服务端建连时):原子单次,返回主体 + 用途 + grant
|
|
522
|
+
const consumed = await iam.ticket.consume(ticket, { purpose: 'ai-audio' })
|
|
523
|
+
if (consumed.success) {
|
|
524
|
+
const { subjectId, grant } = consumed.data
|
|
525
|
+
// 用 grant.operation / grant.model 交叉校验客户端请求
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
特徴:暗号学的に安全な乱数、TTL、アトミックな単回消費(並行消費では 1 件のみ成功)、用途の固定による別用途の再使用防止。
|
|
530
|
+
|
|
531
|
+
## エラー処理
|
|
532
|
+
|
|
533
|
+
すべての操作は `HaiResult<T>` を返し、`HaiIamError` で分岐します:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
import { HaiIamError, iam } from '@h-ai/iam'
|
|
537
|
+
|
|
538
|
+
const result = await iam.auth.login({ identifier: 'admin', password: 'wrong' })
|
|
539
|
+
if (!result.success) {
|
|
540
|
+
if (result.error.code === HaiIamError.INVALID_CREDENTIALS.code) {
|
|
541
|
+
// 用户名或密码错误
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## テスト
|
|
547
|
+
|
|
548
|
+
```bash
|
|
549
|
+
pnpm test
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## ライセンス
|
|
553
|
+
|
|
554
|
+
Apache-2.0
|
|
555
|
+
|
|
556
|
+
ロール一覧は `iam.authz.getRoleUserCounts(roleIds)` の 1 回の集計で現在のページの実際の所属人数を取得し、`HaiResult<Map<string, number>>` を返します。関連がなければ 0、検索失敗は読み込みエラーを表示し、0 件の統計を作りません。
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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)
|
|
6
|
+
|
|
3
7
|
身份与访问管理模块,提供统一的 `iam` 对象实现认证、会话、授权与用户管理。
|
|
4
8
|
|
|
5
9
|
## 功能特性
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@h-ai/iam",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.57",
|
|
5
5
|
"description": "Hai Framework identity and access management module.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"exports": {
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@h-ai/audit": "0.1.0-alpha.
|
|
30
|
-
"@h-ai/cache": "0.1.0-alpha.
|
|
31
|
-
"@h-ai/core": "0.1.0-alpha.
|
|
32
|
-
"@h-ai/reldb": "0.1.0-alpha.
|
|
33
|
-
"@h-ai/crypto": "0.1.0-alpha.
|
|
29
|
+
"@h-ai/audit": "0.1.0-alpha.57",
|
|
30
|
+
"@h-ai/cache": "0.1.0-alpha.57",
|
|
31
|
+
"@h-ai/core": "0.1.0-alpha.57",
|
|
32
|
+
"@h-ai/reldb": "0.1.0-alpha.57",
|
|
33
|
+
"@h-ai/crypto": "0.1.0-alpha.57"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^25.9.1",
|