一、背景:内部数据,怎么放才对

我把公司的一些报表、数据看板放在了 Cloudflare Pages 上,方便随时随地打开。但这类东西有个矛盾:

  • 想让同事随时能看——最好点开链接就行,别搞一堆账号密码;
  • 又绝不能公开——公司数据一旦裸奔,爬虫和 AI 分分钟给你收走。

之前我用「邮箱验证码」把站锁住了(Cloudflare Access),能用,但同事每次要收个验证码,略麻烦。有没有更顺的?

有——让大家用自己的飞书扫码登录

二、为什么「飞书登录」最顺

  • 同事零门槛:公司本来人人都用飞书,扫个码就进,不用记任何新账号。
  • 天然只限公司内部:这个登录应用是「企业自建应用」,只有本公司飞书成员能授权成功,外人根本过不去,等于自动画了一圈围栏。
  • 一次登录,之后免验:登录后本地发一张有期限的票(Cookie),一周内再打开不用重登。

三、原理:飞书当「门口的公证处」

做法是在站点前面加一道网关:未登录就跳飞书授权,授权后本地发一张签名票,之后每次请求验票放行。

飞书登录网关 同事打开 内部网址 网关中间件 没票?跳飞书 有票?放行 飞书授权 扫码/点同意 发一张签名票 ✅ 看到 内容

关键是:飞书只在「登录那一刻」当身份公证处;之后用我们自己签发的票(放 HttpOnly Cookie)本地验,不用每次外网往返飞书。整套跑在 Cloudflare Pages 自带的 Functions 中间件里,不用自建服务器,代码 push 进仓库就自动生效。而且是 fail-closed:票验不过一律不吐内容,配置没弄好也不会裸奔。

补充一句:如果你有飞书企业管理员权限,其实可以走更”正规”的 OIDC/SAML 单点登录。但多数人(比如我)只有开放平台建应用的权限、进不了管理后台——这篇正是给这种情况准备的,用自建应用的普通 OAuth 就够。

四、最省事:把这段 Prompt 复制给你的 AI

不想自己抠细节?下面这段 Prompt 打包了给 AI 的说明 + 完整落地步骤 + 中间件规格。复制 → 丢给你的 AI(Claude / ChatGPT 都行)→ 它会读懂并一步步带你做,能自动写的(中间件、push)直接帮你做掉。

展开看看要复制的内容(复制没反应时可在这手动全选)
你是我的技术助理。我有一个托管在 Cloudflare Pages 上的静态站点(连了 Git 仓库,push 自动部署),想给它加一层「飞书扫码登录」:只有本公司飞书成员登录后才能看,爬虫和 AI 进不来。

请先读懂下面的方案,然后一步步带我做:能用命令行/写文件完成的(写中间件、push 仓库)直接帮我做,需要我在飞书或 Cloudflare 后台点的地方就一步步指挥我,每步等我回报再给下一步。开始前先问我三件事:1) 站点域名和 Git 仓库;2) 我在飞书开放平台能不能建并发布企业自建应用;3) 想让谁能登录(全公司/某部门)。注意:中国版飞书用 feishu.cn 和 open.feishu.cn,国际版 Lark 用 larksuite.com。

方案原理:飞书 OAuth2 授权码模式 + 后端自签会话 Cookie,跑在 Cloudflare Pages 的 functions/_middleware.js 中间件里。未登录跳飞书授权;回调里用 code 换 user_access_token、查用户身份,用服务端密钥 AUTH_SECRET 把 open_id/姓名/邮箱签成 JWT 写进 HttpOnly Cookie;之后每次请求本地验票放行。fail-closed:验不过一律跳登录,绝不吐内容。

