4、用户认证模块:JWT令牌生成与验证、登录/登出接口、密码加密存储
用户认证,说白了就是解决「你是谁」的问题。我见过太多项目,功能做得花里胡哨,结果认证模块一塌糊涂,上线第一天就被拖库。嗯,这一章我们就把这块硬骨头啃下来。
4.1 密码加密存储:别拿明文开玩笑
先聊密码存储。你想想看,如果数据库被拖了,用户的密码明文暴露,那基本就是灾难。我个人习惯,永远不要用 MD5,那玩意儿早就不安全了。彩虹表一查,分分钟还原。
那用什么?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);
4.2 JWT 令牌:无状态认证的核心
JWT(JSON Web Token)现在基本是标配。它为什么流行?因为无状态。服务器不用存 session,令牌里就包含了用户信息。每次请求,客户端把令牌带过来,服务器验证签名就行。
JWT 的结构很简单:Header.Payload.Signature。Header 声明算法,Payload 放数据(比如用户 ID、过期时间),Signature 用来防篡改。
我个人习惯,Payload 里只放必要信息,比如 userId、role。千万别放密码、手机号这些敏感数据。为什么?因为 JWT 的 Payload 只是 Base64 编码,不是加密的。你想想看,谁都能解码看到内容。
生成 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: '令牌无效或已过期' });
}
4.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 } });
});
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: '登出成功' });
});
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 });
});
4.6 避坑指南:我踩过的那些坑
做认证模块,坑真的不少。我列几个印象深刻的:
- 令牌泄露: 我曾经在日志里打印了完整的 JWT,结果日志被第三方看到。后来我学乖了,日志里只记录 userId,不记录 token。
- 时钟偏差: 服务器时间不准,导致 JWT 验证时认为令牌已过期。解决方案是设置 clockTolerance,比如允许 30 秒的偏差。
- 令牌太长: 如果 Payload 里塞太多东西,JWT 会变得很长,影响网络传输。我一般控制在 200 字节以内。
- 忘记刷新令牌: 只用 access token,过期了用户就得重新登录。我一般配合 refresh token 使用,access token 短一点(15 分钟),refresh token 长一点(7 天)。
好了,用户认证模块的核心内容就这些。说白了,就是密码加密存、JWT 安全传、接口防暴力、登出有黑名单。把这四点做好,你的认证模块就稳了。下一章我们聊权限控制,到时候会用到这里的 userId 和 role 信息。