大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
JWT 认证
这一课你将能做到
- 理解 JWT 的结构和签名原理
- 用 Node.js 实现登录签发 JWT、接口鉴权
- 处理 Token 过期、刷新、黑名单
为什么要学
HTTP 是无状态的,服务器不知道每次请求是不是同一个人。传统方案用 Session + Cookie,但分布式部署时 Session 同步麻烦。JWT(JSON Web Token)把用户信息加密后放 Token 里,服务器验证签名即可,不用查数据库,天然支持分布式。
人话:JWT 是「电子门票」,用户登录后拿到,之后每次请求出示门票,服务器验票放行。
先建立图像
JWT 结构(三部分,用 . 分隔):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY0MjAwMDAwMCwiZXhwIjoxNjQyMDAzNjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header.Payload.Signature
Header: {"alg":"HS256","typ":"JWT"} → Base64 编码
Payload: {"userId":1,"iat":1642000000,"exp":1642003600} → Base64 编码
Signature: HMACSHA256(Header.Payload, secret) → 签名
跟着做
步骤 1:安装依赖
npm install jsonwebtoken bcrypt
步骤 2:用户注册与登录
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const SECRET = process.env.JWT_SECRET || 'your-secret-key';
// 注册
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
// 密码哈希(不要明文存储)
const passwordHash = await bcrypt.hash(password, 10);
const result = await pool.query(
'INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username',
[username, passwordHash]
);
res.status(201).json({ code: 0, data: result.rows[0] });
});
// 登录
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// 查用户
const result = await pool.query(
'SELECT * FROM users WHERE username = $1', [username]
);
const user = result.rows[0];
if (!user) {
return res.status(401).json({ code: 401, msg: '用户名或密码错误' });
}
// 校验密码
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ code: 401, msg: '用户名或密码错误' });
}
// 签发 JWT
const token = jwt.sign(
{ userId: user.id, username: user.username },
SECRET,
{ expiresIn: '2h' } // 2 小时过期
);
res.json({ code: 0, data: { token, user: { id: user.id, username: user.username } } });
});
步骤 3:鉴权中间件
// 中间件:验证 Token
function authMiddleware(req, res, next) {
// 从 Header 取 Token
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ code: 401, msg: '未登录' });
}
const token = authHeader.substring(7); // 去掉 "Bearer "
try {
const payload = jwt.verify(token, SECRET);
req.user = payload; // 把用户信息挂到 req 上
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ code: 401, msg: 'Token 已过期' });
}
return res.status(401).json({ code: 401, msg: 'Token 无效' });
}
}
// 使用中间件
app.get('/api/profile', authMiddleware, async (req, res) => {
// req.user 就是 JWT 的 payload
const { userId } = req.user;
const result = await pool.query(
'SELECT id, username, email, avatar FROM users WHERE id = $1',
[userId]
);
res.json({ code: 0, data: result.rows[0] });
});
步骤 4:前端使用 Token
// 登录后保存 Token
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: '123456' })
})
.then(res => res.json())
.then(data => {
if (data.code === 0) {
localStorage.setItem('token', data.data.token);
}
});
// 请求时带上 Token
fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
.then(res => res.json())
.then(data => console.log(data));
步骤 5:Token 刷新(双 Token 机制)
// 登录时签发两个 Token
app.post('/api/login', async (req, res) => {
// ... 校验密码 ...
const accessToken = jwt.sign(
{ userId: user.id },
SECRET,
{ expiresIn: '2h' } // 短期
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
SECRET,
{ expiresIn: '7d' } // 长期
);
// refreshToken 存数据库或 Redis
await client.setEx(`refresh:${user.id}`, 7 * 24 * 3600, refreshToken);
res.json({ code: 0, data: { accessToken, refreshToken } });
});
// 刷新接口
app.post('/api/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, SECRET);
// 检查 Redis 中是否存在(防止被吊销)
const saved = await client.get(`refresh:${payload.userId}`);
if (saved !== refreshToken) {
return res.status(401).json({ code: 401, msg: 'Token 已失效' });
}
// 签发新 Token
const newAccessToken = jwt.sign(
{ userId: payload.userId },
SECRET,
{ expiresIn: '2h' }
);
res.json({ code: 0, data: { accessToken: newAccessToken } });
} catch (err) {
res.status(401).json({ code: 401, msg: 'Token 无效' });
}
});
步骤 6:退出登录(黑名单)
// 退出登录:把 Token 加入黑名单
app.post('/api/logout', authMiddleware, async (req, res) => {
const token = req.headers.authorization.substring(7);
// Token 加入黑名单,过期时间 = Token 剩余有效期
const payload = jwt.decode(token);
const ttl = payload.exp - Math.floor(Date.now() / 1000);
await client.setEx(`blacklist:${token}`, ttl, '1');
res.json({ code: 0, msg: '已退出' });
});
// 鉴权中间件增加黑名单检查
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.substring(7);
if (!token) {
return res.status(401).json({ code: 401, msg: '未登录' });
}
// 检查黑名单
const blacklisted = await client.get(`blacklist:${token}`);
if (blacklisted) {
return res.status(401).json({ code: 401, msg: 'Token 已失效' });
}
try {
const payload = jwt.verify(token, SECRET);
req.user = payload;
next();
} catch (err) {
res.status(401).json({ code: 401, msg: 'Token 无效' });
}
}
步骤 7:角色权限控制
// 鉴权 + 角色检查
function requireRole(role) {
return async (req, res, next) => {
const { userId } = req.user;
const result = await pool.query(
'SELECT role FROM users WHERE id = $1', [userId]
);
if (result.rows[0].role !== role) {
return res.status(403).json({ code: 403, msg: '无权限' });
}
next();
};
}
// 只有管理员能删除课程
app.delete('/api/courses/:id', authMiddleware, requireRole('admin'), async (req, res) => {
// 删除逻辑
});
讲清楚要点
- JWT 三部分:Header(算法)、Payload(数据)、Signature(签名)。
- Payload 不放敏感信息:Base64 编码可解码,密码、手机号别放。
- 签名防篡改:服务器用密钥签名,客户端改 Payload 后签名对不上。
- 过期时间控制:Access Token 短期(2h),Refresh Token 长期(7d)。
- 黑名单实现退出:Token 加入 Redis 黑名单,直到过期。
你可能会懵的地方
-
JWT 和 Session 选哪个? JWT 无状态、支持分布式;Session 有状态、易管理(可直接删除)。单体应用 Session 够用,分布式用 JWT。
-
Token 存 localStorage 还是 Cookie? localStorage 简单但易受 XSS 攻击;Cookie 可设 HttpOnly 防 XSS,但要防 CSRF。看安全需求。
-
Secret 怎么管理? 放环境变量,不要写代码里。生产环境用密钥管理服务(AWS KMS、HashiCorp Vault)。
-
Token 被盗怎么办? 短期过期 + HTTPS + 黑名单。敏感操作要求重新输入密码。
-
Payload 能放多少数据? 别放太多,Token 会变大,每次请求都带。只放
userId,其他信息查数据库。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 密码明文存数据库 | 脱库即泄露 | 用 bcrypt 哈希 |
| Payload 放密码 | 可被解码 | 只放 userId、username |
| Secret 写代码里 | 代码泄露 = 密钥泄露 | 放环境变量 |
| Token 永不过期 | 被盗后永久有效 | 设短期过期(2h) |
| 退出登录只删前端 Token | 后端 Token 仍有效 | 加入黑名单 |
卡点急救
- Token 验证失败:检查 Secret 是否一致、Token 是否过期、格式是否为
Bearer xxx。 - 跨域请求 Token 丢失:前端
fetch加credentials: 'include',后端 CORS 加credentials: true。 - 刷新 Token 循环失败:检查 Refresh Token 是否过期、Redis 中是否被删除。
面试常问
-
JWT 原理? Header.Payload.Signature 三部分,签名用 HMAC 或 RSA。服务器验证签名即可,不用查数据库。
-
如何防止 Token 被篡改? 签名机制。客户端改 Payload 后无法生成正确签名(不知道 Secret)。
-
如何实现单点登录(SSO)? 统一认证中心签发 Token,各子系统验证同一 Secret。或用 OAuth2、CAS 协议。
-
JWT 缺点? 无法主动失效(除非黑名单)、Payload 不能放太多、Token 体积比 Session ID 大。
自测三问
- JWT 三部分分别是什么?
- 为什么 Payload 不能放密码?
- 如何实现退出登录?
小结一句话
JWT 用签名保证 Token 不可篡改,Access Token 短期 + Refresh Token 长期 + 黑名单实现完整认证。
延伸
- JWT 官网
- 下一课:Linux 基础部署后端服务。