· 10 分钟 · 安全 / Cloudflare Workers / 前端 / 设计系统
全站登录墙和粉色主题:为什么只在前端拦是不够的
网站改成登录后才能看,并且换成了樱花粉。讲清楚:只在浏览器里隐藏内容为什么挡不住人;怎么让 Cloudflare 把每个页面请求先交给后端检查;放行名单怎么写才不会被绕过;以及为什么换一套配色只需要改一个文件。
这是 Seabloom 建站记录的第七篇。这次的需求很简单:
- 进网站就要登录,不登录不注册就不给看;
- 界面要粉粉的。
第一个需求看起来只要在页面上加一句「没登录就跳到登录页」,但这样做其实什么都没挡住。这一篇先讲为什么,再讲正确的做法。
一、只在前端拦,为什么挡不住
最直观的做法是在每个页面里加一段代码:
const { user } = useAuth();
if (!user) router.push("/login/");
打开网站,没登录就会被跳走,看起来很完美。但请求实际是这样发生的:
- 浏览器请求
/blog/,服务器把完整的页面 HTML 发过来,里面已经有文章的全部内容; - 浏览器开始执行 JavaScript,查询登录状态;
- 发现没登录,跳到登录页。
第 1 步内容已经到了浏览器,第 3 步的跳转只是「把它藏起来」。要绕过它非常简单:
- 查看网页源代码(Ctrl+U),内容就在 HTML 里;
- 关掉 JavaScript,跳转代码根本不会执行;
- 用
curl直接请求页面或接口,压根不会运行 JavaScript。
还有更隐蔽的地方。Next.js 在页面之间跳转时,不会每次都重新下载整个 HTML,而是请求一个数据文件,比如 /blog/__next._full.txt,里面是这一页内容的序列化结果。只拦页面,不拦这些文件,内容一样会漏出去。
结论:访问控制必须在服务器上做。 服务器在把内容发出去之前就检查身份,没登录的请求,内容根本不离开服务器。前端的判断只是为了体验(比如没登录时隐藏导航栏),不是为了安全。
这和第二篇讲后台权限时是同一个原则:前端判断决定显示什么,后端判断决定能拿到什么。
二、让页面请求先经过后端
原来的结构
第二篇讲过,Cloudflare 收到请求后按路径分流:
/api/* → Worker(后端代码)
其他路径 → 直接返回 out/ 里的静态文件,不经过任何代码
静态文件不经过代码,所以没有地方能检查登录。
改成:几乎所有请求都先到 Worker
wrangler.jsonc 里控制分流的是 run_worker_first:
// 之前:只有接口先到 Worker
"run_worker_first": ["/api/*"],
// 现在:所有请求都先到 Worker,除了这两种
"run_worker_first": ["/*", "!/_next/static/*", "!/icon.svg"],
! 开头表示「排除」。这样,除了 /_next/static/ 下面的脚本、样式文件和网站图标,所有请求都会先交给我们的代码。
为什么排除 /_next/static/? 这里放的是打包后的 JavaScript 和 CSS,打开一个页面要加载十几个这样的文件。它们是代码,不是内容:文章、帖子、照片都不在里面(文章内容在页面 HTML 和上面说的数据文件里)。如果也拦,每打开一个页面,Worker 要多处理十几次请求,白白消耗免费额度(每天 10 万次),还会让页面变慢。
Worker 入口:按路径分两路
export default {
fetch: (request, env, ctx) =>
new URL(request.url).pathname.startsWith("/api/")
? app.fetch(request, env, ctx) // 接口:交给原来的后端
: gate(request, env), // 页面:先检查登录
};
页面拦截:gate
const PUBLIC_PAGE = /^\/(login|register|privacy)(\/|\.txt$|$)/;
export async function gate(request: Request, env: Bindings) {
const url = new URL(request.url);
if (!PUBLIC_PAGE.test(url.pathname)) {
const token = parse(request.headers.get("Cookie") ?? "", SESSION_COOKIE)[SESSION_COOKIE];
const user = await findUser(env.DB, token);
if (!user) {
const login = new URL("/login/", url);
login.searchParams.set("next", url.pathname + url.search);
return Response.redirect(login.toString(), 302);
}
}
const res = await env.ASSETS.fetch(request); // 登录了:去取静态文件
const out = new Response(res.body, res);
out.headers.set("Cache-Control", "private, no-cache");
out.headers.append("Vary", "Cookie");
return out;
}
几个细节:
env.ASSETS.fetch:第二篇提过「绑定」,ASSETS 就是静态文件的绑定。Worker 检查完身份后,用它取出原本的静态文件。_headers(安全响应头)、_redirects(旧地址跳转)这些规则依然生效。
记住原来要去哪:跳到登录页时带上 ?next=/community/?tab=owner,登录成功后跳回去。next 这个参数第二篇专门处理过「开放跳转」漏洞(只接受站内路径),这里直接复用。
和接口共用同一个查身份的函数:findUser 原来写在接口的中间件里。现在页面也要用,就把它抽成一个独立的函数,两边调用。同一条规则只写一遍,以后改了(比如加上「被封禁的不算登录」),两边自动一致。
放行名单要写严
不登录也能打开的只有三个页面:登录、注册、隐私说明(注册前要能看到)。
第一反应可能是这样写:
if (url.pathname.startsWith("/login")) 放行;
问题在于 startsWith("/login") 也会匹配 /loginx/、/login-secret/。现在没有这样的页面,但以后加一个名字以 login 开头的页面,它就会悄悄地对所有人公开。
所以用了一个更严格的正则:
/^\/(login|register|privacy)(\/|\.txt$|$)/;
意思是:/login 后面必须紧跟着 /、字符串结尾,或者 .txt(数据文件)。测试里专门写了一条:
it("名字以 login 开头的其他页面不会被误放行", async () => {
expect((await page("/loginx/")).status).toBe(302);
expect((await page("/registered/")).status).toBe(302);
});
写访问控制时,默认应该是「拒绝」,放行名单越小、越精确越好。 这叫「默认拒绝」(deny by default)原则。
缓存:内容只能给登录的人
登录后取到的页面,加了两个响应头:
Cache-Control: private, no-cache:只允许用户自己的浏览器缓存,而且每次用之前要先问服务器还能不能用。中间的 CDN、公司代理不许缓存。Vary: Cookie:告诉所有缓存「这个响应和 Cookie 有关,Cookie 不同,内容可能不同」。
如果不加,假设某个中间缓存把一个登录用户看到的页面存了下来,下一个没登录的人请求同一个地址,可能直接拿到缓存里的内容,完全绕过了登录检查。
接口也要拦
页面拦住了,但接口还开着的话,curl https://haiyangliu.com/api/posts 一样能拿到所有帖子。所以接口加了一个中间件:
app.use(async (c, next) => {
const open = c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health";
if (!c.get("user") && !open) return fail(c, 401, "unauthenticated", "请先登录");
await next();
});
只有登录、注册、查询登录状态(/api/auth/*)和健康检查不需要登录。
注意这里没登录返回的是 401,而页面返回的是 302 跳转。区别在于:页面是给人看的,跳到登录页是最友好的;接口是给程序调用的,返回明确的错误码,由调用方决定怎么处理。
测试也要跟着改
加上登录墙后,有 21 个测试失败了。原因不是代码错了,而是这些测试里有很多「以没登录的访客身份看帖子」,现在这样做本来就应该被拒绝。
修法是给这些测试文件加一个已登录的「visitor」账号,原来匿名读取的地方改为用它来读。同时专门写了一组新测试,覆盖登录墙本身:
- 没登录打开任何页面都跳登录页,并带着原地址;
- 页面的数据文件、图片、RSS、后台页面一样拦;
- 登录、注册、隐私说明可以直接打开;
- 名字相似的页面不会被误放行;
- 登录后能打开,且响应头禁止 CDN 缓存;
- 被封禁的账号、伪造的 Cookie 打不开;
- 没登录时,读内容的接口全部 401。
有一个值得注意的地方:修改测试时,要分清「测试本身写错了」和「需求变了」。这次是需求变了(匿名访客不能再看内容),所以改测试是对的。但如果代码改完后测试失败,第一反应不该是「改测试让它通过」,而是先想清楚测试保护的那条规则,现在还成不成立。
代价
做之前和站长确认过的几件事:
- 搜索引擎搜不到网站内容了,只能看到登录页。所以把站点地图(sitemap)删掉了,里面列的页面爬虫都打不开。登录页反而改成了允许收录,因为它是唯一的「门面」。
- RSS 订阅也用不了了,RSS 阅读器不会登录。
- 博客也要登录才能看,文章链接不能直接发给别人。
- 每次打开页面多一次 Worker 请求。个人网站离免费额度(每天 10 万次)很远。
三、登录页变成了「大门」
既然所有人第一眼看到的都是登录页,它就不能只是一个表单了。
- 宽屏时左边是网站名、那句「在海里,慢慢开花」和那朵小花,右边是登录框;手机上上下排列。
- 没登录时,导航栏里的链接不显示:点哪个都会被拦回登录页,显示了反而让人困惑。
- 点「退出登录」后,整页刷新跳回登录页,不只是更新状态。因为当前页面上已经加载的内容还显示着,刷新一下才能清掉。
四、粉色主题:为什么只改一个文件
两层颜色变量
第一篇讲过,网站的颜色分两层:
/* 第一层:原始色板,只描述「这是什么颜色」 */
--color-blossom-100: #fdeef3;
/* 第二层:语义色,描述「这个颜色用来干什么」 */
--bg: var(--color-blossom-100); /* 页面底色 */
--fg: #4a1f33; /* 正文 */
--accent: #c0336a; /* 按钮、链接 */
所有组件只用第二层,比如 bg-bg、text-fg、text-accent,没有一个组件里写死了「蓝色」。所以把网站从浅海蓝换成樱花粉,只需要改 globals.css 里第二层变量的值:几十个组件、二十多个页面自动全部变色,连那朵小花、海浪和网站图标都跟着变了。
这就是设计令牌(design tokens)的作用。大公司的设计系统也是这个思路:品牌换色、深色模式、给不同客户做不同主题,都只是换一组令牌的值。
这次唯一要逐个改的地方,是主按钮:它们原来用的是「正文色当背景」(bg-fg),在粉色主题里是深莓色,不够粉。改成了 bg-accent(玫瑰粉)。这也说明一个问题:按钮当初就应该用「按钮色」这个语义,而不是借用「正文色」。借用的时候省事,换主题时就要一个个找出来。
粉色也要看得清:对比度
「粉粉的」很容易做成浅粉底配浅粉字,看起来可爱,但读起来很累,视力不好的人可能根本看不清。
衡量文字清不清楚的标准是对比度(WCAG 无障碍标准):
对比度 = (较亮颜色的相对亮度 + 0.05) / (较暗颜色的相对亮度 + 0.05)
相对亮度是把 RGB 换算成人眼感知的亮度(绿色权重最大,蓝色最小)。普通大小的文字,对比度至少要 4.5 : 1。
每个颜色都先算过再用:
| 用途 | 颜色 | 在浅粉底色上的对比度 |
|---|---|---|
| 正文 | #4a1f33 | 12.2 |
| 次要文字 | #85465f | 6.2 |
| 按钮、链接 | #c0336a | 4.8 |
| 话题、图标 | #b8436f | 4.6 |
按钮和链接的玫瑰粉,一开始选的是更亮的 #d6457a,算下来只有 3.75,不达标,所以调深了一点。设计时的颜色和最终可用的颜色,经常要为可读性让一步。
深色模式也换成了粉色系:深紫红的背景(#22121b),浅粉的文字,正文对比度 15.2。
五、面试怎么讲
一分钟介绍
网站改成了全站登录。我没有在前端做跳转,因为页面 HTML 和 Next.js 的数据文件在跳转前就已经发到浏览器了,查看源代码或者用 curl 就能绕过。我改了 Cloudflare 的路由配置,让除了静态脚本以外的所有请求都先进 Worker,Worker 查会话,没登录就 302 到登录页并带上原地址,登录了才从静态资源绑定里取文件,并加上 private 和 Vary: Cookie 防止共享缓存泄露。放行名单用精确的正则,测试覆盖了名字相似的路径。接口另加一层中间件返回 401。同时把主题换成了粉色:因为所有组件只用语义色变量,换主题只改了一个 CSS 文件,并且每个文字颜色都按 WCAG 算过对比度。
可能被问到的问题
Q:前端路由守卫和后端鉴权有什么区别? A:前端守卫只控制界面怎么显示,内容在判断之前就已经发到浏览器了,关掉 JavaScript、看源代码、直接调接口都能绕过。后端鉴权在发出内容之前检查身份,没通过就不返回内容,才是真正的访问控制。前端守卫只用来改善体验,比如提前跳转、隐藏入口。
Q:静态网站怎么做登录保护? A:静态文件本身没有逻辑,要在它前面加一层能执行代码的东西:CDN 的边缘函数(比如 Cloudflare Workers、Lambda@Edge),或者反向代理(Nginx 的 auth_request)。请求先经过这一层检查会话,通过了再返回静态文件。要注意把数据文件、图片等所有承载内容的资源都纳入保护,只放行不含内容的公共资源。
Q:受保护的页面,缓存要注意什么?
A:不能被共享缓存(CDN、代理)缓存,否则可能把一个用户的内容发给另一个人,用 Cache-Control: private。如果响应会因为 Cookie 或请求头不同而不同,要加 Vary。需要每次都校验的,加 no-cache;完全不能存的,用 no-store。
Q:什么是「默认拒绝」? A:访问控制默认不允许,只有明确列出的才放行。好处是新增的东西默认是安全的:忘了配置的页面是被拦住的,而不是被公开的。实现时放行规则要尽量精确,比如用完整的路径匹配,而不是前缀匹配,否则名字相似的路径会被误放行。
Q:设计令牌是什么?有什么好处? A:把颜色、间距、字号这些设计决策抽成有名字的变量,并分层:底层是原始值,上层是语义,比如「页面背景」「主按钮」。组件只引用语义层。好处是换主题、做深色模式、做多品牌时只改变量的值,组件不用动;也能保证全站一致。要注意语义要用对,比如按钮就该用按钮色,借用文字色在换主题时会出问题。
Q:怎么判断颜色是否可读? A:用 WCAG 对比度:把两个颜色换算成相对亮度,按公式算比值。普通文字 AA 级要求 4.5:1,大号文字 3:1,AAA 级是 7:1。浅色主题、深色主题都要分别检查,尤其是浅色背景上的彩色文字,最容易不达标。
六、下一步
- 博客单独公开:如果以后想把文章分享给别人,可以把
/blog/加进放行名单,只锁社区等其他部分。这只需要改一行正则(再加一条测试)。 - 邀请码注册:现在任何人都能注册,注册后就能看全部内容。如果想做成只有朋友能进的小圈子,可以加邀请码。
- 按钮色单独成一个令牌:把「主按钮」从
accent里独立出来,以后调按钮颜色就不会影响链接。
更新:之后又补上了自动部署、错误日志和数据备份,见第八篇。
这一篇最想说的是:安全检查要放在数据离开你控制范围之前。 内容一旦发到用户的浏览器,它就不再属于你了。