@h-ai/iam 0.1.0-alpha.59 → 0.1.0-alpha.61

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
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)
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) · [@h-ai/reach](../reach/README.en.md)
6
6
 
7
7
  Identity and access management through a unified `iam` object for authentication, sessions, authorization, and user management.
8
8
 
@@ -31,66 +31,182 @@ pnpm add @h-ai/iam
31
31
  - `@h-ai/crypto`: password hashing, used internally and initialized automatically.
32
32
  - `@h-ai/audit`: audit records for key RBAC operations.
33
33
 
34
- ## Quick start
34
+ - `@h-ai/reach`: **optional**, installed, initialized and injected by the application for standard notifications; custom callbacks do not require Reach.
35
+ - `ldapts`: **optional**, loaded only when LDAP is explicitly enabled without an injected `ldapClientFactory`.
36
+
37
+ ## Optional LDAP and notifications
38
+
39
+ Password authentication, users and RBAC work without LDAP or Reach. Both are optional peer dependencies: install only the integrations you use. The snippets below run inside application startup/authentication functions after reldb/cache initialization; `username/password` are validated input.
40
+
41
+ ### LDAP / Active Directory
42
+
43
+ For the default client run `pnpm add ldapts`, then explicitly set `login.ldap: true` and connection settings. Connection settings alone do not enable LDAP. Enabling the default client without the SDK returns an initialization configuration error.
44
+
45
+ ```yaml
46
+ # config/_iam.yml(启动前设置 LDAP_BIND_PASSWORD)
47
+ login:
48
+ ldap: true
49
+ ldap:
50
+ url: ldaps://ldap.example.com:636
51
+ bindDn: cn=service,dc=example,dc=com
52
+ bindPassword: '${LDAP_BIND_PASSWORD}'
53
+ searchBase: ou=users,dc=example,dc=com
54
+ searchFilter: '(uid={{username}})'
55
+ usernameAttribute: uid
56
+ emailAttribute: mail
57
+ displayNameAttribute: cn
58
+ connectTimeout: 5000
59
+ ```
60
+
61
+ `ldaps://` uses TLS; `ldap://` with `useTls: true` uses StartTLS. For Active Directory use `searchFilter: "(sAMAccountName={{username}})"` and `usernameAttribute: sAMAccountName`; the username placeholder is escaped before searching. First login synchronizes a local user and assigns the default role; disabled accounts and lockouts still apply. Set `ldapSyncUser: false` or inject a `ldapClientFactory` returning `bind/search/unbind` methods with HaiResult results. An injected factory does not require ldapts.
35
62
 
36
63
  ```ts
37
- import { cache } from '@h-ai/cache'
64
+ import type { IamConfigSettingsInput } from '@h-ai/iam'
65
+ import { core } from '@h-ai/core'
38
66
  import { iam } from '@h-ai/iam'
39
- import { reldb } from '@h-ai/reldb'
40
67
 
41
- // 1. 初始化依赖
42
- await reldb.init({ type: 'sqlite', database: './data.db' })
43
- await cache.init({ type: 'memory' })
68
+ // core 已加载 config/,reldb 与 cache 已成功初始化。
69
+ const initialized = await iam.init(core.config.getOrThrow<IamConfigSettingsInput>('iam'))
70
+ if (!initialized.success)
71
+ return initialized
72
+ const result = await iam.auth.loginWithLdap({ username, password })
73
+ if (!result.success)
74
+ return result
75
+ return result
76
+ ```
77
+
78
+ ### Automatic delivery with optional Reach
79
+
80
+ Run `pnpm add @h-ai/reach`. The application configures, initializes and closes Reach; IAM uses only the injected instance. These SMTP/HTTP providers are examples; the SMS gateway accepts the [Reach notification interface](../reach/README.en.md). Console logs metadata and does not deliver messages.
81
+
82
+ ```yaml
83
+ # config/_reach.yml;环境变量仅在服务端设置。
84
+ providers:
85
+ - name: email
86
+ type: smtp
87
+ host: '${SMTP_HOST}'
88
+ port: 465
89
+ secure: true
90
+ user: '${SMTP_USER}'
91
+ pass: '${SMTP_PASSWORD}'
92
+ from: '${MAIL_FROM}'
93
+ - name: sms
94
+ type: api
95
+ url: '${SMS_GATEWAY_URL}'
96
+ templates:
97
+ - name: otp_email
98
+ provider: email
99
+ subject: Verification code
100
+ body: 'Your code is {code}.'
101
+ - name: otp_sms
102
+ provider: sms
103
+ body: 'Your code is {code}.'
104
+ - name: password_reset
105
+ provider: email
106
+ subject: Password reset
107
+ body: 'Reset: {resetUrl}. Expires: {expiresAt}.'
108
+ - name: password_reset_sms
109
+ provider: sms
110
+ body: 'Reset: {resetUrl}. Expires: {expiresAt}.'
111
+ ```
112
+
113
+ Standard templates: `otp_email / otp_sms` use `{code}`; `password_reset / password_reset_sms` use `{token}`, `{expiresAt}`, `{resetUrl}`. `passwordReset.mode: phone` selects SMS reset. Configure templates before `iam.init()`; unavailable channels are hidden by the public configuration.
114
+
115
+ ```ts
116
+ import type { ReachConfigInput } from '@h-ai/reach'
117
+ import { core } from '@h-ai/core'
118
+ import { iam } from '@h-ai/iam'
119
+ import { reach } from '@h-ai/reach'
120
+
121
+ // core、reldb、cache 已初始化;按应用生命周期反序关闭 IAM 与 Reach。
122
+ const notification = await reach.init(core.config.getOrThrow<ReachConfigInput>('reach'))
123
+ if (!notification.success)
124
+ return notification
125
+ const initialized = await iam.init({
126
+ reach,
127
+ login: { otp: true },
128
+ otp: { email: true, sms: true },
129
+ passwordReset: { mode: 'email', resetUrl: 'https://app.example.com/auth/reset-password' },
130
+ })
131
+ if (!initialized.success)
132
+ return initialized
133
+ return iam.auth.sendOtp('user@example.com')
134
+ ```
44
135
 
