边缘运行时只有 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;
}
为什么存哈希? 数据库万一泄露(备份、日志、误导出),攻击者拿到的是摘要,不能直接拿去冒充用户。
三、Cookie 的正确姿势
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 四块各自做对,整体就是安全的。