loginbase 1.3.0 → 1.5.0
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 +8 -1
- package/dist/config.d.ts +27 -0
- package/dist/demo_account.d.ts +6 -0
- package/dist/demo_account.js +11 -0
- package/dist/handler.js +87 -18
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/plugins/github.js +101 -8
- package/dist/rate_limit.d.ts +5 -0
- package/dist/rate_limit.js +3 -3
- package/dist/stats.d.ts +28 -0
- package/dist/stats.js +94 -0
- package/migrations/0002_auth_events.sql +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
loginbase/ # 本仓:TS 服务端库 + 协议契约
|
|
18
18
|
├── src/ # TS 服务端库(Hono sub-app 工厂),从 Tono-Server 平移
|
|
19
19
|
├── test/ # vitest
|
|
20
|
+
├── queries/ # 登录统计取数 SQL(人肉执行;口径见 docs/stats-design.md)
|
|
20
21
|
├── docs/
|
|
21
22
|
│ ├── design.md # 路线与仓库设计决策
|
|
22
23
|
│ ├── server-design.md # 服务端技术方案(公共 API/协议草案/会话模型/平移策略)
|
|
23
24
|
│ ├── plan.md # 实施计划(五步任务清单/验收点/版本线)
|
|
24
25
|
│ ├── naming.md # 命名讨论记录
|
|
25
26
|
│ ├── protocol.md # API 契约(唯一权威)——代码平移时落笔
|
|
27
|
+
│ ├── email-identity.md # 邮箱与身份锚点:**接入方必读**(GitHub 邮箱模型 / 为何不该拿邮箱找号 / 业界做法)
|
|
26
28
|
│ └── logto-替换方案-调研.md # 背景调研(自 TrendingProjects 迁入)
|
|
27
29
|
└── README.md
|
|
28
30
|
|
|
@@ -53,5 +55,10 @@ Kotlin 包 wang.harlon.loginbase
|
|
|
53
55
|
|
|
54
56
|
## 设计红线
|
|
55
57
|
|
|
56
|
-
-
|
|
58
|
+
- 依赖准入(2026-08-18 由「依赖最小集」改判):auth 库是供应链攻击的最高价值目标,所以**审查来源,而不是一味压数量**。放行三类,其余一律先停下来问值不值:
|
|
59
|
+
1. 现有基座——服务端 hono + jose(+ zod-validator),客户端 ktor-client-core + kotlinx-serialization-json + kotlinx-coroutines-core;
|
|
60
|
+
2. 业界权威库——四条判据全满足:① 生态事实标准,组织或多人维护、发布节奏稳定;② 发布带 provenance / trusted publishing(npm)或签名(Maven Central);③ 传递依赖 ≤ 2 且同样满足 ①;④ 无安装脚本;
|
|
61
|
+
3. 自己的库(`HarlonWang/*`)——同样走 trusted publishing 发布,在库里优先声明为 peerDependency,由消费方定版本
|
|
62
|
+
|
|
63
|
+
仍然拒绝:为省几十行代码的工具包、单人维护的新包或小众包、运行时联网或带安装脚本的包、为一个功能拖进整个框架的包。版本钉死 + lockfile + provenance 不变
|
|
57
64
|
- 协议变更:服务端实现 + `docs/protocol.md` 同一个 commit,并在 `loginbase-kt` 仓开跟进 issue,客户端版本落地前不关(2026-08-13 由 monorepo 三位一体改判,理由见 design.md)
|
package/dist/config.d.ts
CHANGED
|
@@ -100,6 +100,33 @@ export interface LoginConfig {
|
|
|
100
100
|
/** refresh 会话滑动过期(每次轮换重新起算);null/缺省 = 不过期 */
|
|
101
101
|
refreshTtlMs?: number | null;
|
|
102
102
|
};
|
|
103
|
+
/**
|
|
104
|
+
* 登录统计(方案见 docs/stats-design.md)。事件写入 `db` 的 `auth_events` 表,
|
|
105
|
+
* 不引入新 binding、不加依赖。
|
|
106
|
+
*
|
|
107
|
+
* **默认开启**——数据不能补录,忘配开关就是白丢一段时期的数据;代价(升级后
|
|
108
|
+
* 未跑 migration 0002)由 stats.ts 的三条 fail-safe 兜住:异常吞掉、异步写、
|
|
109
|
+
* 首次失败告警一次。
|
|
110
|
+
*/
|
|
111
|
+
stats?: {
|
|
112
|
+
/** 默认 true;置 false 则一条统计都不写 */
|
|
113
|
+
enabled?: boolean;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* 应用商店审核用的演示账号(Play 与 App Store 共用一个):命中该邮箱时
|
|
117
|
+
* `/code/send` 不真实发信、验证码恒为该固定值;其余一切(限流、存码、
|
|
118
|
+
* verify、建会话)与常规账号完全一致。动因:无密码登录交不出商店要的
|
|
119
|
+
* 静态凭据,TrendingAI 1.4.1 因此被 Play 拒过。**未配置即完全不存在这条路径。**
|
|
120
|
+
*
|
|
121
|
+
* 该码等同这个账号的永久密码:**必须走 secret、不得入库**,且**不能用真实
|
|
122
|
+
* 用户的邮箱**(否则谁拿到码就能登进那个真人的账号)。
|
|
123
|
+
*/
|
|
124
|
+
demoAccount?: {
|
|
125
|
+
/** 比对时 trim + 转小写 */
|
|
126
|
+
email: string;
|
|
127
|
+
/** 与真码同形(6 位数字),客户端才无需特判;trim 后非 6 位数字视同未配置 */
|
|
128
|
+
code: string;
|
|
129
|
+
};
|
|
103
130
|
onVerified: OnVerified;
|
|
104
131
|
/** 提供后才启用 link 端点(未提供 → 404 not_configured,默认关闭、显式启用) */
|
|
105
132
|
onLinked?: OnLinked;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 解析该邮箱是否命中演示账号:命中返回解析结果,否则 null。
|
|
2
|
+
* email 须已 trim+小写。未配置恒 null。 */
|
|
3
|
+
export function matchDemoAccount(config, email) {
|
|
4
|
+
const demo = config.demoAccount;
|
|
5
|
+
if (!demo || demo.email.trim().toLowerCase() !== email)
|
|
6
|
+
return null;
|
|
7
|
+
const code = demo.code.trim();
|
|
8
|
+
// 配错的码(空串/非 6 位数字)视同未配置:verify 把缺失的 code 字段读成
|
|
9
|
+
// 空串,空演示码等于免凭据登录——宁可演示账号登不进,也不能开这个口
|
|
10
|
+
return /^\d{6}$/.test(code) ? { code } : null;
|
|
11
|
+
}
|
package/dist/handler.js
CHANGED
|
@@ -9,13 +9,17 @@ import { createSession, hashRefreshToken, findSession, rotateSession, revokeFami
|
|
|
9
9
|
import { signAccessToken, ACCESS_TTL_SECONDS } from "./token.js";
|
|
10
10
|
import { createAuthMiddleware } from "./middleware.js";
|
|
11
11
|
import { logEvent } from "./log.js";
|
|
12
|
+
import { createTracker } from "./stats.js";
|
|
12
13
|
import { trimmedField } from "./body.js";
|
|
14
|
+
import { matchDemoAccount } from "./demo_account.js";
|
|
13
15
|
import { registerGithubOauth } from "./plugins/github.js";
|
|
14
16
|
export function createAuthApp(getConfig, basePath) {
|
|
15
17
|
const authMiddleware = createAuthMiddleware(getConfig);
|
|
16
18
|
const auth = new Hono().basePath(basePath);
|
|
17
19
|
const cfg = (c) => getConfig(c.env);
|
|
18
20
|
const emit = (c) => cfg(c).onEvent ?? logEvent;
|
|
21
|
+
// 统计事件统一出口:内部先喂 onEvent(形态与 1.3.0 一致),再异步落 auth_events
|
|
22
|
+
const track = createTracker(getConfig);
|
|
19
23
|
const accessTtl = (c) => cfg(c).jwt.accessTtlSeconds ?? ACCESS_TTL_SECONDS;
|
|
20
24
|
const sessionExpiry = (c) => {
|
|
21
25
|
const ttl = cfg(c).session?.refreshTtlMs ?? null;
|
|
@@ -30,28 +34,46 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
30
34
|
if (!/^\S+@\S+\.\S+$/.test(raw)) {
|
|
31
35
|
return c.json({ error: "invalid_email" }, 400);
|
|
32
36
|
}
|
|
37
|
+
// 演示账号(见 demo_account.ts):码为固定值且不真实发信,其余与常规一致。
|
|
38
|
+
// 解析放在限流之前,撞限流的演示流量才带得上 demo 标
|
|
39
|
+
const demo = matchDemoAccount(cfg(c), raw);
|
|
33
40
|
const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
|
|
34
41
|
const rl = await checkSendRateLimit(cfg(c).kv, raw, ip);
|
|
35
42
|
if (!rl.allowed) {
|
|
43
|
+
// 命中限流不只是滥用信号,更是「用户发不出码」的体验问题——分层记录才知道
|
|
44
|
+
// 是自己手快(cooldown)还是真被挡住(email / ip 窗口)
|
|
45
|
+
track(c, {
|
|
46
|
+
event: "rate_limited",
|
|
47
|
+
outcome: rl.layer,
|
|
48
|
+
meta: { endpoint: "code_send", ...(demo ? { demo: true } : {}) },
|
|
49
|
+
});
|
|
36
50
|
return c.json({ error: "too_many_requests", retryAfterSeconds: rl.retryAfterSeconds }, 429);
|
|
37
51
|
}
|
|
38
|
-
const code = generateCode();
|
|
52
|
+
const code = demo?.code ?? generateCode();
|
|
39
53
|
const emitEvent = emit(c);
|
|
40
54
|
warnEmailConfigOnce(cfg(c).email, emitEvent);
|
|
41
55
|
const locale = resolveEmailLocale(cfg(c).email, body.locale);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
if (demo === null) {
|
|
57
|
+
try {
|
|
58
|
+
await sendCodeEmail(cfg(c).email, raw, code, locale.locale);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// 发信失败此前不留任何痕迹;邮件是邮箱登录的命脉,断了整条链就没了。
|
|
62
|
+
// Resend 的 HTTP 码在错误串里,聚合时再解析(不为此改造 email.ts 的抛错形态)。
|
|
63
|
+
track(c, { event: "code_send_failed", meta: { message: String(err) } });
|
|
64
|
+
return c.json({ error: "internal" }, 500);
|
|
65
|
+
}
|
|
47
66
|
}
|
|
48
67
|
// 静默回落意味着「为什么收到英文邮件」在别处查不出来,故选中语言必须留痕
|
|
49
|
-
|
|
68
|
+
track(c, {
|
|
50
69
|
event: "code_sent",
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
meta: {
|
|
71
|
+
...(demo ? { demo: true } : {}),
|
|
72
|
+
locale: {
|
|
73
|
+
resolved: locale.locale,
|
|
74
|
+
...(locale.requested ? { requested: locale.requested } : {}),
|
|
75
|
+
...(locale.fallback ? { fallback: true } : {}),
|
|
76
|
+
},
|
|
55
77
|
},
|
|
56
78
|
});
|
|
57
79
|
await storeCode(cfg(c).kv, raw, code);
|
|
@@ -64,15 +86,24 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
64
86
|
.catch(() => ({}));
|
|
65
87
|
const email = trimmedField(body.email).toLowerCase();
|
|
66
88
|
const code = trimmedField(body.code);
|
|
89
|
+
// 演示账号在 verify 无任何行为分叉;仅给事件补 meta.demo,
|
|
90
|
+
// 否则审核员的登录会混进真实登录漏斗(code_verify 不带 userId,事后剔不掉)
|
|
91
|
+
const isDemo = matchDemoAccount(cfg(c), email) !== null;
|
|
92
|
+
const t = (e) => track(c, isDemo ? { ...e, meta: { ...e.meta, demo: true } } : e);
|
|
67
93
|
const stored = await readCode(cfg(c).kv, email);
|
|
68
|
-
if (!stored)
|
|
94
|
+
if (!stored) {
|
|
95
|
+
// 过期、已焚、从未发过、已用过——在 KV 层都表现为读不到,无法再分(C3 口径)
|
|
96
|
+
t({ event: "code_verify", outcome: "code_not_found" });
|
|
69
97
|
return c.json({ error: "code_expired" }, 400);
|
|
98
|
+
}
|
|
70
99
|
if (stored.code !== code) {
|
|
71
100
|
const attempts = await incrementAttempts(cfg(c).kv, email, stored);
|
|
72
101
|
if (attempts >= MAX_ATTEMPTS) {
|
|
73
102
|
await deleteCode(cfg(c).kv, email);
|
|
103
|
+
t({ event: "code_verify", outcome: "too_many_attempts" });
|
|
74
104
|
return c.json({ error: "too_many_attempts" }, 429);
|
|
75
105
|
}
|
|
106
|
+
t({ event: "code_verify", outcome: "invalid_code" });
|
|
76
107
|
return c.json({ error: "invalid_code" }, 400);
|
|
77
108
|
}
|
|
78
109
|
await deleteCode(cfg(c).kv, email);
|
|
@@ -89,6 +120,7 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
89
120
|
});
|
|
90
121
|
}
|
|
91
122
|
catch {
|
|
123
|
+
t({ event: "code_verify", outcome: "internal" });
|
|
92
124
|
return c.json({ error: "internal" }, 500);
|
|
93
125
|
}
|
|
94
126
|
const { sessionId, refreshToken } = await createSession(cfg(c).db, {
|
|
@@ -98,6 +130,15 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
98
130
|
expiresAt: sessionExpiry(c),
|
|
99
131
|
});
|
|
100
132
|
const accessToken = await signAccessToken(cfg(c).jwt.secret, verified.userId, sessionId, accessTtl(c));
|
|
133
|
+
t({ event: "code_verify", outcome: "ok" });
|
|
134
|
+
// 「登录成功」的唯一口径落点:客户端拿到 token 对。GitHub 轨的同名事件在
|
|
135
|
+
// /oauth/exchange 发(那里才是客户端真正拿到 token 的时刻)。
|
|
136
|
+
t({
|
|
137
|
+
event: "login",
|
|
138
|
+
provider: "email",
|
|
139
|
+
userId: verified.userId,
|
|
140
|
+
...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
|
|
141
|
+
});
|
|
101
142
|
return c.json({
|
|
102
143
|
accessToken,
|
|
103
144
|
refreshToken,
|
|
@@ -111,11 +152,17 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
111
152
|
.catch(() => ({}));
|
|
112
153
|
const token = trimmedField(body.refreshToken);
|
|
113
154
|
if (!token) {
|
|
155
|
+
track(c, { event: "refresh", outcome: "invalid", meta: { reason: "missing_token" } });
|
|
114
156
|
return c.json({ error: "invalid_refresh_token", reason: "missing_token" }, 401);
|
|
115
157
|
}
|
|
116
158
|
const sessionId = await hashRefreshToken(token);
|
|
117
159
|
const row = await findSession(cfg(c).db, sessionId);
|
|
118
160
|
if (!row) {
|
|
161
|
+
track(c, {
|
|
162
|
+
event: "refresh",
|
|
163
|
+
outcome: "invalid",
|
|
164
|
+
meta: { reason: "session_not_found" },
|
|
165
|
+
});
|
|
119
166
|
return c.json({ error: "invalid_refresh_token", reason: "session_not_found" }, 401);
|
|
120
167
|
}
|
|
121
168
|
if (row.revoked_at !== null) {
|
|
@@ -124,37 +171,56 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
124
171
|
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
125
172
|
const rescue = await tryRescueSession(cfg(c).db, row, { userAgent, ip }, sessionExpiry(c));
|
|
126
173
|
if (rescue.status === "rescued") {
|
|
127
|
-
|
|
174
|
+
track(c, {
|
|
128
175
|
event: "refresh",
|
|
129
176
|
outcome: "rescued",
|
|
130
177
|
userId: row.user_id,
|
|
131
|
-
familyId: row.family_id,
|
|
132
|
-
ip: ip ?? null,
|
|
178
|
+
meta: { familyId: row.family_id },
|
|
179
|
+
hookOnly: { ip: ip ?? null },
|
|
133
180
|
});
|
|
134
181
|
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, rescue.session.sessionId, accessTtl(c));
|
|
135
182
|
return c.json({ accessToken, refreshToken: rescue.session.refreshToken });
|
|
136
183
|
}
|
|
137
184
|
// 救活不成立 → 判定真重用,撤销整条会话链。guardrail 与 not_eligible 分开记录。
|
|
138
185
|
await revokeFamily(cfg(c).db, row.family_id);
|
|
139
|
-
|
|
186
|
+
track(c, {
|
|
140
187
|
event: "refresh",
|
|
141
188
|
outcome: rescue.status === "guardrail" ? "guardrail_revoked" : "reuse_revoked",
|
|
142
189
|
userId: row.user_id,
|
|
143
|
-
familyId: row.family_id,
|
|
144
|
-
ip: ip ?? null,
|
|
190
|
+
meta: { familyId: row.family_id },
|
|
191
|
+
hookOnly: { ip: ip ?? null },
|
|
145
192
|
});
|
|
146
193
|
return c.json({ error: "invalid_refresh_token", reason: "session_revoked" }, 401);
|
|
147
194
|
}
|
|
148
195
|
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
196
|
+
track(c, {
|
|
197
|
+
event: "refresh",
|
|
198
|
+
outcome: "invalid",
|
|
199
|
+
userId: row.user_id,
|
|
200
|
+
meta: { reason: "session_expired" },
|
|
201
|
+
});
|
|
149
202
|
return c.json({ error: "invalid_refresh_token", reason: "session_expired" }, 401);
|
|
150
203
|
}
|
|
151
204
|
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
152
205
|
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
153
206
|
const next = await rotateSession(cfg(c).db, sessionId, { userAgent, ip }, sessionExpiry(c));
|
|
154
207
|
if (!next) {
|
|
208
|
+
track(c, {
|
|
209
|
+
event: "refresh",
|
|
210
|
+
outcome: "invalid",
|
|
211
|
+
userId: row.user_id,
|
|
212
|
+
meta: { reason: "rotate_failed" },
|
|
213
|
+
});
|
|
155
214
|
return c.json({ error: "invalid_refresh_token", reason: "rotate_failed" }, 401);
|
|
156
215
|
}
|
|
157
216
|
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, next.sessionId, accessTtl(c));
|
|
217
|
+
// 成功续期此前不发事件,救活率(F2)因此一直没有分母
|
|
218
|
+
track(c, {
|
|
219
|
+
event: "refresh",
|
|
220
|
+
outcome: "ok",
|
|
221
|
+
userId: row.user_id,
|
|
222
|
+
meta: { familyId: row.family_id },
|
|
223
|
+
});
|
|
158
224
|
return c.json({ accessToken, refreshToken: next.refreshToken });
|
|
159
225
|
});
|
|
160
226
|
auth.delete("/sessions", authMiddleware, async (c) => {
|
|
@@ -162,11 +228,14 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
162
228
|
if (!sessionId)
|
|
163
229
|
return c.json({ error: "unauthorized" }, 401);
|
|
164
230
|
await revokeSession(cfg(c).db, sessionId);
|
|
231
|
+
// 主动登出与「被轮换而作废」在 sessions 表里长得很像,只有事件能干净区分
|
|
232
|
+
track(c, { event: "session_revoked", outcome: "current", userId: c.get("userId") });
|
|
165
233
|
return c.body(null, 204);
|
|
166
234
|
});
|
|
167
235
|
auth.delete("/sessions/all", authMiddleware, async (c) => {
|
|
168
236
|
const userId = c.get("userId");
|
|
169
237
|
await revokeAllForUser(cfg(c).db, userId);
|
|
238
|
+
track(c, { event: "session_revoked", outcome: "all", userId });
|
|
170
239
|
return c.body(null, 204);
|
|
171
240
|
});
|
|
172
241
|
registerGithubOauth(auth, getConfig, basePath);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/plugins/github.js
CHANGED
|
@@ -2,6 +2,7 @@ import { trimmedField } from "../body.js";
|
|
|
2
2
|
import { createAuthMiddleware } from "../middleware.js";
|
|
3
3
|
import { generateRefreshToken as randomToken } from "../session.js";
|
|
4
4
|
import { createSession } from "../session.js";
|
|
5
|
+
import { createTracker } from "../stats.js";
|
|
5
6
|
import { signAccessToken, ACCESS_TTL_SECONDS } from "../token.js";
|
|
6
7
|
const GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize";
|
|
7
8
|
const GITHUB_TOKEN = "https://github.com/login/oauth/access_token";
|
|
@@ -122,6 +123,7 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
122
123
|
const cfg = (c) => getConfig(c.env);
|
|
123
124
|
const github = (c) => cfg(c).socials?.github;
|
|
124
125
|
const authMiddleware = createAuthMiddleware(getConfig);
|
|
126
|
+
const track = createTracker(getConfig);
|
|
125
127
|
const callbackUrlFor = (c, gh) => gh.callbackUrl ??
|
|
126
128
|
`${new URL(c.req.url).origin}${basePath}/oauth/github/callback`;
|
|
127
129
|
auth.get("/oauth/github/start", async (c) => {
|
|
@@ -130,13 +132,20 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
130
132
|
return c.json({ error: "not_configured" }, 404);
|
|
131
133
|
const redirect = c.req.query("redirect") ?? "";
|
|
132
134
|
if (!redirect || !redirectAllowed(redirect, gh)) {
|
|
135
|
+
track(c, {
|
|
136
|
+
event: "oauth_start",
|
|
137
|
+
outcome: "invalid_redirect",
|
|
138
|
+
provider: "github",
|
|
139
|
+
});
|
|
133
140
|
return c.json({ error: "invalid_redirect" }, 400);
|
|
134
141
|
}
|
|
135
142
|
const state = randomToken();
|
|
136
|
-
const
|
|
143
|
+
const flowId = crypto.randomUUID();
|
|
144
|
+
const record = { redirect, flowId };
|
|
137
145
|
await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
|
|
138
146
|
expirationTtl: STATE_TTL_SECONDS,
|
|
139
147
|
});
|
|
148
|
+
track(c, { event: "oauth_start", outcome: "ok", provider: "github", flowId });
|
|
140
149
|
return c.redirect(buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state), 302);
|
|
141
150
|
});
|
|
142
151
|
// 已登录用户绑定第二身份。**必须是 POST**:浏览器导航带不了 Authorization 头,
|
|
@@ -151,13 +160,33 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
151
160
|
.catch(() => ({}));
|
|
152
161
|
const redirect = trimmedField(body.redirect);
|
|
153
162
|
if (!redirect || !redirectAllowed(redirect, gh)) {
|
|
163
|
+
track(c, {
|
|
164
|
+
event: "oauth_start",
|
|
165
|
+
outcome: "invalid_redirect",
|
|
166
|
+
provider: "github",
|
|
167
|
+
meta: { mode: "link" },
|
|
168
|
+
});
|
|
154
169
|
return c.json({ error: "invalid_redirect" }, 400);
|
|
155
170
|
}
|
|
156
171
|
const state = randomToken();
|
|
157
|
-
const
|
|
172
|
+
const flowId = crypto.randomUUID();
|
|
173
|
+
const record = {
|
|
174
|
+
redirect,
|
|
175
|
+
mode: "link",
|
|
176
|
+
userId: c.get("userId"),
|
|
177
|
+
flowId,
|
|
178
|
+
};
|
|
158
179
|
await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
|
|
159
180
|
expirationTtl: STATE_TTL_SECONDS,
|
|
160
181
|
});
|
|
182
|
+
track(c, {
|
|
183
|
+
event: "oauth_start",
|
|
184
|
+
outcome: "ok",
|
|
185
|
+
provider: "github",
|
|
186
|
+
userId: c.get("userId"),
|
|
187
|
+
flowId,
|
|
188
|
+
meta: { mode: "link" },
|
|
189
|
+
});
|
|
161
190
|
return c.json({ authorizeUrl: buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state) }, 200);
|
|
162
191
|
});
|
|
163
192
|
auth.get("/oauth/github/callback", async (c) => {
|
|
@@ -169,14 +198,30 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
169
198
|
const stateKey = `oauth:state:${state}`;
|
|
170
199
|
const rawState = state ? await cfg(c).kv.get(stateKey) : null;
|
|
171
200
|
if (!code || !rawState) {
|
|
172
|
-
// state
|
|
201
|
+
// state 无效即回跳地址不可信,只能就地报错。
|
|
202
|
+
// 此分支拿不到 flowId——它就存在读不出来的那条 state 记录里。
|
|
203
|
+
track(c, {
|
|
204
|
+
event: "oauth_callback",
|
|
205
|
+
outcome: "invalid_state",
|
|
206
|
+
provider: "github",
|
|
207
|
+
});
|
|
173
208
|
return c.json({ error: "invalid_state" }, 400);
|
|
174
209
|
}
|
|
175
210
|
await cfg(c).kv.delete(stateKey); // 单次使用,验证即焚
|
|
176
|
-
const { redirect, mode, userId } = JSON.parse(rawState);
|
|
211
|
+
const { redirect, mode, userId, flowId } = JSON.parse(rawState);
|
|
212
|
+
const trackCallback = (outcome, meta) => track(c, {
|
|
213
|
+
event: "oauth_callback",
|
|
214
|
+
outcome,
|
|
215
|
+
provider: "github",
|
|
216
|
+
...(flowId ? { flowId } : {}),
|
|
217
|
+
...(userId ? { userId } : {}),
|
|
218
|
+
meta: { mode: mode ?? "login", ...meta },
|
|
219
|
+
});
|
|
177
220
|
const identity = await fetchGithubIdentity(gh, code);
|
|
178
|
-
if (!identity)
|
|
221
|
+
if (!identity) {
|
|
222
|
+
trackCallback("oauth_failed");
|
|
179
223
|
return c.redirect(withParam(redirect, "error", "oauth_failed"), 302);
|
|
224
|
+
}
|
|
180
225
|
const { ghUser, ghToken, email, verifiedEmails } = identity;
|
|
181
226
|
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
182
227
|
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
@@ -193,6 +238,7 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
193
238
|
const onLinked = cfg(c).onLinked;
|
|
194
239
|
// userId 由 link/start 写入,缺失只可能是载荷被篡改/降级,保守失败
|
|
195
240
|
if (!onLinked || !userId) {
|
|
241
|
+
trackCallback("internal");
|
|
196
242
|
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
197
243
|
}
|
|
198
244
|
let result;
|
|
@@ -201,22 +247,29 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
201
247
|
result = await onLinked({ userId, ...common, ...(email ? { email } : {}) });
|
|
202
248
|
}
|
|
203
249
|
catch {
|
|
250
|
+
trackCallback("internal");
|
|
204
251
|
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
205
252
|
}
|
|
206
253
|
if (!result.ok) {
|
|
207
254
|
const reason = REASON_PATTERN.test(result.reason) ? result.reason : "internal";
|
|
255
|
+
// 冲突原因分布是消费方业务规则的体检表(E2)
|
|
256
|
+
trackCallback("link_conflict", { reason });
|
|
208
257
|
return c.redirect(withParam(redirect, "error", reason), 302);
|
|
209
258
|
}
|
|
259
|
+
trackCallback("linked");
|
|
210
260
|
return c.redirect(withParam(redirect, "linked", "github"), 302);
|
|
211
261
|
}
|
|
212
262
|
// ---- login 分支:email 是账号锚点,缺了就无法找号建号 ----
|
|
213
|
-
if (!email)
|
|
263
|
+
if (!email) {
|
|
264
|
+
trackCallback("no_email");
|
|
214
265
|
return c.redirect(withParam(redirect, "error", "oauth_no_email"), 302);
|
|
266
|
+
}
|
|
215
267
|
let verified;
|
|
216
268
|
try {
|
|
217
269
|
verified = await cfg(c).onVerified({ email, ...common });
|
|
218
270
|
}
|
|
219
271
|
catch {
|
|
272
|
+
trackCallback("internal");
|
|
220
273
|
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
221
274
|
}
|
|
222
275
|
const refreshTtlMs = cfg(c).session?.refreshTtlMs ?? null;
|
|
@@ -233,10 +286,22 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
233
286
|
refreshToken,
|
|
234
287
|
...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
|
|
235
288
|
...(verified.user !== undefined ? { user: verified.user } : {}),
|
|
289
|
+
userId: verified.userId,
|
|
290
|
+
...(flowId ? { flowId } : {}),
|
|
236
291
|
};
|
|
237
292
|
await cfg(c).kv.put(`oauth:otc:${otc}`, JSON.stringify(payload), {
|
|
238
293
|
expirationTtl: OTC_TTL_SECONDS,
|
|
239
294
|
});
|
|
295
|
+
// 服务端签发成功 ≠ 登录成功:客户端可能永远收不到这次回跳(回跳丢失 = D11)
|
|
296
|
+
track(c, {
|
|
297
|
+
event: "oauth_callback",
|
|
298
|
+
outcome: "issued",
|
|
299
|
+
provider: "github",
|
|
300
|
+
userId: verified.userId,
|
|
301
|
+
...(flowId ? { flowId } : {}),
|
|
302
|
+
...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
|
|
303
|
+
meta: { mode: "login" },
|
|
304
|
+
});
|
|
240
305
|
return c.redirect(withParam(redirect, "otc", otc), 302);
|
|
241
306
|
});
|
|
242
307
|
auth.post("/oauth/exchange", async (c) => {
|
|
@@ -249,9 +314,37 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
249
314
|
const otc = trimmedField(body.otc);
|
|
250
315
|
const key = `oauth:otc:${otc}`;
|
|
251
316
|
const raw = otc ? await cfg(c).kv.get(key) : null;
|
|
252
|
-
if (!raw)
|
|
317
|
+
if (!raw) {
|
|
318
|
+
// otc 过期(60s)或已被兑换;与「回跳压根没到达」是两回事,故分开记
|
|
319
|
+
track(c, {
|
|
320
|
+
event: "oauth_exchange",
|
|
321
|
+
outcome: "invalid_otc",
|
|
322
|
+
provider: "github",
|
|
323
|
+
});
|
|
253
324
|
return c.json({ error: "invalid_otc" }, 400);
|
|
325
|
+
}
|
|
254
326
|
await cfg(c).kv.delete(key); // 单次使用,兑换即焚
|
|
255
|
-
|
|
327
|
+
// 解构即剥离:payload 的类型收窄回 OtcPayload,统计字段进不了响应体
|
|
328
|
+
const { userId, flowId, ...payload } = JSON.parse(raw);
|
|
329
|
+
const trace = {
|
|
330
|
+
...(userId ? { userId } : {}),
|
|
331
|
+
...(flowId ? { flowId } : {}),
|
|
332
|
+
};
|
|
333
|
+
track(c, {
|
|
334
|
+
event: "oauth_exchange",
|
|
335
|
+
outcome: "ok",
|
|
336
|
+
provider: "github",
|
|
337
|
+
...trace,
|
|
338
|
+
});
|
|
339
|
+
// 「登录成功」的口径落点在此而非 callback:这里才是客户端真正拿到 token 对的时刻。
|
|
340
|
+
// 顺带一个好处:此处的 country 来自客户端自己的网络,而非浏览器(授权页往往
|
|
341
|
+
// 挂着代理),比 callback 的国家更接近用户真实位置。
|
|
342
|
+
track(c, {
|
|
343
|
+
event: "login",
|
|
344
|
+
provider: "github",
|
|
345
|
+
...trace,
|
|
346
|
+
...(payload.isNewUser !== undefined ? { isNewUser: payload.isNewUser } : {}),
|
|
347
|
+
});
|
|
348
|
+
return c.json(payload, 200);
|
|
256
349
|
});
|
|
257
350
|
}
|
package/dist/rate_limit.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export interface RateLimitResult {
|
|
2
2
|
allowed: boolean;
|
|
3
3
|
retryAfterSeconds: number;
|
|
4
|
+
/**
|
|
5
|
+
* 被哪一层挡下(1.4.0 起,统计用);allowed 时缺省。
|
|
6
|
+
* 显式给出而非由 retryAfterSeconds 反推——那三个值改一次,反推就错一次。
|
|
7
|
+
*/
|
|
8
|
+
layer?: "cooldown" | "email" | "ip";
|
|
4
9
|
}
|
|
5
10
|
export declare function checkSendRateLimit(kv: KVNamespace, email: string, ip: string): Promise<RateLimitResult>;
|
|
6
11
|
export declare function recordSend(kv: KVNamespace, email: string, ip: string): Promise<void>;
|
package/dist/rate_limit.js
CHANGED
|
@@ -6,15 +6,15 @@ const IP_MAX_IN_WINDOW = 10;
|
|
|
6
6
|
export async function checkSendRateLimit(kv, email, ip) {
|
|
7
7
|
const cooldown = await kv.get(`cooldown:${email}`);
|
|
8
8
|
if (cooldown) {
|
|
9
|
-
return { allowed: false, retryAfterSeconds: COOLDOWN_SECONDS };
|
|
9
|
+
return { allowed: false, retryAfterSeconds: COOLDOWN_SECONDS, layer: "cooldown" };
|
|
10
10
|
}
|
|
11
11
|
const emailCount = parseInt((await kv.get(`rl:email:${email}`)) ?? "0", 10);
|
|
12
12
|
if (emailCount >= EMAIL_MAX_IN_WINDOW) {
|
|
13
|
-
return { allowed: false, retryAfterSeconds: EMAIL_WINDOW_SECONDS };
|
|
13
|
+
return { allowed: false, retryAfterSeconds: EMAIL_WINDOW_SECONDS, layer: "email" };
|
|
14
14
|
}
|
|
15
15
|
const ipCount = parseInt((await kv.get(`rl:ip:${ip}`)) ?? "0", 10);
|
|
16
16
|
if (ipCount >= IP_MAX_IN_WINDOW) {
|
|
17
|
-
return { allowed: false, retryAfterSeconds: IP_WINDOW_SECONDS };
|
|
17
|
+
return { allowed: false, retryAfterSeconds: IP_WINDOW_SECONDS, layer: "ip" };
|
|
18
18
|
}
|
|
19
19
|
return { allowed: true, retryAfterSeconds: 0 };
|
|
20
20
|
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LoginConfig } from "./config.js";
|
|
2
|
+
export interface StatEvent {
|
|
3
|
+
event: string;
|
|
4
|
+
outcome?: string;
|
|
5
|
+
provider?: "email" | "github";
|
|
6
|
+
userId?: string;
|
|
7
|
+
/** 串联 OAuth 三段的标识;绝不可用 state / otc 充当(单次凭证不进长期表) */
|
|
8
|
+
flowId?: string;
|
|
9
|
+
isNewUser?: boolean;
|
|
10
|
+
/** 落 meta 列(JSON),并摊平进 onEvent */
|
|
11
|
+
meta?: Record<string, unknown>;
|
|
12
|
+
/** 只摊平进 onEvent,**不落表**(ip 等 v1 判定不入库的字段走这里) */
|
|
13
|
+
hookOnly?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export declare function flushStats(): Promise<void>;
|
|
16
|
+
export interface TrackContext {
|
|
17
|
+
env: unknown;
|
|
18
|
+
req: {
|
|
19
|
+
raw: Request;
|
|
20
|
+
};
|
|
21
|
+
/** Hono 在无 ExecutionContext 时访问此属性会抛,故所有读取都包在 try 内 */
|
|
22
|
+
executionCtx?: ExecutionContext;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 事件出口:先照原样喂 onEvent(消费方钩子,形态与 1.3.0 保持一致),
|
|
26
|
+
* 再异步写自己的表。两条路径并行——onEvent 是给消费方的,不被库劫持去写库表。
|
|
27
|
+
*/
|
|
28
|
+
export declare function createTracker<TEnv>(getConfig: (env: TEnv) => LoginConfig): (c: TrackContext, e: StatEvent) => void;
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { logEvent } from "./log.js";
|
|
2
|
+
/** 供测试等待异步写入完成;生产路径走 waitUntil,不依赖它 */
|
|
3
|
+
const pending = new Set();
|
|
4
|
+
export async function flushStats() {
|
|
5
|
+
await Promise.allSettled([...pending]);
|
|
6
|
+
}
|
|
7
|
+
// 按 config 对象记忆化(同 email.ts 的 warnEmailConfigOnce):生产上 config 由
|
|
8
|
+
// memoizeResolver 按 env 缓存,等价于每个 Worker 一次告警。
|
|
9
|
+
const warnedConfigs = new WeakSet();
|
|
10
|
+
const GEO_ABSENT = {
|
|
11
|
+
country: "unknown",
|
|
12
|
+
asn: null,
|
|
13
|
+
colo: null,
|
|
14
|
+
timezone: null,
|
|
15
|
+
city: null,
|
|
16
|
+
region: null,
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* 全部取自 Cloudflare 边缘的 `request.cf`——它在请求到达 Worker 前就已填好,
|
|
20
|
+
* 零外部依赖、无额外请求。本地 wrangler dev 与测试环境没有 cf,故整体兜底。
|
|
21
|
+
* 注意这是 **IP 归属地**,不是用户声明的位置:代理会显示出口所在地。
|
|
22
|
+
*/
|
|
23
|
+
function geoOf(c) {
|
|
24
|
+
try {
|
|
25
|
+
const cf = c.req.raw.cf;
|
|
26
|
+
if (!cf)
|
|
27
|
+
return GEO_ABSENT;
|
|
28
|
+
const str = (v) => (typeof v === "string" && v ? v : null);
|
|
29
|
+
return {
|
|
30
|
+
country: str(cf.country) ?? "unknown",
|
|
31
|
+
asn: typeof cf.asn === "number" ? cf.asn : null,
|
|
32
|
+
colo: str(cf.colo),
|
|
33
|
+
timezone: str(cf.timezone),
|
|
34
|
+
city: str(cf.city),
|
|
35
|
+
region: str(cf.region),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return GEO_ABSENT;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function defer(c, p) {
|
|
43
|
+
const tracked = p.finally(() => pending.delete(tracked));
|
|
44
|
+
pending.add(tracked);
|
|
45
|
+
try {
|
|
46
|
+
c.executionCtx?.waitUntil(tracked);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// 无 ExecutionContext(如 app.request() 直调):写入照常进行,只是不被延长生命周期
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function writeEvent(db, e, geo, now) {
|
|
53
|
+
await db
|
|
54
|
+
.prepare(`INSERT INTO auth_events
|
|
55
|
+
(at, event, outcome, provider, user_id, flow_id, is_new_user,
|
|
56
|
+
country, asn, colo, timezone, city, region, source, meta)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'server', ?)`)
|
|
58
|
+
.bind(now, e.event, e.outcome ?? null, e.provider ?? null, e.userId ?? null, e.flowId ?? null, e.isNewUser === undefined ? null : e.isNewUser ? 1 : 0, geo.country, geo.asn, geo.colo, geo.timezone, geo.city, geo.region, e.meta ? JSON.stringify(e.meta) : null)
|
|
59
|
+
.run();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 事件出口:先照原样喂 onEvent(消费方钩子,形态与 1.3.0 保持一致),
|
|
63
|
+
* 再异步写自己的表。两条路径并行——onEvent 是给消费方的,不被库劫持去写库表。
|
|
64
|
+
*/
|
|
65
|
+
export function createTracker(getConfig) {
|
|
66
|
+
return (c, e) => {
|
|
67
|
+
const cfg = getConfig(c.env);
|
|
68
|
+
const onEvent = cfg.onEvent ?? logEvent;
|
|
69
|
+
onEvent({
|
|
70
|
+
event: e.event,
|
|
71
|
+
...(e.outcome !== undefined ? { outcome: e.outcome } : {}),
|
|
72
|
+
...(e.provider !== undefined ? { provider: e.provider } : {}),
|
|
73
|
+
...(e.userId !== undefined ? { userId: e.userId } : {}),
|
|
74
|
+
...(e.flowId !== undefined ? { flowId: e.flowId } : {}),
|
|
75
|
+
...(e.isNewUser !== undefined ? { isNewUser: e.isNewUser } : {}),
|
|
76
|
+
...e.meta,
|
|
77
|
+
...e.hookOnly,
|
|
78
|
+
});
|
|
79
|
+
if (cfg.stats?.enabled === false)
|
|
80
|
+
return;
|
|
81
|
+
defer(c, writeEvent(cfg.db, e, geoOf(c), Date.now()).catch((err) => warnUnavailableOnce(cfg, onEvent, err)));
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** 最可能的原因是没执行 migration 0002。只告警一次,避免每请求刷屏。 */
|
|
85
|
+
function warnUnavailableOnce(cfg, onEvent, err) {
|
|
86
|
+
if (warnedConfigs.has(cfg))
|
|
87
|
+
return;
|
|
88
|
+
warnedConfigs.add(cfg);
|
|
89
|
+
onEvent({
|
|
90
|
+
event: "stats_unavailable",
|
|
91
|
+
hint: "auth_events 写入失败,请执行 migration 0002;登录不受影响",
|
|
92
|
+
message: String(err),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- 登录统计事件表(docs/stats-design.md v1)。归库所有,与 sessions 同库。
|
|
2
|
+
-- 升级到 1.4.0 后必须执行本迁移:统计模块默认开启,表不存在时写入会失败
|
|
3
|
+
-- (登录不受影响,会有一次 stats_unavailable 告警事件)。
|
|
4
|
+
CREATE TABLE IF NOT EXISTS auth_events (
|
|
5
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
6
|
+
at INTEGER NOT NULL, -- UTC 毫秒
|
|
7
|
+
event TEXT NOT NULL,
|
|
8
|
+
outcome TEXT,
|
|
9
|
+
provider TEXT, -- email | github
|
|
10
|
+
user_id TEXT,
|
|
11
|
+
flow_id TEXT, -- 串联 OAuth 三段,非凭证
|
|
12
|
+
is_new_user INTEGER, -- 0 | 1 | NULL
|
|
13
|
+
-- 地理字段:全部取自 Cloudflare 边缘的 request.cf,零外部依赖、无额外请求。
|
|
14
|
+
-- 是 IP 归属地而非用户声明位置,代理会显示出口所在地(见 stats-design.md K 组坑①)。
|
|
15
|
+
country TEXT, -- ISO 3166-1 两位码,缺失落 'unknown'
|
|
16
|
+
asn INTEGER, -- 自治域号,识别家宽 vs 云出口(K9)
|
|
17
|
+
colo TEXT, -- Cloudflare 边缘节点码;与 country 不一致 = 流量绕道
|
|
18
|
+
timezone TEXT, -- IANA 时区名
|
|
19
|
+
city TEXT, -- 常为空;当前无指标使用(见 6.11)
|
|
20
|
+
region TEXT, -- 同上
|
|
21
|
+
source TEXT NOT NULL DEFAULT 'server', -- server | client(client 留给二期)
|
|
22
|
+
meta TEXT -- JSON:familyId、locale、错误串等低频字段
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_auth_events_at ON auth_events(at);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_auth_events_event_at ON auth_events(event, at);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_auth_events_flow ON auth_events(flow_id);
|
package/package.json
CHANGED