跳到正文

· 27 分钟 · 后端 / Cloudflare Workers / 安全 / 数据库

给静态网站加上后端:登录、留言板和站长后台

不租服务器,怎么给一个纯静态网站加上账号系统、留言板和站长后台?这篇讲清楚每一层的设计:数据库、密码、会话、限流、分页、权限、审计日志和测试,以及每一项安全措施在防什么攻击。

这是 Seabloom 建站记录的第二篇。第一篇讲的是一个纯静态网站:所有页面在构建时就生成好,放在 CDN 上,没有任何代码在服务器上运行。

纯静态网站又快又便宜,但有一件事做不到:记住用户做过什么。注册、登录、留言,都需要一个能运行代码、能存数据的后端。

这一篇记录怎么在不租服务器的前提下,分三期给网站加上:

  1. 账号系统:邮箱 + 密码注册、登录、退出。
  2. 潮汐留言板:登录用户可以留言,所有人都能看。
  3. 站长后台:站长可以隐藏留言、封禁用户,所有操作都有日志。

读法建议:第一到三节是地基,第四到六节是三期功能,每一项安全措施都按「如果不这样做,会被怎么攻击」来讲。第十一节是面试问答,可以最后看。

一、全貌:一个请求怎么分流

浏览器页面 + fetch带 CookieCloudflare 节点haiyangliu.com看路径分流WorkerHono 路由鉴权 · 校验 · 限流静态文件out/ 里的 HTMLCSS · JS · 图片D1SQLite 数据库users · messagesHTTPS/api/*其他路径SQL
图 1:同一个域名、同一个 Worker 项目,按路径分成两条路

第一篇里,Cloudflare 只做一件事:把 out/ 文件夹里的文件发给访客。现在它多了一个分支:

  • 网址以 /api/ 开头的请求(比如 /api/auth/login),交给一段后端代码处理。这段代码叫 Worker,运行在 Cloudflare 全球的节点上。
  • 其他请求(页面、图片、CSS)和以前一样,直接返回静态文件,不经过后端代码,所以页面速度完全不受影响。
  • Worker 需要存数据时,去查 D1,这是 Cloudflare 提供的数据库,本质上就是 SQLite。

页面和接口在同一个域名下(都是 haiyangliu.com),这一点很重要,第四节会讲为什么。

分流规则写在 wrangler.jsonc 里,逐行看:

{
  "name": "haiyangliu",
  "main": "./worker/index.ts", // 后端代码的入口文件
  "assets": {
    "directory": "./out", // 静态文件还是 out/
    "binding": "ASSETS", // 后端代码里可以用 env.ASSETS 访问静态文件
    "not_found_handling": "404-page",
    "run_worker_first": ["/api/*"], // 只有 /api/* 先交给后端,其余直接发文件
  },
  "d1_databases": [
    {
      "binding": "DB", // 后端代码里用 env.DB 访问数据库
      "database_name": "seabloom",
      "database_id": "ea55f642-…",
      "migrations_dir": "./migrations", // 建表 SQL 放在哪
    },
  ],
}

**binding(绑定)**是 Workers 的核心概念:数据库、静态文件这些资源不是通过「连接字符串 + 密码」访问的,而是由平台直接注入到代码的 env 参数里。代码里写 env.DB.prepare(...) 就能查库,没有密码需要保管,也就不会泄露。

二、技术选型:为什么是这几样

选择是什么为什么选它备选
Cloudflare Workers在 CDN 节点上运行的函数和静态站同一个项目、同一个域名;免费额度每天 10 万次请求;不用管服务器租 VPS 跑 Node.js
HonoWeb 框架(类似 Express)专为 Workers 这类环境设计,体积小;路由、中间件、Cookie 工具都有;写法和 Express 几乎一样,学会了可以迁移直接手写 fetch 处理函数
D1Cloudflare 的 SQLite 数据库标准 SQL,以后换 PostgreSQL 时 SQL 大部分能直接用;免费 5 GBPostgres(要自己部署)
zod运行时数据校验库TypeScript 的类型只在写代码时有效,用户发来的数据必须在运行时检查;zod 写一次规则,同时得到校验和类型手写 if 判断

为什么不租服务器? 租服务器要自己装系统、配 Nginx、申请证书、打安全补丁、做备份,还要付钱。对一个个人网站来说,这些都是负担。Workers 把这些全包了。代价是:

  • 运行环境有限制,比如不能用依赖 Node.js 原生模块的库(第四节的密码哈希会讲到)。
  • 和 Cloudflare 绑得比较紧。不过代码只用了标准的 Request、Response、Web Crypto 和 SQL,迁移成本不高,第十二节会列出要改哪些地方。

三、数据库设计

migration:用文件记录数据库的每一次变化

数据库的表结构不是在后台点出来的,而是写成 SQL 文件,按编号放在 migrations/ 里:

migrations/
├─ 0001_auth.sql       # 第一期:users、sessions、login_attempts
├─ 0002_messages.sql   # 第二期:messages
└─ 0003_admin.sql      # 第三期:messages 加审核字段 + admin_actions

执行 pnpm db:migrate:remote,wrangler 会检查线上数据库已经执行过哪些文件,只执行新的。这样做的好处:

  • 表结构有版本记录:和代码一起提交到 Git,什么时候加了哪个字段,一查便知。
  • 本地和线上一致:本地用 db:migrate:local 执行同一批文件。测试也执行同一批文件(第八节)。
  • 只往后加,不改旧文件:第三期给 messages 加字段,是新写一个 0003,用 ALTER TABLE 加列,而不是回去改 0002。因为线上已经执行过 0002,改它不会生效。

用户表

CREATE TABLE users (
  id            TEXT PRIMARY KEY,                 -- 随机 UUID
  email         TEXT NOT NULL UNIQUE COLLATE NOCASE,
  nickname      TEXT NOT NULL,
  password_hash TEXT NOT NULL,                    -- 不存密码原文
  role          TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
  status        TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'banned')),
  created_at    INTEGER NOT NULL                  -- Unix 毫秒
);

几个设计决定:

  • ID 用随机 UUID,不用自增数字。自增 ID 会暴露信息:如果我的用户 ID 是 37,别人就知道网站大概有多少用户;还能用 1、2、3…… 挨个去试接口。UUID 猜不出来,也不透露数量。
  • email 加 UNIQUE COLLATE NOCASE:唯一,并且不区分大小写。Alice@Example.com 和 alice@example.com 是同一个人。代码里也会先把邮箱转成小写,数据库这一层是第二道保险。
  • CHECK 约束:role 只能是 user 或 admin。即使代码有 bug 写进了别的值,数据库也会拒绝。
  • 时间存成数字(毫秒):SQLite 没有专门的日期类型,存数字比较大小、排序最简单,也不会有时区问题。

留言表和软删除

CREATE TABLE messages (
  id         TEXT PRIMARY KEY,
  user_id    TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  body       TEXT NOT NULL,
  status     TEXT NOT NULL DEFAULT 'visible' CHECK (status IN ('visible', 'hidden')),
  created_at INTEGER NOT NULL,
  deleted_at INTEGER                              -- 软删除
);

CREATE INDEX messages_created ON messages(created_at DESC, id DESC);
CREATE INDEX messages_user_created ON messages(user_id, created_at);

软删除:用户删留言时,不执行 DELETE,而是把 deleted_at 设成当前时间,查询时加上 deleted_at IS NULL 过滤掉。好处是:

  • 误删可以恢复。
  • 有人发了违规内容再删掉,站长在后台仍然能看到,可以留证据、封号。
  • 限流时可以把删掉的也算进去(第五节)。

隐私说明里也写清楚了这一点:删除后所有人都看不到,但数据库会保留记录。

索引是数据库的「目录」。没有索引,查「最新 20 条留言」要把整张表读一遍再排序;有了 (created_at DESC, id DESC) 索引,数据库直接按目录顺序取前 20 条。索引要按实际的查询来建:

  • 留言列表按时间倒序分页,对应第一个索引;
  • 限流要查「某个用户最近发了几条」,对应第二个索引。

第三期的变化:只加不改

ALTER TABLE messages ADD COLUMN hidden_reason TEXT;
ALTER TABLE messages ADD COLUMN hidden_at INTEGER;
ALTER TABLE messages ADD COLUMN review TEXT;   -- 'pending' = 等待站长审核

CREATE TABLE admin_actions (
  id         TEXT PRIMARY KEY,
  admin_id   TEXT NOT NULL REFERENCES users(id),
  action     TEXT NOT NULL,     -- hide_message / unhide_message / ban_user / unban_user
  target_id  TEXT NOT NULL,
  reason     TEXT,
  created_at INTEGER NOT NULL
);

admin_actions 是审计日志表,第六节专门讲。

四、第一期:账号系统

账号系统是整个后端里安全要求最高的部分。下面每一项都按「做法 → 如果不这样做会怎样」来讲。

一次登录的全过程

  1. 01

    浏览器发请求

    登录页的表单调用 fetch('/api/auth/login'),把邮箱和密码以 JSON 发过去。全程 HTTPS,传输过程中别人看不到内容。

  2. 02

    检查请求来源

    中间件先看请求头里的 Origin:必须是 https://haiyangliu.com。别的网站发来的请求直接返回 403。

  3. 03

    校验输入

    zod 检查邮箱格式、密码长度。不合格直接返回 400 和中文提示,不会碰数据库。

  4. 04

    检查限流

    这个邮箱 15 分钟内失败了几次?这个 IP 呢?超过上限返回 429,正确的密码也不让进。

  5. 05

    验证密码

    从数据库取出这个邮箱的密码哈希,用同样的算法把用户输入的密码算一遍,比较结果。失败就记一次失败次数。

  6. 06

    创建会话

    生成一个随机令牌,令牌的哈希存进 sessions 表,令牌原文放进 Cookie 返回给浏览器。

  7. 07

    之后的每个请求

    浏览器自动带上 Cookie。中间件用它查出当前用户,后面的接口直接用 c.get('user') 拿到。

图 2:从点「登录」到右上角出现头像

密码:为什么要「加盐的慢哈希」

最差的做法是存明文。 数据库一旦泄露(被拖库、备份文件丢了、内部人员查看),所有人的密码都暴露了。很多人在不同网站用同一个密码,所以损失会扩散到别的网站。

用普通哈希(比如 SHA-256)也不够。 哈希是单向的:能从密码算出哈希,不能从哈希反推密码。但有两个问题:

  1. 太快。显卡每秒能算几十亿次 SHA-256,攻击者可以把常见密码全部算一遍,和泄露的哈希逐个比对。
  2. 相同密码的哈希相同。攻击者只要算一次 123456 的哈希,就能在整张表里找出所有用这个密码的人。还有现成的「彩虹表」,存着海量常见密码的哈希。

我们的做法:PBKDF2 + 随机盐 + 10 万次迭代,代码在 worker/lib/password.ts:

const ITERATIONS = 100_000;

export async function hashPassword(password: string) {
  const salt = crypto.getRandomValues(new Uint8Array(16)); // 每个用户一份随机盐
  const hash = await derive(password, salt, ITERATIONS); // 重复计算 10 万次
  return `pbkdf2$${ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
}
  • 盐(salt):每个用户生成一串随机数,和密码混在一起再哈希。两个人都用 123456,哈希也完全不同,彩虹表就失效了,攻击者必须一个一个用户单独破解。盐不需要保密,和哈希存在一起。
  • 迭代 10 万次:把哈希重复算 10 万遍。正常登录多花几十毫秒,用户感觉不到;攻击者暴力破解的成本却变成了原来的 10 万倍。
  • 存储格式里写上算法和次数:pbkdf2$100000$盐$哈希。以后想调高次数,新密码用新次数,老密码按它自己记录的次数验证,两者兼容。

为什么不用更常听说的 bcrypt 或 argon2? 它们在 Node.js 里是原生模块(C/C++ 写的),Workers 里跑不了。PBKDF2 是 Web Crypto 标准自带的算法,Workers 原生支持,也是 NIST 推荐的算法之一。Workers 对 PBKDF2 的迭代次数上限是 10 万,我们正好取上限。

验证时有一个细节,比较两个哈希不能用 ===:

function timingSafeEqual(a: Uint8Array, b: Uint8Array) {
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; // 不提前退出
  return diff === 0;
}

普通比较遇到第一个不同的字节就会返回,于是「前 10 个字节都对」比「第 1 个字节就错」慢一点点。攻击者测量响应时间,理论上可以一个字节一个字节地猜。这叫计时攻击。上面的写法无论对错都把所有字节比完,耗时一样。

会话:令牌为什么也要哈希

登录成功后,服务器要让浏览器「记住自己已登录」。做法是生成一个 32 字节的随机令牌:

  • 原文放进浏览器的 Cookie;
  • 哈希存进数据库的 sessions 表。

每次请求,服务器把 Cookie 里的令牌哈希一下,去表里查。

为什么数据库里不直接存令牌? 理由和密码一样:如果数据库泄露,攻击者拿到的只是哈希,没法拿它伪造 Cookie 登录别人的账号。这里用普通的 SHA-256 就够了,不需要慢哈希,因为令牌是 32 字节的随机数(2²⁵⁶ 种可能),不像密码那样能被猜出来。

为什么不用 JWT? JWT 把用户信息签名后直接放在令牌里,服务器不用查库,适合大规模分布式系统。但它有个大问题:发出去就收不回来。用户点「退出」、站长封禁某人,已经发出的 JWT 在过期之前依然有效。我们的会话存在数据库里,退出就删掉那一行,封禁就删掉这个人所有的会话,立即生效。对小网站来说,每次请求多查一次库完全不是问题。

Cookie 的三个属性,各防一种攻击

setCookie(c, "seabloom_session", token, {
  httpOnly: true, // 防 XSS 偷令牌
  secure: true, // 防网络窃听
  sameSite: "Lax", // 防 CSRF
  path: "/",
  maxAge: 30 * 24 * 60 * 60, // 30 天
});
属性作用如果不设
HttpOnly页面里的 JavaScript 读不到这个 Cookie如果网站哪天出了 XSS 漏洞(被注入了恶意脚本),脚本可以 document.cookie 把令牌发走,攻击者拿去就能登录你的账号
Secure只在 HTTPS 连接里发送在公共 Wi-Fi 下用 HTTP 访问时,令牌可能被同一网络的人截获
SameSite=Lax别的网站发起的 POST 请求不带这个 Cookie见下面的 CSRF

CSRF:别的网站冒充你发请求

CSRF(跨站请求伪造)的场景:你登录着 haiyangliu.com,然后打开了一个恶意网页。那个网页里藏着一个自动提交的表单,目标是 https://haiyangliu.com/api/messages。浏览器发请求时会自动带上你的 Cookie,服务器以为是你本人在发留言。

我们有两道防线:

  1. SameSite=Lax 的 Cookie:从别的网站发起的 POST 请求,浏览器根本不带这个 Cookie,服务器看到的就是「没登录」。
  2. 检查 Origin 请求头:浏览器发请求时会在 Origin 头里写明请求来自哪个网站,网页上的 JavaScript 改不了它。所有会改数据的请求(POST、DELETE),Origin 必须是本站:
app.use(async (c, next) => {
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
    const origin = c.req.header("origin");
    if (origin !== new URL(c.req.url).origin) {
      return fail(c, 403, "bad_origin", "请求来源不对");
    }
  }
  await next();
});

这也是为什么前面说页面和接口要在同一个域名下。如果接口放在 api.haiyangliu.com,就变成了「跨站」请求,要额外配置 CORS(跨域资源共享)、Cookie 的域名,事情会复杂很多。

限流:挡住暴力破解

不限流的话,攻击者可以写个脚本,对着一个邮箱一秒钟试几百个密码。我们的规则:

场景上限
同一个邮箱登录失败15 分钟 5 次
同一个 IP 登录失败15 分钟 20 次
同一个 IP 注册15 分钟 10 次

实现很朴素:每次失败往 login_attempts 表里插一行,判断时数一数窗口内有几行:

SELECT COUNT(*) AS n FROM login_attempts WHERE key = ? AND created_at > ?
-- key 形如 'email:a@b.com'、'ip:1.2.3.4';第二个参数是「现在 - 15 分钟」

为什么邮箱和 IP 分开限?只限 IP 的话,攻击者换一堆 IP 就能对同一个账号一直试;只限邮箱的话,攻击者可以用一个 IP 对很多账号各试几次。两个都限,两种攻击都挡住了。

一个代价:有人可以故意输错 5 次,把别人的账号锁 15 分钟。这是所有限流方案都有的权衡。15 分钟后自动解锁,损失有限。

不让人试探哪些邮箱注册过

登录失败时,「邮箱不存在」和「密码错误」返回完全一样的提示:「邮箱或密码不对」。否则攻击者可以拿一份邮箱名单挨个试,筛出哪些在本站注册过,再针对性地攻击。这叫用户枚举。

光是提示一样还不够,耗时也要一样。如果邮箱不存在就立刻返回,存在才去算 10 万次哈希,攻击者看响应快慢就知道了。所以邮箱不存在时,我们也拿一个假哈希算一遍:

const ok = await verifyPassword(password, row?.password_hash ?? DUMMY_HASH);
if (!row || !ok) return fail(c, 401, "bad_credentials", "邮箱或密码不对");

注册接口会提示「这个邮箱已经注册过了」,这也是一种枚举,但不提示的话用户体验太差。大多数网站都这样权衡,再用注册限流减轻风险。

前端:静态页面怎么知道你登录了

页面是构建时生成的,构建时不可能知道谁会来访问。所以登录状态在浏览器里查:

  • AuthProvider 组件在页面打开时请求一次 /api/auth/me,把结果放进 React Context。
  • 导航栏的头像、留言板的输入框都从 Context 里读。
  • 查询完成之前,导航栏留出一块空白占位,不先显示「登录」再变成头像,避免闪一下。

另外,从留言板点「登录」,会跳到 /login/?next=/guestbook/,登录完自动回到留言板。next 参数只接受站内路径:

export function safeNext(next: string | null | undefined) {
  // 浏览器会把 "/\evil.com" 当成 "//evil.com",所以第二个字符是 / 或 \ 都不行
  return next && /^\/(?![/\\])/.test(next) ? next : "/";
}

不做这个检查,攻击者可以发给你一个链接 haiyangliu.com/login/?next=https://钓鱼网站。你在真网站登录完,被带到一个长得一模一样的假网站,提示「密码错误请重新输入」。这叫开放跳转漏洞。//evil.com 这种写法以 / 开头,看起来像站内路径,其实浏览器会把它当成另一个域名。

五、第二期:潮汐留言板

接口一览

接口谁能用作用
GET /api/messages?cursor=…所有人每次 20 条,按时间倒序
POST /api/messages登录用户发一条留言
DELETE /api/messages/:id作者本人删除(软删除)

列表接口只返回昵称,不返回作者的邮箱和用户 ID。返回什么字段要逐个想清楚:前端用不到的,一律不给。

游标分页:为什么不用 OFFSET

第 1 页(每页 3 条)留言 E留言 D留言 C翻页前,有人发了「留言 F」OFFSET 3 取第 2 页留言 F(新)留言 E留言 D留言 C留言 B「留言 C」又出现一次游标:C 之后的 3 条留言 B留言 AWHERE 时间 < C 的时间不重复、不遗漏
图 3:OFFSET 按「跳过几条」翻页,前面一插入就错位;游标按「从哪条之后」翻页

最常见的分页写法是 LIMIT 20 OFFSET 40,意思是「跳过前 40 条,取 20 条」。问题出在翻页期间有新数据插入:你看完第一页,这时有人发了一条新留言,所有留言往后挪了一位,第二页的第一条就是你刚看过的。反过来,如果有人删了一条,就会漏掉一条。留言板、朋友圈这种不停有新内容的列表,这个问题很明显。

游标分页的思路是:不说「跳过几条」,而说「从哪一条之后继续」。第一页返回时,顺便告诉前端最后一条是谁(游标);前端请求下一页时带上它:

SELECT ... FROM messages m JOIN users u ON u.id = m.user_id
WHERE m.status = 'visible' AND m.deleted_at IS NULL
  AND (?1 IS NULL                                     -- 第一页没有游标
       OR m.created_at < ?1                           -- 比游标那条更早
       OR (m.created_at = ?1 AND m.id < ?2))          -- 同一毫秒的用 ID 区分
ORDER BY m.created_at DESC, m.id DESC
LIMIT 21                                              -- 多取一条,判断还有没有下一页

几个细节:

  • 游标是「时间_ID」两部分:只用时间的话,同一毫秒内的两条留言会分不清先后,可能漏掉一条。加上 ID 作为第二排序键,顺序就是唯一确定的。
  • 多取一条:要 20 条就查 21 条。查到 21 条说明后面还有,返回游标;不到 21 条说明到底了。不用额外跑一次 COUNT(*)。
  • 性能更好:OFFSET 10000 要让数据库先数过 1 万条再丢掉;游标直接借助索引定位,第 1 页和第 500 页一样快。
  • 代价是不能直接跳到第 N 页。但「往下滚动加载更多」本来就不需要跳页。

前端在列表底部放了一个看不见的「哨兵」元素,用 IntersectionObserver 监听:它离屏幕还有 400 像素时就开始加载下一页,用户滚动时基本感觉不到等待。

防刷:限流和查重都用一条 SQL

SELECT
  SUM(created_at > ?1)                AS minute, -- 最近 1 分钟发了几条
  COUNT(*)                            AS day,    -- 最近 24 小时发了几条
  SUM(created_at > ?2 AND body = ?3)  AS dup     -- 最近 10 分钟有没有发过一样的
FROM messages WHERE user_id = ?4 AND created_at > ?5   -- ?5 = 24 小时前

SUM(条件) 是 SQLite 的一个小技巧:条件成立算 1,不成立算 0,加起来就是满足条件的行数。一次查询拿到三个数字,不用查三次。

规则是:同一账号 1 分钟最多 3 条、24 小时最多 30 条,10 分钟内不能发重复内容;同一 IP 一天最多 60 条,防止一个人注册很多小号来刷。

注意这条 SQL 没有过滤 deleted_at。如果只数没删的,就可以「发 3 条 → 删 3 条 → 再发 3 条」无限循环。软删除在这里又派上了用场。

XSS:为什么有人写 <script> 也没事

**XSS(跨站脚本)**是指攻击者把一段脚本当成留言内容提交上来,如果网站把它当 HTML 插进页面,每个看留言的人的浏览器都会执行这段脚本,可以偷数据、冒充用户操作。

我们的防线:

  1. 只存纯文本:后端不解析也不过滤 HTML,原样存储。
  2. React 默认转义:在 JSX 里写 {item.body},React 会把 < 变成 &lt; 等实体,浏览器只会把它显示成文字,不会当成标签执行。整个项目从不使用 dangerouslySetInnerHTML,这是 React 里唯一能绕过转义的口子。
  3. HttpOnly Cookie:万一真的出了 XSS,脚本也拿不到登录令牌。

测试里专门有一条:发一条 <script>alert(1)</script>,确认返回的是原样文本。本地也实际发了一条带 <b> 的留言,页面上显示的是尖括号,不是粗体。

换行用 CSS 的 white-space: pre-wrap 保留,不需要把 \n 替换成 <br>。后端还会把连续 3 个以上的空行压成 1 个,防止有人用空行刷屏。

乐观更新:为什么点了发送立刻就出现

  1. 01 · 0ms

    先改界面

    点发送的瞬间,留言就出现在列表最上面,标着「发送中」,颜色略淡。输入框立刻清空。

  2. 02 · 0ms

    同时发请求

    后台调用 POST /api/messages。

  3. 03 · 约 200ms

    成功:换成正式数据

    用服务器返回的留言(有正式 ID 和时间)替换掉那条临时留言,去掉「发送中」。

  4. 04 · 约 200ms

    失败:撤回

    把临时留言从列表里拿掉,把文字放回输入框(不让用户白打),显示服务器给的原因,比如「发得有点快」。

图 4:发一条留言时,界面和服务器各自在做什么

这叫乐观更新:假设请求大概率会成功,先把结果显示出来,失败了再回滚。网络慢的时候,用户感觉不到等待。删除也一样:先从列表里拿掉,失败了再放回原来的位置。

六、第三期:站长后台

权限:后端说了算

后台所有接口都在 /api/admin/* 下,进来之前统一过一道中间件:

admin.use(async (c, next) => {
  const me = c.get("user"); // 每次请求都从数据库现查,不是从 Cookie 里读
  if (!me) return fail(c, 401, "unauthenticated", "请先登录");
  if (me.role !== "admin") return fail(c, 403, "forbidden", "没有权限");
  await next();
});

前端也会判断:不是站长就显示「没有权限」,头像菜单里也不显示后台入口。但那只是为了好看。任何人都可以打开浏览器的开发者工具,直接调用接口,绕过所有前端界面。真正的权限检查必须在服务器上,这是 Web 安全最基本的一条。

区分 401 和 403:401 是「你是谁?请先登录」,403 是「我知道你是谁,但你不能做这件事」。

几条额外的规则:

  • 角色每次都从数据库查。如果把角色写在 Cookie 或 JWT 里,撤销某人的站长权限后,他在令牌过期前仍然是站长。
  • 不能封禁自己,不能封禁其他站长:防止误操作把自己锁在门外,也防止一个站长账号被盗后把其他站长都封掉。
  • 「设为站长」没有网页按钮,只能用一条数据库命令:
pnpm exec wrangler d1 execute seabloom --remote --command "UPDATE users SET role='admin' WHERE email='…'"

这条命令需要 Cloudflare 账号的权限才能执行。这样即使网站某天出了漏洞,攻击者也不可能通过网页把自己提升为站长。这叫最小权限:网页上根本不提供这个能力,也就谈不上被滥用。

封禁:立刻生效

await c.env.DB.batch([
  c.env.DB.prepare("UPDATE users SET status = 'banned' WHERE id = ?").bind(id),
  c.env.DB.prepare("DELETE FROM sessions WHERE user_id = ?").bind(id), // 立刻踢下线
  ...(hideMessages ? [隐藏他的所有留言] : []),
  logAction(c, "ban_user", id, reason), // 写审计日志
]);

删掉这个用户所有的会话,他的下一个请求就变成「未登录」。这正是第四节选择「会话存在数据库」而不是 JWT 的原因。

审计日志:为什么和改动放在同一个事务里

每一次后台操作都会写进 admin_actions:谁、什么时候、对谁、做了什么、为什么。后台界面里只能看,不能改,也不能删。

它有什么用?

  • 出了争议(「我的留言为什么不见了」),可以查到是谁、因为什么隐藏的。
  • 站长账号被盗、被人乱操作,能看出哪些操作不是本人做的。
  • 大公司的后台系统几乎都有审计日志,很多行业的合规要求也明确规定必须有。

关键在于上面代码里的 db.batch([...]):改数据和写日志放在同一个批次里,D1 会把一个批次当成一个事务执行,要么全部成功,要么全部不生效。

如果分开写,先改数据、再写日志,中间可能出意外(网络断了、Worker 超时),结果就是数据改了却没有日志,审计日志就不可信了。事务保证了:有改动就一定有日志。

敏感词:自动拦截,人来判断

网站没有备案,访客又以国内为主,公开留言里出现不当内容,站长要承担一定责任。所以加了一层自动拦截:

function normalize(text: string) {
  return text.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""); // 只保留文字和数字
}

匹配之前先归一化:转成小写,去掉空格和标点。这样「敏 感 词」「敏.感.词」「敏-感-词」都能匹配上。\p{L} 是 Unicode 正则里的「任何语言的字母」,包括汉字。

命中之后不直接拒绝,而是把留言存成「待审核」状态:

  • 其他人看不到;
  • 作者自己能看到,并标着「审核中 · 仅自己可见」;
  • 站长后台的概览页会提示「有 N 条在等你审核」,一键跳过去,选择「审核通过,公开」或「保持隐藏」。

为什么不直接拒绝?关键词匹配一定会误伤,比如一个敏感词恰好是某个正常词语的一部分。直接拒绝会让正常用户莫名其妙;进入审核的话,最终由人来判断。作者能看到自己的留言,也不会以为「发送失败」而反复重发。

词表在 worker/lib/moderation.ts,改完 push 就生效。

七、代码组织

worker/
├─ index.ts            # 入口:把请求交给 Hono
├─ app.ts              # 组装:公共中间件(CSRF、查当前用户)+ 挂载各模块路由
├─ routes/
│  ├─ auth.ts          # /api/auth/*
│  ├─ messages.ts      # /api/messages/*
│  └─ admin.ts         # /api/admin/*
├─ lib/
│  ├─ http.ts          # 类型、统一错误格式 fail()、读请求体
│  ├─ password.ts      # 密码哈希
│  ├─ session.ts       # 会话令牌
│  ├─ rate-limit.ts    # 计数限流
│  ├─ pagination.ts    # 游标分页
│  ├─ validation.ts    # zod 规则
│  └─ moderation.ts    # 敏感词
└─ test/               # 测试工具:假数据库、测试客户端

第一期时所有接口都写在 app.ts 里。第二期加留言板时,文件已经接近 200 行,于是拆成 routes/ 和 lib/:

  • routes/ 按业务模块分:一个文件负责一组网址。加新功能就加新文件,不用动别人的代码。
  • lib/ 放可复用的工具:游标分页一开始写在留言板里,第三期后台也要用,就抽到了 lib/pagination.ts。第二次需要时再抽象,比一开始就设计一堆抽象更实际。

统一的错误格式:所有接口出错都返回 {"error": {"code": "...", "message": "..."}}。code 给程序判断(比如 rate_limited),message 是给用户看的中文。前端的 src/lib/api.ts 统一处理:网络断了、服务器 500、业务错误,都变成一个 ApiError,各个页面直接显示 err.message。

八、测试

在 Node.js 里模拟 D1

后端代码依赖 D1 数据库,测试时怎么办?Cloudflare 官方有一个测试工具,可以在真实的 Workers 环境里跑测试,但它当时只支持 Vitest 4,项目用的是 Vitest 5,装不上。

解决办法:Node.js 24 自带 SQLite(node:sqlite 模块),而 D1 本身就是 SQLite。写一个 50 行的适配器,只实现我们用到的几个方法(prepare、bind、first、all、run、batch),就能让后端代码以为自己在用 D1:

export function createFakeD1() {
  const db = new DatabaseSync(":memory:"); // 内存数据库,每个测试一份全新的
  for (const file of readdirSync("migrations").sort()) {
    db.exec(readFileSync(join("migrations", file), "utf8")); // 执行和线上同一批 SQL
  }
  // ……包装成 D1 的接口
}

关键是测试执行的是 migrations/ 里同一批 SQL 文件。如果建表语句写错了,或者新功能忘了写 migration,测试会立刻失败,而不是等上线才发现。

测试时不启动服务器,直接调用 Hono 的 app.request(),就像浏览器发了一个请求:

const res = await client.post("/auth/login", { email, password });
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toContain("HttpOnly");

62 个测试都在测什么

模块数量重点
账号12数据库里没有明文密码;会话表只有令牌哈希;Cookie 带齐三个安全属性;输错 5 次后正确密码也进不去;「邮箱不存在」和「密码错」提示一样;CSRF 被拒;封禁后旧 Cookie 失效
留言板12不泄露邮箱;<script> 原样返回;45 条数据翻页不重复不遗漏;限流;查重;只能删自己的;删了再发绕不过限流
站长后台188 个接口 × 2 种身份的权限检查;审核流程;审计日志;封禁和解封;不能封自己和其他站长;搜索时 % 当普通字符
前端工具和内容20相对时间;开放跳转防护;阅读时长;和弦数据等

为什么权限测试要把每个接口都测一遍

后台的权限检查写在一个中间件里,看起来测一个接口就够了。但实际项目里,最常见的漏洞恰恰是某一个接口忘了加权限检查:比如以后有人新加了一个接口,不小心挂在了中间件外面。所以测试把 8 个接口全部列出来,每个都用「没登录」和「普通用户」各调一次:

it.each(routes)("%s %s:没登录 401,普通用户 403", async (method, path) => {
  expect((await call()).status).toBe(401);
  expect((await call(alice)).status).toBe(403);
});

以后再加后台接口,只要把它加进这个列表,权限就会被自动检查。

九、本地开发和上线顺序

本地怎么同时跑前端和后端

pnpm db:migrate:local   # 第一次:在本地数据库建表
pnpm dev:api            # 终端 1:本地 Worker,http://localhost:8787
pnpm dev                # 终端 2:Next.js 开发服务器,http://localhost:3000

问题是:前端在 3000 端口,后端在 8787 端口,页面里写的是 fetch('/api/...'),会发到 3000。解决办法是只在开发时让 Next.js 当一个转发代理:

const devConfig: NextConfig = {
  ...nextConfig,
  output: undefined, // 开发时关掉静态导出(静态导出不支持 rewrites)
  rewrites: async () => [
    { source: "/api/:path*", destination: "http://localhost:8787/api/:path*" },
  ],
};

export default (phase: string) =>
  withMDX(phase === PHASE_DEVELOPMENT_SERVER ? devConfig : nextConfig);

next.config.ts 可以导出一个函数,Next.js 会告诉它当前是开发还是构建阶段。构建时还是原来的静态导出配置,线上完全不受影响。

开发时浏览器的 Origin 是 http://localhost:3000,和后端自己的地址对不上,会被 CSRF 检查拦住。所以 dev:api 启动时额外传了一个变量 DEV_ORIGIN=http://localhost:3000,只在本地允许这个来源,线上没有这个变量。

另一种更接近线上的方式:先 pnpm build,再只开 pnpm dev:api。wrangler 会同时提供 out/ 里的页面和接口,和线上的行为一模一样。这篇文章里的功能,最后都是这样在本地测过一遍再上线的。

上线顺序:先改数据库,再推代码

每一期上线都是这个顺序:

  1. pnpm db:migrate:remote:在线上数据库建新表、加新字段。
  2. git push:自动构建、部署新代码。
  3. 用 curl 检查线上接口:比如没登录访问后台接口应该返回 401。

顺序不能反。 如果先推代码,新代码上线的那一刻会去查一张还不存在的表,所有请求都会报错,直到 migration 执行完。先改数据库就没有这个问题:新加的表和字段,旧代码根本不会去碰。

这背后是一个通用原则:数据库变更要向后兼容。新加字段、新加表是安全的;删字段、改字段名则要分几步走,先让代码不再用它,再删。

十、踩过的坑

1. 测试工具和 Vitest 版本不兼容 现象:想装 Cloudflare 官方的 @cloudflare/vitest-pool-workers,发现它要求 Vitest 4,项目是 Vitest 5。解决:用 Node 24 自带的 node:sqlite 写了一个 D1 适配器(第八节)。收获:先想清楚要模拟的是什么(D1 就是 SQLite),往往有更轻的办法。

2. 测试里出现 500 错误:事务嵌套 现象:「密码错误」的测试返回了 500。原因:登录失败时要同时记录「邮箱失败一次」和「IP 失败一次」,我用 Promise.all 让两次写入并行,每次写入都是一个事务,在模拟的数据库里两个事务交错了,报「不能在事务里开始新事务」。解决:改成一个批次里插入两行。收获:能一次做完的数据库写入就放进同一个事务,更快也更安全。

3. 手机上导航栏被挤成竖排 现象:登录后右上角显示「昵称 + 退出」,手机屏幕放不下,导航文字被挤成一个字一行。解决:改成一个圆形头像(昵称的第一个字),点开才是菜单。收获:每加一个界面元素,都要在手机宽度下看一眼。

4. 自动部署坏了一下午 见第一篇第九节:Cloudflare 克隆仓库失败,临时改成本地 wrangler deploy,几小时后恢复。

5. Windows 终端里的中文变成乱码 现象:本地用 curl 发了一条中文测试留言,存进数据库的是乱码。原因:Windows 命令行默认用 GBK 编码,curl 把 GBK 字节原样发了出去,而服务器按 UTF-8 解析。解决:只影响本地测试数据;以后用脚本或浏览器发中文,不要直接在命令行里写中文。收获:遇到乱码,先问「这串字节是用什么编码写的,又是用什么编码读的」。

6. 开放跳转里的 /\ 第一版的检查是「以 / 开头,并且不以 // 开头」。写测试时想到,浏览器会把反斜杠也当成斜杠,/\evil.com 一样会跳走。于是改成正则 ^\/(?![/\\]),并补上这条测试。收获:安全检查要考虑浏览器「宽容」的解析方式,不能只按字面意思来。

7. 后端 SQL 里的 LIKE 通配符 后台搜索用的是 LIKE '%关键词%'。如果关键词本身包含 % 或 _,它们会被当成通配符:搜 100% 会匹配到所有以 100 开头的内容。解决:先把关键词里的 %、_ 和 \ 转义,再用 ESCAPE '\' 告诉数据库。也有测试覆盖。

十一、面试怎么讲

一分钟介绍

网站上线后,我在不租服务器的前提下加了后端:用 Cloudflare Workers 和 Hono 写接口,数据库是 D1,也就是 SQLite,和静态页面部署在同一个域名下,按路径分流。分三期做了邮箱密码登录、留言板和站长后台。安全上,密码用 PBKDF2 加盐哈希,会话令牌在数据库里只存哈希,Cookie 设置了 HttpOnly、Secure 和 SameSite,再加上 Origin 校验防 CSRF,登录和发帖都有限流。留言板用游标分页和乐观更新;后台的每个操作都和审计日志写在同一个事务里。测试方面,我用 Node 自带的 SQLite 模拟 D1,测试执行和线上同一份 migration,一共 62 个测试,覆盖了每个后台接口的权限检查。

可能被问到的问题

Q:密码是怎么存的?为什么不用 MD5 或 SHA-256? A:用 PBKDF2-SHA256,每个用户一份 16 字节随机盐,迭代 10 万次,存储格式里带上算法和次数,方便以后升级。MD5、SHA-256 是快哈希,显卡每秒能算几十亿次,暴力破解太便宜;没有盐的话,相同密码的哈希相同,彩虹表能直接查。慢哈希加盐让攻击者只能一个用户一个用户地破解,而且每次尝试都很贵。没用 bcrypt 或 argon2,是因为 Workers 里跑不了原生模块,PBKDF2 是 Web Crypto 自带的。

Q:Session 和 JWT 你怎么选? A:我用的是数据库会话。JWT 的优点是服务器不用查库,适合大规模分布式;缺点是发出去就收不回,退出登录和封禁都要等它过期,或者额外维护一个黑名单,那又回到了查库。我的场景需要「封禁立刻生效」,用户量也不大,数据库会话更简单直接。令牌在库里只存 SHA-256,数据库泄露也不能直接拿来登录。

Q:怎么防 CSRF? A:两层。第一层是 Cookie 设置 SameSite=Lax,跨站发起的 POST 不带 Cookie。第二层是服务端校验 Origin 请求头,所有非 GET 请求的 Origin 必须是本站。另外,接口和页面同域,不需要开放 CORS,也就没有跨域请求能带着凭证进来。

Q:怎么防 XSS? A:用户内容只当纯文本存储和显示,React 默认会转义,项目里不使用 dangerouslySetInnerHTML。登录 Cookie 设置了 HttpOnly,即使有 XSS,脚本也拿不到令牌。有专门的测试确认 <script> 会原样返回。如果以后要支持富文本,会在服务端用白名单做 HTML 清洗,并加上 CSP 响应头。

Q:限流是怎么实现的?有什么问题? A:计数法:每次尝试在表里插一行,判断时数时间窗口内的行数,邮箱和 IP 分开限。问题有两个:一是可以被利用来故意锁别人的账号,所以窗口设为 15 分钟、自动解锁;二是数据库计数在高并发下不够精确,也会给数据库增加写入压力。流量大了会换成 Redis 或 Cloudflare 自带的限流服务,用滑动窗口或令牌桶算法。

Q:分页为什么用游标而不是 OFFSET? A:OFFSET 在翻页期间有数据插入或删除时,会重复或遗漏;而且 OFFSET 越大越慢,因为数据库要先扫过前面的行。游标分页用「上一页最后一条的时间和 ID」作为条件,配合复合索引,每一页都一样快,结果也稳定。代价是不能跳页,但无限滚动不需要跳页。游标里加 ID 是为了处理同一毫秒的两条记录。

Q:前端的权限判断和后端的权限判断有什么区别? A:前端判断只决定显示什么,任何人都能绕过前端直接调接口,所以真正的权限必须在后端判断。我在所有 /admin 接口前加了中间件,每次请求都从数据库读角色,不信任客户端传来的任何角色信息。测试里把每个后台接口都用未登录和普通用户各测了一遍。

Q:审计日志为什么要和业务操作放在一个事务里? A:保证「有改动就一定有日志」。如果分开写,中间失败就会出现改了数据却没有记录的情况,日志就不可信了。D1 的 batch 是事务性的,要么全部成功,要么全部回滚。

Q:数据库表结构怎么管理?怎么上线? A:用编号的 migration 文件,和代码一起进 Git,只追加、不修改已执行的文件。上线时先执行 migration,再部署代码,因为新增表和字段对旧代码是向后兼容的;反过来,新代码会先访问到不存在的表。需要删字段时,会分两次发布:先让代码不再用它,再删。

Q:没有真实的 D1,你怎么测试? A:D1 是 SQLite,Node 24 自带 SQLite,我写了一个只实现所用方法的适配器,每个测试一份内存数据库,并执行和线上同一份 migration。测试不起服务器,直接调用 Hono 的 app.request,所以很快,62 个测试一秒左右跑完。缺点是和真实环境有细微差异,所以每次上线前还会用 wrangler dev 在本地真实运行时里走一遍,上线后再用 curl 验证。

Q:如果用户量涨到 100 万,哪里会先出问题? A:几个点。一是 SQLite 是单写入者,高并发写入会排队,留言和限流计数都是写操作,可以把限流挪到 Redis 或 Durable Objects,数据库换成 PostgreSQL。二是 D1 现在在北美,国内访问延迟高,可以开启读副本,或者迁到国内云厂商。三是列表接口可以加缓存,比如热门的第一页缓存几秒钟。四是内容审核:关键词表不够用,要接专门的内容安全服务。

Q:这个项目里你觉得最难的是什么? A:可以讲「同一个安全目标要在好几层同时做」。比如防止用户枚举,不仅提示语要一样,响应时间也要一样,所以邮箱不存在时也要算一次哈希。或者讲开放跳转:最初只检查了 //,写测试时才想到浏览器会把 /\ 也当成 //。这些都是写测试时逼自己从攻击者角度想出来的。

十二、下一步

  • 找回密码:需要发邮件。可以接 Resend 这类发信服务,流程是「生成一次性令牌(只存哈希)→ 发链接 → 限时 30 分钟有效 → 用过即作废」。
  • 数据库搬到亚太区:D1 当前在北美西部,国内访问每个接口要多绕半个地球。可以新建一个亚太区的数据库,把数据迁过去,或者开启读副本。
  • 迁移到自己的 VPS:如果以后要做更重的后端,迁移时需要改的地方是:
    • Hono 换一个 Node.js 适配器,路由代码基本不变;
    • env.DB 换成 PostgreSQL 客户端,SQL 小改(参数占位符从 ? 变成 $1);
    • crypto.subtle 在 Node.js 里同样可用,密码代码不用改;
    • 前面放 Nginx 反向代理 /api/,或者继续用 Cloudflare 做 CDN 和防护。
  • 更多互动:给留言点赞(「浪花」)、回复、站长置顶。

更新:上线后做了一次完整的检查,查出并修好了 9 个问题(比如昵称可以冒充站长),测试增加到 67 个。过程写在第三篇:《上线之后:给网站做一次体检》。

从第一篇到这一篇,网站从「只能看」变成了「能互动」。最大的体会是:后端功能本身不难,难的是把每一种「如果有人故意这样做」都想到。 安全不是最后加上的一层,而是每个接口、每个字段在设计时就要回答的问题。