@h-ai/iam 0.1.0-alpha.59 → 0.1.0-alpha.60
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 +212 -55
- package/README.ja.md +212 -57
- package/README.md +225 -57
- package/dist/index.d.ts +104 -9
- package/dist/index.js +665 -339
- package/dist/index.js.map +1 -1
- package/package.json +20 -6
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
42
|
-
await
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
118
|
-
| --------------------------------------------------- |
|
|
119
|
-
| Users, roles, permissions, associations, API keys | DB (
|
|
120
|
-
| Sessions (accessToken → Session) | Cache
|
|
121
|
-
| Token mapping (refreshToken → userId + accessToken) | Cache
|
|
122
|
-
| OTP codes | Cache
|
|
123
|
-
| Password reset tokens | Cache
|
|
124
|
-
| API Key | DB
|
|
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
|
|
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**:
|
|
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`:
|