在边缘运行时做登录认证: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;
}
Cookie 的四个属性,一个都不能少
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 方案,因为我想让限流逻辑跟着代码走,而不是散落在控制台配置里。
最后:别忘了登录页本身的防护
几个容易被忽略的点:
- 登录失败不要区分「用户不存在」和「密码错误」,统一返回「用户名或密码不正确」。
- 不要回显用户输入的用户名到 HTML 里,避免自 XSS。
?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、常量时间比较、限流这几个「不做就出事」的点都做对。