用户注册与登录模块(后端):密码加密存储、JWT令牌、Token刷新

说实话,用户注册登录这块,看着简单,坑是真不少。我早期做项目时,密码直接明文存数据库,后来被安全审计骂得狗血淋头。从那以后,我对认证这块就特别较真。

今天咱们就聊聊,怎么用 bcrypt 加密密码,怎么用 JWT 做令牌,以及怎么实现一套靠谱的注册、登录、刷新流程。

核心要点:密码绝不能明文存!令牌要有过期时间!刷新机制要防滥用!

1. 密码加密存储:为什么选 bcrypt?

你可能会问:MD5、SHA256 不行吗?嗯,它们确实能加密,但问题是——它们太快了。攻击者拿到哈希值,用彩虹表一跑,分分钟还原密码。

bcrypt 不一样。它故意设计得很慢,而且每次加密都会自动加盐。说白了,就算两个用户密码一样,存到数据库里的哈希值也完全不同。

我在项目中遇到过一件事:有一次数据库被拖库,但因为密码全是 bcrypt 加密的,攻击者根本没法破解。老板当时就说了句:「这钱花得值。」

个人建议:bcrypt 的成本因子(cost factor)设成 10 或 12 就行。太高了响应慢,太低了不安全。我一般用 10。

代码示例:bcrypt 加密与验证

// 注册时加密密码
const bcrypt = require('bcrypt');
const saltRounds = 10;

async function hashPassword(plainPassword) {
  const salt = await bcrypt.genSalt(saltRounds);
  const hash = await bcrypt.hash(plainPassword, salt);
  return hash;
}

// 登录时验证密码
async function verifyPassword(plainPassword, hash) {
  const match = await bcrypt.compare(plainPassword, hash);
  return match; // true 或 false
}

2. JWT 令牌生成与验证

JWT 说白了就是一个「通行证」。用户登录成功后,服务器给他发一个令牌。以后每次请求,带上这个令牌就行。服务器不用查数据库,直接解析令牌就能知道是谁。

JWT 由三部分组成:HeaderPayloadSignature。我习惯把用户 ID、角色、过期时间放在 Payload 里。注意啊,千万别放密码这种敏感信息——因为 Payload 只是 Base64 编码,不是加密的。

我曾经踩过的坑:有一次我把用户手机号放进了 Payload,结果前端截获了别人的令牌,直接看到了手机号。从那以后,Payload 里我只放「非敏感」信息。

代码示例:生成与验证 JWT

const jwt = require('jsonwebtoken');
const secretKey = process.env.JWT_SECRET || 'your-secret-key';

// 生成令牌(有效期 2 小时)
function generateToken(userId, role) {
  const payload = {
    sub: userId,
    role: role,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 7200 // 2小时
  };
  return jwt.sign(payload, secretKey);
}

// 验证令牌
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, secretKey);
    return decoded; // 返回 { sub, role, exp, iat }
  } catch (err) {
    return null; // 令牌无效或过期
  }
}

3. 用户注册接口

注册接口其实不复杂,但有几个细节要注意:

  • 邮箱/用户名唯一性检查——别让两个人用同一个账号
  • 密码强度校验——至少 8 位,包含字母和数字
  • bcrypt 加密后再存库

我习惯在注册成功后,直接返回一个 JWT 令牌。这样用户注册完就能直接进入系统,不用再登录一次。用户体验好很多。

代码示例:注册接口

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

  // 1. 检查邮箱是否已注册
  const existingUser = await User.findOne({ email });
  if (existingUser) {
    return res.status(409).json({ message: '邮箱已被注册' });
  }

  // 2. 密码强度校验
  if (password.length < 8) {
    return res.status(400).json({ message: '密码至少8位' });
  }

  // 3. 加密密码
  const hashedPassword = await hashPassword(password);

  // 4. 创建用户
  const user = new User({ email, password: hashedPassword });
  await user.save();

  // 5. 生成令牌并返回
  const token = generateToken(user._id, 'user');
  res.status(201).json({ token, user: { id: user._id, email } });
});

4. 用户登录接口

登录流程更直接:查用户 → 验密码 → 发令牌。但有个小细节——登录失败时,不要告诉用户「密码错误」还是「用户不存在」。统一返回「邮箱或密码错误」,防止攻击者枚举有效账号。

避坑指南:我曾经遇到过暴力破解攻击。后来加了登录失败次数限制——连续 5 次失败,锁定账号 15 分钟。效果立竿见影。

代码示例:登录接口

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

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

  // 2. 验证密码
  const isValid = await verifyPassword(password, user.password);
  if (!isValid) {
    return res.status(401).json({ message: '邮箱或密码错误' });
  }

  // 3. 生成令牌
  const token = generateToken(user._id, user.role);
  res.json({ token, user: { id: user._id, email: user.email, role: user.role } });
});

5. Token 刷新机制

JWT 过期了怎么办?让用户重新登录?太不友好了。所以我们需要一个 刷新令牌(Refresh Token)

思路是这样的:

  • 登录时发两个令牌:Access Token(短期,比如 2 小时)和 Refresh Token(长期,比如 7 天)
  • Access Token 过期后,用 Refresh Token 去换一个新的 Access Token
  • Refresh Token 用过一次就作废,发一个新的回来——这叫「轮换机制」

为什么要轮换?因为如果 Refresh Token 被盗,攻击者可以一直用。轮换之后,每次刷新都换新令牌,就算被盗,也只能用一次。

重要提醒:Refresh Token 一定要存数据库!而且要标记是否已使用。我见过有人把 Refresh Token 放内存里,一重启服务所有用户都得重新登录……

代码示例:刷新令牌接口

app.post('/api/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  // 1. 验证 refresh token 是否有效
  const storedToken = await RefreshToken.findOne({ token: refreshToken, used: false });
  if (!storedToken) {
    return res.status(401).json({ message: '无效的刷新令牌' });
  }

  // 2. 标记旧令牌为已使用(轮换)
  storedToken.used = true;
  await storedToken.save();

  // 3. 生成新令牌
  const newAccessToken = generateToken(storedToken.userId, storedToken.role);
  const newRefreshToken = crypto.randomBytes(32).toString('hex');

  // 4. 存储新 refresh token
  await RefreshToken.create({
    token: newRefreshToken,
    userId: storedToken.userId,
    role: storedToken.role,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7天
  });

  res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
});

6. 整体流程示意图

下面这张图,把注册、登录、刷新、验证的完整流程串起来了。你仔细看看,应该能一目了然。

用户注册与登录流程 客户端 注册接口 登录接口 bcrypt 加密/验证 JWT 生成 数据库 刷新令牌接口 接口 加密/验证 令牌生成 数据库

7. 安全建议总结

环节 建议做法 常见坑
密码存储 bcrypt,成本因子 ≥ 10 用 MD5、SHA256 或自创算法
JWT 签名 使用强密钥,定期更换 密钥硬编码在代码里
Access Token 有效期 15 分钟 ~ 2 小时 设置成 7 天甚至更长
Refresh Token 有效期 7 天,支持轮换 不轮换,或者不存数据库
登录失败处理 统一错误提示,限制尝试次数 明确提示「密码错误」或「用户不存在」

最后说一句:认证模块是系统的第一道门。你花 80% 的精力把这里做扎实了,后面 80% 的安全问题都不会找上门。我这些年见过的安全漏洞,十有八九都是认证没做好。

专注资料整理