loginbase 1.4.0 → 1.6.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 +15 -0
- package/dist/demo_account.d.ts +6 -0
- package/dist/demo_account.js +11 -0
- package/dist/handler.js +31 -16
- package/dist/plugins/github.js +13 -2
- 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
|
@@ -112,6 +112,21 @@ export interface LoginConfig {
|
|
|
112
112
|
/** 默认 true;置 false 则一条统计都不写 */
|
|
113
113
|
enabled?: boolean;
|
|
114
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
|
+
};
|
|
115
130
|
onVerified: OnVerified;
|
|
116
131
|
/** 提供后才启用 link 端点(未提供 → 404 not_configured,默认关闭、显式启用) */
|
|
117
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
|
@@ -11,6 +11,7 @@ import { createAuthMiddleware } from "./middleware.js";
|
|
|
11
11
|
import { logEvent } from "./log.js";
|
|
12
12
|
import { createTracker } from "./stats.js";
|
|
13
13
|
import { trimmedField } from "./body.js";
|
|
14
|
+
import { matchDemoAccount } from "./demo_account.js";
|
|
14
15
|
import { registerGithubOauth } from "./plugins/github.js";
|
|
15
16
|
export function createAuthApp(getConfig, basePath) {
|
|
16
17
|
const authMiddleware = createAuthMiddleware(getConfig);
|
|
@@ -33,31 +34,41 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
33
34
|
if (!/^\S+@\S+\.\S+$/.test(raw)) {
|
|
34
35
|
return c.json({ error: "invalid_email" }, 400);
|
|
35
36
|
}
|
|
37
|
+
// 演示账号(见 demo_account.ts):码为固定值且不真实发信,其余与常规一致。
|
|
38
|
+
// 解析放在限流之前,撞限流的演示流量才带得上 demo 标
|
|
39
|
+
const demo = matchDemoAccount(cfg(c), raw);
|
|
36
40
|
const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
|
|
37
41
|
const rl = await checkSendRateLimit(cfg(c).kv, raw, ip);
|
|
38
42
|
if (!rl.allowed) {
|
|
39
43
|
// 命中限流不只是滥用信号,更是「用户发不出码」的体验问题——分层记录才知道
|
|
40
44
|
// 是自己手快(cooldown)还是真被挡住(email / ip 窗口)
|
|
41
|
-
track(c, {
|
|
45
|
+
track(c, {
|
|
46
|
+
event: "rate_limited",
|
|
47
|
+
outcome: rl.layer,
|
|
48
|
+
meta: { endpoint: "code_send", ...(demo ? { demo: true } : {}) },
|
|
49
|
+
});
|
|
42
50
|
return c.json({ error: "too_many_requests", retryAfterSeconds: rl.retryAfterSeconds }, 429);
|
|
43
51
|
}
|
|
44
|
-
const code = generateCode();
|
|
52
|
+
const code = demo?.code ?? generateCode();
|
|
45
53
|
const emitEvent = emit(c);
|
|
46
54
|
warnEmailConfigOnce(cfg(c).email, emitEvent);
|
|
47
55
|
const locale = resolveEmailLocale(cfg(c).email, body.locale);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
}
|
|
56
66
|
}
|
|
57
67
|
// 静默回落意味着「为什么收到英文邮件」在别处查不出来,故选中语言必须留痕
|
|
58
68
|
track(c, {
|
|
59
69
|
event: "code_sent",
|
|
60
70
|
meta: {
|
|
71
|
+
...(demo ? { demo: true } : {}),
|
|
61
72
|
locale: {
|
|
62
73
|
resolved: locale.locale,
|
|
63
74
|
...(locale.requested ? { requested: locale.requested } : {}),
|
|
@@ -75,20 +86,24 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
75
86
|
.catch(() => ({}));
|
|
76
87
|
const email = trimmedField(body.email).toLowerCase();
|
|
77
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);
|
|
78
93
|
const stored = await readCode(cfg(c).kv, email);
|
|
79
94
|
if (!stored) {
|
|
80
95
|
// 过期、已焚、从未发过、已用过——在 KV 层都表现为读不到,无法再分(C3 口径)
|
|
81
|
-
|
|
96
|
+
t({ event: "code_verify", outcome: "code_not_found" });
|
|
82
97
|
return c.json({ error: "code_expired" }, 400);
|
|
83
98
|
}
|
|
84
99
|
if (stored.code !== code) {
|
|
85
100
|
const attempts = await incrementAttempts(cfg(c).kv, email, stored);
|
|
86
101
|
if (attempts >= MAX_ATTEMPTS) {
|
|
87
102
|
await deleteCode(cfg(c).kv, email);
|
|
88
|
-
|
|
103
|
+
t({ event: "code_verify", outcome: "too_many_attempts" });
|
|
89
104
|
return c.json({ error: "too_many_attempts" }, 429);
|
|
90
105
|
}
|
|
91
|
-
|
|
106
|
+
t({ event: "code_verify", outcome: "invalid_code" });
|
|
92
107
|
return c.json({ error: "invalid_code" }, 400);
|
|
93
108
|
}
|
|
94
109
|
await deleteCode(cfg(c).kv, email);
|
|
@@ -105,7 +120,7 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
105
120
|
});
|
|
106
121
|
}
|
|
107
122
|
catch {
|
|
108
|
-
|
|
123
|
+
t({ event: "code_verify", outcome: "internal" });
|
|
109
124
|
return c.json({ error: "internal" }, 500);
|
|
110
125
|
}
|
|
111
126
|
const { sessionId, refreshToken } = await createSession(cfg(c).db, {
|
|
@@ -115,10 +130,10 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
115
130
|
expiresAt: sessionExpiry(c),
|
|
116
131
|
});
|
|
117
132
|
const accessToken = await signAccessToken(cfg(c).jwt.secret, verified.userId, sessionId, accessTtl(c));
|
|
118
|
-
|
|
133
|
+
t({ event: "code_verify", outcome: "ok" });
|
|
119
134
|
// 「登录成功」的唯一口径落点:客户端拿到 token 对。GitHub 轨的同名事件在
|
|
120
135
|
// /oauth/exchange 发(那里才是客户端真正拿到 token 的时刻)。
|
|
121
|
-
|
|
136
|
+
t({
|
|
122
137
|
event: "login",
|
|
123
138
|
provider: "email",
|
|
124
139
|
userId: verified.userId,
|
package/dist/plugins/github.js
CHANGED
|
@@ -37,8 +37,12 @@ function redirectAllowed(redirect, github) {
|
|
|
37
37
|
target.pathname.startsWith(base.pathname));
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
+
// 回跳地址允许自带 query 与 fragment(redirectAllowed 只比对 protocol/host/pathname),
|
|
41
|
+
// 文本拼接会把参数追加进 fragment、或让已有的同名键遮蔽新值,客户端 searchParams 取不到
|
|
40
42
|
function withParam(url, key, value) {
|
|
41
|
-
|
|
43
|
+
const target = new URL(url);
|
|
44
|
+
target.searchParams.set(key, value);
|
|
45
|
+
return target.toString();
|
|
42
46
|
}
|
|
43
47
|
// providerProfile 只透传建档需要的公开档案白名单——GET /user 的认证视图含
|
|
44
48
|
// two_factor_authentication、私有仓库统计等敏感字段,不该流出库边界。
|
|
@@ -194,10 +198,11 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
194
198
|
if (!gh)
|
|
195
199
|
return c.json({ error: "not_configured" }, 404);
|
|
196
200
|
const code = c.req.query("code") ?? "";
|
|
201
|
+
const providerError = c.req.query("error") ?? "";
|
|
197
202
|
const state = c.req.query("state") ?? "";
|
|
198
203
|
const stateKey = `oauth:state:${state}`;
|
|
199
204
|
const rawState = state ? await cfg(c).kv.get(stateKey) : null;
|
|
200
|
-
if (!
|
|
205
|
+
if (!rawState) {
|
|
201
206
|
// state 无效即回跳地址不可信,只能就地报错。
|
|
202
207
|
// 此分支拿不到 flowId——它就存在读不出来的那条 state 记录里。
|
|
203
208
|
track(c, {
|
|
@@ -217,6 +222,12 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
217
222
|
...(userId ? { userId } : {}),
|
|
218
223
|
meta: { mode: mode ?? "login", ...meta },
|
|
219
224
|
});
|
|
225
|
+
// GitHub 用 ?error= 回报用户拒绝授权,此时没有 code;state 已验过,回跳地址可信
|
|
226
|
+
if (!code) {
|
|
227
|
+
const reason = REASON_PATTERN.test(providerError) ? providerError : "no_code";
|
|
228
|
+
trackCallback(reason);
|
|
229
|
+
return c.redirect(withParam(redirect, "error", reason), 302);
|
|
230
|
+
}
|
|
220
231
|
const identity = await fetchGithubIdentity(gh, code);
|
|
221
232
|
if (!identity) {
|
|
222
233
|
trackCallback("oauth_failed");
|
package/package.json
CHANGED