4、用户认证模块:JWT令牌生成与验证、登录/登出接口、密码加密存储

用户认证,说白了就是解决「你是谁」的问题。我见过太多项目,功能做得花里胡哨,结果认证模块一塌糊涂,上线第一天就被拖库。嗯,这一章我们就把这块硬骨头啃下来。

4.1 密码加密存储:别拿明文开玩笑

先聊密码存储。你想想看,如果数据库被拖了,用户的密码明文暴露,那基本就是灾难。我个人习惯,永远不要用 MD5,那玩意儿早就不安全了。彩虹表一查,分分钟还原。

⚠️ 警告: 千万别用 MD5、SHA-1 这类快速哈希算法存密码。它们设计出来就不是干这个的。

那用什么?BCrypt 或者 Argon2。我项目中一直用 BCrypt,它自带盐值(salt),每次加密结果都不一样,而且可以调节计算强度。说白了,就是让暴力破解的成本高到 attackers 想放弃。

我曾经接手过一个遗留系统,密码用 MD5 存的。用户量不大,但我还是花了一周时间,搞了个平滑迁移方案:用户登录时,如果检测到是 MD5 哈希,就自动升级成 BCrypt。嗯,这里要注意,迁移过程不能影响用户体验。

代码示例(Node.js + bcrypt):

const bcrypt = require('bcrypt');

// 注册时加密密码
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);

// 登录时验证密码
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
💡 提示: saltRounds 一般设 10-12 就够了。太高了用户登录会卡,太低了不安全。我一般用 10,平衡得不错。

4.2 JWT 令牌:无状态认证的核心

JWT(JSON Web Token)现在基本是标配。它为什么流行?因为无状态。服务器不用存 session,令牌里就包含了用户信息。每次请求,客户端把令牌带过来,服务器验证签名就行。

JWT 的结构很简单:Header.Payload.Signature。Header 声明算法,Payload 放数据(比如用户 ID、过期时间),Signature 用来防篡改。

我个人习惯,Payload 里只放必要信息,比如 userId、role。千万别放密码、手机号这些敏感数据。为什么?因为 JWT 的 Payload 只是 Base64 编码,不是加密的。你想想看,谁都能解码看到内容。

🔑 关键点: JWT 是签名防篡改,不是加密防窥视。敏感数据别往里塞。

生成 JWT 的代码示例:

const jwt = require('jsonwebtoken');

const payload = { userId: user.id, role: user.role };
const secret = process.env.JWT_SECRET; // 从环境变量读取
const options = { expiresIn: '7d' };

const token = jwt.sign(payload, secret, options);

验证 JWT 的代码示例:

try {
  const decoded = jwt.verify(token, secret);
  // decoded 就是 { userId, role, iat, exp }
  req.user = decoded;
  next();
} catch (err) {
  // 令牌过期或无效
  res.status(401).json({ message: '令牌无效或已过期' });
}
⚠️ 注意: JWT_SECRET 一定要足够复杂,而且绝对不能硬编码在代码里。我见过有人把 secret 写成 '123456',那跟没锁门有什么区别?

4.3 登录接口:流程与细节

登录接口的流程,其实就三步:

  1. 接收用户名和密码
  2. 验证密码是否正确
  3. 生成 JWT 并返回

但细节里全是坑。我一个个说。

第一,限制登录频率。 我曾经遇到过一个项目,没做限流,结果被脚本暴力破解,一天试了十万次。后来我加了 rate limiting,比如同一个 IP 一分钟内最多试 5 次。代码可以用 express-rate-limit 实现。

第二,返回信息要模糊。 登录失败时,别告诉用户「用户名不存在」或「密码错误」。统一返回「用户名或密码错误」。为什么?防止 attackers 通过枚举确认哪些账号存在。

第三,考虑「记住我」功能。 如果用户勾选了「记住我」,JWT 的过期时间可以设长一点,比如 30 天。否则,7 天就够。

登录接口代码示例:

app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;

  // 1. 查找用户
  const user = await User.findOne({ where: { username } });
  if (!user) {
    return res.status(401).json({ message: '用户名或密码错误' });
  }

  // 2. 验证密码
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ message: '用户名或密码错误' });
  }

  // 3. 生成 JWT
  const token = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  res.json({ token, user: { id: user.id, username: user.username } });
});
💡 提示: 返回用户信息时,别把密码哈希也返回了。我一般用 Sequelize 的 attributes 排除掉 password 字段。

4.4 登出接口:让令牌失效

JWT 是无状态的,登出怎么办?说白了,你没法让已经发出的令牌失效。但我们可以用黑名单机制。

我常用的做法:

  • 在 Redis 里维护一个黑名单,存已经登出的 JWT 的 jti(JWT ID)
  • 每次请求时,先检查令牌是否在黑名单里
  • 登出时,把当前令牌的 jti 加入黑名单,设置过期时间等于令牌剩余有效期

代码示例:

app.post('/api/logout', async (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(400).json({ message: '缺少令牌' });

  const decoded = jwt.decode(token);
  const jti = decoded.jti; // 需要生成 JWT 时加入 jti

  // 将 jti 加入 Redis 黑名单,过期时间设为令牌剩余时间
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);
  await redis.set(`blacklist:${jti}`, '1', 'EX', ttl);

  res.json({ message: '登出成功' });
});
🔑 关键点: 黑名单的过期时间一定要设置。否则 Redis 里会堆满无用的 key,浪费内存。

4.5 中间件:保护你的路由

有了 JWT,我们还需要一个中间件来保护需要登录的路由。说白了,就是每次请求进来,先验证令牌,再把用户信息挂到 req 上。

我一般这样写:

const authMiddleware = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: '未提供认证令牌' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ message: '令牌无效或已过期' });
  }
};

// 使用中间件保护路由
app.get('/api/profile', authMiddleware, (req, res) => {
  res.json({ user: req.user });
});
⚠️ 注意: 中间件里别忘了检查黑名单。如果令牌在黑名单里,直接返回 401。

4.6 避坑指南:我踩过的那些坑

做认证模块,坑真的不少。我列几个印象深刻的:

  • 令牌泄露: 我曾经在日志里打印了完整的 JWT,结果日志被第三方看到。后来我学乖了,日志里只记录 userId,不记录 token。
  • 时钟偏差: 服务器时间不准,导致 JWT 验证时认为令牌已过期。解决方案是设置 clockTolerance,比如允许 30 秒的偏差。
  • 令牌太长: 如果 Payload 里塞太多东西,JWT 会变得很长,影响网络传输。我一般控制在 200 字节以内。
  • 忘记刷新令牌: 只用 access token,过期了用户就得重新登录。我一般配合 refresh token 使用,access token 短一点(15 分钟),refresh token 长一点(7 天)。
💡 提示: 如果你用 refresh token,记得在 Redis 里存一份,登出时一并清除。这样即使 refresh token 泄露,也能通过登出让它失效。

好了,用户认证模块的核心内容就这些。说白了,就是密码加密存、JWT 安全传、接口防暴力、登出有黑名单。把这四点做好,你的认证模块就稳了。下一章我们聊权限控制,到时候会用到这里的 userId 和 role 信息。