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

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.ja.md ADDED
@@ -0,0 +1,961 @@
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) · [@h-ai/reach](../reach/README.ja.md)
6
+
7
+ ## 概要
8
+
9
+ 共通の `iam` オブジェクトで認証、セッション、認可、ユーザー管理を提供する Identity and Access Management モジュールです。
10
+
11
+ ## 主な機能
12
+
13
+ | 機能 | 説明 |
14
+ | ------------------------------ | ----------------------------------------------------------------------------------------- |
15
+ | **認証** | パスワード、OTP(メール・SMS コード)、LDAP、API Key の認証方式 |
16
+ | **セッション** | 状態を持つセッション(ランダム access token + キャッシュ、任意の自動延長) |
17
+ | **認可** | RBAC のロール・権限管理(DB 一括検索、ワイルドカード照合) |
18
+ | **ユーザー管理** | 登録、検索、更新、パスワードリセット、管理者によるリセット |
19
+ | **API Key** | scope・期限付き API Key の作成、失効、検証 |
20
+ | **一度限りのチケット** | Bearer Token を付けられない WebSocket などのための短期チケット(発行 / アトミックな消費) |
21
+ | **フロントエンドクライアント** | 独立フロントエンド向けの HTTP API 契約 |
22
+
23
+ ## 使い始める
24
+
25
+ ### インストール
26
+
27
+ ```bash
28
+ pnpm add @h-ai/iam
29
+ ```
30
+
31
+ ### 依存関係
32
+
33
+ - `@h-ai/reldb`:ユーザー・ロール・権限の永続化。**iam.init() より先に初期化**。
34
+ - `@h-ai/cache`:セッション・OTP・一度限りのチケット。**iam.init() より先に初期化**。
35
+ - `@h-ai/crypto`:内部で使用するパスワードハッシュ。自動初期化。
36
+ - `@h-ai/audit`:ホストが初期化した場合に IAM の安全イベントを自動記録します。未使用時は収集せず、使用中の書込失敗は記録し、外部トランザクションはロールバックします。
37
+
38
+ - `@h-ai/reach`:**任意**。アプリがインストール、初期化、注入して通知テンプレートを使用します。独自送信コールバックでは不要です。
39
+ - `ldapts`:**任意**。LDAP を明示的に有効化し、`ldapClientFactory` がない場合だけ読み込みます。
40
+
41
+ ### 任意の LDAP と通知連携
42
+
43
+ パスワード認証、ユーザー管理、RBAC は LDAP/Reach なしで動作します。両方とも任意の peer dependency で、必要な連携だけをインストールします。以下は reldb/cache 初期化後の起動・認証関数内の例で、`username/password` は検証済み入力です。
44
+
45
+ #### LDAP / Active Directory
46
+
47
+ 既定クライアントは `pnpm add ldapts` で追加し、`login.ldap: true` と接続設定を明示します。接続設定だけでは LDAP は有効になりません。SDK なしで既定クライアントを有効にすると初期化が設定エラーを返します。
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` は DB 資格情報世代を持つ有効なローカルユーザーを必要とし、仮想 DN ユーザーは作りません。 `bind/search/unbind` を HaiResult で返す `ldapClientFactory` を注入できます。独自ファクトリでは ldapts は不要です。
66
+
67
+ ```ts
68
+ import type { IamConfigSettingsInput } from '@h-ai/iam'
69
+ import { core } from '@h-ai/core'
70
+ import { iam } from '@h-ai/iam'
71
+
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
+ ```
81
+
82
+ #### 任意の Reach による自動送信
83
+
84
+ `pnpm add @h-ai/reach` で追加します。設定、初期化、終了はアプリが管理し、IAM は注入されたインスタンスだけを使用します。SMTP/HTTP は例で、SMS ゲートウェイは [Reach 通知仕様](../reach/README.ja.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
+ ```
116
+
117
+ `otp_email / otp_sms` は `{code}`、`password_reset / password_reset_sms` は `{token}`、`{expiresAt}`、`{resetUrl}` を使用します。`passwordReset.mode: phone` で SMS リセットを選択します。テンプレートは `iam.init()` より前に設定し、利用できないチャネルは公開設定で非表示になります。
118
+
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' },
134
+ })
135
+ if (!initialized.success)
136
+ return initialized
137
+ return iam.auth.sendOtp('user@example.com')
138
+ ```
139
+
140
+ #### 独自メール・SMS 送信
141
+
142
+ `onOtpSendEmail`、`onOtpSendSms`、`onPasswordResetRequest` を注入して既存の送信サービスを使用します。必要なものだけ実装でき、Reach と併用する場合は明示コールバックを優先します。例のサービスはアプリ実装で、送信成功時のみ Promise を解決します。HaiResult を返すサービスは `success` を検査し、失敗時はコールバック内で例外にします。IAM は例外を捕捉して業務エラーを返し、コードや Token、送信内部情報をユーザーに表示しません。
143
+
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
+ },
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'
178
+
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
+ }
206
+
207
+ const result = await example()
208
+ if (!result.success)
209
+ core.logger.error('Authentication example failed', { code: result.error.code })
210
+ ```
211
+
212
+ LDAP は既定で無効です。公開 OTP は `otp` 設定と送信コールバック、パスワードリセットは通知コールバックが必要です。
213
+
214
+ `iam.config` は**機密情報をマスクした設定スナップショット**を返します。LDAP の `url` / `bindPassword` などは自動的に非表示になります。状態確認用で、元の設定の代わりにはなりません。
215
+
216
+ ### 設計
217
+
218
+ #### アーキテクチャ
219
+
220
+ IAM は `iam.init()` / `iam.close()` で状態を管理する**ライフサイクル付きシングルトン**です。内部の 5 つのサブモジュールを依存順に初期化します:
221
+
222
+ ```
223
+ iam.init()
224
+ ├─ session (キャッシュによるセッション、現在の資格情報は共有キャッシュ/DB参照)
225
+ ├─ authz (RBAC ロール・権限、DB 一括検索)
226
+ ├─ authn (認証、session + authz に依存)
227
+ ├─ user (ユーザー管理、session + authz + passwordStrategy に依存)
228
+ └─ seed (初期データ、authz に依存)
229
+ ```
230
+
231
+ 各サブモジュールは class ではなく**ファクトリー + クロージャー**(`createSessionOperations(deps)` など)で作成し、getter で `iam.auth` / `iam.user` / `iam.authz` / `iam.session` / `iam.apiKey` として公開します。
232
+
233
+ 初期化前のサブモジュール呼び出しは `NotInitializedKit` Proxy により `NOT_INITIALIZED` を返します。
234
+
235
+ #### データの保存先
236
+
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 消費状態 | Redis ユーザー分区 | セッション・索引と単一キー CAS で更新 |
242
+ | OTP コード | キャッシュ | TTL + 試行回数制限 |
243
+ | パスワードリセット Token | ユーザー DB 行 | SM3 hash・DB 期限・試行回数・条件付き改密 |
244
+ | API Key | DB | `hai_iam_api_keys`。平文は保存せず hash のみ。接頭辞で候補検索 |
245
+
246
+ ---
247
+
248
+ #### 認証(authn)— `iam.auth`
249
+
250
+ ##### 主要な型
251
+
252
+ - **`AuthnOperations`**:認証操作。`login` / `loginWithOtp` / `loginWithLdap` / `loginWithApiKey` / `logout` / `verifyToken` / `sendOtp` / `registerAndLogin`。
253
+ - **`AuthStrategy`**:認証方式のインターフェース(`type` + `authenticate` + `challenge?`)。パスワード・OTP・LDAP・API Key の 4 実装。
254
+ - **`AuthResult`**:ユーザー情報、`TokenPair`(accessToken + refreshToken)、ロール・権限を含むログイン結果。
255
+ - **`Credentials`**:`{ type: 'password' | 'otp' | 'ldap' | 'apikey' }` と各方式の認証情報を組み合わせた共通の共用体型。
256
+
257
+ ##### 主要な処理
258
+
259
+ **パスワードによるログイン:**
260
+
261
+ ```
262
+ login(credentials)
263
+ → パスワードログインの有効化を確認
264
+ → identifier でユーザー検索(username / email / phone)
265
+ → 未存在なら dummy hash(固定時間でタイミング攻撃を防止)
266
+ → アカウントロックを確認(lockoutDuration 内で loginFailedCount >= maxLoginAttempts)
267
+ → パスワードハッシュ検証(@h-ai/crypto)
268
+ → 失敗:回数を記録し、上限でロック
269
+ → 成功:失敗回数をリセット
270
+ → 期限を確認(expirationDays > 0 なら passwordUpdatedAt)
271
+ → ロール + 権限を検索
272
+ → セッション作成(session.create → TokenPair)
273
+ → AuthResult { user, tokens, roles, permissions, agreements? } を返す
274
+ ```
275
+
276
+ **Token の検証:**
277
+
278
+ ```
279
+ verifyToken(accessToken)
280
+ → session.get(accessToken) → キャッシュ検索
281
+ → 共有世代キャッシュから現在のユーザー/資格情報を読み、有効状態・期限・セッションの credentialVersion を検証。不確かな場合は DB を参照
282
+ → 自動延長モードなら TTL を延長
283
+ → 上記のユーザー情報を再利用し、共有世代キャッシュから現在の認可を取得。コールド読込は単一 JOIN
284
+ → 必要な状態の読取失敗、ユーザー不在・無効・期限切れ、資格情報の世代不一致は拒否し、API Key scope を再適用
285
+ → 現在の Session を返す(userId、roles、permissions)
286
+ ```
287
+
288
+ ##### 使用方法
289
+
290
+ 例は reldb/cache/IAM の初期化に成功したサーバーで実行し、検証済みの入力を使います。パスワード認証は既定で有効です。OTP には `login.otp: true`、`otp` チャネル、送信コールバック/Reach テンプレートが必要です。LDAP は `login.ldap: true`、API Key は `login.apikey: true` を有効にします。ログインと登録後ログインは `AuthResult` を返します。登録とパスワード認証のスイッチは独立しています。
291
+
292
+ OTP は送信と受信したコードによるログインの二段階です。両方とも HaiResult を返します。LDAP は `iam.auth.loginWithLdap({ username, password })`、API Key は `loginWithApiKey({ key })` を使います。`data` の前に `success` を確認してください。
293
+
294
+ ```ts
295
+ import { iam } from '@h-ai/iam'
296
+
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
+ }
304
+
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
+ }
314
+
315
+ async function registerWithSession(username: string, password: string) {
316
+ return iam.auth.registerAndLogin({ username, password })
317
+ }
318
+ ```
319
+
320
+ ##### 注意点
321
+
322
+ - **タイミング攻撃対策**:未存在ユーザーにも dummy password hash を実行し、応答時間をそろえます。
323
+ - **アカウントロック**:連続 `maxLoginAttempts` 回の失敗で `lockoutDuration` 秒ロック。
324
+ - **方式ごとの有効化**:`login.password` / `login.otp` / `login.ldap` / `login.apikey` で制御。
325
+ - **OTP の安全性**:rejection sampling で剰余偏りを除去、定時間比較でタイミング攻撃を防ぎ、`resendInterval` で頻度制限。
326
+
327
+ `iam.auth.logout(accessToken)` は完全な不透明 Token を使い、ユーザー文書の一回の CAS で access と refresh を同時に取り消します。access 期限後も実行します。不正・不存在 Token は冪等成功ですが、キャッシュの読取・確定障害は失敗で返し、復旧後に同じ Token で再試行できます。HTTP 入口は保持している Token だけを取り消し、userId を対象にせず、有効な access 認証を前提にしません。呼出側は `success` を確認し、Cookie・安全ストレージのローカル消去だけでサーバー失効を成功と扱わず、失敗を表示します。
328
+
329
+ ---
330
+
331
+ #### セッション(session)— `iam.session`
332
+
333
+ ##### 主要な型
334
+
335
+ - **`SessionOperations`**:`create` / `get` / `verifyToken` / `update` / `delete` / `deleteByUserId` / `refresh` / `revokeRefresh` / `patchUserSessions`。
336
+ - **`Session`**:userId、roles、permissions、accessToken、期限などを含むセッション。
337
+ - **`TokenPair`**:Token のペア。`{ accessToken, refreshToken, expiresIn, tokenType: 'Bearer' }`。
338
+
339
+ ##### 主要な処理
340
+
341
+ **Token 更新(Rotation):**
342
+
343
+ ```
344
+ refresh(refreshToken)
345
+ → ユーザー分区と会話バージョンを読み、認可を検証
346
+ → 未公開の新 Token 候補を生成
347
+ → 単一キー CAS で version/refresh を再検証し、旧会話と原子的に置換
348
+ → コミット後のみ唯一の後続 TokenPair を返す
349
+ ```
350
+
351
+ **単一端末のログイン:**
352
+
353
+ ```
354
+ create(options) [singleDevice=true]
355
+ → 新 accessToken + refreshToken の候補を生成
356
+ → 単一キー CAS で既存会話と索引を同時に置換
357
+ → コミット失敗時は旧会話を維持
358
+ ```
359
+
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` は信頼できるサーバー連携専用で、クライアント指定のロール/権限/Key バインドを受け入れません。
365
+
366
+ ```ts
367
+ import { iam } from '@h-ai/iam'
368
+
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
+ }
384
+
385
+ async function signOut(accessToken: string) {
386
+ return iam.auth.logout(accessToken)
387
+ }
388
+
389
+ async function forceSignOut(userId: string) {
390
+ // 调用前已验证管理员权限,返回撤销的会话数。
391
+ return iam.session.deleteByUserId(userId)
392
+ }
393
+ ```
394
+
395
+ ##### 注意点
396
+
397
+ - **キャッシュのみの保存**:Memory は再起動時に消失します。共有 Redis のセッションは IAM サービス再起動後も TTL または失効まで保持します。
398
+ - **自動延長**:`sliding: true` なら各 `get()` で TTL を延長。
399
+ - **Refresh Rotation**:refresh は一度だけ消費します。重複は正常な並行処理や応答喪失でも発生し、それだけで漏洩と判断しません。
400
+ - **Token 強度**:256-bit の暗号学的乱数(`crypto.getRandomValues(new Uint8Array(32))`)、base64url 形式。
401
+
402
+ `create({ maxAge })` は設定の既定値を正の安全な整数秒で上書きし、`Session.maxAge` に保存します。発行と更新の `expiresIn` はこの値を使います。自動延長は同じ期間を使い、確定済みの `lastActiveAt/expiresAt` を返し、遅いリクエストが新しい延長を短縮しません。明示的な `update` は有効な日時を反映して内容版を更新します。クライアントは返された `expiresIn` から更新時刻を決めます。
403
+
404
+ 追加の休止・絶対期限が無効な場合、access と refresh は独立して期限切れになります。ユーザー文書は両期限の最大まで保持しますが、access 認証は自身の expiresAt を確認します。追加設定が有効なら access/refresh の両方で休止・絶対期限も確認します。access 期限後も有効な refresh で更新でき、更新ごとに新しい refreshTokenMaxAge を開始して旧資格情報を失効させます。access 削除・全ログアウト・単一端末置換は更新待ち記録も取り消し、revokeRefresh は有効な access を残します。更新経路で旧 access の認証成功を前提にしません。
405
+
406
+ セッションの読取・確定障害は失敗 HaiResult として返し、不存在や期限切れと解釈せず補償削除もしません。索引と会話は同じ文書で保持し、個別 get/ttl や二重索引を廃止します。確定した期限だけで整理し、復旧後の再読取・全ログアウトは元の全記録を対象にします。破損状態は保持して認証を拒否し、空文書で上書きせず運用者が修復します。
407
+
408
+ Session.credentialVersion は作成時の DB 資格情報世代に結び付けます。CreateSessionOptions.expectedCredentialVersion は検証済みの信頼する戦略だけが指定し、旧世代は拒否します。省略した低レベル create でも実在する有効なローカルユーザーを DB から読み、現世代に結び付けるため、DB 記録のない認証済み ID は作れません。
409
+
410
+ ---
411
+
412
+ #### 認可(authz)— `iam.authz`
413
+
414
+ ロール一覧は `iam.authz.getRoleUserCounts(roleIds)` の 1 回の集計で現在のページの実際の所属人数を取得し、`HaiResult<Map<string, number>>` を返します。関連がなければ 0、検索失敗は読み込みエラーを表示し、0 件の統計を作りません。
415
+
416
+ ##### 主要な型
417
+
418
+ - **`AuthzOperations`**:ロール・権限 CRUD、ロールと権限・ユーザーとロールの割り当て、権限確認。
419
+ - **`Role`**:ロール。`{ id, code, name, description, isSystem }`。
420
+ - **`Permission`**:権限。`{ id, code, name, type, resource, action }`、type は `menu` / `api` / `button`。
421
+ - **`PermissionType`**:権限種別の enum。
422
+
423
+ ##### 主要な処理
424
+
425
+ **権限確認(`checkPermission`):**
426
+
427
+ ```
428
+ checkPermission(userId, permission)
429
+ → RBAC 無効なら true
430
+ → 共有世代キャッシュから現在のロール・権限を取得。コールド読込は単一 JOIN
431
+ → 最高管理者なら true。必要な状態の読取失敗はエラーを返す
432
+ → 厳密・ワイルドカード照合(`admin:*` は `admin:read` に一致)
433
+ → true / false を返す
434
+ ```
435
+
436
+ **ロール・権限変更後のセッション同期:**
437
+
438
+ ```
439
+ assignPermissionToRole(roleId, permId)
440
+ → 関連を DB に保存
441
+ → 所属ユーザー検索 → 各権限を再解決 → 活動中 session を更新
442
+ (best-effort:失敗は log.error のみで割り当て結果に影響しない)
443
+ ```
444
+
445
+ ##### 使用方法
446
+
447
+ 権限を作成し、その **ID** を `permissionIds` に保存してロールを作成し、ロール **ID** でユーザーに割り当てます。セッションとガードは安定した **code** を使います。表示名は独立して変更できます。例は RBAC が有効であることを前提とし、管理操作の権限とリソース範囲はアプリで確認します。
448
+
449
+ `createRole/updateRole` はロール情報と完全な権限集合を同一トランザクションに保存します。省略は維持、空配列は解除、重複は除去、不明な ID は失敗です。同時更新は最後にコミットした完全な集合を採用します。自管理トランザクションはコミット後にセッションを同期し、外部トランザクションは呼び出し側がコミット後に同期します。
450
+
451
+ `checkPermission(userId, code)` と `getUserAuthorization` はユーザーの RBAC を読み、リクエストの API Key scopes を考慮しません。Key リクエストでは `iam.auth.verifyToken` の収束済み権限を使用し、userId の再照会だけで許可しないでください。serv の `.permission('record:read')` は厳密な権限ガードです。メニュー/ボタンの非表示は API 認可の代替ではありません。
452
+
453
+ ```ts
454
+ import { iam } from '@h-ai/iam'
455
+
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
+ }
481
+
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
+ }
491
+ ```
492
+
493
+ ##### 注意点
494
+
495
+ - **ワイルドカード**:`admin:*` は `admin:read`、`admin:write:detail` などに一致。1 つの `*` がコロン以降全体に一致します。
496
+ - **最高管理者**:`rbac.superAdminRole`(既定 `super_admin`)はすべての権限を持ちます。
497
+ - **トランザクション**:`deleteRole` / `deletePermission` は DB トランザクションで関連を削除してから本体を削除。
498
+ - **セッション同期**:キャッシュ更新は best-effort です。保護されたリクエストは `iam.auth.verifyToken` で共有世代キャッシュで現在のユーザー/資格情報と認可を検証し、不確かな場合は DB を参照します。必要な読取が失敗すれば拒否します。`iam.session.get/verifyToken` は同じユーザー/資格情報の読取を再利用し、有効状態・期限・`credentialVersion` を検証しますが、ロール・権限はセッションのスナップショットであり、現在の認可を代替しません。`iam.authz.getUserAuthorization(userId)` はロール JOIN と権限一括検索を共有します。
499
+ - **初期データ**:`seedDefaultData: true` で admin/user/guest と基本権限を冪等に作成。
500
+
501
+ ---
502
+
503
+ #### ユーザー管理(user)— `iam.user`
504
+
505
+ ##### 主要な型
506
+
507
+ - **`UserOperations`**:登録、検索、更新、削除、パスワード変更・リセット、強度検証。
508
+ - **`User`**(公開フィールド):`{ id, username, email, phone, displayName, avatarUrl, accountType?, accountStatus, accountExpiresAt?, roles? }`。
509
+ - **`StoredUser`**:内部保存用ユーザー。`passwordHash`、`loginFailedCount`、`lockedUntil` などの機密フィールドを含みます。
510
+ - **`RegisterOptions`** / **`RegisterResult`**:登録の入力・出力。
511
+ - **`UpdateCurrentUserInput`**:現在のユーザーが変更できるフィールド(`username` / `email` / `displayName` / `avatarUrl` / `phone` / `metadata`)。
512
+
513
+ ##### 主要な処理
514
+
515
+ **ユーザー登録:**
516
+
517
+ ```
518
+ register(options)
519
+ → 登録の有効化を確認
520
+ → 強度検証(Zod schema + 独自ルール)
521
+ → パスワードハッシュ(@h-ai/crypto)
522
+ → DB トランザクション:作成 + 結果取得
523
+ → 既定ロール付与(rbac.defaultRole、失敗時は新規アカウントを取り消して失敗を返す)
524
+ → RegisterResult { user, agreements? } を返す
525
+ ```
526
+
527
+ **パスワードリセット:**
528
+
529
+ ```
530
+ requestPasswordReset(email)
531
+ → メールで検索(未存在は静かに ok、列挙攻撃を防止)
532
+ → 256-bit ランダム Token を生成
533
+ → DB のユーザー行に Token 要約、期限、試行回数、resetVersion を保存
534
+ → onPasswordResetRequest を呼ぶ(アプリがメール・SMS 送信)
535
+
536
+ confirmPasswordReset(token, newPassword)
537
+ → DB の Token 要約、期限、試行回数、resetVersion を検証
538
+ → 強度検証 → ハッシュ → DB 更新
539
+ → 全活動セッションを削除して再ログインを要求
540
+ → 条件 SQL で Token 消費、パスワード更新、資格情報世代の更新を同時に確定
541
+ ```
542
+
543
+ ##### 使用方法
544
+
545
+ `register` はユーザーだけを返し、セッションは作りません。同時ログインには `auth.registerAndLogin` を使います。管理用の `createUser` は自己登録設定と独立しています。自己プロフィールは `getCurrentUser/updateCurrentUser` で username/email/displayName/avatarUrl/phone/metadata を更新します。安全性に関わるフィールドとロールは認可された管理入口を使います。
546
+
547
+ 一覧は `PaginatedResult<User>` を返し、`roleCode` はページング前に絞り込みます。必要時のみ `include: ['roles']` を指定します。一括取得は入力を重複排除した順序で返し、不明 ID は省略するため、配列位置ではなく id で関連付けます。公開 DTO でもデータ範囲と開示フィールドはアプリで制御します。
548
+
549
+ `getUser(id)` は検索成功・不存在時に `null`、`getCurrentUser(token)` は無効セッション/不存在時に失敗します。DB エラーを空データ扱いにしません。`validatePassword` は同期 HaiResult です。削除、無効化、パスワード変更/リセットは対象セッションを取り消します。
550
+
551
+ ```ts
552
+ import { iam } from '@h-ai/iam'
553
+
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
+ }
563
+
564
+ async function updateMyProfile(accessToken: string, displayName: string) {
565
+ return iam.user.updateCurrentUser(accessToken, { displayName })
566
+ }
567
+
568
+ async function changeMyPassword(accessToken: string, oldPassword: string, newPassword: string) {
569
+ // 成功后撤销该用户全部会话,客户端回到登录入口。
570
+ return iam.user.changeCurrentUserPassword(accessToken, oldPassword, newPassword)
571
+ }
572
+
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
+ }
599
+ ```
600
+
601
+ ##### 注意点
602
+
603
+ - **`toUser()` の機密除去**:公開ユーザー情報から `passwordHash`、`loginFailedCount` などを除外。
604
+ - **セッション連動**:`deleteUser` / `updateUser({ accountStatus: 'dormant' })` / `changePassword` / `confirmPasswordReset` は対象ユーザーの全セッションを削除。
605
+ - **列挙攻撃対策**:未登録メールへの `requestPasswordReset` はエラーでなく `ok(undefined)`。
606
+ - **validatePassword は同期**:`UserOperations` の唯一の同期メソッドで、ほかは非同期。
607
+ - **部分一致検索**:`listUsers` はユーザー名・メール・電話・表示名を検索し、LIKE のワイルドカードはエスケープ済み。
608
+ - **サーバー側の並び順**:`listUsers` は `username`、`email`、`displayName`、`accountStatus`、`createdAt` のみを許可リストで対応付け、既定は作成日時の降順。
609
+
610
+ ---
611
+
612
+ #### API Key — `iam.apiKey`
613
+
614
+ `scopes` は主体の権限との共通範囲を適用し、`*` と名前空間 `:*` に対応します。既定の空配列は本人確認のみで操作権限を付与しません。制限付き key のセッション `roles` は空で permission ガードを使用し、明示的な `*` も主体の権限を超えません。ログインは key ID に結び付き、get/verifyToken/refresh は共有キャッシュまたは DB で現在の状態を確認し、失効・無効・期限切れ・必要な状態の読取失敗を拒否します。verifyApiKey 単独は鍵のメタデータ検証のみで、主体・操作の認可は別途必要です。
615
+
616
+ ##### 主要な型
617
+
618
+ - **`ApiKeyOperations`**:`createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`。
619
+ - **`ApiKey`**(公開フィールド):`{ id, userId, name, keyPrefix, enabled, expiresAt, scopes }`。
620
+ - **`CreateApiKeyResult`**:`{ apiKey, rawKey }`。平文の鍵を返すのは一度のみ。
621
+
622
+ ##### 主要な処理
623
+
624
+ **API Key の検証:**
625
+
626
+ ```
627
+ verifyApiKey(rawKey)
628
+ → SHA-256(rawKey) 指紋で共有世代キャッシュを参照し、ヒット時も期限を確認
629
+ → コールド読込では rawKey から接頭辞取得(prefix + 最初の 8 文字)
630
+ → DB で接頭辞による候補検索
631
+ → 各 hash を同期検証
632
+ → 成功後に期限・無効化を確認
633
+ → lastUsedAt を非同期更新(応答をブロックしない)
634
+ → ApiKey を返す
635
+ ```
636
+
637
+ ##### 使用方法
638
+
639
+ 初期化設定で API Key を有効にします。Key はユーザーの制限付き認証情報です:**有効権限 = 現在のユーザー権限 ∩ scopes**。scope は権限 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` を持つ場合、権限変更時にも交集を再計算します:
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 検証と操作認可 → 一覧 → 取り消しの例です。管理入口は本人の所有権または管理権限を検証します。IAM 自体は業務上の呼び出し者を知りません。
664
+
665
+ ```ts
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
+ }
701
+
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
+ }
710
+
711
+ async function revokeKey(keyId: string) {
712
+ // 调用前校验本人归属或管理权限。
713
+ return iam.apiKey.revokeApiKey(keyId)
714
+ }
715
+ ```
716
+
717
+ Key ログインには `rawKey` のみ使用できます。`apiKey.id` は管理 ID、`keyPrefix` は表示/検索用です。`verifyApiKey(rawKey)` は Key のメタデータ検証のみで、ユーザー状態・scope・操作認可を代替しません。
718
+
719
+ Key セッションは `iam.session.refresh` 後も Key に紐付きます。取り消し成功後は既存 accessToken の get/verify と refreshToken の refresh が失敗します。期限切れ、無効化、永続化読み取りエラーも拒否します。ロール変更は `iam.auth.verifyToken` の現在の認可で反映します。scope 更新 API はないため、新 Key を作成して旧 Key を取り消します。
720
+
721
+ `expirationDays: 0` は無期限、省略時は `apikey.defaultExpirationDays` を使用します。期限が必要なら明示します。生 Key/Token を URL、ログ、公開設定、通常の localStorage に保存しません。
722
+
723
+ ##### 注意点
724
+
725
+ - **平文は保存しない**:DB は hash のみを保存し、作成時の `rawKey` が唯一の取得機会。
726
+ - **接頭辞検索**:`keyPrefix` で候補を絞り、全テーブル走査を避けます。
727
+ - **件数制限**:ユーザーあたり最大 `maxKeysPerUser`(既定 10)件。
728
+ - **明示的有効化**:`login.apikey: true` の場合のみ初期化。
729
+
730
+ ---
731
+
732
+ ### その他の使い方
733
+
734
+ API 引数・エラーコード・統合パターンの詳細は中国語の Skill テンプレート(`packages/cli/templates/skills/hai-iam/SKILL.md`)を参照してください。
735
+
736
+ ### フロントエンドの API 呼び出し
737
+
738
+ 公開 HTTP API は `@h-ai/api-contract` で定義し、`@h-ai/api-client` の型付きクライアントで呼び出します:
739
+
740
+ ```ts
741
+ import { apiClient } from '@h-ai/api-client'
742
+
743
+ await apiClient.init({ baseUrl: '/api/v1' })
744
+
745
+ // 登录
746
+ const result = await apiClient.iam.auth.login({
747
+ identifier: 'admin',
748
+ password: 'Password123',
749
+ })
750
+
751
+ // 获取当前用户
752
+ const user = await apiClient.iam.auth.currentUser()
753
+
754
+ // 修改密码
755
+ await apiClient.iam.auth.changePassword({
756
+ oldPassword: 'Password123',
757
+ newPassword: 'NewPassword456',
758
+ })
759
+ ```
760
+
761
+ browser 条件の入口は設定 Schema、型、HaiIamError のみを公開し、サーバーの `iam` インスタンスは含みません。認証には HTTP/typed client を使用します。
762
+
763
+ ### パスワードリセット
764
+
765
+ リセットには `passwordReset.enabled` と送信コールバック/Reach テンプレートが必要です。成功はアカウントの存在や受信を証明しません。送信・内部処理の HaiResult エラーも処理します。期限/試行回数制限があり、一度だけ成功消費でき、成功時に旧セッションを取り消します。管理者リセットに旧パスワードは不要ですが、アプリで管理権限を確認します。
766
+
767
+ ```ts
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
+ WebSocket URL など Bearer Token を付けられない経路向けの短期チケットです。認証済み 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、アトミックな単回消費(並行消費では 1 件のみ成功)、用途の固定による別用途の再使用防止。
820
+
821
+ ### 設定による認証画面
822
+
823
+ パスワードログインの送信項目は `identifier` と `password` のみです。`AuthPanel` は `LoginForm` の `username` を `identifier` に変換します。有効期間はサーバーの `session.maxAge` / `session.refreshTokenMaxAge` で設定し、フォームにログインごとの永続セッション選択肢はありません。
824
+
825
+ `iam.publicConfig` とクライアントの HTTP `client.iam.auth.publicConfig()` は秘密情報を含まない設定だけを公開し、実際に利用可能な認証方式を表示します。`login.password/otp/ldap`、`otp.email/sms`、`register.enabled/defaultAccountStatus`、`passwordReset.enabled/mode/resetUrl` と規約表示を個別に設定できます。`ui` はカード/分割レイアウト、タイトル、説明、ブランド、Logo、画像を指定します。LDAP は任意で既定は無効です。既定クライアントを選択する時だけ ldapts を追加するか、独自ファクトリを注入します。
826
+
827
+ 任意の Reach を選択した場合、reldb/cache/Reach の初期化後、`iam.init({ ...settings, reach })` を呼び出します。`otp_email`、`otp_sms`、`password_reset`、`password_reset_sms` テンプレートを対応する送信 Provider に接続します。明示的なコールバックは既定動作を上書きします。Console はメタデータだけを記録するため、実際の配送には SMTP、SMS または HTTP サービスが必要です。送信失敗はエラーになります。
828
+
829
+ SvelteKit は `kit.auth.createApi(iam)` と匿名用の `kit.auth.publicPaths` を使用し、Cookie 認証のフォームには `kit.client.createAuthActions()` を使用します。HTTP クライアントは `apiClient.createAuthActions(client)` により安全な設定済みストレージに Token を保存します。`AuthPanel` に設定と actions を渡すと、ログイン、登録、パスワード復旧を利用できます。`LoginForm` は複数方式と再送間隔を、パスワードフォームは完全な `passwordPolicy` を受け取ります。管理者は権限を検証した上で `iam.user.createUser()` を使用できます。
830
+
831
+ 規約は入力を保持したまま Modal で開きます。URL のページは iframe 埋め込みを許可する必要があり、スクリプトを無効にし別ウィンドウ表示も用意します。LoginForm/RegisterForm は `header/extraFields/footer`、AuthPanel は `loginHeader/loginFields/loginFooter` と `registerHeader/registerFields/registerFooter` を提供します。追加フィールドはアプリの状態と actions で処理します。[UI の使い方](../ui/README.ja.md) を参照してください。
832
+
833
+ 全ノードで同じ DB と Redis を共有します。Memory は単一プロセスのテスト用です。OTP のコード・試行回数・再送間隔は単一キーの CAS で公開・消費し、旧世代の競合を拒否します。消費後も再送制限を保持します。リセットの hash・期限・試行回数・resetVersion はユーザーの DB 行に保存し、ポリシー違反も計数します。最終 SQL が条件を検証してパスワード変更・Token 消費・credentialVersion 更新を同時に行い、新 Token・後続試行・無効化・期限切れ後の遅延書込を拒否します。リセット要求は resetVersion のみ進め、改密成功時に credentialVersion を進めます。認証で取得した資格情報世代を保持し、セッション作成・認証・更新で DB の現世代を照合するため、キャッシュ削除失敗でも旧会話を許可しません。通知は公開成功後に実行し、第3引数 { expiresAt, idempotencyKey } の安定キーをアプリと Reach Provider が永続的に重複排除します。例外・タイムアウトは送達済みの可能性があるため結果不明とし、自動再送や公開済み資格情報の巻き戻しはしません。外部副作用の厳密な一回実行には受信側の冪等性が必要です。
834
+
835
+ 標準 HTTP 入口は `GET /auth/config`、`POST /auth/login/ldap`、`POST /auth/password-reset/request`、`POST /auth/password-reset/confirm` です。セキュリティエラーは `SECURITY_BUSY (hai:iam:023)` と `PASSWORD_RESET_DISABLED (hai:iam:024)` です。`ldap` などの任意項目は YAML に宣言してから環境変数で上書きするか、`HAI_IAM` の JSON 値でモジュール設定全体を置き換えます。既存項目には `HAI_IAM_LOGIN_OTP` などを使用できます。
836
+
837
+ `passwordReset.mode` は `email`(既定、`password_reset`)または `phone`(`password_reset_sms`)を選び、公開設定に応じて復旧フォームが入力欄を切り替えます。
838
+
839
+ #### Markdown の規約
840
+
841
+ `agreements.userAgreementMarkdown` と `privacyPolicyMarkdown` で設定から直接本文を読み込みます。YAML の `|` は段落と改行を保持します。Modal は安全な MarkdownRenderer を再利用し、生 HTML、スクリプト、コードツールを無効にします。同じ規約に本文と URL を指定した場合は本文を優先し、URL は任意の外部表示リンクとして使用します。URL のみの場合は隔離 iframe で表示します。
842
+
843
+ ```yaml
844
+ agreements:
845
+ showOnRegister: true
846
+ showOnLogin: false
847
+ userAgreementMarkdown: |
848
+ # 用户协议
849
+
850
+ **账户使用条款**
851
+
852
+ - 请妥善保管账户凭据。
853
+ privacyPolicyMarkdown: |
854
+ # 隐私政策
855
+
856
+ 注册资料用于账户管理,会话信息用于身份认证。
857
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
858
+ # userAgreementUrl: https://example.com/terms
859
+ # privacyPolicyUrl: https://example.com/privacy
860
+ ```
861
+
862
+ ### エラー処理
863
+
864
+ すべての操作は `HaiResult<T>` を返し、`HaiIamError` で分岐します:
865
+
866
+ ```ts
867
+ import { HaiIamError, iam } from '@h-ai/iam'
868
+
869
+ const result = await iam.auth.login({ identifier: 'admin', password: 'wrong' })
870
+ if (!result.success) {
871
+ if (result.error.code === HaiIamError.INVALID_CREDENTIALS.code) {
872
+ // 用户名或密码错误
873
+ }
874
+ }
875
+ ```
876
+
877
+ 会話と refresh 消費状態を `hai:iam:sessions:{SHA256(userId)}` のバージョン付き文書にまとめ、独立した対応キーと索引を廃止します。Bearer Token は認証には使えない分区ヒントと256ビットの乱数を含み、クライアントは不透明な文字列として扱います。更新時は Session.version を再検証します。Memory は単一プロセスのテスト用で、本番は全ノードが Redis(Cluster 対応)を共有します。競合は最新状態から再試行し、上限で CONFLICT を返します。応答喪失で結果が不明なら認証を拒否し再ログインします。すべての会話は同じユーザー索引で管理されます。
878
+
879
+ `iam.session.update(token, patch, expectedVersion)` は読み取った Session.version が必須です。旧版は CONFLICT、失効 Token は SESSION_NOT_FOUND を返し、新しい状態を上書きしません。自動延長の時刻だけの更新は内容版を変更せず、通常の並行リクエストを許容します。権限一括更新は最初の Token/版集合に限定し、後続ログインに古い値を適用しません。
880
+
881
+ ### 共有認可キャッシュ
882
+
883
+ Redisは秘密を含まないアカウント/資格情報の状態、ロール・権限、機密操作、検証済みAPI Key DTOを60秒保存します。キャッシュ値にはDBのパスワード/Key検証ハッシュ、平文Key、再設定トークンを保存しません。API Keyのキャッシュアドレスには平文Keyの一方向SHA-256指紋を使います。期限は毎回現在時刻で検証し、認可のコールド読込は単一JOINです。IAM書込はDB更新前に共有dirtyマーカーを登録し、外部トランザクションもcommit/rollback完了後に世代を更新します。競合したコールド読込は古い値を公開しません。通常の高頻度チェックはPGを照会せず、更新中・無効化未確認・キャッシュ読込障害時はDBを参照します。セッションRedis自体の障害は認証を拒否します。
884
+
885
+ 無効化登録に失敗した書込はDB更新前に拒否します。commit後の無効化失敗は成功済み更新を取り消した扱いにせず、永続dirtyマーカーでDB参照を継続します。プロセスクラッシュやcommit結果不明をTTLで解除せず、IAM書込がない保守時間帯に確認して修復してください。全ノード同時更新とIAM API経由の書込が必要です。直接SQL、旧版ノード、外部テーブル更新は書込停止とIAMキャッシュ全体の無効化が必要です。平文Key検証のヒットは高負荷ハッシュを省略し、lastUsedAtはコールド検証時に記録するため最大約1分遅れます。
886
+
887
+ ## 自己ホスト型の対話チャレンジ
888
+
889
+ captcha.enabled は既定で false。ALTCHA の計算チャレンジを自己ホストし、ttlSeconds(既定120、1–600秒)、cost(既定5000、100–50000)、maxCounter(既定100、1–2000)で期限と計算量を指定します。外部 CAPTCHA アカウントは不要で、HMAC 秘密と一度限りの状態は既存の共有 Cache にのみ保存します。本番では共有バックエンドが必要です。計算量は自動化のコストを増やしますが、利用者が人間であることは保証しません。
890
+
891
+ 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}) は任意の第2引数です。用途を固定し、検証後に原子的に消費するため、誤ったパスワードでも再利用できません。Cache 障害で通過せず、TTL は再初期化で延長しません。管理者作成/招待、認証済みの変更、API Key の機械認証は既存の認可と認証情報を使います。
892
+
893
+ Kit/Serv の標準入口は POST /auth/captcha(Kit は同一生成元の /api 接頭辞)。captcha を保持し、iam.publicConfig.captcha は enabled のみ公開します。AuthPanel/createAuthActions は有効時に送信操作で自動生成・非同期求解し、無効時には求解器を読み込みません。独自フォームも同じ手順を使えます。有効設定で createCaptcha がなければ送信を拒否し、取消/破棄後にログインしません。証明は資格情報として常に秘匿し、ブラウザーに永続保存しません。030/031/032/033 は確認必須・無効/使用済/期限切れ・無効設定・一時利用不可です。
894
+
895
+ ## 任意のパスワード・ログイン保護
896
+
897
+ 公開ログインと `renewPassword` は、存在しない・パスワード未設定・休止・終了・期限切れ・ロック中のアカウントに `INVALID_CREDENTIALS`(「ユーザー名またはパスワードが正しくありません。」)を返します。制限中はパスワードの推測を照合せず、正誤によらず同じ応答とし、既存の失敗回数を変更しません。認証済み管理画面では具体的な状態を確認できます。パスワード計算や保存の障害は基盤エラーとして扱い、入力失敗として加算しません。
898
+
899
+ 現在のパスワードの再使用は `HaiIamError.PASSWORD_REUSE_NOT_ALLOWED`(`hai:iam:034`)、強度不足は従来の `PASSWORD_POLICY_VIOLATION`(`008`)です。UI はコードに応じた案内を表示し、未知の例外の原文を隠します。初回変更に失敗すると通常セッションは発行されず、成功後は新しいパスワードでログインします。
900
+
901
+ `password.minLength/maxLength` と各 `require*` は強度を指定します。`minCharacterClasses` は大文字、小文字、数字、特殊文字の任意 N 種類を要求し、0は追加制約なしです。CLI/admin 例の既定は8–128文字で、追加の文字種数制限はありません。任意3種類を要求する場合は `minCharacterClasses: 3` を明示します。作成・変更・リセットで同じ検証を使います。
902
+
903
+ `expirationDays: 0` は定期変更を無効にし、正数は日数です。`preventSamePassword` は単独でも有効化でき、有効期限制約・初回変更時は旧パスワードを拒否します。`requireInitialChange` は管理者作成・リセットの認証情報に適用され、自分で登録時に設定したパスワードは再変更不要です。初期・期限切れパスワードでは通常セッションを取得・更新できません。`iam.user.renewPassword({ identifier, password, newPassword })` で旧パスワードを確認し、変更後に再ログインします。Kit/Serv と AuthPanel はこの完了経路を提供します。
904
+
905
+ `security.lockoutEnabled` はフレームワークSchemaでは既定で有効ですが、簡易CLI/admin例では明示的に無効にし、必要に応じて有効化します。`failureWindow` は最初の失敗からの固定期間(既定86400秒)、`maxLoginAttempts` は閾値、`lockoutDuration` はロック秒数です。例は1200秒です。無効時は既存ロックも無視します。共有DBで計数・閾値を原子的に処理します。
906
+
907
+ `initialCredentials.enabled` は `iam.user.inviteUser()` を有効にします。利用者ごとに非公開のランダム秘密を作り、短期・単回の設定招待だけ送ります。初期秘密は返却・送信しません。passwordReset の通知・期限・試行回数を再利用し、受信者は再設定画面でパスワードを選びます。通知未設定・リセット無効なら作成しません。配送未確認時は失敗とし、作成済みアカウント・招待を保持します。配送を確認後に再設定通知を要求してください。
908
+
909
+ `loginAccess.enabled` は制限を有効にします。`allowIps/denyIps` は IPv4/IPv6/CIDR を受け付け、拒否を優先します。空リストは対象を制限しません。`windows` は `days`(月曜1〜日曜7)、`start/end`(HH:mm、開始を含み終了を含まず、日跨ぎ対応)、IANA `timezone` を指定します。ログインの第2引数は信頼できる `AuthRequestContext` です。Kit はプラットフォーム getClientAddress()、Serv は信頼プロキシで解決した context.ip を使います。任意の転送ヘッダー・入力 JSON の IP を信用してはいけません。アドレス制限時に信頼できるアドレスがなければ拒否し、自動ログイン・API Key にも適用します。
910
+
911
+ `reauthentication.enabled/operations` で対象操作、`ttlSeconds` で証明期限を選びます。`reauthenticate(token, password, operation)` は単回証明を発行し、入口は権限・資源検証後に `checkSensitiveOperation(token, operation, ticket)` を呼びます。主体・現在セッション・操作に結びつけ、権限を増やしません。Serv IAM 更新操作と admin 守衛は `x-hai-reauthentication`、Kit は固有 ID と `kit.guard.requireSensitive(iam, event, operation)` を使います。証明は短時間メモリに保持し、公開設定は開閉・操作リストのみです。admin/fullstack標準UIでは、ログイン済み利用者が機密操作を選ぶと、現在のアカウントのパスワード確認ダイアログを表示します。取消時は送信せず、誤りなら操作を止め、成功時だけ元の操作を続けます。別のログインセッションは作成しません。
912
+
913
+ RBAC 権限は `sensitiveOperations: string[]` を持ち、可空 JSON 列 `sensitive_operations` に保存します。管理側は `iam.authz.setPermissionSensitiveOperations(permissionId, operations)` の `success` を確認します。空配列はその権限の追加規則だけを削除します。`reauthentication.enabled` が有効なら全権限の規則とファイルの `operations` を和集合にして即時検証します。ID は実際の業務ガードと一致させ、既存の認可を省略しません。非同期 `iam.getPublicConfig()` は統合した公開方針を返し、DB 読取障害時は失敗します。同期 `iam.publicConfig` はファイルのスナップショットのみです。RBAC 無効時は DB 規則を無視し、移行は既存の認可関係を保持します。
914
+
915
+ ## アカウント種別とライフサイクル
916
+
917
+ 既定は `accountType: 'permanent'` と `accountStatus: 'active'` です。管理側は `accountExpiresAt` を指定して `temporary` アカウントを作成し、`updateUser` で `dormant` または `closed` に変更できます。状態と認証世代を同じトランザクションで更新し、期限切れまたは非アクティブなアカウントはすべてのログイン、access 検証、refresh で拒否します。管理者が明示的に再有効化しても旧トークンは復活せず、再ログインが必要です。`accountStatus` だけでライフサイクルを表し、独立した有効/無効スイッチはありません。`closed` は記録を保持し、物理削除は `deleteUser` が行います。個人データの保持と退会手順はアプリが決定します。
918
+
919
+ beta.1 への更新前に旧バージョンの書き込みを停止し、DB をバックアップしてください。移行 v6 は旧 enabled が false/NULL で active のアカウントを dormant に変更し、既存の dormant/closed を保持したうえで列を削除します。影響する資格情報も無効になります。入力、絞り込み、並び替えは accountStatus に統一します。旧イメージへの切り戻しだけでは DB スキーマは戻らず、検証済みバックアップの復元が必要です。権限のある管理者はどの状態のパスワードもリセットできますが、状態は変わりません。初回変更の要否は password.requireInitialChange で決まります。
920
+
921
+ `accounts.dormantAfterDays` の既定値 `0` は自動休止を無効にします。`90` にすると、最後のログイン成功から90日間使用していない有効なアカウントを `accounts.scanIntervalSeconds` ごと(既定3600秒)に確認します。未使用のアカウントは作成時刻から計算し、成功した認証だけが `lastLoginAt` を更新します。条件付きSQLのため複数ノードで繰り返しても重複変更しません。信頼された処理や既存スケジューラーから `iam.user.scanInactiveAccounts()` を呼び出すこともできます。`close()` はタイマーを停止して実行中の処理を待ちます。公開登録APIに管理用の項目を公開しないでください。
922
+
923
+ ## 任意のセッションセキュリティ設定
924
+
925
+ `session.idleTimeout: 1800` は認証済みリクエストが30分間ない場合に再ログインを要求します。`0` は無効です。access の読み取り、活動の保存、refresh の更新で同じ期限を確認し、長期 refresh でも休止したセッションを復活できません。自動 refresh は休止期限を延長しません。バックグラウンド業務のポーリングは認証活動になるため、アプリは利用者の操作方針に合わせて設計してください。
926
+
927
+ `absoluteTimeout` は最初のログインからの上限秒数で、更新しても起点を変更しません。`0` は無効です。`maxSessions` は利用者ごとの有効セッション数を制限し、access が期限切れでも更新可能な記録を含みます。`0` は無制限です。同時ログインの上限判定は共有状態の同じCASで行います。`singleDevice: true` は既存セッションを置換し、`singleDeviceMode: 'reject'` を併用すると既存を保持して重複ログインを拒否します。明示的なログアウトは両方のトークンを失効させます。ブラウザーを閉じることはサーバー側の期限の代わりにはなりません。
928
+
929
+ 期限を無効にした場合、access と refresh は独立した有効期間を保ちます。ログイン成功ごとに新しいランダムな認証情報を生成します。`SESSION_LIMIT_EXCEEDED` は設定されたセッション数の上限を示します。
930
+
931
+ ## スキーマと認証情報の通知
932
+
933
+ RelDB 初期化後の `migrateIam({ dryRun: true })` は読み取り専用です。iam.init は対応する認証・リセット列を補い、ユーザー、パスワードと権限を保持します。事前に旧書込を停止しバックアップしてください。安全世代のない旧セッションは再ログインが必要で、認可復元のため世代を戻してはいけません。既定 Reach 通知は sensitive/expiresAt/idempotencyKey で平文保存や遅延再送を防ぎます。独自コールバックも同じ期限と保護を守ってください。
934
+
935
+ audit が初期化済みで capture.enabled が有効なら、IAM はアカウント・認証・パスワード・権限・API Key・セッションの重要イベントと失敗/拒否の安全な理由コードを記録します。audit を IAM より先に初期化し、逆順に終了してください。パスワード・単回証明・トークンは記録しません。ログイン成功後に主体を確定し、信頼できるサービス/CLI は audit.withContext で操作者と関連文脈を設定します。外部権限トランザクション内のイベントは一緒にコミット/ロールバックされ、失敗試行はロールバック後に呼出境界で記録します。監査失敗は診断を出し、反映済み業務を危険な再試行に変えません。
936
+
937
+ `session.terminalLimits: { web: 2, app: 1 }` は端末種別ごとの上限で、既定 `{}` は無効です。有効時は未記載の種別を拒否し、旧セッションは `web` とします。`terminalLimitMode` の既定 `reject` は新規ログインを拒否し、`replace` は同種の古いセッションだけを置換します。全体の `maxSessions` と `singleDevice` も同じ CAS 内で適用します。ログインコンテキスト `terminalType` / 標準クライアント `auth.terminalType` は分類用であり、機器の真正性や認可を保証しません。
938
+
939
+ 画面内の復帰は UI の `createSessionRecovery` / `SessionRecoveryDialog` と SDK `recoverSession` を組み合わせます。通常のパスワード・OTP・LDAP 方針を適用し、`expectedUserId` で元の本人に限定してからセッションを発行します。認証拒否が明確な要求だけ1回再送でき、結果不明のネットワーク障害は再送しません。画面再読込や終了で失われたメモリの入力は復元できません。
940
+
941
+ ## ページ離脱時のセッション方針
942
+
943
+ `session.endOnPageHide` の既定値は `false` です。ホストは `actions.bindPageExit({ enabled: config.session.endOnPageHide })` を呼び、破棄時に戻り値の解除関数を実行します。標準 admin/H5/fullstack テンプレートは接続済みです。BFCache 以外の `pagehide`(再読み込み、文書全体の移動を含む)で POST `/auth/session/close` を最善努力で送信し、保持する access と関連 refresh を失効します。既存の CSRF/暗号化転送を使用し、`keepalive` を付け、自動更新しません。古い応答が新しいログインを上書きしないよう Cookie/ローカル資格情報を消去しません。明示的ログアウトは通常どおり失効と消去を行います。ブラウザーが離脱要求を送らない場合もあるため、業務に応じてサーバーの `idleTimeout`/`absoluteTimeout` を併用してください。閉じた瞬間の失効は保証できません。
944
+
945
+ 標準 `/auth/me` も `iam.profile.update` / `iam.profile.avatar` を検査します。複合更新では有効な資料変更規則を優先し、それ以外は画像規則を使います。`authz.getSensitiveOperations(operation?)` は単一操作だけ照会でき、省略時はDB規則一覧を返します。超管理者の役割は共有DBから毎回取得し、種子の並行作成競合は再読込、権限割当失敗は上位へ返します。
946
+
947
+ ## 開発とテスト
948
+
949
+ 公開例は `@h-ai/iam` を使用し、既定値を省略できる `IamConfigInput` と解析済み `IamConfig` を区別します。依存を先に初期化し、`HaiResult.success` を確認し、会話更新には読取時の `version`、通知には永続的な冪等キーを使います。「模块内部上下文」の戦略・倉庫例はアプリ向け深い import の契約ではありません。チェッカーは型だけを確認し、Redis/DB・LDAP・配送の動作には対応する統合テストが必要です。[JSDoc 規約](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证)も参照してください。
950
+
951
+ ```bash
952
+ pnpm --filter @h-ai/iam typecheck
953
+ pnpm --filter @h-ai/iam lint
954
+ pnpm --filter @h-ai/iam build
955
+ pnpm --filter @h-ai/iam test
956
+ node scripts/check-module-jsdoc.mjs --package iam
957
+ ```
958
+
959
+ ## ライセンス
960
+
961
+ Apache-2.0