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:
+31
-39
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user