@homurajs/auth 26.10.14 → 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 +108 -31
- package/dist/hono.d.ts +61 -15
- package/dist/hono.js +294 -19
- package/dist/index.d.ts +46 -2
- package/dist/index.js +323 -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-_5q5sluE.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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { _ as TokenResponse, g as OAuth2Endpoints, h as AuthUser, i as UserStore, n as LocalUser, r as OAuth2LinkStore } from "./types-C-SlEMFJ.js";
|
|
2
|
+
import { TtlKv } from "@homurajs/core";
|
|
3
|
+
//#region src/oauth2/client.d.ts
|
|
4
|
+
interface OAuth2ClientConfig {
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
scopes?: string[];
|
|
9
|
+
endpoints: OAuth2Endpoints;
|
|
10
|
+
/** 上游原始 userinfo → 规范身份(预设来自 @homurajs/3p/*) */
|
|
11
|
+
mapUser: (raw: unknown) => AuthUser;
|
|
12
|
+
/** state 存储(core 的内存或 db 实现),TTL 5min 一次性消费 */
|
|
13
|
+
stateKv: TtlKv;
|
|
14
|
+
/** 测试注入口;全程不 mock 全局 fetch */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
authorizeExtraParams?: Record<string, string>;
|
|
17
|
+
/** dev 显式 opt-in 才允许无 state(修复 Suzuran 的 Redis 缺失放行后门) */
|
|
18
|
+
allowNoState?: boolean;
|
|
19
|
+
/** state TTL(默认 5min) */
|
|
20
|
+
stateTtlMs?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 通用标准 OAuth2 (Authorization Code Flow) 客户端。
|
|
24
|
+
* 服务端(如 ../oauth2-dingtalk)由 3p 预设注入,本类不感知具体 IdP。
|
|
25
|
+
*/
|
|
26
|
+
declare class OAuth2Client {
|
|
27
|
+
private config;
|
|
28
|
+
private fetchImpl;
|
|
29
|
+
private stateTtl;
|
|
30
|
+
constructor(config: OAuth2ClientConfig);
|
|
31
|
+
/** 生成 authorize URL(state 入 kv),returnTo 编码进 state 关联存储 */
|
|
32
|
+
authorizeUrl(returnTo?: string): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* 回调:state 一次性消费校验 → code 换 token → 拉 userinfo → mapUser
|
|
35
|
+
* 返回 { user, returnTo },供 hono 路由建 session 后跳转
|
|
36
|
+
*/
|
|
37
|
+
callback(params: {
|
|
38
|
+
code: string;
|
|
39
|
+
state?: string;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
user: AuthUser;
|
|
42
|
+
returnTo: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** 内部:code 换 token(POST tokenUrl,form 编码) */
|
|
45
|
+
protected exchangeToken(code: string): Promise<TokenResponse>;
|
|
46
|
+
/** 内部:Bearer 拉 userinfo */
|
|
47
|
+
protected fetchUserinfo(accessToken: string): Promise<unknown>;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/identity/resolver.d.ts
|
|
51
|
+
interface IdentityResolverOptions {
|
|
52
|
+
/** 首次 OAuth2 登录时自动建户(默认 true) */
|
|
53
|
+
jit?: boolean;
|
|
54
|
+
}
|
|
55
|
+
interface ResolveProfile {
|
|
56
|
+
nickname?: string;
|
|
57
|
+
avatarUrl?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Identity Resolver 接口。
|
|
61
|
+
* App 可自行实现(如拒绝新注册、合并账户、审批制等)。
|
|
62
|
+
*/
|
|
63
|
+
interface IdentityResolver {
|
|
64
|
+
resolve(provider: string, providerSub: string, profile: ResolveProfile): Promise<LocalUser>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 默认实现:查 link → 有则返回已有 user,无则 JIT 建户+建 link。
|
|
68
|
+
* 关闭 JIT 时,无 link 抛错。
|
|
69
|
+
*/
|
|
70
|
+
declare function createDefaultIdentityResolver(userStore: UserStore, linkStore: OAuth2LinkStore, opts?: IdentityResolverOptions): IdentityResolver;
|
|
71
|
+
/** 身份未关联(JIT 关闭时抛出) */
|
|
72
|
+
declare class IdentityNotLinkedError extends Error {
|
|
73
|
+
readonly provider: string;
|
|
74
|
+
readonly providerSub: string;
|
|
75
|
+
constructor(provider: string, providerSub: string);
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/password/store.d.ts
|
|
79
|
+
/**
|
|
80
|
+
* 密码凭证仓储接口。
|
|
81
|
+
* Drizzle 双方言与 PostgREST 两版实现共享此接口。
|
|
82
|
+
* PasswordProvider 通过此接口操作 passwords 表,不直接依赖 DrizzleDb。
|
|
83
|
+
*/
|
|
84
|
+
interface PasswordRecord {
|
|
85
|
+
userId: string;
|
|
86
|
+
passwordHash: string;
|
|
87
|
+
createdAt: number;
|
|
88
|
+
updatedAt: number;
|
|
89
|
+
}
|
|
90
|
+
interface PasswordStore {
|
|
91
|
+
/** 按 userId 查密码记录;不存在返回 null */
|
|
92
|
+
findByUserId(userId: string): Promise<PasswordRecord | null>;
|
|
93
|
+
/** 创建密码记录(user_id 即 PK,1:1 约束) */
|
|
94
|
+
create(input: {
|
|
95
|
+
userId: string;
|
|
96
|
+
passwordHash: string;
|
|
97
|
+
}): Promise<PasswordRecord>;
|
|
98
|
+
/** 更新密码哈希 */
|
|
99
|
+
updateHash(userId: string, passwordHash: string): Promise<void>;
|
|
100
|
+
/** 删除密码记录 */
|
|
101
|
+
delete(userId: string): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/password/provider.d.ts
|
|
105
|
+
interface PasswordProviderOptions {
|
|
106
|
+
userStore: UserStore;
|
|
107
|
+
passwordStore: PasswordStore;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 用户名已被占用。register 预检 username 时抛出(跨方言确定的类型化错误),
|
|
111
|
+
* 路由层据此回 409;唯一索引是并发兜底(见 hono/password-routes.ts 的 isUniqueViolation)。
|
|
112
|
+
*/
|
|
113
|
+
declare class UsernameTakenError extends Error {
|
|
114
|
+
readonly username: string;
|
|
115
|
+
constructor(username: string);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 密码认证 Provider。
|
|
119
|
+
* 依赖 UserStore(用户 CRUD)+ PasswordStore(密码 CRUD),不直接依赖数据库实例。
|
|
120
|
+
*/
|
|
121
|
+
declare class PasswordProvider {
|
|
122
|
+
private readonly userStore;
|
|
123
|
+
private readonly passwordStore;
|
|
124
|
+
constructor(opts: PasswordProviderOptions);
|
|
125
|
+
/**
|
|
126
|
+
* 注册用户:创建 user + password 记录。
|
|
127
|
+
*
|
|
128
|
+
* username 占用走**两道防线**:
|
|
129
|
+
* 1. 预检 findByUsername → 抛类型化 UsernameTakenError(跨方言确定,路由回 409 的主路径);
|
|
130
|
+
* 2. users.username 唯一索引兜底并发(两个请求同时过预检时,create 撞唯一约束,
|
|
131
|
+
* 路由层 isUniqueViolation 识别后同样回 409)。
|
|
132
|
+
*
|
|
133
|
+
* ⚠ 两步非原子(store 抽象不暴露事务):先建 user 再建 password,若 password 创建失败,
|
|
134
|
+
* 会留下一个无密码、username 已被占用的孤儿 user(既登不进又报废了用户名)。
|
|
135
|
+
* 故 password 创建失败时 best-effort 删除刚建的 user 再抛原错误,把窗口收窄到
|
|
136
|
+
* 「user 删除也失败」这种双重故障(此时日志可见,运维可手动清理)。
|
|
137
|
+
*/
|
|
138
|
+
register(input: {
|
|
139
|
+
username: string;
|
|
140
|
+
password: string;
|
|
141
|
+
nickname?: string;
|
|
142
|
+
}): Promise<LocalUser>;
|
|
143
|
+
/**
|
|
144
|
+
* 验证用户名密码,返回用户(供 session 创建)。
|
|
145
|
+
* 用户不存在或密码错误均返回 null(响应不区分,且耗时均衡,防枚举攻击)。
|
|
146
|
+
*/
|
|
147
|
+
verify(username: string, password: string): Promise<LocalUser | null>;
|
|
148
|
+
/**
|
|
149
|
+
* 修改密码(需验证旧密码)。
|
|
150
|
+
* 旧密码错误抛错;无密码记录抛错。
|
|
151
|
+
*/
|
|
152
|
+
changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void>;
|
|
153
|
+
/** 删除密码记录(用户将无法用密码登录) */
|
|
154
|
+
deletePassword(userId: string): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
export { PasswordStore as a, IdentityResolverOptions as c, OAuth2Client as d, OAuth2ClientConfig as f, PasswordRecord as i, ResolveProfile as l, PasswordProviderOptions as n, IdentityNotLinkedError as o, UsernameTakenError as r, IdentityResolver as s, PasswordProvider as t, createDefaultIdentityResolver as u };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/identity/resolver.ts
|
|
3
|
+
/**
|
|
4
|
+
* 默认实现:查 link → 有则返回已有 user,无则 JIT 建户+建 link。
|
|
5
|
+
* 关闭 JIT 时,无 link 抛错。
|
|
6
|
+
*/
|
|
7
|
+
function createDefaultIdentityResolver(userStore, linkStore, opts = {}) {
|
|
8
|
+
const jit = opts.jit ?? true;
|
|
9
|
+
return { async resolve(provider, providerSub, profile) {
|
|
10
|
+
const existing = await linkStore.findByProvider(provider, providerSub);
|
|
11
|
+
if (existing) {
|
|
12
|
+
const user = await userStore.findById(existing.userId);
|
|
13
|
+
if (!user) await linkStore.delete(existing.id);
|
|
14
|
+
else {
|
|
15
|
+
const patch = {};
|
|
16
|
+
if (!user.nickname && profile.nickname) patch.nickname = profile.nickname;
|
|
17
|
+
if (!user.avatarUrl && profile.avatarUrl) patch.avatarUrl = profile.avatarUrl;
|
|
18
|
+
if (Object.keys(patch).length > 0) return userStore.update(user.id, patch);
|
|
19
|
+
return user;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (!jit) throw new IdentityNotLinkedError(provider, providerSub);
|
|
23
|
+
const user = await userStore.create({
|
|
24
|
+
nickname: profile.nickname ?? null,
|
|
25
|
+
avatarUrl: profile.avatarUrl ?? null
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
await linkStore.create({
|
|
29
|
+
userId: user.id,
|
|
30
|
+
provider,
|
|
31
|
+
providerSub,
|
|
32
|
+
rawProfile: JSON.stringify(profile)
|
|
33
|
+
});
|
|
34
|
+
return user;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
const winner = await linkStore.findByProvider(provider, providerSub);
|
|
37
|
+
if (winner && winner.userId === user.id) return user;
|
|
38
|
+
await userStore.delete(user.id).catch(() => {});
|
|
39
|
+
if (winner) {
|
|
40
|
+
const winnerUser = await userStore.findById(winner.userId);
|
|
41
|
+
if (winnerUser) return winnerUser;
|
|
42
|
+
}
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
} };
|
|
46
|
+
}
|
|
47
|
+
/** 身份未关联(JIT 关闭时抛出) */
|
|
48
|
+
var IdentityNotLinkedError = class extends Error {
|
|
49
|
+
provider;
|
|
50
|
+
providerSub;
|
|
51
|
+
constructor(provider, providerSub) {
|
|
52
|
+
super(`OAuth2 身份未关联: provider=${provider}, sub=${providerSub}(JIT 已关闭)`);
|
|
53
|
+
this.provider = provider;
|
|
54
|
+
this.providerSub = providerSub;
|
|
55
|
+
this.name = "IdentityNotLinkedError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/password/provider.ts
|
|
60
|
+
/**
|
|
61
|
+
* 密码认证 Provider —— 基于 scrypt 的密码哈希 + 验证。
|
|
62
|
+
*
|
|
63
|
+
* 职责:
|
|
64
|
+
* - register: 创建用户 + 密码记录
|
|
65
|
+
* - verify: 验证用户名密码,返回用户(供 session 创建)
|
|
66
|
+
* - changePassword: 修改密码(需验证旧密码)
|
|
67
|
+
* - deletePassword: 删除密码记录
|
|
68
|
+
*
|
|
69
|
+
* ⚠ scrypt 参数(N=16384, r=8, p=1)适合 2024 年硬件,未来可升级。
|
|
70
|
+
* ⚠ 密码哈希格式:$scrypt$N,r,p$salt$hash(便于未来升级算法)。
|
|
71
|
+
* ⚠ 通过 PasswordStore 接口操作数据库,不直接依赖 DrizzleDb。
|
|
72
|
+
*/
|
|
73
|
+
const SCRYPT_N = 16384;
|
|
74
|
+
const SCRYPT_R = 8;
|
|
75
|
+
const SCRYPT_P = 1;
|
|
76
|
+
const SALT_BYTES = 16;
|
|
77
|
+
const HASH_BYTES = 64;
|
|
78
|
+
const SCRYPT_OPTIONS = {
|
|
79
|
+
N: SCRYPT_N,
|
|
80
|
+
r: SCRYPT_R,
|
|
81
|
+
p: SCRYPT_P
|
|
82
|
+
};
|
|
83
|
+
/** 生成密码哈希(scrypt)。格式:$scrypt$N,r,p$salt$hash */
|
|
84
|
+
function hashPassword(password) {
|
|
85
|
+
const salt = randomBytes(SALT_BYTES);
|
|
86
|
+
const hash = scryptSync(password, salt, HASH_BYTES, SCRYPT_OPTIONS);
|
|
87
|
+
return `$scrypt$${SCRYPT_N},${SCRYPT_R},${SCRYPT_P}$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
88
|
+
}
|
|
89
|
+
/** 验证密码(timing-safe 比较) */
|
|
90
|
+
function verifyPassword(password, stored) {
|
|
91
|
+
const parts = stored.split("$");
|
|
92
|
+
if (parts.length !== 5 || parts[1] !== "scrypt") return false;
|
|
93
|
+
const params = parts[2];
|
|
94
|
+
if (!params) return false;
|
|
95
|
+
const saltB64 = parts[3];
|
|
96
|
+
const hashB64 = parts[4];
|
|
97
|
+
if (!saltB64 || !hashB64) return false;
|
|
98
|
+
const [nStr, rStr, pStr] = params.split(",");
|
|
99
|
+
const N = Number(nStr);
|
|
100
|
+
const r = Number(rStr);
|
|
101
|
+
const p = Number(pStr);
|
|
102
|
+
if (!N || !r || !p) return false;
|
|
103
|
+
const salt = Buffer.from(saltB64, "base64");
|
|
104
|
+
const expected = Buffer.from(hashB64, "base64");
|
|
105
|
+
const actual = scryptSync(password, salt, HASH_BYTES, {
|
|
106
|
+
N,
|
|
107
|
+
r,
|
|
108
|
+
p
|
|
109
|
+
});
|
|
110
|
+
return timingSafeEqual(actual, expected);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 时序均衡用的假哈希(懒生成 + 缓存)。
|
|
114
|
+
*
|
|
115
|
+
* 为什么需要:verify 在「用户不存在 / 无密码记录」时若直接返回 null,就跳过了
|
|
116
|
+
* ~50-100ms 的 scrypt 运算,而用户存在时会跑——响应时间差可被用来枚举有效用户名
|
|
117
|
+
* (即便响应体已统一)。故对不存在的用户也拿这个假哈希跑一遍 verifyPassword 再丢弃,
|
|
118
|
+
* 把两条路径的耗时拉平。首次调用付一次生成成本,之后复用缓存。
|
|
119
|
+
*/
|
|
120
|
+
let dummyHashCache;
|
|
121
|
+
function dummyHash() {
|
|
122
|
+
if (dummyHashCache === void 0) dummyHashCache = hashPassword("timing-equalization-dummy-password");
|
|
123
|
+
return dummyHashCache;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 用户名已被占用。register 预检 username 时抛出(跨方言确定的类型化错误),
|
|
127
|
+
* 路由层据此回 409;唯一索引是并发兜底(见 hono/password-routes.ts 的 isUniqueViolation)。
|
|
128
|
+
*/
|
|
129
|
+
var UsernameTakenError = class extends Error {
|
|
130
|
+
username;
|
|
131
|
+
constructor(username) {
|
|
132
|
+
super(`用户名已被注册: ${username}`);
|
|
133
|
+
this.username = username;
|
|
134
|
+
this.name = "UsernameTakenError";
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* 密码认证 Provider。
|
|
139
|
+
* 依赖 UserStore(用户 CRUD)+ PasswordStore(密码 CRUD),不直接依赖数据库实例。
|
|
140
|
+
*/
|
|
141
|
+
var PasswordProvider = class {
|
|
142
|
+
userStore;
|
|
143
|
+
passwordStore;
|
|
144
|
+
constructor(opts) {
|
|
145
|
+
this.userStore = opts.userStore;
|
|
146
|
+
this.passwordStore = opts.passwordStore;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 注册用户:创建 user + password 记录。
|
|
150
|
+
*
|
|
151
|
+
* username 占用走**两道防线**:
|
|
152
|
+
* 1. 预检 findByUsername → 抛类型化 UsernameTakenError(跨方言确定,路由回 409 的主路径);
|
|
153
|
+
* 2. users.username 唯一索引兜底并发(两个请求同时过预检时,create 撞唯一约束,
|
|
154
|
+
* 路由层 isUniqueViolation 识别后同样回 409)。
|
|
155
|
+
*
|
|
156
|
+
* ⚠ 两步非原子(store 抽象不暴露事务):先建 user 再建 password,若 password 创建失败,
|
|
157
|
+
* 会留下一个无密码、username 已被占用的孤儿 user(既登不进又报废了用户名)。
|
|
158
|
+
* 故 password 创建失败时 best-effort 删除刚建的 user 再抛原错误,把窗口收窄到
|
|
159
|
+
* 「user 删除也失败」这种双重故障(此时日志可见,运维可手动清理)。
|
|
160
|
+
*/
|
|
161
|
+
async register(input) {
|
|
162
|
+
if (await this.userStore.findByUsername(input.username)) throw new UsernameTakenError(input.username);
|
|
163
|
+
const user = await this.userStore.create({
|
|
164
|
+
username: input.username,
|
|
165
|
+
nickname: input.nickname
|
|
166
|
+
});
|
|
167
|
+
const passwordHash = hashPassword(input.password);
|
|
168
|
+
try {
|
|
169
|
+
await this.passwordStore.create({
|
|
170
|
+
userId: user.id,
|
|
171
|
+
passwordHash
|
|
172
|
+
});
|
|
173
|
+
} catch (e) {
|
|
174
|
+
try {
|
|
175
|
+
await this.userStore.delete(user.id);
|
|
176
|
+
} catch {}
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
return user;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 验证用户名密码,返回用户(供 session 创建)。
|
|
183
|
+
* 用户不存在或密码错误均返回 null(响应不区分,且耗时均衡,防枚举攻击)。
|
|
184
|
+
*/
|
|
185
|
+
async verify(username, password) {
|
|
186
|
+
const user = await this.userStore.findByUsername(username);
|
|
187
|
+
if (!user) {
|
|
188
|
+
verifyPassword(password, dummyHash());
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const record = await this.passwordStore.findByUserId(user.id);
|
|
192
|
+
if (!record) {
|
|
193
|
+
verifyPassword(password, dummyHash());
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return verifyPassword(password, record.passwordHash) ? user : null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 修改密码(需验证旧密码)。
|
|
200
|
+
* 旧密码错误抛错;无密码记录抛错。
|
|
201
|
+
*/
|
|
202
|
+
async changePassword(userId, oldPassword, newPassword) {
|
|
203
|
+
const record = await this.passwordStore.findByUserId(userId);
|
|
204
|
+
if (!record) throw new Error("密码记录不存在");
|
|
205
|
+
if (!verifyPassword(oldPassword, record.passwordHash)) throw new Error("旧密码错误");
|
|
206
|
+
const newHash = hashPassword(newPassword);
|
|
207
|
+
await this.passwordStore.updateHash(userId, newHash);
|
|
208
|
+
}
|
|
209
|
+
/** 删除密码记录(用户将无法用密码登录) */
|
|
210
|
+
async deletePassword(userId) {
|
|
211
|
+
await this.passwordStore.delete(userId);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
//#endregion
|
|
215
|
+
export { createDefaultIdentityResolver as i, UsernameTakenError as n, IdentityNotLinkedError as r, PasswordProvider as t };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { randomToken } from "@homurajs/core";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import { isSqlite } from "@homurajs/db";
|
|
4
|
+
//#region src/webauthn/credential-drizzle-store.ts
|
|
5
|
+
/**
|
|
6
|
+
* Drizzle 双方言 WebauthnCredentialStore 实现。
|
|
7
|
+
*
|
|
8
|
+
* ⚠ 列名必须与 tables.ts 的显式 snake_case 对齐
|
|
9
|
+
* (id/user_id/public_key/counter/device_type/backed_up/transports/display_name/created_at)。
|
|
10
|
+
*/
|
|
11
|
+
async function exec(db, q) {
|
|
12
|
+
if (isSqlite(db)) await db.run(q);
|
|
13
|
+
else await db.execute(q);
|
|
14
|
+
}
|
|
15
|
+
async function query(db, q) {
|
|
16
|
+
if (isSqlite(db)) return await db.all(q);
|
|
17
|
+
return await db.execute(q) ?? [];
|
|
18
|
+
}
|
|
19
|
+
function toRecord$1(row) {
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
userId: row.user_id,
|
|
23
|
+
publicKey: row.public_key,
|
|
24
|
+
counter: Number(row.counter),
|
|
25
|
+
deviceType: row.device_type,
|
|
26
|
+
backedUp: Number(row.backed_up),
|
|
27
|
+
transports: row.transports,
|
|
28
|
+
displayName: row.display_name,
|
|
29
|
+
createdAt: Number(row.created_at)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function createDrizzleWebauthnCredentialStore(db, opts) {
|
|
33
|
+
const now = opts?.now ?? (() => Date.now());
|
|
34
|
+
return {
|
|
35
|
+
async findById(id) {
|
|
36
|
+
const rows = await query(db, sql`SELECT id, user_id, public_key, counter, device_type, backed_up, transports, display_name, created_at
|
|
37
|
+
FROM webauthn_credentials WHERE id = ${id}`);
|
|
38
|
+
return rows[0] ? toRecord$1(rows[0]) : null;
|
|
39
|
+
},
|
|
40
|
+
async findByUserId(userId) {
|
|
41
|
+
return (await query(db, sql`SELECT id, user_id, public_key, counter, device_type, backed_up, transports, display_name, created_at
|
|
42
|
+
FROM webauthn_credentials WHERE user_id = ${userId}`)).map(toRecord$1);
|
|
43
|
+
},
|
|
44
|
+
async create(input) {
|
|
45
|
+
const ts = now();
|
|
46
|
+
await exec(db, sql`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, device_type, backed_up, transports, display_name, created_at)
|
|
47
|
+
VALUES (${input.id}, ${input.userId}, ${input.publicKey}, ${input.counter}, ${input.deviceType}, ${input.backedUp}, ${input.transports ?? null}, ${input.displayName ?? null}, ${ts})`);
|
|
48
|
+
return {
|
|
49
|
+
id: input.id,
|
|
50
|
+
userId: input.userId,
|
|
51
|
+
publicKey: input.publicKey,
|
|
52
|
+
counter: input.counter,
|
|
53
|
+
deviceType: input.deviceType,
|
|
54
|
+
backedUp: input.backedUp,
|
|
55
|
+
transports: input.transports ?? null,
|
|
56
|
+
displayName: input.displayName ?? null,
|
|
57
|
+
createdAt: ts
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async updateCounter(id, counter) {
|
|
61
|
+
await exec(db, sql`UPDATE webauthn_credentials SET counter = ${counter} WHERE id = ${id}`);
|
|
62
|
+
},
|
|
63
|
+
async delete(id) {
|
|
64
|
+
await exec(db, sql`DELETE FROM webauthn_credentials WHERE id = ${id}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/webauthn/credential-rest-store.ts
|
|
70
|
+
const WEBAUTHN_CREDENTIALS_TABLE = "webauthn_credentials";
|
|
71
|
+
function toRecord(row) {
|
|
72
|
+
return {
|
|
73
|
+
id: row.id,
|
|
74
|
+
userId: row.user_id,
|
|
75
|
+
publicKey: row.public_key,
|
|
76
|
+
counter: Number(row.counter),
|
|
77
|
+
deviceType: row.device_type,
|
|
78
|
+
backedUp: Number(row.backed_up),
|
|
79
|
+
transports: row.transports,
|
|
80
|
+
displayName: row.display_name,
|
|
81
|
+
createdAt: Number(row.created_at)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function createRestWebauthnCredentialStore(rest, opts = {}) {
|
|
85
|
+
const t = rest.table(opts.table ?? "webauthn_credentials");
|
|
86
|
+
const now = opts.now ?? (() => Date.now());
|
|
87
|
+
return {
|
|
88
|
+
async findById(id) {
|
|
89
|
+
const row = (await t.select({ id }, { limit: 1 }))[0];
|
|
90
|
+
return row ? toRecord(row) : null;
|
|
91
|
+
},
|
|
92
|
+
async findByUserId(userId) {
|
|
93
|
+
return (await t.select({ user_id: userId })).map(toRecord);
|
|
94
|
+
},
|
|
95
|
+
async create(input) {
|
|
96
|
+
const ts = now();
|
|
97
|
+
const row = (await t.insert({
|
|
98
|
+
id: input.id,
|
|
99
|
+
user_id: input.userId,
|
|
100
|
+
public_key: input.publicKey,
|
|
101
|
+
counter: input.counter,
|
|
102
|
+
device_type: input.deviceType,
|
|
103
|
+
backed_up: input.backedUp,
|
|
104
|
+
transports: input.transports ?? null,
|
|
105
|
+
display_name: input.displayName ?? null,
|
|
106
|
+
created_at: ts
|
|
107
|
+
}))[0];
|
|
108
|
+
if (!row) throw new Error("insert failed");
|
|
109
|
+
return toRecord(row);
|
|
110
|
+
},
|
|
111
|
+
async updateCounter(id, counter) {
|
|
112
|
+
await t.update({ counter }, { id });
|
|
113
|
+
},
|
|
114
|
+
async delete(id) {
|
|
115
|
+
await t.remove({ id });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/webauthn/provider.ts
|
|
121
|
+
/**
|
|
122
|
+
* WebAuthn Provider —— 封装 @simplewebauthn/server 的注册/认证 ceremony。
|
|
123
|
+
*
|
|
124
|
+
* @simplewebauthn/server 是 optional peer dep,通过动态 import 加载——
|
|
125
|
+
* 不用 WebAuthn 的 App 不需要安装它。
|
|
126
|
+
*
|
|
127
|
+
* 流程:
|
|
128
|
+
* 注册:generateRegistrationOptions → 客户端 attestation → verifyRegistrationResponse → store credential
|
|
129
|
+
* 认证:generateAuthenticationOptions → 客户端 assertion → verifyAuthenticationResponse → 更新 counter
|
|
130
|
+
*
|
|
131
|
+
* challenge 存储复用 core 的 TtlKv(与 OAuth2 state 同一机制)。
|
|
132
|
+
*/
|
|
133
|
+
const DEFAULT_CHALLENGE_TTL = 3e5;
|
|
134
|
+
var WebAuthProvider = class {
|
|
135
|
+
config;
|
|
136
|
+
challengeTtl;
|
|
137
|
+
nowFn;
|
|
138
|
+
constructor(config) {
|
|
139
|
+
this.config = config;
|
|
140
|
+
this.challengeTtl = config.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL;
|
|
141
|
+
this.nowFn = config.now ?? (() => Date.now());
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 生成注册 options(已登录用户发起)。
|
|
145
|
+
* challenge 存入 TtlKv(key = userId:challengeType),TTL 5min。
|
|
146
|
+
*/
|
|
147
|
+
async generateRegistrationOptions(userId) {
|
|
148
|
+
const { generateRegistrationOptions } = await import("@simplewebauthn/server");
|
|
149
|
+
const user = await this.config.userStore.findById(userId);
|
|
150
|
+
if (!user) throw new Error("user 不存在: " + userId);
|
|
151
|
+
const existingCreds = await this.config.credentialStore.findByUserId(userId);
|
|
152
|
+
const options = await generateRegistrationOptions({
|
|
153
|
+
rpName: this.config.rpName,
|
|
154
|
+
rpID: this.config.rpId,
|
|
155
|
+
userID: new TextEncoder().encode(userId),
|
|
156
|
+
userName: user.username ?? user.nickname ?? user.id,
|
|
157
|
+
userDisplayName: user.nickname ?? user.username ?? user.id,
|
|
158
|
+
attestationType: "none",
|
|
159
|
+
excludeCredentials: existingCreds.map((c) => ({
|
|
160
|
+
id: c.id,
|
|
161
|
+
transports: c.transports ? JSON.parse(c.transports) : void 0
|
|
162
|
+
})),
|
|
163
|
+
authenticatorSelection: {
|
|
164
|
+
userVerification: "preferred",
|
|
165
|
+
residentKey: "preferred"
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
await this.config.challengeKv.set(`${userId}:register`, options.challenge, this.challengeTtl);
|
|
169
|
+
return options;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 验证注册响应并存储凭证。
|
|
173
|
+
* challenge 一次性消费(take 语义)。
|
|
174
|
+
*/
|
|
175
|
+
async verifyRegistration(userId, response, displayName) {
|
|
176
|
+
const { verifyRegistrationResponse } = await import("@simplewebauthn/server");
|
|
177
|
+
const challenge = await this.config.challengeKv.take(`${userId}:register`);
|
|
178
|
+
if (!challenge) throw new Error("challenge 无效或已过期");
|
|
179
|
+
const verification = await verifyRegistrationResponse({
|
|
180
|
+
response,
|
|
181
|
+
expectedChallenge: challenge,
|
|
182
|
+
expectedOrigin: this.config.origin,
|
|
183
|
+
expectedRPID: this.config.rpId
|
|
184
|
+
});
|
|
185
|
+
if (!verification.verified || !verification.registrationInfo) throw new Error("注册验证失败");
|
|
186
|
+
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
187
|
+
const credId = typeof credential.id === "string" ? credential.id : Buffer.from(credential.id).toString("base64url");
|
|
188
|
+
const publicKey = typeof credential.publicKey === "string" ? credential.publicKey : Buffer.from(credential.publicKey).toString("base64url");
|
|
189
|
+
await this.config.credentialStore.create({
|
|
190
|
+
id: credId,
|
|
191
|
+
userId,
|
|
192
|
+
publicKey,
|
|
193
|
+
counter: credential.counter,
|
|
194
|
+
deviceType: credentialDeviceType,
|
|
195
|
+
backedUp: credentialBackedUp ? 1 : 0,
|
|
196
|
+
transports: response.response.transports ? JSON.stringify(response.response.transports) : null,
|
|
197
|
+
displayName: displayName ?? null
|
|
198
|
+
});
|
|
199
|
+
return { id: credId };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 生成认证 options(未登录用户发起)。
|
|
203
|
+
* challenge 存入 TtlKv,返回 { options, challengeKey }——challengeKey 必须由调用方
|
|
204
|
+
* 在 verifyAuthentication 时回传,否则匿名(discoverable credential)流程无法定位 challenge:
|
|
205
|
+
* 匿名场景下 options 阶段还不知道是哪个用户/凭证,key 只能是 `anon:${random}`,
|
|
206
|
+
* 而 verify 阶段是按 response.id 反查凭证的,两条腿对不上同一个 key。
|
|
207
|
+
* 已知 userId 时 key 固定为 `${userId}:auth`,回传与否都能命中(向后兼容)。
|
|
208
|
+
*/
|
|
209
|
+
async generateAuthenticationOptions(userId) {
|
|
210
|
+
const { generateAuthenticationOptions } = await import("@simplewebauthn/server");
|
|
211
|
+
let allowCredentials = [];
|
|
212
|
+
let challengeKey;
|
|
213
|
+
if (userId) {
|
|
214
|
+
allowCredentials = (await this.config.credentialStore.findByUserId(userId)).map((c) => ({
|
|
215
|
+
id: c.id,
|
|
216
|
+
transports: c.transports ? JSON.parse(c.transports) : void 0
|
|
217
|
+
}));
|
|
218
|
+
challengeKey = `${userId}:auth`;
|
|
219
|
+
} else challengeKey = `anon:${randomToken(16)}`;
|
|
220
|
+
const options = await generateAuthenticationOptions({
|
|
221
|
+
rpID: this.config.rpId,
|
|
222
|
+
allowCredentials,
|
|
223
|
+
userVerification: "preferred"
|
|
224
|
+
});
|
|
225
|
+
await this.config.challengeKv.set(challengeKey, options.challenge, this.challengeTtl);
|
|
226
|
+
return {
|
|
227
|
+
options,
|
|
228
|
+
challengeKey
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 验证认证响应,返回已验证的用户(供 session 创建)。
|
|
233
|
+
* challenge 一次性消费。
|
|
234
|
+
*
|
|
235
|
+
* @param challengeKey generateAuthenticationOptions 返回的 key。匿名流程**必须**回传;
|
|
236
|
+
* 已知 userId 的流程可省略(缺省按 `${cred.userId}:auth` 取)。
|
|
237
|
+
*/
|
|
238
|
+
async verifyAuthentication(response, challengeKey) {
|
|
239
|
+
const { verifyAuthenticationResponse } = await import("@simplewebauthn/server");
|
|
240
|
+
const cred = await this.config.credentialStore.findById(response.id);
|
|
241
|
+
if (!cred) throw new Error("凭证不存在: " + response.id);
|
|
242
|
+
const key = challengeKey ?? `${cred.userId}:auth`;
|
|
243
|
+
const challenge = await this.config.challengeKv.take(key);
|
|
244
|
+
if (!challenge) throw new Error("challenge 无效或已过期");
|
|
245
|
+
const verification = await verifyAuthenticationResponse({
|
|
246
|
+
response,
|
|
247
|
+
expectedChallenge: challenge,
|
|
248
|
+
expectedOrigin: this.config.origin,
|
|
249
|
+
expectedRPID: this.config.rpId,
|
|
250
|
+
credential: {
|
|
251
|
+
id: cred.id,
|
|
252
|
+
publicKey: Buffer.from(cred.publicKey, "base64url"),
|
|
253
|
+
counter: cred.counter,
|
|
254
|
+
transports: cred.transports ? JSON.parse(cred.transports) : void 0
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
if (!verification.verified) throw new Error("认证验证失败");
|
|
258
|
+
await this.config.credentialStore.updateCounter(cred.id, verification.authenticationInfo.newCounter);
|
|
259
|
+
const user = await this.config.userStore.findById(cred.userId);
|
|
260
|
+
if (!user) throw new Error("user 不存在: " + cred.userId);
|
|
261
|
+
return user;
|
|
262
|
+
}
|
|
263
|
+
/** 列出用户的所有凭证(管理用) */
|
|
264
|
+
async listCredentials(userId) {
|
|
265
|
+
return this.config.credentialStore.findByUserId(userId);
|
|
266
|
+
}
|
|
267
|
+
/** 删除凭证 */
|
|
268
|
+
async deleteCredential(userId, credentialId) {
|
|
269
|
+
const cred = await this.config.credentialStore.findById(credentialId);
|
|
270
|
+
if (!cred) throw new Error("凭证不存在");
|
|
271
|
+
if (cred.userId !== userId) throw new Error("凭证不属于该用户");
|
|
272
|
+
await this.config.credentialStore.delete(credentialId);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
//#endregion
|
|
276
|
+
export { createDrizzleWebauthnCredentialStore as i, WEBAUTHN_CREDENTIALS_TABLE as n, createRestWebauthnCredentialStore as r, WebAuthProvider as t };
|