要你写的中间件规格(functions/_middleware.js,纯 Web 标准 API,无三方依赖):

  • 路由 /auth/login:生成随机 nonce 存短期 Cookie,把 nonce 和回跳路径编进 state,302 跳飞书授权页 accounts.feishu.cn/open-apis/authen/v1/authorize,带 client_id、redirect_uri(站点域名 + /auth/callback)、state。
  • 路由 /auth/callback:先校验 state 里 nonce 和 Cookie 一致(防 CSRF);POST open.feishu.cn/open-apis/authen/v2/oauth/token(JSON body:grant_type=authorization_code, client_id, client_secret, code, redirect_uri)拿 access_token;GET open.feishu.cn/open-apis/authen/v1/user_info(Header:Authorization Bearer access_token)拿 open_id/name/邮箱;可选按 ALLOWED_EMAIL_DOMAIN 限制邮箱域;用 HMAC-SHA256(Web Crypto)+ AUTH_SECRET 签发会话 JWT,写 HttpOnly、Secure、SameSite=Lax、7 天有效的 Cookie,302 回原路径。
  • 路由 /auth/logout:清 Cookie。
  • 其它所有请求:验 Cookie 里的 JWT(验签 + 过期),通过就 next() 放行静态资源,否则 302 跳 /auth/login。
  • 环境变量:FEISHU_APP_ID、FEISHU_APP_SECRET、AUTH_SECRET 必填,ALLOWED_EMAIL_DOMAIN 可选;缺必填变量时返回明确报错页、绝不放行。

落地步骤:

  1. 飞书开放平台建「企业自建应用」,拿 App ID 和 App Secret;安全设置里重定向 URL 填 https://你的域名/auth/callback;权限管理开通「获取用户基本信息」和「获取用户邮箱」;可用范围设为全体成员(或目标部门);到「版本管理与发布」发布版本。注意:改任何配置/权限/可用范围都必须发布新版本才生效。
  2. 把中间件写进站点仓库 functions/_middleware.js,push(Cloudflare Pages 自动部署成 Functions)。
  3. Cloudflare Pages 项目 Settings 的 Variables and secrets 里加 FEISHU_APP_ID、FEISHU_APP_SECRET、AUTH_SECRET(用 openssl rand -hex 32 生成一串),然后到 Deployments 里 Retry deployment 让变量生效。
  4. 若该站之前挂了 Cloudflare Access,去 Zero Trust 的 Access Applications 把它删掉,否则会先拦、中间件够不着。
  5. 无痕窗口测试:应跳飞书授权,授权后看到内容;可用范围外的账号被拒。

常见坑:同事报「没有使用权限」= 飞书应用「可用范围」没含他,或改了没发布版本;自己还看到旧登录页 = 该站的 Access 没删;改了环境变量不生效 = 没 Retry deployment;redirect_uri mismatch = 回调地址和飞书后台不一致或没发布。

五、想自己动手:步骤 + 代码

每步标了「谁来做」:🤖 AI = 让 AI 写/跑;🖱️ 你 = 后台点几下。

① 飞书开放平台建应用 🖱️ 你
建「企业自建应用」→ 拿 App ID / App Secret → 安全设置里重定向 URL 填 https://你的域名/auth/callback → 权限开「获取用户基本信息 + 邮箱」→ 可用范围设全体成员发布版本
⚠️ 两个最容易翻车的点:「可用范围」(谁能用这个应用登录)要设对,别只设了「数据权限范围」;以及改完必须「发布版本」才生效——同事登录报「没权限」,八成就是这两个。
② 写中间件、push 仓库 🤖 AI
在站点仓库放 functions/_middleware.js(下面附完整代码),push 即自动部署。让 AI 帮你写和提交。
③ Cloudflare 配环境变量 🖱️ 你
项目 Settings → Variables and secrets 加 FEISHU_APP_IDFEISHU_APP_SECRETAUTH_SECRET(用 openssl rand -hex 32 生成)→ 到 Deployments Retry deployment 让变量生效。
④ 删掉旧的 Cloudflare Access 🖱️ 你
若这个站之前挂了 Access(邮箱验证码那套),去 Zero Trust → Access → Applications 删掉它,否则它会先拦、中间件够不着。删了也不会裸奔(中间件 fail-closed)。
⑤ 无痕窗口验证
打开你的域名 → 应跳飞书授权 → 授权后看到内容;用「可用范围」外的账号试应被拒。

