边缘运行时只有 Web 标准 API:crypto.subtle、Request、Response。没有 Node 的 crypto 模块,也没有 bcrypt 这类原生扩展。这意味着认证要自己写——但其实并没有想象中麻烦。

一、密码哈希

crypto.subtle 支持 PBKDF2,这是 OWASP 仍然推荐的方案之一(在缺少 Argon2 的环境里)。

const encoder = new TextEncoder();

const bytesToHex = (bytes) =>
  Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');

export async function hashPassword(password, saltHex, iterations = 100000) {
  const salt = new Uint8Array(saltHex.match(/../g).map((h) => parseInt(h, 16)));
  const key = await crypto.subtle.importKey(
    'raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits'],
  );
  const bits = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
    key,
    256,
  );
  return bytesToHex(new Uint8Array(bits));
}

几个实践要点:

  • 盐必须随机且每用户独立:crypto.getRandomValues(new Uint8Array(16))
  • 迭代次数要存进数据库。以后想升级到 20 万次,老用户还能正常登录
  • 比较用恒定时间,不要用 ===:
function timingSafeEqual(a, b) {
  const ab = encoder.encode(a), bb = encoder.encode(b);
  if (ab.length !== bb.length) return false;
  if (crypto.subtle.timingSafeEqual) return crypto.subtle.timingSafeEqual(ab, bb);
  let diff = 0;
  for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
  return diff === 0;
}

⚠️ 注意 CPU 限制:免费版 Workers 每个请求只有 10ms CPU。10 万次 PBKDF2 很可能超限,可以降到 5 万次,或者把登录接口放到付费计划。

二、会话不该是「签名的 JWT」

很多人第一反应是用 JWT,但JWT 无法主动失效。用户点了「退出登录」,token 在过期前依然有效;管理员要踢人,也没办法。

我更推荐随机令牌 + 服务端会话表:

CREATE TABLE sessions (
  id         TEXT PRIMARY KEY,   -- 存 token 的 SHA-256,不是 token 本身
  user_id    INTEGER NOT NULL,
  expires_at TEXT NOT NULL,
  user_agent TEXT
);
export async function createSession(db, userId, userAgent) {
  const token = randomHex(32);                    // 返回给浏览器的原文
  const tokenHash = await sha256Hex(token);       // 存进数据库的摘要
  const expires = new Date(Date.now() + 7 * 86400_000).toISOString();
  await db.prepare(
    'INSERT INTO sessions (id, user_id, expires_at, user_agent) VALUES (?, ?, ?, ?)',
  ).bind(tokenHash, userId, expires, userAgent.slice(0, 200)).run();
  return token;
}

为什么存哈希? 数据库万一泄露(备份、日志、误导出),攻击者拿到的是摘要,不能直接拿去冒充用户。

const cookie = [
  `blog_session=${token}`,
  'Path=/',
  'HttpOnly',            // JS 读不到,防 XSS 窃取
  'SameSite=Lax',        // 防 CSRF,同时保留站内跳转
  `Max-Age=${7 * 86400}`,
  secure ? 'Secure' : '', // HTTPS 才加
].filter(Boolean).join('; ');

SameSite=Lax 是关键:跨站发起的 POST 不会带上 Cookie,等价于默认开启了 CSRF 防护的一大半。

四、CSRF 再补一刀

SameSite=Lax 覆盖不了老浏览器,也覆盖不了同站子域的攻击。再加一层来源校验:

export async function verifyOrigin(c, next) {
  const method = c.req.method.toUpperCase();
  if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return next();

  const site = c.req.header('sec-fetch-site');
  if (site && site !== 'same-origin' && site !== 'none') {
    return c.json({ error: '跨站请求被拒绝' }, 403);
  }

  const origin = c.req.header('origin');
  if (origin && origin !== new URL(c.req.url).origin) {
    return c.json({ error: '跨站请求被拒绝' }, 403);
  }
  return next();
}

注意中间件里先校验再执行业务,而且对所有写方法生效,不要只在登录接口上做。

五、第一用户怎么来

常见做法是写死一个默认管理员,这非常危险。更安全的是「首次初始化」路由:

app.get('/setup', async (c) => {
  if (await countUsers(c.env.DB) > 0) return c.redirect('/login');
  return c.html(renderSetupPage());
});

一旦存在用户,/setup 永久关闭。域名一上线就立刻创建管理员,否则任何人都能抢注。

六、别忘了这些细节

  • 登录失败不要区分「用户不存在」和「密码错误」,统一提示
  • 加登录限流:按 IP 记失败次数,超过阈值锁定一段时间
  • 会话定期清理:用 Cron Trigger 删除过期记录,别让表无限增长
  • 改密码后强制所有会话失效:DELETE FROM sessions WHERE user_id = ?

认证这件事没有魔法,把哈希、令牌、Cookie、CSRF 四块各自做对,整体就是安全的。