Kodokon kodokon.com

身份认证:哈希、会话 vs JWT

用一个代价高昂的函数对密码做哈希,在会话和 JWT 之间充分知情地做出选择,并用中间件保护你的路由。

10 分钟 · 3 题

在 Kodokon 中打开本课

存储一个密码意味着要为未来某次泄露做好准备:把哈希设计得能在数据库被盗后依然扛得住。像 SHA-256 这样的快速哈希,在 GPU 上能以每秒数十亿次的尝试被暴力破解。你需要一个刻意高成本的函数,比如 node:crypto 提供的 scrypt,或者 bcrypt/argon2,再加上一个每个用户随机的,它能让预计算的表变得毫无用处。盐不是秘密:它以明文形式存储在哈希旁边。

JAVASCRIPT
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。

JAVASCRIPT
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)),从而保护一整组路由,而不是逐条路由地保护。

JAVASCRIPT
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'));
  }
}
验证、附加 req.user,或者返回 401,别无其他。

知识检测

确认你已牢记本课的重点内容。

  1. 为什么单用 SHA-256 不适合存储密码?
    • 因为这个算法在密码学上已经被攻破
    • 因为它太快了:一块 GPU 每秒能测试数十亿个候选,你需要一个慢速、加盐的函数
    • 因为它在短密码上会产生碰撞
    • 因为 Node 没有原生实现它
  2. 哪种机制能让你在任何过期之前就立即吊销访问权限?
    • JWT,因为它内嵌了自己的过期日期
    • 服务器端会话,因为你只需从存储中删除它的条目
    • 两者都可以,效果相当
  3. 在密码验证中,timingSafeEqual 是做什么用的?
    • 为了加快两个哈希的比较
    • 为了以恒定时间比较,让响应时长不会泄露有多少字节匹配
    • 为了把十六进制的哈希转换成 Buffer