中间件完整代码(functions/_middleware.js

放进你站点仓库的 functions/ 目录即可。纯 Web 标准 API,无三方依赖:

// functions/_middleware.js —— 飞书登录网关(Cloudflare Pages Functions)
// 环境变量:FEISHU_APP_ID / FEISHU_APP_SECRET / AUTH_SECRET(必填),ALLOWED_EMAIL_DOMAIN(可选)
const COOKIE_NAME = "kc_session";
const NONCE_COOKIE = "kc_oauth_nonce";
const SESSION_TTL = 7 * 24 * 60 * 60; // 7 天
const FEISHU_AUTHORIZE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize";
const FEISHU_TOKEN = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
const FEISHU_USERINFO = "https://open.feishu.cn/open-apis/authen/v1/user_info";

export async function onRequest(context) {
  const { request, env, next } = context;
  const url = new URL(request.url);
  const path = url.pathname;
  if (!env.FEISHU_APP_ID || !env.FEISHU_APP_SECRET || !env.AUTH_SECRET) {
    return errorPage("服务未配置完成:请设置 FEISHU_APP_ID / FEISHU_APP_SECRET / AUTH_SECRET。", 503);
  }
  if (path === "/auth/login") return handleLogin(url, env);
  if (path === "/auth/callback") return handleCallback(request, url, env);
  if (path === "/auth/logout") return handleLogout(url);
  const session = await verifySession(request, env);
  if (session) return next();
  const to = new URL("/auth/login", url.origin);
  to.searchParams.set("redirect", path + url.search);
  return Response.redirect(to.toString(), 302);
}

async function handleLogin(url, env) {
  const redirectTo = sanitizePath(url.searchParams.get("redirect") || "/");
  const nonce = randomHex(16);
  const state = base64urlEncode(JSON.stringify({ n: nonce, r: redirectTo }));
  const callback = `${url.origin}/auth/callback`;
  const authUrl = new URL(FEISHU_AUTHORIZE);
  authUrl.searchParams.set("client_id", env.FEISHU_APP_ID);
  authUrl.searchParams.set("redirect_uri", callback);
  authUrl.searchParams.set("state", state);
  const headers = new Headers();
  headers.append("Set-Cookie", serializeCookie(NONCE_COOKIE, nonce, { maxAge: 600, httpOnly: true, secure: true, sameSite: "Lax", path: "/" }));
  headers.set("Location", authUrl.toString());
  return new Response(null, { status: 302, headers });
}

async function handleCallback(request, url, env) {
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  if (!code || !state) return errorPage("回调缺少 code 或 state。", 400);
  let parsed;
  try { parsed = JSON.parse(base64urlDecode(state)); } catch { return errorPage("state 解析失败。", 400); }
  const nonceCookie = getCookie(request, NONCE_COOKIE);
  if (!nonceCookie || nonceCookie !== parsed.n) return errorPage("state 校验失败(CSRF),请重新登录。", 400);
  const callback = `${url.origin}/auth/callback`;
  let tokenData;
  try {
    const r = await fetch(FEISHU_TOKEN, {
      method: "POST",
      headers: { "Content-Type": "application/json; charset=utf-8" },
      body: JSON.stringify({ grant_type: "authorization_code", client_id: env.FEISHU_APP_ID, client_secret: env.FEISHU_APP_SECRET, code, redirect_uri: callback }),
    });
    tokenData = await r.json();
  } catch (e) { return errorPage("请求飞书 token 失败:" + e, 502); }
  const accessToken = tokenData.access_token || (tokenData.data && tokenData.data.access_token);
  if (!accessToken) return errorPage("换取 token 失败:" + safeJson(tokenData), 502);
  let infoData;
  try {
    const r = await fetch(FEISHU_USERINFO, { headers: { Authorization: `Bearer ${accessToken}` } });
    infoData = await r.json();
  } catch (e) { return errorPage("请求飞书用户信息失败:" + e, 502); }
  const u = infoData.data || infoData;
  if (!u || !u.open_id) return errorPage("获取用户信息失败:" + safeJson(infoData), 502);
  const email = u.enterprise_email || u.email || "";
  if (env.ALLOWED_EMAIL_DOMAIN) {
    const ok = email.toLowerCase().endsWith("@" + env.ALLOWED_EMAIL_DOMAIN.toLowerCase());
    if (!ok) return errorPage("你的飞书账号不在允许访问的范围内。", 403);
  }
  const now = Math.floor(Date.now() / 1000);
  const jwt = await signJWT({ sub: u.open_id, name: u.name || "", email, iat: now, exp: now + SESSION_TTL }, env.AUTH_SECRET);
  const dest = sanitizePath(parsed.r || "/");
  const headers = new Headers();
  headers.append("Set-Cookie", serializeCookie(COOKIE_NAME, jwt, { maxAge: SESSION_TTL, httpOnly: true, secure: true, sameSite: "Lax", path: "/" }));
  headers.append("Set-Cookie", serializeCookie(NONCE_COOKIE, "", { maxAge: 0, path: "/" }));
  headers.set("Location", url.origin + dest);
  return new Response(null, { status: 302, headers });
}

function handleLogout(url) {
  const headers = new Headers();
  headers.append("Set-Cookie", serializeCookie(COOKIE_NAME, "", { maxAge: 0, path: "/" }));
  headers.set("Location", url.origin + "/");
  return new Response(null, { status: 302, headers });
}

async function verifySession(request, env) {
  const token = getCookie(request, COOKIE_NAME);
  if (!token) return null;
  return await verifyJWT(token, env.AUTH_SECRET);
}

async function hmacKey(secret) {
  return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
}
async function signJWT(payload, secret) {
  const enc = (o) => base64urlFromBytes(new TextEncoder().encode(JSON.stringify(o)));
  const data = enc({ alg: "HS256", typ: "JWT" }) + "." + enc(payload);
  const key = await hmacKey(secret);
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  return data + "." + base64urlFromBytes(new Uint8Array(sig));
}
async function verifyJWT(token, secret) {
  const parts = token.split(".");
  if (parts.length !== 3) return null;
  const data = parts[0] + "." + parts[1];
  const key = await hmacKey(secret);
  let ok = false;
  try { ok = await crypto.subtle.verify("HMAC", key, base64urlToBytes(parts[2]), new TextEncoder().encode(data)); } catch { return null; }
  if (!ok) return null;
  let payload;
  try { payload = JSON.parse(new TextDecoder().decode(base64urlToBytes(parts[1]))); } catch { return null; }
  if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null;
  return payload;
}

function base64urlFromBytes(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
function base64urlToBytes(str) { str = str.replace(/-/g, "+").replace(/_/g, "/"); while (str.length % 4) str += "="; const b = atob(str); const a = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) a[i] = b.charCodeAt(i); return a; }
function base64urlEncode(str) { return base64urlFromBytes(new TextEncoder().encode(str)); }
function base64urlDecode(str) { return new TextDecoder().decode(base64urlToBytes(str)); }
function randomHex(n) { const b = new Uint8Array(n); crypto.getRandomValues(b); return [...b].map(x => x.toString(16).padStart(2, "0")).join(""); }
function serializeCookie(name, value, opt = {}) { let s = `${name}=${value}`; if (opt.maxAge != null) s += `; Max-Age=${opt.maxAge}`; s += `; Path=${opt.path || "/"}`; if (opt.httpOnly) s += "; HttpOnly"; if (opt.secure) s += "; Secure"; if (opt.sameSite) s += `; SameSite=${opt.sameSite}`; return s; }
function getCookie(request, name) { const c = request.headers.get("Cookie") || ""; const m = c.match(new RegExp("(?:^|; )" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)")); return m ? m[1] : null; }
function sanitizePath(p) { if (!p || typeof p !== "string" || !p.startsWith("/") || p.startsWith("//")) return "/"; return p; }
function safeJson(o) { try { return JSON.stringify(o); } catch { return String(o); } }
function errorPage(msg, status) {
  const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录</title>` +
    `<div style="font-family:sans-serif;max-width:420px;margin:12vh auto;text-align:center;color:#333">` +
    `<h2>🔒 内部站</h2><p style="color:#666">${msg}</p><a href="/auth/login" style="display:inline-block;margin-top:12px;background:#2f6f6a;color:#fff;padding:.6rem 1.2rem;border-radius:8px;text-decoration:none">用飞书登录</a></div>`;
  return new Response(html, { status: status || 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
}

六、踩过的坑(照着避)

现象原因 / 解决
同事报「你没有 XX 的使用权限」飞书应用可用范围没含他,或改了没发布版本。设全体成员 + 发布。
自己打开还是旧的邮箱登录页该站的 Cloudflare Access 应用没删,被它先拦了。
改了环境变量不生效Pages 变量改动要 Retry deployment 才生效。
redirect_uri mismatch飞书后台重定向 URL 和实际回调不一致,或改了没发布。必须是 /auth/callback 且逐字符一致。
国际版 Lark 调不通域名用错:飞书 feishu.cn,Lark 国际版 larksuite.com

配好之后真的顺:同事点开链接、飞书扫码,就进来了;外人和爬虫连门都摸不到。内部数据放网上的那点心理负担,一下就没了。

—— Carolyn