45
- // 2. 初始化 IAM(自动使用已初始化的 reldb 和 cache 单例)
46
- await iam.init({
47
- session: { maxAge: 86400, sliding: true },
48
- // OTP 回调(启用 OTP 登录时注入发送逻辑)
136
+ ### Custom email and SMS delivery
137
+
138
+ Inject `onOtpSendEmail`, `onOtpSendSms` and `onPasswordResetRequest` to use existing application services. Implement only needed callbacks or mix them with Reach; explicit callbacks take priority. These services are application implementations whose promises resolve only after successful delivery. If a service returns HaiResult, check `success` and throw inside the callback on failure. IAM catches callback exceptions and returns a business error without exposing codes, tokens or internal delivery details.
139
+
140
+ ```ts
141
+ import { iam } from '@h-ai/iam'
142
+ import { mailService, smsService } from './notifications.js'
143
+
144
+ // mailService/smsService 为应用已有的发送服务;不需要安装或初始化 Reach。
145
+ // 调用方须检查服务投递结果,失败时拒绝 Promise,不能吞掉错误。
146
+ const initialized = await iam.init({
147
+ otp: { email: true, sms: true },
49
148
  onOtpSendEmail: async (email, code) => {
50
- await sendEmail(email, `验证码: ${code}`)
149
+ await mailService.send({ to: email, subject: 'Verification code', text: code })
150
+ },
151
+ onOtpSendSms: async (phone, code) => {
152
+ await smsService.send({ to: phone, code })
51
153
  },
52
- // 密码重置回调(启用密码重置时注入通知逻辑)
154
+ passwordReset: { mode: 'email' },
53
155
  onPasswordResetRequest: async (user, token, expiresAt) => {
54
- await sendEmail(user.email!, `重置链接: https://example.com/reset?token=${token}`)
156
+ if (!user.email)
157
+ throw new Error('Reset recipient unavailable')
158
+ const url = new URL('https://app.example.com/auth/reset-password')
159
+ url.searchParams.set('token', token)
160
+ await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt })
55
161
  },
56
162
  })
163
+ if (!initialized.success)
164
+ return initialized
165
+ ```
57
166
 
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)
167
+ ## Quick start
76
168
 
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
- })
169
+ ```ts
170
+ import { cache } from '@h-ai/cache'
171
+ import { core } from '@h-ai/core'
172
+ import { iam } from '@h-ai/iam'
173
+ import { reldb } from '@h-ai/reldb'
83
174
 
