refactor: v2.2.0 移除本地 sim 模式,Cloudflare OAuth 只走真实授权

- 删除 SimCFApi / WMAILHUB_SIM_CF / isRealOAuth / POST /api/oauth/cloudflare/sim/approve
- 删除前端 OAuthSim 模拟授权页与 /oauth/sim/authorize 路由;账号页「添加账号」整页跳 Cloudflare
- 迁移 0005 删除 cf_sim_resources 表
- 未配置 CF_OAUTH_CLIENT_ID/SECRET 时 start/callback 一律 503(不再降级出假账号假资源)
- 修 OAuth 回调端点:跨站重定向不携带 SameSite=Strict 会话 cookie,改为由一次性 state 承担 CSRF 防护
- 测试改用 tests/cf-mock.ts 在 fetch 层拦截 Cloudflare API,断言真实 HTTP 调用(72/72 通过)
- 本地 dev 无账号时只拉起 Console 并提示人工授权,不再自动造账号
This commit is contained in:
bigcode
2026-09-30 15:33:58 +08:00
parent 067863806f
commit e2c3c353cf
20 changed files with 429 additions and 336 deletions
-2
View File
@@ -53,8 +53,6 @@ export interface Env {
CF_OAUTH_CLIENT_ID?: string;
CF_OAUTH_CLIENT_SECRET?: string;
CF_OAUTH_REDIRECT_URI?: string;
/** 强制使用 sim 模式("1") **/
WMAILHUB_SIM_CF?: string;
/** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string;
}
+9 -1
View File
@@ -50,9 +50,17 @@ const env: Env = {
R2: new LocalR2(path.join(DATA_DIR, "r2")),
SESSION_SECRET: process.env.WMAILHUB_SESSION_SECRET,
PUBLIC_BASE_URL: process.env.WMAILHUB_PUBLIC_BASE_URL ?? `http://127.0.0.1:${PORT}`,
WMAILHUB_SIM_CF: process.env.WMAILHUB_SIM_CF ?? "1",
// 真实 Cloudflare OAuth 客户端(无模拟模式兜底,缺失时接入接口返回 503)
CF_OAUTH_CLIENT_ID: process.env.CF_OAUTH_CLIENT_ID,
CF_OAUTH_CLIENT_SECRET: process.env.CF_OAUTH_CLIENT_SECRET,
CF_OAUTH_REDIRECT_URI: process.env.CF_OAUTH_REDIRECT_URI,
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: process.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS ?? "1",
};
if (!env.CF_OAUTH_CLIENT_ID || !env.CF_OAUTH_CLIENT_SECRET) {
console.warn(
"[console] 未配置 CF_OAUTH_CLIENT_ID / CF_OAUTH_CLIENT_SECRET:账号页「添加账号」将返回 503,无法接入 Cloudflare 账号",
);
}
// ---- 静态资源 ----------------------------------------------------------------
const MIME: Record<string, string> = {
+31 -39
View File
@@ -2,7 +2,7 @@
* Cloudflare OAuth 路由(docs/02、docs/11 §14):
* GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号)
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入
* POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地)
* GET /api/oauth/mode OAuth 是否已配置
* GET /api/oauth/connections 已建立的 OAuth 连接
*
* 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 ->
@@ -10,46 +10,51 @@
*
* 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库,
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
* 授权回调不校验会话:浏览器从 dash.cloudflare.com 跨站重定向回来时
* SameSite=Strict 的会话 cookie 不会携带,该端点由一次性 state 承担 CSRF 防护。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { ids } from "../../../shared/src/index.ts";
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi, isOAuthConfigured } from "../services/cfapi.ts";
import { provisionAccount, createAccount } from "../services/provision.ts";
import { encryptConnectionToken } from "../db.ts";
import { nowIso, readJson } from "../util.ts";
import { nowIso } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: OAuth state 有效期(秒) **/
const STATE_TTL_SECONDS = 300;
/** # 函数
* 是否使用真实 Cloudflare OAuth
*/
export function isRealOAuth(env: Env): boolean {
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF);
}
/** # 函数
* 装配 OAuth 路由
*/
export function oauthRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
app.use("*", async (c, next) => {
// 授权回调是浏览器从 Cloudflare 跨站重定向回来的请求,会话 cookie 不会带上,
// 因此该端点跳过会话校验,改由一次性 state 校验防护(docs/08)。
// 注意:子应用里 c.req.path 是挂载后的完整路径(/api/oauth/...),不能直接等值比较。
if (c.req.path.endsWith("/cloudflare/callback")) return next();
return requireSession(c, next);
});
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
app.get("/mode", (c) => {
const configured = isOAuthConfigured(c.env);
return c.json({
mode: isRealOAuth(c.env) ? "cloudflare" : "sim",
authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null,
mode: configured ? "cloudflare" : "unconfigured",
authorize_url_base: configured ? CF_OAUTH_AUTHORIZE_URL : null,
});
});
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => {
if (!isOAuthConfigured(c.env)) {
return c.json({ error: "cloudflare oauth not configured (CF_OAUTH_CLIENT_ID/CF_OAUTH_CLIENT_SECRET)" }, 503);
}
const created = await createAccount(c.env, {
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
});
@@ -61,23 +66,22 @@ export function oauthRoutes(): App {
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.run();
if (isRealOAuth(c.env)) {
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const url =
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
`&state=${encodeURIComponent(state)}`;
return c.json({ url, state });
}
// sim 模式:前端内置模拟授权页
return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state });
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const url =
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
`&state=${encodeURIComponent(state)}`;
return c.json({ url, state });
});
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
app.get("/cloudflare/callback", async (c) => {
if (!isOAuthConfigured(c.env)) {
return c.text("Cloudflare OAuth not configured", 503);
}
const url = new URL(c.req.url);
const code = url.searchParams.get("code") ?? "";
const state = url.searchParams.get("state") ?? "";
@@ -85,7 +89,7 @@ export function oauthRoutes(): App {
if (!verdict) {
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
}
const cf = getCFApi(c.env, c.env.DB);
const cf = getCFApi(c.env);
try {
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
@@ -100,18 +104,6 @@ export function oauthRoutes(): App {
}
});
/** sim 模式:模拟授权页的确认动作(本地开发用,需会话) */
app.post("/cloudflare/sim/approve", async (c) => {
const body = await readJson<{ state?: string }>(c.req.raw);
const verdict = await consumeState(c.env, body.state ?? "");
if (!verdict) return c.json({ error: "state invalid or expired" }, 400);
const cf = getCFApi(c.env, c.env.DB);
const token = await cf.exchangeToken("sim-code", "urn:unused");
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
return c.json({ ok: result.ok, error: result.error, account_id: verdict.accountId });
});
/** 已有连接列表(不含 token) */
app.get("/connections", async (c) => {
const rows = await c.env.DB.prepare(
+20 -88
View File
@@ -1,25 +1,22 @@
/**
* Cloudflare API 客户端抽象。
* Cloudflare API 客户端:真实调用 api.cloudflare.com / dash.cloudflare.com。
*
* - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token)
* - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表,
* 让 Connect Cloudflare Account 自动接入链路在本地完整可演示、可测试。
*
* Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
* 未配置 CF_OAUTH_CLIENT_ID/SECRET 时不提供任何降级通道——
* /api/oauth/cloudflare/start 直接返回 503,避免出现"看似接入成功"的假数据。
*/
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
import type { Env } from "../env.ts";
import { ids } from "../../../shared/src/index.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: OAuth 授权端点(real 模式) **/
/** # 成员变量: OAuth 授权端点 **/
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
/** # 成员变量: OAuth token 端点(real 模式) **/
/** # 成员变量: OAuth token 端点 **/
export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
/** # 成员变量: Cloudflare API 基址(real 模式) **/
/** # 成员变量: Cloudflare API 基址 **/
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
@@ -45,7 +42,7 @@ export interface CFZone {
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
export interface CFApi {
/** 用 code 换 access token(real);sim 直接签发假 token */
/** 用 code 换 access token */
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
/** 发现 token 可访问的账户 */
listAccounts(accessToken: string): Promise<CFAccount[]>;
@@ -74,15 +71,20 @@ export interface CFApi {
}
/** # 函数
* 根据环境构造 Cloudflare API 客户端(real 或 sim)
* Cloudflare OAuth 是否已配置(client id + secret 齐备)
*/
export function getCFApi(env: Env, db: Env["DB"]): CFApi {
const real = env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF;
if (real) return new RealCFApi(env);
return new SimCFApi(db);
export function isOAuthConfigured(env: Env): boolean {
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET);
}
/** # 成员变量: 真实 Cloudflare API 实现 **/
/** # 函数
* 构造 Cloudflare API 客户端
*/
export function getCFApi(env: Env): CFApi {
return new RealCFApi(env);
}
/** # 成员变量: Cloudflare API 实现 **/
export class RealCFApi implements CFApi {
/** # 成员变量 **/
constructor(private env: Env) {}
@@ -186,73 +188,3 @@ export class RealCFApi implements CFApi {
return { ok: true };
}
}
/** # 成员变量: 本地 sim 实现:资源写入 cf_sim_resources,行为与真实链路一致 **/
export class SimCFApi implements CFApi {
/** # 成员变量 **/
constructor(private db: Env["DB"]) {}
private async record(kind: string, refId: string, payload: unknown): Promise<void> {
await this.db
.prepare(
"INSERT INTO cf_sim_resources (id, kind, ref_id, payload, created_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(id) DO UPDATE SET payload = excluded.payload",
)
.bind(ids.simResource(), kind, refId, JSON.stringify(payload), nowIso())
.run();
}
async exchangeToken(_code: string, _redirectUri: string) {
return { accessToken: "sim_" + ids.oauth(), expiresIn: 3600 };
}
async listAccounts(_token: string): Promise<CFAccount[]> {
// sim 固定一个演示账户;幂等记录
const account = { id: "sim-account-0001", name: "WmailHub Sim Account" };
await this.record("account", account.id, account);
return [account];
}
async listZones(_token: string, accountId: string): Promise<CFZone[]> {
// sim 返回多个演示域名,覆盖「OAuth 后自动同步全部域名」的展示
const zones = [
{ id: "sim-zone-example.test", name: "example.test", account_id: accountId, status: "active" },
{ id: "sim-zone-demo.test", name: "demo.test", account_id: accountId, status: "active" },
{ id: "sim-zone-mailbox.dev", name: "mailbox.dev", account_id: accountId, status: "active" },
];
for (const z of zones) await this.record("zone", z.id, z);
return zones;
}
async putEmailWorker(opts: {
accessToken: string;
accountId: string;
scriptName: string;
consoleUrl: string;
workerId: string;
workerSecret: string;
}) {
// 与真实实现同一模板渲染,但只记录不外发
await this.record("email_worker", opts.scriptName, {
account_id: opts.accountId,
console_url: opts.consoleUrl,
worker_id: opts.workerId,
script: renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret).length,
});
return { id: opts.scriptName };
}
async enableEmailRouting(opts: { zoneId: string }) {
await this.record("routing_settings", opts.zoneId, { enabled: true });
return { enabled: true };
}
async setCatchAllRule(opts: { zoneId: string; workerName: string }) {
await this.record("routing_rule", opts.zoneId, {
type: "catch_all",
action: "worker",
value: opts.workerName,
});
return { ok: true };
}
}
+3 -3
View File
@@ -109,7 +109,7 @@ export interface ProvisionInput {
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
*/
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
const cf: CFApi = getCFApi(env, env.DB);
const cf: CFApi = getCFApi(env);
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
.bind(input.accountId)
.first<{ id: string; name: string; status: string }>();
@@ -249,7 +249,7 @@ async function putWorkerScript(
workerId: string,
workerSecret: string,
): Promise<void> {
const cf: CFApi = getCFApi(env, env.DB);
const cf: CFApi = getCFApi(env);
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
@@ -278,7 +278,7 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
const accessToken = await decryptConnectionToken(env);
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
const cf: CFApi = getCFApi(env, env.DB);
const cf: CFApi = getCFApi(env);
try {
const workerId = await deployAccountWorker(env, account.id, accessToken);
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });