一、背景:内部数据,怎么放才对
我把公司的一些报表、数据看板放在了 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 可选;缺必填变量时返回明确报错页、绝不放行。
落地步骤:
- 飞书开放平台建「企业自建应用」,拿 App ID 和 App Secret;安全设置里重定向 URL 填 https://你的域名/auth/callback;权限管理开通「获取用户基本信息」和「获取用户邮箱」;可用范围设为全体成员(或目标部门);到「版本管理与发布」发布版本。注意:改任何配置/权限/可用范围都必须发布新版本才生效。
- 把中间件写进站点仓库 functions/_middleware.js,push(Cloudflare Pages 自动部署成 Functions)。
- Cloudflare Pages 项目 Settings 的 Variables and secrets 里加 FEISHU_APP_ID、FEISHU_APP_SECRET、AUTH_SECRET(用 openssl rand -hex 32 生成一串),然后到 Deployments 里 Retry deployment 让变量生效。
- 若该站之前挂了 Cloudflare Access,去 Zero Trust 的 Access Applications 把它删掉,否则会先拦、中间件够不着。
- 无痕窗口测试:应跳飞书授权,授权后看到内容;可用范围外的账号被拒。
常见坑:同事报「没有使用权限」= 飞书应用「可用范围」没含他,或改了没发布版本;自己还看到旧登录页 = 该站的 Access 没删;改了环境变量不生效 = 没 Retry deployment;redirect_uri mismatch = 回调地址和飞书后台不一致或没发布。
五、想自己动手:步骤 + 代码
每步标了「谁来做」:🤖 AI = 让 AI 写/跑;🖱️ 你 = 后台点几下。
https://你的域名/auth/callback → 权限开「获取用户基本信息 + 邮箱」→ 可用范围设全体成员 → 发布版本。
functions/_middleware.js(下面附完整代码),push 即自动部署。让 AI 帮你写和提交。
FEISHU_APP_ID、FEISHU_APP_SECRET、AUTH_SECRET(用 openssl rand -hex 32 生成)→ 到 Deployments Retry deployment 让变量生效。
中间件完整代码(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