@homurajs/auth 26.10.13 → 26.10.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -20
- package/dist/hono.d.ts +61 -15
- package/dist/hono.js +294 -19
- package/dist/index.d.ts +55 -2
- package/dist/index.js +386 -9
- package/dist/provider-Bf7d5mkl.d.ts +164 -0
- package/dist/provider-BkUYQ703.d.ts +157 -0
- package/dist/provider-DMtwLgGf.js +215 -0
- package/dist/provider-zTWN9myS.js +276 -0
- package/dist/tables.d.ts +850 -15
- package/dist/tables.js +101 -11
- package/dist/{manager-DUAs-ASy.d.ts → types-C-SlEMFJ.d.ts} +86 -49
- package/dist/webauthn.d.ts +21 -0
- package/dist/webauthn.js +164 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -1,31 +1,134 @@
|
|
|
1
1
|
# @homurajs/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
多凭证身份认证:OAuth2 客户端 + TextPassword(scrypt)+ WebAuthn(可选)+ 自管 session(HS256 短 JWT + opaque refresh rotate + 族吊销)。
|
|
4
|
+
|
|
5
|
+
## 架构
|
|
6
|
+
|
|
7
|
+
**身份模型(ADR-022)**:`users` 表是本地身份锚点,三种凭证都通过 `user_id` 关联:
|
|
8
|
+
|
|
9
|
+
- `passwords`:1:1 users,scrypt 哈希(Node 内置 crypto,零依赖,ADR-023)
|
|
10
|
+
- `oauth2_identity_links`:1:N users,UNIQUE(provider, provider_sub)
|
|
11
|
+
- `webauthn_credentials`:1:N users,PK = credentialId base64url
|
|
12
|
+
|
|
13
|
+
**session.sub = local user ID**(Breaking:不再是 IdP subject)。Identity Resolver 在 OAuth2 callback 时桥接 IdP sub → local user,JIT 默认开启(首次登录自动建户),可配 false 拒绝未知身份。
|
|
14
|
+
|
|
15
|
+
**SDK 不渲染 HTML**:所有路由只返回 JSON + cookies,登录 UI 由 App 自行实现。
|
|
16
|
+
|
|
17
|
+
## 存储实现
|
|
18
|
+
|
|
19
|
+
所有 store 都有双版本,接口语义严格对齐,按数据方言选:
|
|
20
|
+
|
|
21
|
+
| 实现 | 用于 | 说明 |
|
|
22
|
+
| ----------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `createDrizzleSessionStore(db)` | `sqlite` / `pg` | 原生 SQL,`UPDATE...WHERE used_at IS NULL RETURNING` 做原子抢占 |
|
|
24
|
+
| `createRestSessionStore(rest)` | `rest`(qw-pages) | PostgREST:`PATCH ?token_hash=eq&expires_at=gt&used_at=is.null` + `return=representation`,返回空数组即抢占失败 |
|
|
25
|
+
| `createDrizzleUserStore(db)` | `sqlite` / `pg` | users 表 CRUD |
|
|
26
|
+
| `createRestUserStore(rest)` | `rest` | PostgREST |
|
|
27
|
+
| `createDrizzleOAuth2LinkStore(db)` | `sqlite` / `pg` | oauth2_identity_links 表 CRUD |
|
|
28
|
+
| `createRestOAuth2LinkStore(rest)` | `rest` | PostgREST |
|
|
29
|
+
| `createDrizzlePasswordStore(db)` | `sqlite` / `pg` | passwords 表 CRUD |
|
|
30
|
+
| `createRestPasswordStore(rest)` | `rest` | PostgREST |
|
|
31
|
+
| `createDrizzleWebauthnCredentialStore(db)` | `sqlite` / `pg` | webauthn_credentials 表 CRUD |
|
|
32
|
+
| `createRestWebauthnCredentialStore(rest)` | `rest` | PostgREST |
|
|
33
|
+
|
|
34
|
+
## 使用示例
|
|
35
|
+
|
|
36
|
+
### OAuth2 + Identity Resolver
|
|
4
37
|
|
|
5
38
|
```ts
|
|
6
|
-
// OAuth 预设来自 @homurajs/3p(结构化契约,鸭子类型互操作)
|
|
7
39
|
import { dingtalkOAuth } from '@homurajs/3p/dingtalk'
|
|
8
|
-
import {
|
|
9
|
-
|
|
40
|
+
import {
|
|
41
|
+
OAuth2Client,
|
|
42
|
+
SessionManager,
|
|
43
|
+
createDrizzleSessionStore,
|
|
44
|
+
createDrizzleUserStore,
|
|
45
|
+
createDrizzleOAuth2LinkStore,
|
|
46
|
+
createDefaultIdentityResolver,
|
|
47
|
+
createMemoryTtlKv,
|
|
48
|
+
} from '@homurajs/auth'
|
|
49
|
+
import { createAuthRoutes, sessionMiddleware } from '@homurajs/auth/hono'
|
|
10
50
|
|
|
11
|
-
const preset = dingtalkOAuth({ orgId: 'my_org' })
|
|
51
|
+
const preset = dingtalkOAuth({ orgId: 'my_org' })
|
|
12
52
|
const client = new OAuth2Client({
|
|
13
|
-
clientId: '...',
|
|
14
|
-
|
|
15
|
-
|
|
53
|
+
clientId: '...',
|
|
54
|
+
clientSecret: '...',
|
|
55
|
+
redirectUri: 'https://app.example/auth/oauth2/callback',
|
|
56
|
+
stateKv: createMemoryTtlKv(),
|
|
57
|
+
...preset,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const userStore = createDrizzleUserStore(db)
|
|
61
|
+
const linkStore = createDrizzleOAuth2LinkStore(db)
|
|
62
|
+
const identityResolver = createDefaultIdentityResolver(userStore, linkStore, { jit: true })
|
|
63
|
+
|
|
64
|
+
const sessions = new SessionManager({ secret: SESSION_SECRET }, createDrizzleSessionStore(db))
|
|
65
|
+
|
|
66
|
+
app.use('*', sessionMiddleware(sessions, { secure }))
|
|
67
|
+
app.route('/', createAuthRoutes({
|
|
68
|
+
sessions,
|
|
69
|
+
oauth2: { client, identityResolver, provider: 'dingtalk' },
|
|
70
|
+
}))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### TextPassword
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { PasswordProvider, createDrizzlePasswordStore } from '@homurajs/auth'
|
|
77
|
+
|
|
78
|
+
const passwordProvider = new PasswordProvider({
|
|
79
|
+
userStore,
|
|
80
|
+
passwordStore: createDrizzlePasswordStore(db),
|
|
16
81
|
})
|
|
17
82
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// Hono 集成
|
|
24
|
-
import { createAuthRoutes, sessionMiddleware, requireAuth } from '@homurajs/auth/hono'
|
|
25
|
-
app.use('*', sessionMiddleware(sessions))
|
|
26
|
-
app.route('/', createAuthRoutes({ client, sessions }))
|
|
27
|
-
app.use('/api/*', requireAuth)
|
|
28
|
-
app.get('/api/me', (c) => c.json(c.get('auth')))
|
|
83
|
+
app.route('/', createAuthRoutes({
|
|
84
|
+
sessions,
|
|
85
|
+
password: passwordProvider,
|
|
86
|
+
allowRegister: true, // 默认 true,可配 false 禁止自助注册
|
|
87
|
+
}))
|
|
29
88
|
```
|
|
30
89
|
|
|
31
|
-
|
|
90
|
+
路由:
|
|
91
|
+
- `POST /auth/password/register` → session cookies + 200 JSON
|
|
92
|
+
- `POST /auth/password/login` → session cookies + 200 JSON
|
|
93
|
+
- `POST /auth/password/change` → 需已登录 → 200 JSON
|
|
94
|
+
- `POST /auth/password/delete` → 需已登录 → 200 JSON
|
|
95
|
+
|
|
96
|
+
### WebAuthn(可选)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { WebAuthProvider, createDrizzleWebauthnCredentialStore } from '@homurajs/auth/webauthn'
|
|
100
|
+
import { createWebAuthnRoutes } from '@homurajs/auth/webauthn'
|
|
101
|
+
|
|
102
|
+
const webauthn = new WebAuthProvider({
|
|
103
|
+
rpName: 'My App',
|
|
104
|
+
rpID: 'app.example.com',
|
|
105
|
+
origin: 'https://app.example.com',
|
|
106
|
+
userStore,
|
|
107
|
+
credentialStore: createDrizzleWebauthnCredentialStore(db),
|
|
108
|
+
challengeKv: createMemoryTtlKv(),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
app.route('/', createWebAuthnRoutes({ webauthn, sessions }))
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`@simplewebauthn/server` 是 optional peer dep,不用 WebAuthn 的 App 无需安装。
|
|
115
|
+
|
|
116
|
+
## 子路径导出
|
|
117
|
+
|
|
118
|
+
- `@homurajs/auth`:核心(OAuth2Client, SessionManager, PasswordProvider, IdentityResolver, 各 store 工厂)
|
|
119
|
+
- `@homurajs/auth/hono`:Hono 路由(createAuthRoutes, createPasswordRoutes, sessionMiddleware, requireAuth)
|
|
120
|
+
- `@homurajs/auth/tables`:表定义(pgAuthTables, sqliteAuthTables,含 5 张表)
|
|
121
|
+
- `@homurajs/auth/webauthn`:WebAuthn(WebAuthProvider, createWebAuthnRoutes, credential store 工厂)
|
|
122
|
+
|
|
123
|
+
## 注意事项
|
|
124
|
+
|
|
125
|
+
**`secure` 必须按形态传**:qw-pages 在平台网关后、对外是 https,`secure` 应为 `true`,否则 cookie 不带 `Secure`。模板里由 `adapter.trustProxy()` 接线。
|
|
126
|
+
|
|
127
|
+
**qw-pages 前置条件**:所有 auth 表(users / passwords / oauth2_identity_links / webauthn_credentials / refresh_tokens)与 anon RLS policy 必须在部署期用平台受控迁移建好(App 无 DDL 权限)。缺 policy 时 PostgREST 是静默失败。
|
|
128
|
+
|
|
129
|
+
**Breaking(2026-10-14)**:
|
|
130
|
+
- session.sub 从 IdP subject 改为 local user ID(现有 refresh token 全部失效,重登即恢复)
|
|
131
|
+
- OAuth2 路径从 `/auth/login` + `/auth/callback` 迁移到 `/auth/oauth2/authorize` + `/auth/oauth2/callback`
|
|
132
|
+
- `createAuthRoutes` 签名变更:`client` → `oauth2: { client, identityResolver, provider }`
|
|
133
|
+
|
|
134
|
+
`consume` 的原子抢占语义已在真实 PostgREST 上验证。详见 `docs/specs/module-oauth2`、`module-session`、`module-password`、`module-webauthn`、ADR-022、ADR-023。使用示范见模板 `src/server/app.ts`(buildAuth)。
|
package/dist/hono.d.ts
CHANGED
|
@@ -1,29 +1,68 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { o as SessionClaims, s as SessionManager } from "./types-C-SlEMFJ.js";
|
|
2
|
+
import { d as OAuth2Client, s as IdentityResolver, t as PasswordProvider } from "./provider-BkUYQ703.js";
|
|
2
3
|
import { Hono, MiddlewareHandler } from "hono";
|
|
3
4
|
//#region src/hono/routes.d.ts
|
|
4
|
-
interface
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
interface OAuth2Inject {
|
|
6
|
+
client: OAuth2Client;
|
|
7
|
+
identityResolver: IdentityResolver;
|
|
8
|
+
/** OAuth2 provider 标识(如 'dingtalk') */
|
|
9
|
+
provider: string;
|
|
9
10
|
}
|
|
10
11
|
interface AuthRoutesOptions {
|
|
11
|
-
client: OAuth2Client;
|
|
12
12
|
sessions: SessionManager;
|
|
13
|
-
|
|
13
|
+
/** OAuth2 能力(不传则不挂载 OAuth2 路由) */
|
|
14
|
+
oauth2?: OAuth2Inject;
|
|
15
|
+
/** Password 能力(不传则不挂载 Password 路由) */
|
|
16
|
+
password?: PasswordProvider;
|
|
17
|
+
/** 是否允许自助注册(默认 true) */
|
|
18
|
+
allowRegister?: boolean;
|
|
14
19
|
/** cookie Secure(qw-pages/https 传 true) */
|
|
15
20
|
secure?: boolean;
|
|
16
|
-
/**
|
|
21
|
+
/** 登录成功后的缺省跳转 */
|
|
17
22
|
defaultReturnTo?: string;
|
|
18
23
|
}
|
|
19
24
|
/**
|
|
20
|
-
*
|
|
21
|
-
* GET /auth/callback → 建 session 写双 cookie → 302 returnTo
|
|
22
|
-
* POST /auth/refresh → rotate(读 refresh cookie)
|
|
23
|
-
* POST /auth/logout → 吊销 session + 清 cookie
|
|
25
|
+
* 组合 auth 路由:OAuth2 + Password(按配置)+ refresh + logout。
|
|
24
26
|
*/
|
|
25
27
|
export declare function createAuthRoutes(opts: AuthRoutesOptions): Hono;
|
|
26
28
|
//#endregion
|
|
29
|
+
//#region src/hono/oauth2-routes.d.ts
|
|
30
|
+
interface OAuth2Paths {
|
|
31
|
+
authorize?: string;
|
|
32
|
+
callback?: string;
|
|
33
|
+
}
|
|
34
|
+
interface OAuth2RoutesOptions {
|
|
35
|
+
client: OAuth2Client;
|
|
36
|
+
sessions: SessionManager;
|
|
37
|
+
/** OAuth2 回调时调用:IdP sub → local user */
|
|
38
|
+
identityResolver: IdentityResolver;
|
|
39
|
+
/** OAuth2 provider 标识(如 'dingtalk'),用于 identity link */
|
|
40
|
+
provider: string;
|
|
41
|
+
paths?: OAuth2Paths;
|
|
42
|
+
/** cookie Secure(qw-pages/https 传 true) */
|
|
43
|
+
secure?: boolean;
|
|
44
|
+
/** 登录成功后的缺省跳转(returnTo 优先) */
|
|
45
|
+
defaultReturnTo?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function createOAuth2Routes(opts: OAuth2RoutesOptions): Hono;
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/hono/password-routes.d.ts
|
|
50
|
+
interface PasswordPaths {
|
|
51
|
+
register?: string;
|
|
52
|
+
login?: string;
|
|
53
|
+
change?: string;
|
|
54
|
+
delete?: string;
|
|
55
|
+
}
|
|
56
|
+
interface PasswordRoutesOptions {
|
|
57
|
+
password: PasswordProvider;
|
|
58
|
+
sessions: SessionManager;
|
|
59
|
+
/** 是否允许自助注册(默认 true) */
|
|
60
|
+
allowRegister?: boolean;
|
|
61
|
+
paths?: PasswordPaths;
|
|
62
|
+
secure?: boolean;
|
|
63
|
+
}
|
|
64
|
+
export declare function createPasswordRoutes(opts: PasswordRoutesOptions): Hono;
|
|
65
|
+
//#endregion
|
|
27
66
|
//#region src/hono/middleware.d.ts
|
|
28
67
|
interface SessionMiddlewareOptions {
|
|
29
68
|
/** 允许 Authorization: Bearer(默认 true) */
|
|
@@ -37,7 +76,14 @@ interface SessionMiddlewareOptions {
|
|
|
37
76
|
* 无身份不报错(交给 requireAuth 决定 401/302)。
|
|
38
77
|
*/
|
|
39
78
|
export declare function sessionMiddleware(sessions: SessionManager, opts?: SessionMiddlewareOptions): MiddlewareHandler;
|
|
40
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login。
|
|
81
|
+
*
|
|
82
|
+
* ⚠ loginPath 缺省 = OAuth2 授权入口 `/auth/oauth2/authorize`(旧 `/auth/login` 已废弃,
|
|
83
|
+
* 见 ADR-022)。SDK 不渲染登录页,故缺省指向唯一内置的 GET 登录入口;
|
|
84
|
+
* 纯密码认证或自带登录 UI 的 App 必须显式传 `loginPath` 指向自己的登录页,
|
|
85
|
+
* 否则页面请求会 302 到一个不存在的路径(被 SPA fallback 判成 404 JSON)。
|
|
86
|
+
*/
|
|
41
87
|
export declare function requireAuth(opts?: {
|
|
42
88
|
loginPath?: string;
|
|
43
89
|
}): MiddlewareHandler;
|
|
@@ -47,4 +93,4 @@ declare module 'hono' {
|
|
|
47
93
|
}
|
|
48
94
|
}
|
|
49
95
|
//#endregion
|
|
50
|
-
export type {
|
|
96
|
+
export type { AuthRoutesOptions, OAuth2Inject, OAuth2Paths, OAuth2RoutesOptions, PasswordPaths, PasswordRoutesOptions, SessionClaims, SessionMiddlewareOptions };
|
package/dist/hono.js
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
+
import { n as UsernameTakenError, r as IdentityNotLinkedError } from "./provider-DMtwLgGf.js";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { getCookie, setCookie } from "hono/cookie";
|
|
3
|
-
//#region src/hono/routes.ts
|
|
4
|
+
//#region src/hono/oauth2-routes.ts
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* OAuth2 路由(新路径统一到 /auth/oauth2/*)。
|
|
7
|
+
*
|
|
8
|
+
* GET /auth/oauth2/authorize → 302 到 IdP authorize URL
|
|
9
|
+
* GET /auth/oauth2/callback → identity resolver → session → 302 returnTo
|
|
10
|
+
*
|
|
11
|
+
* 旧路径 /auth/login + /auth/callback 已废弃(Breaking change)。
|
|
9
12
|
*/
|
|
10
|
-
function
|
|
11
|
-
const { client, sessions } = opts;
|
|
13
|
+
function createOAuth2Routes(opts) {
|
|
14
|
+
const { client, sessions, identityResolver, provider } = opts;
|
|
12
15
|
const paths = {
|
|
13
|
-
|
|
14
|
-
callback: opts.paths?.callback ?? "/auth/callback"
|
|
15
|
-
refresh: opts.paths?.refresh ?? "/auth/refresh",
|
|
16
|
-
logout: opts.paths?.logout ?? "/auth/logout"
|
|
16
|
+
authorize: opts.paths?.authorize ?? "/auth/oauth2/authorize",
|
|
17
|
+
callback: opts.paths?.callback ?? "/auth/oauth2/callback"
|
|
17
18
|
};
|
|
18
19
|
const secure = opts.secure ?? false;
|
|
19
20
|
const names = sessions.cookies;
|
|
20
21
|
const app = new Hono();
|
|
21
|
-
app.get(paths.
|
|
22
|
+
app.get(paths.authorize, async (c) => {
|
|
22
23
|
const returnTo = c.req.query("returnTo");
|
|
23
24
|
const url = await client.authorizeUrl(returnTo);
|
|
24
25
|
return c.redirect(url);
|
|
@@ -28,11 +29,20 @@ function createAuthRoutes(opts) {
|
|
|
28
29
|
const state = c.req.query("state");
|
|
29
30
|
if (!code) return c.json({ error: "missing_code" }, 400);
|
|
30
31
|
try {
|
|
31
|
-
const { user, returnTo } = await client.callback({
|
|
32
|
+
const { user: idpUser, returnTo } = await client.callback({
|
|
32
33
|
code,
|
|
33
34
|
state: state ?? void 0
|
|
34
35
|
});
|
|
35
|
-
const
|
|
36
|
+
const localUser = await identityResolver.resolve(provider, idpUser.sub, {
|
|
37
|
+
nickname: idpUser.nickname,
|
|
38
|
+
avatarUrl: idpUser.avatarUrl
|
|
39
|
+
});
|
|
40
|
+
const authUser = {
|
|
41
|
+
sub: localUser.id,
|
|
42
|
+
nickname: localUser.nickname ?? idpUser.nickname,
|
|
43
|
+
avatarUrl: localUser.avatarUrl ?? idpUser.avatarUrl
|
|
44
|
+
};
|
|
45
|
+
const tokens = await sessions.createSession(authUser);
|
|
36
46
|
setCookie(c, names.access, tokens.accessToken, {
|
|
37
47
|
httpOnly: true,
|
|
38
48
|
sameSite: "Lax",
|
|
@@ -49,13 +59,271 @@ function createAuthRoutes(opts) {
|
|
|
49
59
|
});
|
|
50
60
|
return c.redirect(returnTo || opts.defaultReturnTo || "/");
|
|
51
61
|
} catch (e) {
|
|
62
|
+
if (e instanceof IdentityNotLinkedError) return c.json({
|
|
63
|
+
error: "identity_not_linked",
|
|
64
|
+
message: e.message
|
|
65
|
+
}, 403);
|
|
52
66
|
return c.json({
|
|
53
67
|
error: "oauth_failed",
|
|
54
68
|
message: e.message
|
|
55
69
|
}, 401);
|
|
56
70
|
}
|
|
57
71
|
});
|
|
58
|
-
app
|
|
72
|
+
return app;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/hono/password-routes.ts
|
|
76
|
+
/**
|
|
77
|
+
* 密码认证路由(JSON API,不渲染页面)。
|
|
78
|
+
*
|
|
79
|
+
* POST /auth/password/register → 注册(username + password)→ session cookies + 200 JSON
|
|
80
|
+
* POST /auth/password/login → 登录 → session cookies + 200 JSON
|
|
81
|
+
* POST /auth/password/change → 改密码(需已登录)→ 200 JSON
|
|
82
|
+
* POST /auth/password/delete → 删密码(需已登录)→ 200 JSON
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* 跨方言唯一约束冲突判定(username 并发注册的兜底)。
|
|
86
|
+
*
|
|
87
|
+
* ⚠ 必须走 cause 链:drizzle 把底层驱动错误包成 DrizzleQueryError,其顶层 message 是
|
|
88
|
+
* "Failed query: INSERT ...",**不含** UNIQUE 字样——只看顶层 message 会漏判(曾导致
|
|
89
|
+
* 重复用户名注册回落 400 而非 409)。真正的信号在 cause:
|
|
90
|
+
* - sqlite:cause.errcode === 2067(SQLITE_CONSTRAINT_UNIQUE),cause.message 含 "UNIQUE constraint failed"
|
|
91
|
+
* - pg:cause.code === '23505'(unique_violation),message 含 "duplicate key"
|
|
92
|
+
* 逐层向下最多走 5 层,命中任一信号即判定为唯一冲突。
|
|
93
|
+
*/
|
|
94
|
+
function isUniqueViolation(e) {
|
|
95
|
+
let cur = e;
|
|
96
|
+
for (let depth = 0; cur && depth < 5; depth++) {
|
|
97
|
+
if (cur.errcode === 2067) return true;
|
|
98
|
+
if (cur.code === "23505") return true;
|
|
99
|
+
if (typeof cur.message === "string" && /unique constraint|duplicate key|unique violation/i.test(cur.message)) return true;
|
|
100
|
+
cur = cur.cause;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
function createPasswordRoutes(opts) {
|
|
105
|
+
const { password, sessions } = opts;
|
|
106
|
+
const allowRegister = opts.allowRegister ?? true;
|
|
107
|
+
const paths = {
|
|
108
|
+
register: opts.paths?.register ?? "/auth/password/register",
|
|
109
|
+
login: opts.paths?.login ?? "/auth/password/login",
|
|
110
|
+
change: opts.paths?.change ?? "/auth/password/change",
|
|
111
|
+
delete: opts.paths?.delete ?? "/auth/password/delete"
|
|
112
|
+
};
|
|
113
|
+
const secure = opts.secure ?? false;
|
|
114
|
+
const names = sessions.cookies;
|
|
115
|
+
const app = new Hono();
|
|
116
|
+
app.post(paths.register, async (c) => {
|
|
117
|
+
if (!allowRegister) return c.json({
|
|
118
|
+
error: "registration_disabled",
|
|
119
|
+
message: "自助注册已关闭"
|
|
120
|
+
}, 403);
|
|
121
|
+
let body;
|
|
122
|
+
try {
|
|
123
|
+
body = await c.req.json();
|
|
124
|
+
} catch {
|
|
125
|
+
return c.json({ error: "invalid_body" }, 400);
|
|
126
|
+
}
|
|
127
|
+
if (!body.username || !body.password) return c.json({
|
|
128
|
+
error: "missing_fields",
|
|
129
|
+
message: "需要 username 和 password"
|
|
130
|
+
}, 400);
|
|
131
|
+
if (body.password.length < 8) return c.json({
|
|
132
|
+
error: "weak_password",
|
|
133
|
+
message: "密码至少 8 位"
|
|
134
|
+
}, 400);
|
|
135
|
+
try {
|
|
136
|
+
const user = await password.register({
|
|
137
|
+
username: body.username,
|
|
138
|
+
password: body.password,
|
|
139
|
+
nickname: body.nickname
|
|
140
|
+
});
|
|
141
|
+
const authUser = {
|
|
142
|
+
sub: user.id,
|
|
143
|
+
nickname: user.nickname ?? void 0,
|
|
144
|
+
avatarUrl: user.avatarUrl ?? void 0
|
|
145
|
+
};
|
|
146
|
+
const tokens = await sessions.createSession(authUser);
|
|
147
|
+
setCookie(c, names.access, tokens.accessToken, {
|
|
148
|
+
httpOnly: true,
|
|
149
|
+
sameSite: "Lax",
|
|
150
|
+
secure,
|
|
151
|
+
path: "/",
|
|
152
|
+
maxAge: sessions.accessTokenTtlSec
|
|
153
|
+
});
|
|
154
|
+
setCookie(c, names.refresh, tokens.refreshToken, {
|
|
155
|
+
httpOnly: true,
|
|
156
|
+
sameSite: "Lax",
|
|
157
|
+
secure,
|
|
158
|
+
path: "/",
|
|
159
|
+
maxAge: sessions.refreshTokenTtlSec
|
|
160
|
+
});
|
|
161
|
+
return c.json({
|
|
162
|
+
ok: true,
|
|
163
|
+
user: {
|
|
164
|
+
id: user.id,
|
|
165
|
+
username: user.username,
|
|
166
|
+
nickname: user.nickname
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (e instanceof UsernameTakenError || isUniqueViolation(e)) return c.json({
|
|
171
|
+
error: "username_taken",
|
|
172
|
+
message: "用户名已被注册"
|
|
173
|
+
}, 409);
|
|
174
|
+
return c.json({
|
|
175
|
+
error: "register_failed",
|
|
176
|
+
message: e.message
|
|
177
|
+
}, 400);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
app.post(paths.login, async (c) => {
|
|
181
|
+
let body;
|
|
182
|
+
try {
|
|
183
|
+
body = await c.req.json();
|
|
184
|
+
} catch {
|
|
185
|
+
return c.json({ error: "invalid_body" }, 400);
|
|
186
|
+
}
|
|
187
|
+
if (!body.username || !body.password) return c.json({
|
|
188
|
+
error: "missing_fields",
|
|
189
|
+
message: "需要 username 和 password"
|
|
190
|
+
}, 400);
|
|
191
|
+
const user = await password.verify(body.username, body.password);
|
|
192
|
+
if (!user) return c.json({
|
|
193
|
+
error: "invalid_credentials",
|
|
194
|
+
message: "用户名或密码错误"
|
|
195
|
+
}, 401);
|
|
196
|
+
const authUser = {
|
|
197
|
+
sub: user.id,
|
|
198
|
+
nickname: user.nickname ?? void 0,
|
|
199
|
+
avatarUrl: user.avatarUrl ?? void 0
|
|
200
|
+
};
|
|
201
|
+
const tokens = await sessions.createSession(authUser);
|
|
202
|
+
setCookie(c, names.access, tokens.accessToken, {
|
|
203
|
+
httpOnly: true,
|
|
204
|
+
sameSite: "Lax",
|
|
205
|
+
secure,
|
|
206
|
+
path: "/",
|
|
207
|
+
maxAge: sessions.accessTokenTtlSec
|
|
208
|
+
});
|
|
209
|
+
setCookie(c, names.refresh, tokens.refreshToken, {
|
|
210
|
+
httpOnly: true,
|
|
211
|
+
sameSite: "Lax",
|
|
212
|
+
secure,
|
|
213
|
+
path: "/",
|
|
214
|
+
maxAge: sessions.refreshTokenTtlSec
|
|
215
|
+
});
|
|
216
|
+
return c.json({
|
|
217
|
+
ok: true,
|
|
218
|
+
user: {
|
|
219
|
+
id: user.id,
|
|
220
|
+
username: user.username,
|
|
221
|
+
nickname: user.nickname
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
app.post(paths.change, async (c) => {
|
|
226
|
+
const claims = c.get("auth");
|
|
227
|
+
if (!claims) return c.json({ error: "unauthorized" }, 401);
|
|
228
|
+
let body;
|
|
229
|
+
try {
|
|
230
|
+
body = await c.req.json();
|
|
231
|
+
} catch {
|
|
232
|
+
return c.json({ error: "invalid_body" }, 400);
|
|
233
|
+
}
|
|
234
|
+
if (!body.oldPassword || !body.newPassword) return c.json({
|
|
235
|
+
error: "missing_fields",
|
|
236
|
+
message: "需要 oldPassword 和 newPassword"
|
|
237
|
+
}, 400);
|
|
238
|
+
if (body.newPassword.length < 8) return c.json({
|
|
239
|
+
error: "weak_password",
|
|
240
|
+
message: "密码至少 8 位"
|
|
241
|
+
}, 400);
|
|
242
|
+
try {
|
|
243
|
+
await password.changePassword(claims.sub, body.oldPassword, body.newPassword);
|
|
244
|
+
await sessions.revokeUser(claims.sub);
|
|
245
|
+
setCookie(c, names.access, "", {
|
|
246
|
+
httpOnly: true,
|
|
247
|
+
secure,
|
|
248
|
+
path: "/",
|
|
249
|
+
maxAge: 0
|
|
250
|
+
});
|
|
251
|
+
setCookie(c, names.refresh, "", {
|
|
252
|
+
httpOnly: true,
|
|
253
|
+
secure,
|
|
254
|
+
path: "/",
|
|
255
|
+
maxAge: 0
|
|
256
|
+
});
|
|
257
|
+
return c.json({
|
|
258
|
+
ok: true,
|
|
259
|
+
reloginRequired: true
|
|
260
|
+
});
|
|
261
|
+
} catch (e) {
|
|
262
|
+
return c.json({
|
|
263
|
+
error: "change_failed",
|
|
264
|
+
message: e.message
|
|
265
|
+
}, 400);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
app.post(paths.delete, async (c) => {
|
|
269
|
+
const claims = c.get("auth");
|
|
270
|
+
if (!claims) return c.json({ error: "unauthorized" }, 401);
|
|
271
|
+
try {
|
|
272
|
+
await password.deletePassword(claims.sub);
|
|
273
|
+
return c.json({ ok: true });
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return c.json({
|
|
276
|
+
error: "delete_failed",
|
|
277
|
+
message: e.message
|
|
278
|
+
}, 400);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return app;
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/hono/routes.ts
|
|
285
|
+
/**
|
|
286
|
+
* 主路由组合器 —— 按配置的能力(OAuth2 / Password)组装路由。
|
|
287
|
+
*
|
|
288
|
+
* createAuthRoutes(opts) 根据 opts 提供的 capability 自动组合:
|
|
289
|
+
* - opts.oauth2(OAuth2Client + IdentityResolver + provider)→ /auth/oauth2/*
|
|
290
|
+
* - opts.password(PasswordProvider)→ /auth/password/*
|
|
291
|
+
* - 无条件挂载:/auth/refresh、/auth/logout
|
|
292
|
+
*
|
|
293
|
+
* WebAuthn 路由**不在**此组合器内——消费者从 @homurajs/auth/webauthn 取
|
|
294
|
+
* createWebAuthnRoutes 自行挂载。
|
|
295
|
+
*
|
|
296
|
+
* ⚠ Breaking:旧 /auth/login + /auth/callback 路径已迁移到 /auth/oauth2/*。
|
|
297
|
+
*/
|
|
298
|
+
/**
|
|
299
|
+
* 组合 auth 路由:OAuth2 + Password(按配置)+ refresh + logout。
|
|
300
|
+
*/
|
|
301
|
+
function createAuthRoutes(opts) {
|
|
302
|
+
const { sessions } = opts;
|
|
303
|
+
const secure = opts.secure ?? false;
|
|
304
|
+
const names = sessions.cookies;
|
|
305
|
+
const app = new Hono();
|
|
306
|
+
if (opts.oauth2) {
|
|
307
|
+
const oauth2App = createOAuth2Routes({
|
|
308
|
+
client: opts.oauth2.client,
|
|
309
|
+
sessions,
|
|
310
|
+
identityResolver: opts.oauth2.identityResolver,
|
|
311
|
+
provider: opts.oauth2.provider,
|
|
312
|
+
secure,
|
|
313
|
+
defaultReturnTo: opts.defaultReturnTo
|
|
314
|
+
});
|
|
315
|
+
app.route("/", oauth2App);
|
|
316
|
+
}
|
|
317
|
+
if (opts.password) {
|
|
318
|
+
const passwordApp = createPasswordRoutes({
|
|
319
|
+
password: opts.password,
|
|
320
|
+
sessions,
|
|
321
|
+
allowRegister: opts.allowRegister,
|
|
322
|
+
secure
|
|
323
|
+
});
|
|
324
|
+
app.route("/", passwordApp);
|
|
325
|
+
}
|
|
326
|
+
app.post("/auth/refresh", async (c) => {
|
|
59
327
|
const refreshToken = getCookie(c, names.refresh);
|
|
60
328
|
if (!refreshToken) return c.json({ error: "no_refresh_token" }, 401);
|
|
61
329
|
const rotated = await sessions.refresh(refreshToken);
|
|
@@ -76,7 +344,7 @@ function createAuthRoutes(opts) {
|
|
|
76
344
|
});
|
|
77
345
|
return c.json({ ok: true });
|
|
78
346
|
});
|
|
79
|
-
app.post(
|
|
347
|
+
app.post("/auth/logout", async (c) => {
|
|
80
348
|
const claims = c.get("auth");
|
|
81
349
|
if (claims?.sid) await sessions.revokeSession(claims.sid);
|
|
82
350
|
setCookie(c, names.access, "", {
|
|
@@ -140,9 +408,16 @@ function sessionMiddleware(sessions, opts) {
|
|
|
140
408
|
await next();
|
|
141
409
|
};
|
|
142
410
|
}
|
|
143
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login。
|
|
413
|
+
*
|
|
414
|
+
* ⚠ loginPath 缺省 = OAuth2 授权入口 `/auth/oauth2/authorize`(旧 `/auth/login` 已废弃,
|
|
415
|
+
* 见 ADR-022)。SDK 不渲染登录页,故缺省指向唯一内置的 GET 登录入口;
|
|
416
|
+
* 纯密码认证或自带登录 UI 的 App 必须显式传 `loginPath` 指向自己的登录页,
|
|
417
|
+
* 否则页面请求会 302 到一个不存在的路径(被 SPA fallback 判成 404 JSON)。
|
|
418
|
+
*/
|
|
144
419
|
function requireAuth(opts) {
|
|
145
|
-
const loginPath = opts?.loginPath ?? "/auth/
|
|
420
|
+
const loginPath = opts?.loginPath ?? "/auth/oauth2/authorize";
|
|
146
421
|
return async (c, next) => {
|
|
147
422
|
if (c.get("auth")) {
|
|
148
423
|
await next();
|
|
@@ -154,4 +429,4 @@ function requireAuth(opts) {
|
|
|
154
429
|
};
|
|
155
430
|
}
|
|
156
431
|
//#endregion
|
|
157
|
-
export { createAuthRoutes, requireAuth, sessionMiddleware };
|
|
432
|
+
export { createAuthRoutes, createOAuth2Routes, createPasswordRoutes, requireAuth, sessionMiddleware };
|