loginbase 1.1.0 → 1.3.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 +14 -10
- package/dist/body.d.ts +11 -0
- package/dist/body.js +13 -0
- package/dist/config.d.ts +56 -0
- package/dist/email.d.ts +64 -11
- package/dist/email.js +170 -11
- package/dist/handler.js +19 -6
- package/dist/index.d.ts +1 -1
- package/dist/plugins/github.d.ts +1 -1
- package/dist/plugins/github.js +122 -49
- package/dist/templates/en.d.ts +2 -2
- package/dist/templates/en.js +9 -11
- package/dist/templates/zh.d.ts +2 -2
- package/dist/templates/zh.js +9 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> The shared login foundation for my apps — email OTP + social OAuth + session management.
|
|
4
4
|
> Cloudflare Workers server library + Kotlin Multiplatform client.
|
|
5
5
|
|
|
6
|
-
多 App 共用的登录底座:邮箱验证码登录、社交 OAuth(GitHub 等)、会话管理(refresh 轮换 + 重用检测 + 丢回执救活)。服务端库跑在各 App 自己的 Cloudflare Worker 里(数据在各自 D1,不做中心化账号),客户端库以 Kotlin Multiplatform
|
|
6
|
+
多 App 共用的登录底座:邮箱验证码登录、社交 OAuth(GitHub 等)、会话管理(refresh 轮换 + 重用检测 + 丢回执救活)。服务端库跑在各 App 自己的 Cloudflare Worker 里(数据在各自 D1,不做中心化账号),客户端库以 Kotlin Multiplatform 提供,住在姊妹仓 [HarlonWang/loginbase-kt](https://github.com/HarlonWang/loginbase-kt)(第 4 步新建)。**协议契约 [docs/protocol.md](docs/protocol.md) 只住本仓,是两端的唯一权威。**
|
|
7
7
|
|
|
8
8
|
## 由来
|
|
9
9
|
|
|
@@ -11,13 +11,12 @@
|
|
|
11
11
|
- 服务端实现的母本是 Tono-Server 已在生产验证的邮箱验证码登录(含全套测试),本仓库是它的抽取 + 泛化
|
|
12
12
|
- 路线选择(公共库而非中心化服务)与仓库设计见 [docs/design.md](docs/design.md),命名记录见 [docs/naming.md](docs/naming.md)
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## 结构(两个仓库:一份协议,两个产物)
|
|
15
15
|
|
|
16
16
|
```
|
|
17
|
-
loginbase/
|
|
17
|
+
loginbase/ # 本仓:TS 服务端库 + 协议契约
|
|
18
18
|
├── src/ # TS 服务端库(Hono sub-app 工厂),从 Tono-Server 平移
|
|
19
19
|
├── test/ # vitest
|
|
20
|
-
├── kotlin/ # KMP 客户端库(独立 gradle 工程)
|
|
21
20
|
├── docs/
|
|
22
21
|
│ ├── design.md # 路线与仓库设计决策
|
|
23
22
|
│ ├── server-design.md # 服务端技术方案(公共 API/协议草案/会话模型/平移策略)
|
|
@@ -26,20 +25,25 @@ loginbase/
|
|
|
26
25
|
│ ├── protocol.md # API 契约(唯一权威)——代码平移时落笔
|
|
27
26
|
│ └── logto-替换方案-调研.md # 背景调研(自 TrendingProjects 迁入)
|
|
28
27
|
└── README.md
|
|
28
|
+
|
|
29
|
+
loginbase-kt/ # 姊妹仓:KMP 客户端库(独立 gradle 工程、独立 CI 与版本线)
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
## 产物坐标
|
|
32
33
|
|
|
33
34
|
```
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
服务端仓 HarlonWang/loginbase (含 protocol.md,协议唯一权威)
|
|
36
|
+
客户端仓 HarlonWang/loginbase-kt
|
|
37
|
+
npm loginbase (npm registry,本仓 tag 触发 CI 发布)
|
|
38
|
+
Maven wang.harlon:loginbase-kt (Maven Central,客户端仓 tag 触发 CI 发布)
|
|
37
39
|
Kotlin 包 wang.harlon.loginbase
|
|
38
40
|
```
|
|
39
41
|
|
|
42
|
+
两仓各自独立版本线,tag 为裸版本号;协议兼容关系由 `protocol.md` 版本历史 + 客户端仓 README 的对齐声明表达,不靠版本号相等表达。
|
|
43
|
+
|
|
40
44
|
## 状态与路线
|
|
41
45
|
|
|
42
|
-
**落地第 1
|
|
46
|
+
**落地第 1~3 步已完成**(2026-08-13):`loginbase@1.1.0` 上线 npm;Tono-Server(钩子化)与 github-ai-trending-api(双轨迁移:loginbase 优先 + Logto fallback)均已部署生产并通过分层验收。下一步 = 第 4 步 KMP 客户端。落地顺序:
|
|
43
47
|
|
|
44
48
|
1. 从 Tono-Server 平移服务端代码与测试,Tono-Server 改为依赖本包(其现有测试即抽取验收)
|
|
45
49
|
2. 钩子化(`onVerified` 用户回调)+ zh/en 邮件模板 + github-oauth 可选插件
|
|
@@ -49,5 +53,5 @@ Kotlin 包 wang.harlon.loginbase
|
|
|
49
53
|
|
|
50
54
|
## 设计红线
|
|
51
55
|
|
|
52
|
-
- 依赖最小集:服务端 hono + jose(+ zod-validator),客户端 ktor + kotlinx-serialization +
|
|
53
|
-
-
|
|
56
|
+
- 依赖最小集:服务端 hono + jose(+ zod-validator),客户端 ktor-client-core + kotlinx-serialization-json + kotlinx-coroutines-core。auth 库是供应链攻击的最高价值目标,每加一个依赖都要过一遍这个念头
|
|
57
|
+
- 协议变更:服务端实现 + `docs/protocol.md` 同一个 commit,并在 `loginbase-kt` 仓开跟进 issue,客户端版本落地前不关(2026-08-13 由 monorepo 三位一体改判,理由见 design.md)
|
package/dist/body.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求体字段取字符串。
|
|
3
|
+
*
|
|
4
|
+
* JSON 里客户端可以塞任意类型,而 `(body.x ?? "").trim()` 遇到数字/对象/数组会抛
|
|
5
|
+
* TypeError——把一个本该 400 的坏请求变成 500:日志噪音,且给客户端「服务端故障、
|
|
6
|
+
* 可重试」的错误信号(实际重试多少次都一样)。TS 的字段类型只在编译期成立,
|
|
7
|
+
* 运行时边界必须自己兜(见 design.md 技术选型第 1 条)。
|
|
8
|
+
*
|
|
9
|
+
* 非字符串一律当空串,让各端点照常走自己的 400 分支。
|
|
10
|
+
*/
|
|
11
|
+
export declare function trimmedField(value: unknown): string;
|
package/dist/body.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求体字段取字符串。
|
|
3
|
+
*
|
|
4
|
+
* JSON 里客户端可以塞任意类型,而 `(body.x ?? "").trim()` 遇到数字/对象/数组会抛
|
|
5
|
+
* TypeError——把一个本该 400 的坏请求变成 500:日志噪音,且给客户端「服务端故障、
|
|
6
|
+
* 可重试」的错误信号(实际重试多少次都一样)。TS 的字段类型只在编译期成立,
|
|
7
|
+
* 运行时边界必须自己兜(见 design.md 技术选型第 1 条)。
|
|
8
|
+
*
|
|
9
|
+
* 非字符串一律当空串,让各端点照常走自己的 400 分支。
|
|
10
|
+
*/
|
|
11
|
+
export function trimmedField(value) {
|
|
12
|
+
return typeof value === "string" ? value.trim() : "";
|
|
13
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -10,6 +10,18 @@ export interface VerifiedIdentity {
|
|
|
10
10
|
* 认证视图的敏感字段(私有统计、2FA 状态等)在库边界内剔除。
|
|
11
11
|
*/
|
|
12
12
|
providerProfile?: unknown;
|
|
13
|
+
/**
|
|
14
|
+
* OAuth provider 的 access token(1.2.0 起)。库**只透传、不存储、不再分发**——
|
|
15
|
+
* 第三方 API 凭据是各 App 的产品决策,不属登录底座(见 docs/design.md 定位边界)。
|
|
16
|
+
* GitHub OAuth App 的 token 不过期,用户撤销授权才失效。email provider 恒缺省。
|
|
17
|
+
*/
|
|
18
|
+
providerAccessToken?: string;
|
|
19
|
+
/**
|
|
20
|
+
* provider 返回的全部**已验证**邮箱,归一化小写(1.2.0 起)。`email` 是其中
|
|
21
|
+
* 选定的主锚点(primary+verified 优先);App 可用整个列表做账号对账——GitHub 上
|
|
22
|
+
* 挂了多个验证邮箱、其中之一才是 App 账号邮箱的用户,只比对 `email` 会被误判为新人。
|
|
23
|
+
*/
|
|
24
|
+
verifiedEmails?: string[];
|
|
13
25
|
requestMeta: {
|
|
14
26
|
ip?: string;
|
|
15
27
|
userAgent?: string;
|
|
@@ -21,6 +33,42 @@ export interface VerifiedResult {
|
|
|
21
33
|
user?: unknown;
|
|
22
34
|
}
|
|
23
35
|
export type OnVerified = (identity: VerifiedIdentity) => Promise<VerifiedResult> | VerifiedResult;
|
|
36
|
+
/**
|
|
37
|
+
* onLinked:**已登录用户绑定第二身份**的语义出口(1.2.0 起)。
|
|
38
|
+
* 与 onVerified 方向相反——onVerified **返回** userId(用外部身份换会话),
|
|
39
|
+
* onLinked **接收** userId(用已有会话认领外部身份);不建会话、不发 token。
|
|
40
|
+
*/
|
|
41
|
+
export interface LinkedIdentity {
|
|
42
|
+
/** 当前登录用户,来自 link/start 的 Bearer 校验并存于 state;绝不接受客户端传入 */
|
|
43
|
+
userId: string;
|
|
44
|
+
provider: "github";
|
|
45
|
+
providerUserId: string;
|
|
46
|
+
/**
|
|
47
|
+
* link 时 userId 已确定,email 只是附加信息——provider 未给也**不构成失败**
|
|
48
|
+
* (与 onVerified 不同:那里 email 是账号锚点,缺了就无法找号建号)。
|
|
49
|
+
*/
|
|
50
|
+
email?: string;
|
|
51
|
+
verifiedEmails?: string[];
|
|
52
|
+
providerProfile?: unknown;
|
|
53
|
+
providerAccessToken?: string;
|
|
54
|
+
requestMeta: {
|
|
55
|
+
ip?: string;
|
|
56
|
+
userAgent?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 冲突用返回值而非抛错表达:「该外部身份已属他人」是预期业务结果,不是异常
|
|
61
|
+
* (抛错留给真异常 → `internal`)。库不裁决冲突——拒绝/换绑/重复绑定是否幂等
|
|
62
|
+
* 全由 App 决定,库只把 `reason` 映射成回跳参数。
|
|
63
|
+
* `reason` 须为 `[A-Za-z0-9_]{1,64}`,否则回落为 `internal`(回跳 URL 卫生)。
|
|
64
|
+
*/
|
|
65
|
+
export type LinkResult = {
|
|
66
|
+
ok: true;
|
|
67
|
+
} | {
|
|
68
|
+
ok: false;
|
|
69
|
+
reason: string;
|
|
70
|
+
};
|
|
71
|
+
export type OnLinked = (identity: LinkedIdentity) => Promise<LinkResult> | LinkResult;
|
|
24
72
|
export type OnEvent = (event: Record<string, unknown>) => void;
|
|
25
73
|
export interface GithubSocialConfig {
|
|
26
74
|
clientId: string;
|
|
@@ -32,6 +80,12 @@ export interface GithubSocialConfig {
|
|
|
32
80
|
allowedRedirects: string[];
|
|
33
81
|
/** GitHub OAuth App 注册的回调地址;缺省由请求 origin + basePath 推导 */
|
|
34
82
|
callbackUrl?: string;
|
|
83
|
+
/**
|
|
84
|
+
* authorize 的 scope,默认 `user:email`——取 primary+verified 邮箱的最小集。
|
|
85
|
+
* 需要额外能力的消费方自配,如 TrendingAI 的 `"user:email public_repo"`
|
|
86
|
+
* (star 写操作要求 public_repo)。scope 越大授权页越吓人,按需给。
|
|
87
|
+
*/
|
|
88
|
+
scope?: string;
|
|
35
89
|
}
|
|
36
90
|
export interface LoginConfig {
|
|
37
91
|
db: D1Database;
|
|
@@ -47,6 +101,8 @@ export interface LoginConfig {
|
|
|
47
101
|
refreshTtlMs?: number | null;
|
|
48
102
|
};
|
|
49
103
|
onVerified: OnVerified;
|
|
104
|
+
/** 提供后才启用 link 端点(未提供 → 404 not_configured,默认关闭、显式启用) */
|
|
105
|
+
onLinked?: OnLinked;
|
|
50
106
|
onEvent?: OnEvent;
|
|
51
107
|
socials?: {
|
|
52
108
|
github?: GithubSocialConfig;
|
package/dist/email.d.ts
CHANGED
|
@@ -1,17 +1,70 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/** 模板渲染上下文(2.0.0:由裸 code 参数改为 ctx 对象) */
|
|
2
|
+
export interface TemplateContext {
|
|
3
|
+
code: string;
|
|
4
|
+
brand?: string;
|
|
5
|
+
/** 本封邮件的「选中语言」——已归一化的 BCP 47 标签 */
|
|
6
|
+
locale: string;
|
|
7
|
+
/** 收件地址(已归一化小写) */
|
|
8
|
+
email: string;
|
|
9
|
+
/** 验证码有效期,解开「10 分钟」写死在文案里的约定耦合 */
|
|
10
|
+
ttlMinutes: number;
|
|
11
|
+
}
|
|
12
|
+
export type TemplatePart = (ctx: TemplateContext) => string;
|
|
13
|
+
/** 三件皆可选:内置已有的语言可以只覆盖其中一件,其余由内置同语言补齐 */
|
|
14
|
+
export interface EmailTemplate {
|
|
15
|
+
subject?: TemplatePart;
|
|
16
|
+
html?: TemplatePart;
|
|
17
|
+
text?: TemplatePart;
|
|
5
18
|
}
|
|
6
19
|
export interface EmailConfig {
|
|
7
20
|
resendApiKey: string;
|
|
8
21
|
from: string;
|
|
9
|
-
/**
|
|
22
|
+
/** 品牌名,经 ctx.brand 送达内置模板与消费方模板 */
|
|
10
23
|
brand?: string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
/**
|
|
25
|
+
* 客户端没说时用哪个语言(2.0.0:原 `locale`,语义由「本 App 邮件语言」正名为
|
|
26
|
+
* 「兜底语言」)。默认 "en";配了库不支持的语言则回落 "en" 并告警。
|
|
27
|
+
*/
|
|
28
|
+
fallbackLocale?: string;
|
|
29
|
+
/** 按 locale 键:覆盖内置语言的任意部件,或新增内置没有的语言(须三件齐全) */
|
|
30
|
+
templates?: Record<string, EmailTemplate>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* BCP 47 标签归一化:`_`→`-`(Android `Locale.toString()` 给的是 `zh_CN`,
|
|
34
|
+
* 只有 `toLanguageTag()` 才是 BCP 47)、转小写(大小写不敏感)、截断 64。
|
|
35
|
+
*
|
|
36
|
+
* 非字符串、空串、含 `[a-z0-9-]` 以外字符者一律返回 null=「视为未传」:
|
|
37
|
+
* 这类值本就匹配不到任何语言,行为与未传等价,顺便把垃圾挡在事件日志之外。
|
|
38
|
+
*
|
|
39
|
+
* `und`(BCP 47 的「未确定语言」)同样归为未传——客户端取不到平台语言时可能传它,
|
|
40
|
+
* 若当成普通标签处理,它会匹配失败并留下 `fallback: true`,把「取不到语言」伪装成
|
|
41
|
+
* 「要了一门不支持的语言」,污染观测(见 plan.md 的哨兵口径)。
|
|
42
|
+
*/
|
|
43
|
+
export declare function normalizeLocale(value: unknown): string | null;
|
|
44
|
+
export interface LocaleResolution {
|
|
45
|
+
/** 选中语言 */
|
|
46
|
+
locale: string;
|
|
47
|
+
/** 客户端传来并通过归一化的值(未传/非法时缺省) */
|
|
48
|
+
requested?: string;
|
|
49
|
+
/** true = 客户端要的语言没给到(未传不算回落) */
|
|
50
|
+
fallback: boolean;
|
|
15
51
|
}
|
|
16
|
-
|
|
17
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 规则 ①:语言只解析一次——请求 → fallbackLocale → 库内置 en,第一个命中者胜。
|
|
54
|
+
* **静默回落,永不 4xx**:语言是展示偏好不是凭据,一个 4xx 会让用户登不进去。
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveEmailLocale(config: EmailConfig, requested: unknown): LocaleResolution;
|
|
57
|
+
/**
|
|
58
|
+
* 规则 ②:模板在选中语言内部合并——内置打底,消费方逐部件覆盖,永不跨语言。
|
|
59
|
+
* (末尾的内置兜底只为防御式直调;走 resolveEmailLocale 的路径上取不到它。)
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveTemplate(config: EmailConfig, locale: string): Required<EmailTemplate>;
|
|
62
|
+
/**
|
|
63
|
+
* 配置层面的问题清单(纯函数,便于测试):
|
|
64
|
+
* - `incomplete`:给了内置没有的语言却没写全三件——他的模板一次都不会被用上,
|
|
65
|
+
* 而配置语法完全合法、从代码里看不出来,不报就只能靠用户投诉发现;
|
|
66
|
+
* - `unsupported_fallback`:兜底语言自己都不在支持集里,实际会用库内置 en。
|
|
67
|
+
*/
|
|
68
|
+
export declare function emailConfigWarnings(config: EmailConfig): Record<string, unknown>[];
|
|
69
|
+
export declare function warnEmailConfigOnce(config: EmailConfig, emit: (event: Record<string, unknown>) => void): void;
|
|
70
|
+
export declare function sendCodeEmail(config: EmailConfig, email: string, code: string, locale?: string): Promise<void>;
|
package/dist/email.js
CHANGED
|
@@ -1,19 +1,178 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { CODE_TTL_SECONDS } from "./code.js";
|
|
2
|
+
import { enTemplate } from "./templates/en.js";
|
|
3
|
+
import { zhTemplate } from "./templates/zh.js";
|
|
3
4
|
const RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const BUILTIN = {
|
|
6
|
+
en: enTemplate,
|
|
7
|
+
zh: zhTemplate,
|
|
8
|
+
};
|
|
9
|
+
const BUILTIN_FALLBACK_LOCALE = "en";
|
|
10
|
+
const MAX_LOCALE_LENGTH = 64;
|
|
11
|
+
const PARTS = ["subject", "html", "text"];
|
|
12
|
+
/**
|
|
13
|
+
* BCP 47 标签归一化:`_`→`-`(Android `Locale.toString()` 给的是 `zh_CN`,
|
|
14
|
+
* 只有 `toLanguageTag()` 才是 BCP 47)、转小写(大小写不敏感)、截断 64。
|
|
15
|
+
*
|
|
16
|
+
* 非字符串、空串、含 `[a-z0-9-]` 以外字符者一律返回 null=「视为未传」:
|
|
17
|
+
* 这类值本就匹配不到任何语言,行为与未传等价,顺便把垃圾挡在事件日志之外。
|
|
18
|
+
*
|
|
19
|
+
* `und`(BCP 47 的「未确定语言」)同样归为未传——客户端取不到平台语言时可能传它,
|
|
20
|
+
* 若当成普通标签处理,它会匹配失败并留下 `fallback: true`,把「取不到语言」伪装成
|
|
21
|
+
* 「要了一门不支持的语言」,污染观测(见 plan.md 的哨兵口径)。
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeLocale(value) {
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return null;
|
|
26
|
+
const tag = value.trim().slice(0, MAX_LOCALE_LENGTH).replace(/_/g, "-").toLowerCase();
|
|
27
|
+
if (tag === "" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(tag))
|
|
28
|
+
return null;
|
|
29
|
+
if (tag === "und" || tag.startsWith("und-"))
|
|
30
|
+
return null;
|
|
31
|
+
return tag;
|
|
8
32
|
}
|
|
9
|
-
|
|
10
|
-
|
|
33
|
+
// 消费方模板的键同样要归一化(他可能写 "zh-Hant"),按 templates 对象记忆化
|
|
34
|
+
const indexCache = new WeakMap();
|
|
35
|
+
function customIndex(config) {
|
|
36
|
+
const src = config.templates;
|
|
37
|
+
if (!src)
|
|
38
|
+
return new Map();
|
|
39
|
+
const cached = indexCache.get(src);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
const index = new Map();
|
|
43
|
+
for (const [key, template] of Object.entries(src)) {
|
|
44
|
+
const tag = normalizeLocale(key);
|
|
45
|
+
if (tag && template)
|
|
46
|
+
index.set(tag, template);
|
|
47
|
+
}
|
|
48
|
+
indexCache.set(src, index);
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
function isComplete(template) {
|
|
52
|
+
return !!template && PARTS.every((p) => typeof template[p] === "function");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 支持集 = 库内置语言 ∪ { 消费方写全三件的语言 }。
|
|
56
|
+
*
|
|
57
|
+
* 判据不是「消费方写了几件」,而是「缺的几件能不能用**同一种语言**补上」:
|
|
58
|
+
* 内置已有的语言永远补得上,故部分覆盖合法;内置没有的语言只有消费方一个来源,
|
|
59
|
+
* 不写全就没人能补——此时判定该语言不成立,整封回落兜底语言(见 server-design.md
|
|
60
|
+
* 「为什么禁止跨语言混搭」)。
|
|
61
|
+
*/
|
|
62
|
+
function supports(config, locale) {
|
|
63
|
+
return locale in BUILTIN || isComplete(customIndex(config).get(locale));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* RFC 4647 Lookup 简化版:逐级砍子标签(`zh-hans-cn`→`zh-hans`→`zh`),
|
|
67
|
+
* 不一步截到主语言——将来加繁体模板时 `zh-Hant` 才不会掉进简体。
|
|
68
|
+
*/
|
|
69
|
+
function lookup(tag, has) {
|
|
70
|
+
let current = tag;
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (has(current))
|
|
73
|
+
return current;
|
|
74
|
+
const cut = current.lastIndexOf("-");
|
|
75
|
+
if (cut < 0)
|
|
76
|
+
return null;
|
|
77
|
+
current = current.slice(0, cut);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 规则 ①:语言只解析一次——请求 → fallbackLocale → 库内置 en,第一个命中者胜。
|
|
82
|
+
* **静默回落,永不 4xx**:语言是展示偏好不是凭据,一个 4xx 会让用户登不进去。
|
|
83
|
+
*/
|
|
84
|
+
export function resolveEmailLocale(config, requested) {
|
|
85
|
+
const has = (locale) => supports(config, locale);
|
|
86
|
+
const req = normalizeLocale(requested);
|
|
87
|
+
if (req) {
|
|
88
|
+
const hit = lookup(req, has);
|
|
89
|
+
if (hit)
|
|
90
|
+
return { locale: hit, requested: req, fallback: false };
|
|
91
|
+
}
|
|
92
|
+
const configured = normalizeLocale(config.fallbackLocale);
|
|
93
|
+
const locale = (configured ? lookup(configured, has) : null) ?? BUILTIN_FALLBACK_LOCALE;
|
|
94
|
+
return req
|
|
95
|
+
? { locale, requested: req, fallback: true }
|
|
96
|
+
: { locale, fallback: false };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 规则 ②:模板在选中语言内部合并——内置打底,消费方逐部件覆盖,永不跨语言。
|
|
100
|
+
* (末尾的内置兜底只为防御式直调;走 resolveEmailLocale 的路径上取不到它。)
|
|
101
|
+
*/
|
|
102
|
+
export function resolveTemplate(config, locale) {
|
|
103
|
+
const custom = customIndex(config).get(locale);
|
|
104
|
+
const builtin = BUILTIN[locale];
|
|
105
|
+
const pick = (part) => custom?.[part] ?? builtin?.[part] ?? BUILTIN[BUILTIN_FALLBACK_LOCALE][part];
|
|
106
|
+
return { subject: pick("subject"), html: pick("html"), text: pick("text") };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 配置层面的问题清单(纯函数,便于测试):
|
|
110
|
+
* - `incomplete`:给了内置没有的语言却没写全三件——他的模板一次都不会被用上,
|
|
111
|
+
* 而配置语法完全合法、从代码里看不出来,不报就只能靠用户投诉发现;
|
|
112
|
+
* - `unsupported_fallback`:兜底语言自己都不在支持集里,实际会用库内置 en。
|
|
113
|
+
*/
|
|
114
|
+
export function emailConfigWarnings(config) {
|
|
115
|
+
const warnings = [];
|
|
116
|
+
// 归一化认不出的键(如 "中文"、"zh Hant")会被索引直接丢掉——语法合法、
|
|
117
|
+
// 却永远不会被任何请求命中,是彻底的死配置,不报就只能靠用户投诉发现
|
|
118
|
+
for (const key of Object.keys(config.templates ?? {})) {
|
|
119
|
+
if (normalizeLocale(key) === null) {
|
|
120
|
+
warnings.push({
|
|
121
|
+
event: "email_template_config",
|
|
122
|
+
status: "invalid_locale_key",
|
|
123
|
+
key,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const [locale, template] of customIndex(config)) {
|
|
128
|
+
if (locale in BUILTIN)
|
|
129
|
+
continue; // 内置能同语言补齐,部分覆盖合法
|
|
130
|
+
const missing = PARTS.filter((p) => typeof template[p] !== "function");
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
warnings.push({
|
|
133
|
+
event: "email_template_config",
|
|
134
|
+
status: "incomplete",
|
|
135
|
+
locale,
|
|
136
|
+
missing,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const configured = normalizeLocale(config.fallbackLocale);
|
|
141
|
+
if (configured && !lookup(configured, (l) => supports(config, l))) {
|
|
142
|
+
warnings.push({
|
|
143
|
+
event: "email_template_config",
|
|
144
|
+
status: "unsupported_fallback",
|
|
145
|
+
locale: configured,
|
|
146
|
+
resolved: BUILTIN_FALLBACK_LOCALE,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return warnings;
|
|
150
|
+
}
|
|
151
|
+
// 每个 config 对象只报一次(config 按 isolate 记忆化 → 等价于每实例一次)。
|
|
152
|
+
// 不抛错、不阻断:模板配错不该让登录服务起不来,与「静默回落」同一取向。
|
|
153
|
+
const warned = new WeakSet();
|
|
154
|
+
export function warnEmailConfigOnce(config, emit) {
|
|
155
|
+
if (warned.has(config))
|
|
156
|
+
return;
|
|
157
|
+
warned.add(config);
|
|
158
|
+
for (const warning of emailConfigWarnings(config))
|
|
159
|
+
emit(warning);
|
|
160
|
+
}
|
|
161
|
+
export async function sendCodeEmail(config, email, code, locale = BUILTIN_FALLBACK_LOCALE) {
|
|
162
|
+
const template = resolveTemplate(config, locale);
|
|
163
|
+
const ctx = {
|
|
164
|
+
code,
|
|
165
|
+
brand: config.brand,
|
|
166
|
+
locale,
|
|
167
|
+
email,
|
|
168
|
+
ttlMinutes: Math.round(CODE_TTL_SECONDS / 60),
|
|
169
|
+
};
|
|
11
170
|
const body = {
|
|
12
171
|
from: config.from,
|
|
13
172
|
to: [email],
|
|
14
|
-
subject:
|
|
15
|
-
html:
|
|
16
|
-
text:
|
|
173
|
+
subject: template.subject(ctx),
|
|
174
|
+
html: template.html(ctx),
|
|
175
|
+
text: template.text(ctx),
|
|
17
176
|
};
|
|
18
177
|
const res = await fetch(RESEND_ENDPOINT, {
|
|
19
178
|
method: "POST",
|
package/dist/handler.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// 事件出口接 onEvent,refreshTtlMs / accessTtlSeconds 可配生效。
|
|
4
4
|
import { Hono } from "hono";
|
|
5
5
|
import { generateCode, storeCode, readCode, deleteCode, incrementAttempts, MAX_ATTEMPTS, } from "./code.js";
|
|
6
|
-
import { sendCodeEmail } from "./email.js";
|
|
6
|
+
import { sendCodeEmail, resolveEmailLocale, warnEmailConfigOnce, } from "./email.js";
|
|
7
7
|
import { checkSendRateLimit, recordSend } from "./rate_limit.js";
|
|
8
8
|
import { createSession, hashRefreshToken, findSession, rotateSession, revokeFamily, tryRescueSession, revokeSession, revokeAllForUser, } from "./session.js";
|
|
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 { trimmedField } from "./body.js";
|
|
12
13
|
import { registerGithubOauth } from "./plugins/github.js";
|
|
13
14
|
export function createAuthApp(getConfig, basePath) {
|
|
14
15
|
const authMiddleware = createAuthMiddleware(getConfig);
|
|
@@ -25,7 +26,7 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
25
26
|
const body = await c.req
|
|
26
27
|
.json()
|
|
27
28
|
.catch(() => ({}));
|
|
28
|
-
const raw = (body.email
|
|
29
|
+
const raw = trimmedField(body.email).toLowerCase();
|
|
29
30
|
if (!/^\S+@\S+\.\S+$/.test(raw)) {
|
|
30
31
|
return c.json({ error: "invalid_email" }, 400);
|
|
31
32
|
}
|
|
@@ -35,12 +36,24 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
35
36
|
return c.json({ error: "too_many_requests", retryAfterSeconds: rl.retryAfterSeconds }, 429);
|
|
36
37
|
}
|
|
37
38
|
const code = generateCode();
|
|
39
|
+
const emitEvent = emit(c);
|
|
40
|
+
warnEmailConfigOnce(cfg(c).email, emitEvent);
|
|
41
|
+
const locale = resolveEmailLocale(cfg(c).email, body.locale);
|
|
38
42
|
try {
|
|
39
|
-
await sendCodeEmail(cfg(c).email, raw, code);
|
|
43
|
+
await sendCodeEmail(cfg(c).email, raw, code, locale.locale);
|
|
40
44
|
}
|
|
41
45
|
catch {
|
|
42
46
|
return c.json({ error: "internal" }, 500);
|
|
43
47
|
}
|
|
48
|
+
// 静默回落意味着「为什么收到英文邮件」在别处查不出来,故选中语言必须留痕
|
|
49
|
+
emitEvent({
|
|
50
|
+
event: "code_sent",
|
|
51
|
+
locale: {
|
|
52
|
+
resolved: locale.locale,
|
|
53
|
+
...(locale.requested ? { requested: locale.requested } : {}),
|
|
54
|
+
...(locale.fallback ? { fallback: true } : {}),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
44
57
|
await storeCode(cfg(c).kv, raw, code);
|
|
45
58
|
await recordSend(cfg(c).kv, raw, ip);
|
|
46
59
|
return c.json({ cooldownSeconds: 60 }, 200);
|
|
@@ -49,8 +62,8 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
49
62
|
const body = await c.req
|
|
50
63
|
.json()
|
|
51
64
|
.catch(() => ({}));
|
|
52
|
-
const email = (body.email
|
|
53
|
-
const code = (body.code
|
|
65
|
+
const email = trimmedField(body.email).toLowerCase();
|
|
66
|
+
const code = trimmedField(body.code);
|
|
54
67
|
const stored = await readCode(cfg(c).kv, email);
|
|
55
68
|
if (!stored)
|
|
56
69
|
return c.json({ error: "code_expired" }, 400);
|
|
@@ -96,7 +109,7 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
96
109
|
const body = await c.req
|
|
97
110
|
.json()
|
|
98
111
|
.catch(() => ({}));
|
|
99
|
-
const token = (body.refreshToken
|
|
112
|
+
const token = trimmedField(body.refreshToken);
|
|
100
113
|
if (!token) {
|
|
101
114
|
return c.json({ error: "invalid_refresh_token", reason: "missing_token" }, 401);
|
|
102
115
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface Login<TEnv> {
|
|
|
15
15
|
}>;
|
|
16
16
|
}
|
|
17
17
|
export declare function createLogin<TEnv = unknown>(resolve: (env: TEnv) => LoginConfig, options?: CreateLoginOptions): Login<TEnv>;
|
|
18
|
-
export type { LoginConfig, CreateLoginOptions, VerifiedIdentity, VerifiedResult, OnVerified, OnEvent, GithubSocialConfig, } from "./config.js";
|
|
18
|
+
export type { LoginConfig, CreateLoginOptions, VerifiedIdentity, VerifiedResult, OnVerified, LinkedIdentity, LinkResult, OnLinked, OnEvent, GithubSocialConfig, } from "./config.js";
|
|
19
19
|
export type { AuthVariables } from "./middleware.js";
|
|
20
20
|
export { createAuthMiddleware } from "./middleware.js";
|
|
21
21
|
export * from "./code.js";
|
package/dist/plugins/github.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
2
|
import type { LoginConfig } from "../config.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type AuthVariables } from "../middleware.js";
|
|
4
4
|
export declare function registerGithubOauth<TEnv>(auth: Hono<{
|
|
5
5
|
Variables: AuthVariables;
|
|
6
6
|
}>, getConfig: (env: TEnv) => LoginConfig, basePath: string): void;
|
package/dist/plugins/github.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { trimmedField } from "../body.js";
|
|
2
|
+
import { createAuthMiddleware } from "../middleware.js";
|
|
1
3
|
import { generateRefreshToken as randomToken } from "../session.js";
|
|
2
4
|
import { createSession } from "../session.js";
|
|
3
5
|
import { signAccessToken, ACCESS_TTL_SECONDS } from "../token.js";
|
|
@@ -7,6 +9,9 @@ const GITHUB_API_USER = "https://api.github.com/user";
|
|
|
7
9
|
const GITHUB_API_EMAILS = "https://api.github.com/user/emails";
|
|
8
10
|
const STATE_TTL_SECONDS = 600;
|
|
9
11
|
const OTC_TTL_SECONDS = 60;
|
|
12
|
+
const DEFAULT_SCOPE = "user:email";
|
|
13
|
+
// App 自定义的冲突 reason 会进回跳 URL,限制字符集防畸形/敏感内容外泄
|
|
14
|
+
const REASON_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
|
|
10
15
|
// 结构化校验而非字符串前缀:startsWith("https://example.com") 会被
|
|
11
16
|
// https://example.com.evil.com 绕过(开放重定向 → otc 泄露给攻击者域)。
|
|
12
17
|
// scheme + host 精确匹配,path 只允许白名单条目的前缀扩展。
|
|
@@ -58,9 +63,67 @@ function publicProfile(user) {
|
|
|
58
63
|
}
|
|
59
64
|
return out;
|
|
60
65
|
}
|
|
66
|
+
function buildAuthorizeUrl(gh, callbackUrl, state) {
|
|
67
|
+
const url = new URL(GITHUB_AUTHORIZE);
|
|
68
|
+
url.searchParams.set("client_id", gh.clientId);
|
|
69
|
+
url.searchParams.set("redirect_uri", callbackUrl);
|
|
70
|
+
url.searchParams.set("scope", gh.scope ?? DEFAULT_SCOPE);
|
|
71
|
+
url.searchParams.set("state", state);
|
|
72
|
+
return url.toString();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 换 token → 取用户 → 取邮箱。login 与 link 两条流程共用(callback 是同一个端点:
|
|
76
|
+
* GitHub OAuth App 的回调地址注册在 GitHub 侧,多一个即多一处配置漂移)。
|
|
77
|
+
* 返回 null = 换码或取用户失败,调用方回跳 oauth_failed。
|
|
78
|
+
*/
|
|
79
|
+
async function fetchGithubIdentity(gh, code) {
|
|
80
|
+
// server-side 换 token:client_secret 只在此出现,客户端不可见
|
|
81
|
+
const tokenRes = await fetch(GITHUB_TOKEN, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
client_id: gh.clientId,
|
|
86
|
+
client_secret: gh.clientSecret,
|
|
87
|
+
code,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
const tokenBody = tokenRes.ok
|
|
91
|
+
? (await tokenRes.json().catch(() => null))
|
|
92
|
+
: null;
|
|
93
|
+
const ghToken = tokenBody?.access_token;
|
|
94
|
+
if (!ghToken)
|
|
95
|
+
return null;
|
|
96
|
+
// GitHub API 要求 User-Agent
|
|
97
|
+
const ghHeaders = {
|
|
98
|
+
Authorization: `Bearer ${ghToken}`,
|
|
99
|
+
Accept: "application/vnd.github+json",
|
|
100
|
+
"User-Agent": "loginbase",
|
|
101
|
+
};
|
|
102
|
+
const userRes = await fetch(GITHUB_API_USER, { headers: ghHeaders });
|
|
103
|
+
if (!userRes.ok)
|
|
104
|
+
return null;
|
|
105
|
+
const ghUser = (await userRes.json());
|
|
106
|
+
const emailsRes = await fetch(GITHUB_API_EMAILS, { headers: ghHeaders });
|
|
107
|
+
const emails = emailsRes.ok
|
|
108
|
+
? (await emailsRes.json())
|
|
109
|
+
: [];
|
|
110
|
+
const normalize = (e) => e.trim().toLowerCase();
|
|
111
|
+
const verifiedEmails = emails.filter((e) => e.verified).map((e) => normalize(e.email));
|
|
112
|
+
const email = emails.find((e) => e.primary && e.verified)?.email ??
|
|
113
|
+
emails.find((e) => e.verified)?.email;
|
|
114
|
+
return {
|
|
115
|
+
ghUser,
|
|
116
|
+
ghToken,
|
|
117
|
+
...(email ? { email: normalize(email) } : {}),
|
|
118
|
+
verifiedEmails,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
61
121
|
export function registerGithubOauth(auth, getConfig, basePath) {
|
|
62
122
|
const cfg = (c) => getConfig(c.env);
|
|
63
123
|
const github = (c) => cfg(c).socials?.github;
|
|
124
|
+
const authMiddleware = createAuthMiddleware(getConfig);
|
|
125
|
+
const callbackUrlFor = (c, gh) => gh.callbackUrl ??
|
|
126
|
+
`${new URL(c.req.url).origin}${basePath}/oauth/github/callback`;
|
|
64
127
|
auth.get("/oauth/github/start", async (c) => {
|
|
65
128
|
const gh = github(c);
|
|
66
129
|
if (!gh)
|
|
@@ -74,14 +137,28 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
74
137
|
await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
|
|
75
138
|
expirationTtl: STATE_TTL_SECONDS,
|
|
76
139
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
140
|
+
return c.redirect(buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state), 302);
|
|
141
|
+
});
|
|
142
|
+
// 已登录用户绑定第二身份。**必须是 POST**:浏览器导航带不了 Authorization 头,
|
|
143
|
+
// 故不能像 login 那样 GET 直接 302——客户端先换 authorizeUrl,再自己打开浏览器。
|
|
144
|
+
auth.post("/oauth/github/link/start", authMiddleware, async (c) => {
|
|
145
|
+
const gh = github(c);
|
|
146
|
+
// onLinked 未提供 = 该能力未启用(默认关闭、显式启用)
|
|
147
|
+
if (!gh || !cfg(c).onLinked)
|
|
148
|
+
return c.json({ error: "not_configured" }, 404);
|
|
149
|
+
const body = await c.req
|
|
150
|
+
.json()
|
|
151
|
+
.catch(() => ({}));
|
|
152
|
+
const redirect = trimmedField(body.redirect);
|
|
153
|
+
if (!redirect || !redirectAllowed(redirect, gh)) {
|
|
154
|
+
return c.json({ error: "invalid_redirect" }, 400);
|
|
155
|
+
}
|
|
156
|
+
const state = randomToken();
|
|
157
|
+
const record = { redirect, mode: "link", userId: c.get("userId") };
|
|
158
|
+
await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
|
|
159
|
+
expirationTtl: STATE_TTL_SECONDS,
|
|
160
|
+
});
|
|
161
|
+
return c.json({ authorizeUrl: buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state) }, 200);
|
|
85
162
|
});
|
|
86
163
|
auth.get("/oauth/github/callback", async (c) => {
|
|
87
164
|
const gh = github(c);
|
|
@@ -96,52 +173,48 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
96
173
|
return c.json({ error: "invalid_state" }, 400);
|
|
97
174
|
}
|
|
98
175
|
await cfg(c).kv.delete(stateKey); // 单次使用,验证即焚
|
|
99
|
-
const { redirect } = JSON.parse(rawState);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
104
|
-
body: JSON.stringify({
|
|
105
|
-
client_id: gh.clientId,
|
|
106
|
-
client_secret: gh.clientSecret,
|
|
107
|
-
code,
|
|
108
|
-
}),
|
|
109
|
-
});
|
|
110
|
-
const tokenBody = tokenRes.ok
|
|
111
|
-
? (await tokenRes.json().catch(() => null))
|
|
112
|
-
: null;
|
|
113
|
-
const ghToken = tokenBody?.access_token;
|
|
114
|
-
if (!ghToken)
|
|
176
|
+
const { redirect, mode, userId } = JSON.parse(rawState);
|
|
177
|
+
const identity = await fetchGithubIdentity(gh, code);
|
|
178
|
+
if (!identity)
|
|
115
179
|
return c.redirect(withParam(redirect, "error", "oauth_failed"), 302);
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
180
|
+
const { ghUser, ghToken, email, verifiedEmails } = identity;
|
|
181
|
+
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
182
|
+
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
183
|
+
const common = {
|
|
184
|
+
provider: "github",
|
|
185
|
+
providerUserId: String(ghUser.id),
|
|
186
|
+
providerProfile: publicProfile(ghUser), // GitHub /user 公开档案白名单字段
|
|
187
|
+
providerAccessToken: ghToken,
|
|
188
|
+
verifiedEmails,
|
|
189
|
+
requestMeta: { ip, userAgent },
|
|
121
190
|
};
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
191
|
+
// ---- link 分支:认领身份,不建会话、不发 token ----
|
|
192
|
+
if (mode === "link") {
|
|
193
|
+
const onLinked = cfg(c).onLinked;
|
|
194
|
+
// userId 由 link/start 写入,缺失只可能是载荷被篡改/降级,保守失败
|
|
195
|
+
if (!onLinked || !userId) {
|
|
196
|
+
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
197
|
+
}
|
|
198
|
+
let result;
|
|
199
|
+
try {
|
|
200
|
+
// email 在此可缺省:userId 已定,邮箱只是附加信息(与 login 分支的关键差别)
|
|
201
|
+
result = await onLinked({ userId, ...common, ...(email ? { email } : {}) });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
205
|
+
}
|
|
206
|
+
if (!result.ok) {
|
|
207
|
+
const reason = REASON_PATTERN.test(result.reason) ? result.reason : "internal";
|
|
208
|
+
return c.redirect(withParam(redirect, "error", reason), 302);
|
|
209
|
+
}
|
|
210
|
+
return c.redirect(withParam(redirect, "linked", "github"), 302);
|
|
211
|
+
}
|
|
212
|
+
// ---- login 分支:email 是账号锚点,缺了就无法找号建号 ----
|
|
132
213
|
if (!email)
|
|
133
214
|
return c.redirect(withParam(redirect, "error", "oauth_no_email"), 302);
|
|
134
|
-
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
135
|
-
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
136
215
|
let verified;
|
|
137
216
|
try {
|
|
138
|
-
verified = await cfg(c).onVerified({
|
|
139
|
-
email: email.trim().toLowerCase(),
|
|
140
|
-
provider: "github",
|
|
141
|
-
providerUserId: String(ghUser.id),
|
|
142
|
-
providerProfile: publicProfile(ghUser), // GitHub /user 公开档案白名单字段
|
|
143
|
-
requestMeta: { ip, userAgent },
|
|
144
|
-
});
|
|
217
|
+
verified = await cfg(c).onVerified({ email, ...common });
|
|
145
218
|
}
|
|
146
219
|
catch {
|
|
147
220
|
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
@@ -173,7 +246,7 @@ export function registerGithubOauth(auth, getConfig, basePath) {
|
|
|
173
246
|
const body = await c.req
|
|
174
247
|
.json()
|
|
175
248
|
.catch(() => ({}));
|
|
176
|
-
const otc = (body.otc
|
|
249
|
+
const otc = trimmedField(body.otc);
|
|
177
250
|
const key = `oauth:otc:${otc}`;
|
|
178
251
|
const raw = otc ? await cfg(c).kv.get(key) : null;
|
|
179
252
|
if (!raw)
|
package/dist/templates/en.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare
|
|
1
|
+
import type { EmailTemplate } from "../email.js";
|
|
2
|
+
export declare const enTemplate: Required<EmailTemplate>;
|
package/dist/templates/en.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// en 模板即 Tono 生产模板的 brand 参数化:brand="Tono" 时输出与平移前逐字节一致。
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
html: (code) => `
|
|
2
|
+
const title = (brand) => brand ? `Your ${brand} verification code` : "Your verification code";
|
|
3
|
+
export const enTemplate = {
|
|
4
|
+
subject: (ctx) => `${title(ctx.brand)}: ${ctx.code}`,
|
|
5
|
+
html: (ctx) => `
|
|
7
6
|
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:24px;">
|
|
8
|
-
<h2 style="margin:0 0 16px 0;">${title}</h2>
|
|
9
|
-
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
|
|
10
|
-
<p style="color:#666;font-size:14px;">This code expires in
|
|
7
|
+
<h2 style="margin:0 0 16px 0;">${title(ctx.brand)}</h2>
|
|
8
|
+
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${ctx.code}</p>
|
|
9
|
+
<p style="color:#666;font-size:14px;">This code expires in ${ctx.ttlMinutes} minutes. If you didn't request it, you can ignore this email.</p>
|
|
11
10
|
</div>
|
|
12
11
|
`,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
12
|
+
text: (ctx) => `${title(ctx.brand)} is ${ctx.code}. It expires in ${ctx.ttlMinutes} minutes.`,
|
|
13
|
+
};
|
package/dist/templates/zh.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare
|
|
1
|
+
import type { EmailTemplate } from "../email.js";
|
|
2
|
+
export declare const zhTemplate: Required<EmailTemplate>;
|
package/dist/templates/zh.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
html: (code) => `
|
|
1
|
+
const title = (brand) => (brand ? `${brand} 登录验证码` : "登录验证码");
|
|
2
|
+
export const zhTemplate = {
|
|
3
|
+
subject: (ctx) => `${title(ctx.brand)}:${ctx.code}`,
|
|
4
|
+
html: (ctx) => `
|
|
6
5
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;padding:24px;">
|
|
7
|
-
<h2 style="margin:0 0 16px 0;">${title}</h2>
|
|
8
|
-
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
|
|
9
|
-
<p style="color:#666;font-size:14px;">验证码
|
|
6
|
+
<h2 style="margin:0 0 16px 0;">${title(ctx.brand)}</h2>
|
|
7
|
+
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${ctx.code}</p>
|
|
8
|
+
<p style="color:#666;font-size:14px;">验证码 ${ctx.ttlMinutes} 分钟内有效。如果这不是你的操作,请忽略这封邮件。</p>
|
|
10
9
|
</div>
|
|
11
10
|
`,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
11
|
+
text: (ctx) => `你的${title(ctx.brand)}是 ${ctx.code},${ctx.ttlMinutes} 分钟内有效。`,
|
|
12
|
+
};
|
package/package.json
CHANGED