用户注册与登录模块(后端):密码加密存储、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 由三部分组成:Header、Payload、Signature。我习惯把用户 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. 整体流程示意图
下面这张图,把注册、登录、刷新、验证的完整流程串起来了。你仔细看看,应该能一目了然。
7. 安全建议总结
| 环节 | 建议做法 | 常见坑 |
|---|---|---|
| 密码存储 | bcrypt,成本因子 ≥ 10 | 用 MD5、SHA256 或自创算法 |
| JWT 签名 | 使用强密钥,定期更换 | 密钥硬编码在代码里 |
| Access Token 有效期 | 15 分钟 ~ 2 小时 | 设置成 7 天甚至更长 |
| Refresh Token 有效期 | 7 天,支持轮换 | 不轮换,或者不存数据库 |
| 登录失败处理 | 统一错误提示,限制尝试次数 | 明确提示「密码错误」或「用户不存在」 |
最后说一句:认证模块是系统的第一道门。你花 80% 的精力把这里做扎实了,后面 80% 的安全问题都不会找上门。我这些年见过的安全漏洞,十有八九都是认证没做好。