在边缘运行时做登录认证:PBKDF2 + Cookie 会话

Worker 里没有 bcrypt、没有 Redis、没有 req.session。用 Web Crypto 的 PBKDF2 加一张 D1 会话表,从零实现一套够用的认证。

边缘环境缺什么

如果你习惯 Express + Passport 那一套,到 Worker 里会有点失落:

  • 没有 bcrypt。它是原生扩展(native addon),Worker 跑不了 WASM 之外的二进制。
  • 没有进程内内存。你不能把 session 存在一个 Map 里——下一个请求可能落在另一个机房。
  • 没有中间件的生态。你得自己写 onRequest 钩子。

但也不是没有武器:Web Crypto API 在 Worker 里是原生支持的,crypto.subtle 提供了 PBKDF2、HMAC、AES-GCM 等一堆原语,全部在硬件加速路径上。

密码哈希:PBKDF2-SHA256

OWASP 现在的建议是 Argon2id 优先,其次 scrypt,再其次 PBKDF2。前两者在 Worker 里要么需要 WASM 打包,要么根本没有 API。PBKDF2 是唯一开箱即用的选择,只要迭代次数够高,它是可以接受的。

const ITERATIONS = 100_000;

function base64(bytes) {
  let s = '';
  for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b);
  return btoa(s);
}

async function hashPassword(password) {
  const salt = crypto.getRandomValues(new Uint8Array(16));

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    'PBKDF2',
    false,
    ['deriveBits']
  );

  const bits = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
    key,
    256
  );

  return 'pbkdf2$' + ITERATIONS + '$' + base64(salt) + '$' + base64(bits);
}

存进数据库的字符串自带参数:pbkdf2$100000$<salt>$<hash>。这样将来想提高迭代次数时,老用户的记录仍然能被正确校验——校验的时候从字符串里读迭代次数,而不是用当前的常量。

校验:必须用常量时间比较

这是一个很多人会写错的地方:

// 错误:一旦发现第一个不同的字节就返回,会泄漏信息
if (computed === stored) { /* ok */ }

字符串比较会在第一个不等的字符处短路。虽然通过时序攻击还原一个 32 字节哈希在实践中非常困难(尤其经过网络抖动),但这是零成本的正确性,没有理由不做:

function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

注意 diff |= 这一句:它不短路,无论是否已经发现差异都会把所有字节算完。这才是「常量时间」的含义。

完整的校验逻辑还要处理一件事:用户不存在时也要跑一次 PBKDF2。否则攻击者可以通过响应时间差异判断某个用户名是否存在。

async function verifyPassword(password, stored) {
  const parts = stored.split('$');
  if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false;

  const iterations = Number(parts[1]);
  const salt = decodeBase64(parts[2]);

  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
  const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256);

  return timingSafeEqual(base64(bits), parts[3]);
}

会话:Cookie 里只放随机串

Worker 是无状态的,所以会话必须落到共享存储。D1 是最顺手的选择。

关键设计:Cookie 里不放任何用户信息,只放一个 256 位的随机 token。 用户信息全部在服务端查。

Cookie: blog_session = 9f3a...c1e7
          ↓
SELECT u.id, u.username FROM sessions s
  JOIN users u ON u.id = s.user_id
 WHERE s.token = ? AND s.expires_at > datetime('now')

这样做的好处是:登出就是删一行,不需要维护黑名单;改密码时可以一键踢掉所有会话。

export async function createSession(db, userId, userAgent) {
  const token = base64(crypto.getRandomValues(new Uint8Array(32)));

  await db
    .prepare(
      "INSERT INTO sessions (token, user_id, user_agent, expires_at) VALUES (?, ?, ?, datetime('now', '+7 days'))"
    )
    .bind(token, userId, userAgent || '')
    .run();

  return token;
}
cookies.set('blog_session', token, {
  path: '/',
  httpOnly: true,                                  // JS 读不到,防 XSS 窃取
  secure: import.meta.env.PROD,                    // 线上只走 HTTPS
  sameSite: 'lax',                                 // 防 CSRF,同时保留正常跳转
  maxAge: 60 * 60 * 24 * 7,
});

逐个解释:

  • httpOnly:这是防御 XSS 的最后一道墙。如果 token 能被 document.cookie 读到,一个第三方脚本就能把它偷走。
  • secure:本地开发是 http,所以用 import.meta.env.PROD 判断,避免本地登录直接失效。
  • sameSite: 'lax':能让 Cookie 随顶级导航发送(用户点链接进来仍保持登录),但阻止跨站 POST。这是防 CSRF 的性价比最高的一档。用 'strict' 会导致从外站点进来的第一个请求丢失登录态,体验很差。
  • maxAge:别用 session cookie(关浏览器就丢),博客后台没人愿意每次重新登录。

服务端还要有过期清理

会话表会一直增长。D1 免费额度里写操作是有限额的,所以别在每次请求里都跑清理。用两个策略配合:

// 1. 查询时就过滤掉过期的(保证正确性)
//    WHERE s.token = ? AND s.expires_at > datetime('now')

// 2. 概率性清理(大约 2% 的请求触发一次)
if (Math.random() < 0.02) {
  ctx.waitUntil(db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run());
}

ctx.waitUntil 是关键——它让清理任务在响应返回之后继续执行,不会拖慢用户的请求。

限流:登录接口必须加

没有限流的话,admin + 123456 可以在几分钟内被爆破出来。Worker 环境里最简单的做法是把失败次数记在 D1 里:

CREATE TABLE login_attempts (
  ip TEXT PRIMARY KEY,
  count INTEGER NOT NULL DEFAULT 0,
  first_at TEXT NOT NULL,
  locked_until TEXT
);

规则可以很朴素:

  • 10 分钟内失败 5 次 → 锁定 15 分钟;
  • 成功登录后清零。

如果不想动数据库,也可以用 Cloudflare 的 Rate Limiting 规则,在控制台按路径 /api/auth/login 配置,免费版有每天 1 万次请求的额度。

这个博客用的是 D1 方案,因为我想让限流逻辑跟着代码走,而不是散落在控制台配置里。

最后:别忘了登录页本身的防护

几个容易被忽略的点:

  1. 登录失败不要区分「用户不存在」和「密码错误」,统一返回「用户名或密码不正确」。
  2. 不要回显用户输入的用户名到 HTML 里,避免自 XSS。
  3. ?next= 跳转参数必须校验,只允许站内相对路径:
function safeNext(next) {
  if (!next || typeof next !== 'string') return '/admin';
  if (!next.startsWith('/') || next.startsWith('//')) return '/admin';  // 挡掉 //evil.com
  return next;
}

//evil.com 这种写法会被浏览器当成协议相对 URL,是个经典的开放重定向漏洞。

小结

边缘环境下的认证,本质上就是用 Web Crypto 补齐哈希能力,用 D1 补齐状态存储。没有魔法,代码量大概两三百行。关键不在于写得多复杂,而在于把 httpOnly、sameSite、常量时间比较、限流这几个「不做就出事」的点都做对。

← 回到文章列表