84
- // 6. 检查权限
85
- const hasPermission = await iam.authz.checkPermission(
86
- loginResult.data.user.id,
87
- 'user:read',
88
- )
175
+ // 基础密码认证不需要安装 Reach 或 ldapts。
176
+ async function example() {
177
+ try {
178
+ const db = await reldb.init({ type: 'sqlite', database: './data.db' })
179
+ if (!db.success)
180
+ return db
181
+ const cached = await cache.init({ type: 'memory' })
182
+ if (!cached.success)
183
+ return cached
184
+ const initialized = await iam.init({})
185
+ if (!initialized.success)
186
+ return initialized
187
+ const registered = await iam.user.register({ username: 'demo', password: 'Password123' })
188
+ if (!registered.success)
189
+ return registered
190
+ const login = await iam.auth.login({ identifier: 'demo', password: 'Password123' })
191
+ if (!login.success)
192
+ return login
193
+ return await iam.auth.verifyToken(login.data.tokens.accessToken)
194
+ }
195
+ finally {
196
+ // 应用关闭时按依赖的反向顺序释放。
197
+ await iam.close()
198
+ await cache.close()
199
+ await reldb.close()
200
+ }
201
+ }
89
202
 
90
- // 7. 关闭
91
- await iam.close()
203
+ const result = await example()
204
+ if (!result.success)
205
+ core.logger.error('Authentication example failed', { code: result.error.code })
92
206
  ```
93
207
 
208
+ LDAP is disabled by default. Public OTP methods require `otp` settings and send callbacks; password reset requires a notification callback.
209
+
94
210
  `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
211
 
96
212
  ## Design
@@ -114,14 +230,14 @@ Before initialization, every subsystem method returns `NOT_INITIALIZED` through
114
230
 
115
231
  ### Data storage
116
232
 
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 |
233
+ | Data | Storage | Description |
234
+ | --------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
235
+ | Users, roles, permissions, associations, API keys | DB (5 base tables, 1 optional API Key table) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
236
+ | Sessions (accessToken → Session) | Cache | TTL, optional sliding renewal |
237
+ | Token mapping (refreshToken → userId + accessToken) | Cache | Separate keys; old tokens expire immediately after refresh |
238
+ | OTP codes | Cache | TTL + attempt limit |
239
+ | Password reset tokens | Cache | TTL + maximum verification attempts |
240
+ | API Key | DB | `hai_iam_api_keys`; store hashes only, never plaintext; prefix narrows candidates |
125
241
 
126
242
  ---
127
243
 
