用一个代价高昂的函数对密码做哈希,在会话和 JWT 之间充分知情地做出选择,并用中间件保护你的路由。
在 Kodokon 中打开本课存储一个密码意味着要为未来某次泄露做好准备:把哈希设计得能在数据库被盗后依然扛得住。像 SHA-256 这样的快速哈希,在 GPU 上能以每秒数十亿次的尝试被暴力破解。你需要一个刻意高成本的函数,比如 node:crypto 提供的 scrypt,或者 bcrypt/argon2,再加上一个每个用户随机的盐,它能让预计算的表变得毫无用处。盐不是秘密:它以明文形式存储在哈希旁边。
import {
randomBytes,
scryptSync,
timingSafeEqual,
} from 'node:crypto';
export function hashPassword(password) {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 64)
.toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
const [salt, hash] = stored.split(':');
const candidate = scryptSync(password, salt, 64);
const expected = Buffer.from(hash, 'hex');
return timingSafeEqual(candidate, expected);
}让用户保持登录状态有两种模型。会话:一个不透明的标识符放在 httpOnly cookie 里,状态保存在服务器一侧。可以即时吊销,cookie 对 JavaScript 不可见,但只要你增加第二个实例,就必须引入一个共享存储(Redis)。JWT:把签名后的状态内嵌进令牌本身,无需任何存储即可验证,非常适合服务之间使用,但在过期之前无法吊销。诚实的取舍是:经典的 Web 应用用会话,供第三方消费的 API 或分布式架构用短寿命的 JWT。
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET;
if (!SECRET) {
throw new Error('JWT_SECRET must be set');
}
export function signToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email },
SECRET,
{ expiresIn: '15m' },
);
}保护中间件把访问控制抽取出来:它从 Authorization: Bearer 请求头中提取令牌,进行验证,把解码后的身份附加到 req.user 上,或者以一个 401 打断流程,交给上一课的错误中间件处理。把它应用在路由器层级(router.use(requireAuth)),从而保护一整组路由,而不是逐条路由地保护。
import jwt from 'jsonwebtoken';
import { HttpError } from './http-error.js';
const SECRET = process.env.JWT_SECRET;
export function requireAuth(req, res, next) {
const header = req.headers.authorization ?? '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return next(new HttpError(401, 'Missing token'));
}
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
next(new HttpError(401, 'Invalid or expired token'));
}
}timingSafeEqual 是做什么用的?