JWT 身份验证:JSON Web Token 的工作原理
· 12 分钟阅读
目录
什么是 JSON Web Token?
JSON Web Token (JWT) 是一种紧凑、URL 安全的方式,用于在两方之间表示声明。JWT 已成为现代 Web 应用程序、API 和微服务架构中身份验证和授权的事实标准。
当用户登录时,服务器生成一个包含用户信息的 JWT 并将其发送给客户端。然后客户端在后续请求中包含此令牌以证明其身份。可以把它想象成一个数字护照,包含您的凭证,并且可以在不每次都回调发行机构的情况下进行验证。
与传统的基于会话的身份验证(服务器在内存或数据库中维护会话状态)不同,JWT 是无状态的。验证令牌所需的所有信息都包含在令牌本身中。这种架构决策带来了几个优势:
- 可扩展性: 在负载均衡环境中无需会话存储或粘性会话
- 跨域身份验证: JWT 可以在不同的域和服务之间无缝工作
- 移动友好: 非常适合需要与后端 API 进行身份验证的移动应用
- 微服务: 每个服务都可以独立验证令牌,无需中央会话存储
- 性能: 消除了每个经过身份验证的请求的数据库查询
您可以使用我们的 JWT 解码器工具检查和解码任何 JWT 以查看其内容,而无需编写任何代码。这在调试身份验证问题或了解令牌包含哪些数据时特别有用。
专业提示: JWT 默认是签名的,而不是加密的。任何人都可以解码和读取 JWT 的内容。切勿在 JWT 负载中存储敏感信息,如密码、信用卡号或社会安全号码。
JWT 结构详解
JWT 由三个不同的部分组成,用点(.)分隔:头部、负载和签名。以下是一个真实的 JWT 示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
让我们分解每个组件,以了解它们如何协同工作以创建安全、可验证的令牌。
1. 头部
头部通常包含两个关键字段,定义应如何处理令牌:
{
"alg": "HS256",
"typ": "JWT"
}
alg(算法): 指定用于签名令牌的加密算法typ(类型): 声明这是一个 JWT 令牌
然后将此 JSON 对象进行 Base64Url 编码,形成 JWT 的第一部分。编码使其 URL 安全且紧凑,便于在 HTTP 头中传输。
2. 负载
负载包含声明 — 关于用户和附加元数据的陈述。这是您存储要传输的实际数据的地方:
{
"sub": "1234567890",
"name": "John Doe",
"email": "[email protected]",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
声明分为三类:
注册声明(标准化且推荐):
sub(主题): 用户的唯一标识符iat(签发时间): 令牌创建时的时间戳exp(过期时间): 令牌过期时的时间戳iss(签发者): 标识谁签发了令牌aud(受众): 标识令牌的目标接收者nbf(生效时间): 令牌在此时间戳之前无效jti(JWT ID): 令牌本身的唯一标识符
公共声明是应在 IANA JSON Web Token 注册表中定义或使用抗冲突名称(如 URL)的自定义声明。
私有声明是各方之间约定的自定义声明,如 role、permissions 或 department。
3. 签名
签名使 JWT 安全且防篡改。它是通过获取编码的头部、编码的负载、密钥,并应用头部中指定的算法来创建的:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
签名有两个关键目的:
- 完整性验证: 确保令牌自签发以来未被修改
- 身份验证: 证明令牌是由有权访问密钥的人创建的
如果有人试图修改头部或负载,签名验证将失败,令牌将被拒绝。
JWT 身份验证的工作原理
了解完整的身份验证流程对于正确实现 JWT 至关重要。以下是分步过程:
步骤 1: 用户登录
用户将其凭证(用户名和密码)提交到身份验证端点。服务器根据数据库验证这些凭证。
步骤 2: 令牌生成
如果凭证有效,服务器生成包含用户信息和声明的 JWT。服务器使用密钥(对于对称算法)或私钥(对于非对称算法)对令牌进行签名。
步骤 3: 令牌传递
服务器将 JWT 发送回客户端,通常在响应正文中。客户端存储此令牌以供将来请求使用。
步骤 4: 经过身份验证的请求
对于后续请求,客户端使用 Bearer 模式在 Authorization 头中包含 JWT:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
步骤 5: 令牌验证
服务器接收请求,从头部提取 JWT,并使用密钥或公钥验证签名。如果有效,服务器从负载中提取用户信息并处理请求。
步骤 6: 令牌过期
当令牌过期时,客户端必须获取新令牌,可以通过重新身份验证或使用刷新令牌(在下一节中介绍)。
快速提示: 始终在服务器端验证 exp 声明,即使您在客户端检查过期时间。客户端检查可以被绕过,但服务器端验证是权威的。
访问令牌与刷新令牌
一个强大的 JWT 身份验证系统通常使用两种类型的令牌协同工作:访问令牌和刷新令牌。理解这种区别对于构建安全的应用程序至关重要。
访问令牌
访问令牌是短期的 JWT(通常为 15 分钟到 1 小时),授予对受保护资源的访问权限。它们包含授权请求所需的用户身份和权限。
特征:
- 短过期时间(5-60 分钟)
- 包含在每个 API 请求中
- 包含用户权限和角色
- 在过期前无法撤销(无状态)
刷新令牌
刷新令牌是长期令牌(数天到数月),专门用于获取新的访问令牌。它们被安全存储,仅发送到令牌刷新端点。
特征:
- 长过期时间(数天到数月)
- 仅发送到刷新端点
- 可以在数据库中撤销
- 通常与附加元数据(设备、IP、用户代理)一起存储
为什么同时使用两者?
这种双令牌方法平衡了安全性和用户体验:
| 方面 | 仅访问令牌 | 访问令牌 + 刷新令牌 |
|---|---|---|
| 安全性 | 较低(长期令牌存在风险) | 较高(短期访问令牌) |
| 用户体验 | 较好(无需重新身份验证) | 较好(无缝令牌刷新) |
| 撤销 | 困难(无状态) | 可能(刷新令牌在数据库中) |
| 网络开销 | 较低 | 略高(刷新请求) |
| 实现复杂度 | 简单 | 中等 |
令牌刷新流程
- 客户端检测到访问令牌已过期或即将过期
- 客户端将刷新令牌发送到
/auth/refresh端点 - 服务器根据数据库验证刷新令牌
- 服务器生成新的访问令牌(以及可选的新刷新令牌)
- 客户端接收并存储新令牌
- 客户端使用新的访问令牌重试原始请求
专业提示: 实现刷新令牌轮换 — 每次使用刷新令牌时发放新的刷新令牌并使旧令牌失效。这限制了刷新令牌被泄露时的损害。
Node.js 实现指南
让我们使用 Node.js、Express 和 jsonwebtoken 库构建一个完整的 JWT 身份验证系统。此实现包括访问令牌和刷新令牌。
安装
npm install express jsonwebtoken bcrypt dotenv
环境配置
创建一个包含密钥的 .env 文件:
ACCESS_TOKEN_SECRET=your-super-secret-access-key-change-this
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
令牌生成
const jwt = require('jsonwebtoken');
function generateAccessToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
);
}
登录端点
const bcrypt = require('bcrypt');
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// 在数据库中查找用户
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: '凭证无效' });
}
// 验证密码
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: '凭证无效' });
}
// 生成令牌
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// 在数据库中存储刷新令牌
await RefreshToken.create({
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: '服务器错误' });
}
});
身份验证中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '需要访问令牌' });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: '令牌无效或已过期' });
}
req.user = user;
next();
});
}
// 使用方法
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({
message: '受保护的数据',
user: req.user
});
});
令牌刷新端点
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: '需要刷新令牌' });
}
try {
// 验证刷新令牌是否存在于数据库中
const storedToken = await RefreshToken.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({ error: '刷新令牌无效' });
}
// 验证令牌签名
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, user) => {
if (err) {
return res.status(403).json({ error: '刷新令牌无效' });
}
// 获取用户数据
const userData = await User.findById(user.userId);
// 生成新的访问令牌
const accessToken = generateAccessToken(userData);
res.json({ accessToken });