@@ -174,7 +290,7 @@ const result = await iam.auth.login({ identifier: 'admin', password: 'Password12
174
290
  await iam.auth.sendOtp('user@example.com')
175
291
  const result = await iam.auth.loginWithOtp({ identifier: 'user@example.com', code: '123456' })
176
292
 
177
- // LDAP 登录(需在 init 时提供 ldapClientFactory)
293
+ // LDAP 登录(内置客户端,可选注入 ldapClientFactory)
178
294
  const result = await iam.auth.loginWithLdap({ username: 'jdoe', password: 'pass' })
179
295
 
180
296
  // API Key 登录(需启用 login.apikey: true)
@@ -250,7 +366,7 @@ await iam.session.deleteByUserId(userId)
250
366
 
251
367
  #### Considerations
252
368
 
253
- - **Cache-only storage**: no database persistence; all sessions expire after restart, following the security design.
369
+ - **Cache-only storage**: Memory sessions are lost on restart; shared Redis sessions survive IAM service restarts until TTL or revocation.
254
370
  - **Sliding renewal**: with `sliding: true`, each `get()` extends TTL automatically.
255
371
  - **Refresh rotation**: old refreshToken expires after one use; replay detection indicates token leakage.
256
372
  - **Token strength**: 256-bit cryptographic randomness (`crypto.getRandomValues(new Uint8Array(32))`), base64url encoded.
@@ -259,6 +375,8 @@ await iam.session.deleteByUserId(userId)
259
375
 
260
376
  ### Authorization (authz) — `iam.authz`
261
377
 
378
+ Role lists use `iam.authz.getRoleUserCounts(roleIds)` to aggregate actual membership for the current page in one query, returning `HaiResult<Map<string, number>>`. Missing relations mean zero; query failures display an error instead of invented zero counts.
379
+
262
380
  #### Key types
263
381
 
264
382
  - **`AuthzOperations`**: role/permission CRUD, role-permission/user-role assignments, and permission checks.
@@ -528,6 +646,45 @@ if (consumed.success) {
528
646
 
529
647
  Features: cryptographically secure randomness, TTL, atomic single use (only one concurrent consumer succeeds), and purpose binding to prevent cross-purpose replay.
530
648
 
649
+ ## Configuration driven authentication
650
+
651
+ `iam.publicConfig` and the client's `client.iam.auth.publicConfig()` HTTP contract expose a safe configuration whitelist and only advertise configured, available methods. `login.password/otp/ldap`, `otp.email/sms`, `register.enabled/defaultEnabled`, `passwordReset.enabled/mode/resetUrl` and agreement display flags are independent. `ui` configures card/split layout, title, subtitle, brand text, Logo and illustration URLs. Browser links only accept HTTP(S). LDAP is optional and disabled by default; install ldapts only for the default client, or inject a custom factory.
652
+
653
+ When using optional Reach, initialize it after reldb/cache and call `iam.init({ ...settings, reach })`. Standard `otp_email`, `otp_sms`, `password_reset` and `password_reset_sms` templates supply notification callbacks. Explicit callbacks override these defaults. Configure a real SMTP, SMS or HTTP provider for delivery; Console records metadata only. Delivery failures return errors.
654
+
655
+ SvelteKit routes export handlers from `kit.auth.createApi(iam)`; use `kit.auth.publicPaths` for anonymous endpoints and `kit.client.createAuthActions()` for Cookie based form actions. HTTP clients use `apiClient.createAuthActions(client)`, which saves successful authentication tokens through the configured storage. Pass actions and config to `AuthPanel` for login, registration and password recovery. Individual `LoginForm` components support `methods/onsendotp/resendInterval`; password forms accept the full `passwordPolicy`. Administrators use permission-guarded `iam.user.createUser()`; disabling public registration does not disable administrator creation.
656
+
657
+ Agreements open in a Modal without losing form input. URL pages must allow iframe embedding; scripts are disabled and a new-window fallback is available. LoginForm/RegisterForm expose `header/extraFields/footer` snippets. AuthPanel forwards `loginHeader/loginFields/loginFooter` and `registerHeader/registerFields/registerFooter`; application state and actions handle additional fields. See [UI usage](../ui/README.en.md#configuration-driven-authentication).
658
+
659
+ All nodes must share a database and Redis. Memory is limited to one process. Login counters and lock expiry use atomic database updates. OTP attempt limits, resend intervals and consumption use shared state; consuming/exhausting a code does not reset the resend interval. Reset TTL and attempt limits use shared cache and per-user locks: policy failures count, only the newest token is valid, and concurrent use succeeds once. Reset revokes old sessions and clears login locks. Disabled users cannot authenticate. Server password policy is authoritative.
660
+
661
+ Standard endpoints: `GET /auth/config`, `POST /auth/login/ldap`, `POST /auth/password-reset/request`, `POST /auth/password-reset/confirm`. Security errors: `SECURITY_BUSY (hai:iam:023)` and `PASSWORD_RESET_DISABLED (hai:iam:024)`. Declare optional fields such as `ldap` in YAML before overriding them, or replace the module configuration with a JSON value in `HAI_IAM`. Existing fields support overrides such as `HAI_IAM_LOGIN_OTP`.
662
+
663
+ `passwordReset.mode` selects `email` (default, `password_reset`) or `phone` (`password_reset_sms`); the public config tells the recovery form which identifier to request.
664
+
665
+ ### Markdown agreements
666
+
667
+ Configure `agreements.userAgreementMarkdown` and `privacyPolicyMarkdown` to load agreement content directly from configuration. YAML `|` preserves paragraphs and newlines. The modal reuses the safe MarkdownRenderer with raw HTML, scripts and code tools disabled. Markdown takes priority over a URL for the same agreement; a URL remains an optional external-view link. URL-only agreements use a sandboxed iframe.
668
+
669
+ ```yaml
670
+ agreements:
671
+ showOnRegister: true
672
+ showOnLogin: false
673
+ userAgreementMarkdown: |
674
+ # 用户协议
675
+
676
+ **账户使用条款**
677
+
678
+ - 请妥善保管账户凭据。
679
+ privacyPolicyMarkdown: |
680
+ # 隐私政策
681
+
682
+ 注册资料用于账户管理,会话信息用于身份认证。
683
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
684
+ # userAgreementUrl: https://example.com/terms
685
+ # privacyPolicyUrl: https://example.com/privacy
686
+ ```
687
+
531
688
  ## Error handling
532
689
 
533
690
  All operations return `HaiResult<T>`; branch on `HaiIamError`: