JWT 认证机制
JWT(JSON Web Token)是一种开放标准(RFC 7519),定义了一种紧凑、自包含的方式,在各方之间安全地传输信息。与传统 Session 不同,JWT 将用户信息和签名直接存储在令牌中,服务端无需查询数据库即可验证用户身份。
理解 JWT 的设计动机
在传统的 Session 认证中,服务端需要在内存或数据库中保存每个用户的会话状态。这种方式在单体应用中运行良好,但在分布式系统中会遇到问题:
- 扩展困难:多台服务器之间需要同步 Session 数据
- 内存开销:服务端需要为每个用户维护会话记录
- 跨域复杂:不同域名的应用共享认证状态需要额外配置
JWT 通过将用户信息编码到令牌本身,让认证过程变成无状态的。服务端只需验证令牌签名,不需要存储任何会话数据。
JWT 的结构
一个 JWT 由三部分组成,用点号(.)分隔:
header.payload.signature
Header(头部)
头部声明令牌类型和签名算法:
{
"alg": "HS256",
"typ": "JWT"
}
这段 JSON 经过 Base64Url 编码后成为 JWT 的第一部分。
Payload(载荷)
载荷包含实际要传递的数据,称为声明(Claims)。声明分为三类:
注册声明:JWT 规范预定义的字段
| 字段 | 含义 | 说明 |
|---|---|---|
iss | Issuer | 签发者 |
sub | Subject | 主题(通常是用户 ID) |
aud | Audience | 接收方 |
exp | Expiration Time | 过期时间(Unix 时间戳) |
nbf | Not Before | 生效时间 |
iat | Issued At | 签发时间 |
jti | JWT ID | 令牌唯一标识 |
公共声明:为避免冲突,在 IANA 注册或使用命名空间的字段。
私有声明:通信双方约定的自定义字段。
示例 Payload:
{
"sub": "1234567890",
"name": "张三",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
载荷部分仅经过 Base64 编码,并非加密。任何人都能解码读取其中内容。严禁在载荷中存放密码、银行卡号等敏感信息。
Signature(签名)
签名用于验证令牌是否被篡改。计算方式:
服务端收到 JWT 后,用相同的密钥重新计算签名,与令牌中的签名比对。如果一致,说明令牌未被修改。
签名算法
JWT 支持多种签名算法,分为对称和非对称两类:
对称算法(HMAC)
使用同一个密钥签名和验证:
- HS256:HMAC + SHA-256,最常用
- HS384:HMAC + SHA-384
- HS512:HMAC + SHA-512
优点是简单快速,缺点是签发方和验证方必须共享密钥。
非对称算法(RSA / ECDSA)
使用私钥签名,公钥验证:
- RS256:RSA + SHA-256
- RS384:RSA + SHA-384
- RS512:RSA + SHA-512
- ES256:ECDSA + P-256 + SHA-256
优点是公钥可以公开分发,适合微服务架构。推荐在生产环境中使用 RS256。
完整认证流程
代码实现
Node.js
使用 jsonwebtoken 库:
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your-secret-key';
// 生成令牌
const token = jwt.sign(
{ userId: 123, role: 'admin' },
SECRET_KEY,
{ expiresIn: '15m' }
);
// 验证令牌
try {
const decoded = jwt.verify(token, SECRET_KEY);
console.log(decoded.userId); // 123
} catch (err) {
console.log('令牌无效:', err.message);
}
Python
使用 PyJWT 库:
import jwt
import datetime
SECRET_KEY = 'your-secret-key'
# 生成令牌
token = jwt.encode(
{
'user_id': 123,
'role': 'admin',
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
},
SECRET_KEY,
algorithm='HS256'
)
# 验证令牌
try:
decoded = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
print(decoded['user_id']) # 123
except jwt.ExpiredSignatureError:
print('令牌已过期')
except jwt.InvalidTokenError:
print('令牌无效')
Go
使用 golang-jwt/jwt 库:
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secretKey = []byte("your-secret-key")
// 生成令牌
func generateToken(userID int) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(15 * time.Minute).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secretKey)
}
// 验证令牌
func verifyToken(tokenString string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return secretKey, nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
}
return nil, err
}
Java
使用 jjwt 库:
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.Claims;
String secretKey = "your-secret-key";
// 生成令牌
String token = Jwts.builder()
.setSubject("123")
.claim("role", "admin")
.setExpiration(new Date(System.currentTimeMillis() + 900000)) // 15分钟
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
// 验证令牌
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
String userId = claims.getSubject();
令牌生命周期管理
短期令牌 + 刷新令牌
JWT 的一个限制是:一旦签发,在过期之前无法主动失效。为解决这个问题,通常采用双令牌方案:
- Access Token:短期令牌(15 分钟 - 1 小时),用于日常 API 调用
- Refresh Token:长期令牌(7 天 - 30 天),仅用于获取新的 Access Token
工作流程:
客户端 服务端
| |
|-- 登录请求 ---------->|
|<-- AT + RT -----------| 返回双令牌
| |
|-- 携带 AT 请求资源 -->|
|<-- 返回资源 ----------|
| |
|-- AT 过期,携带 RT -->|
|<-- 新 AT -------------| 刷新令牌
| |
|-- 用户登销 RT ------->| 主动失效
这样做的好处是:
- Access Token 泄露后,影响时间窗口很小
- 通过在服务端存储 Refresh Token,可以在需要时强制用户下线
- 平衡了安全性和用户体验
令牌黑名单
对于需要主动撤销令牌的场景,可以维护一个黑名单:
# 使用 Redis 存储已撤销的令牌
import redis
redis_client = redis.Redis()
def revoke_token(jti, exp):
"""将令牌加入黑名单"""
ttl = exp - time.time()
if ttl > 0:
redis_client.setex(f"token:blacklist:{jti}", int(ttl), "1")
def is_token_revoked(jti):
"""检查令牌是否被撤销"""
return redis_client.exists(f"token:blacklist:{jti}")
安全防护要点
1. 算法攻击防护
明确限制服务端接受的签名算法,防止 alg: none 攻击:
# 正确做法:明确指定允许的算法
decoded = jwt.decode(token, key, algorithms=['HS256'])
# 错误做法:允许任何算法
decoded = jwt.decode(token, key) # 危险!
2. 密钥管理
- 使用足够长度的随机密钥(至少 256 位)
- 定期轮换密钥
- 使用环境变量或密钥管理服务存储密钥,不要硬编码
3. 令牌存储
| 存储方式 | 安全性 | 说明 |
|---|---|---|
| HttpOnly Cookie | 较高 | 可防御 XSS,但需要注意 CSRF |
| LocalStorage | 较低 | 易受 XSS 攻击 |
| 内存变量 | 较高 | 刷新页面丢失,适合 SPA |
推荐方案:Access Token 存储在内存中,Refresh Token 存储在 HttpOnly Cookie 中。
4. 设置合理的过期时间
Access Token 不要设置过长的有效期。过期时间越短,泄露后的影响越小。
JWT vs Session 选型
| 场景 | 推荐方案 |
|---|---|
| 分布式微服务 | JWT(无状态,易于扩展) |
| 移动端 App | JWT(适合 API 调用) |
| 传统 Web 应用 | Session(实现简单) |
| 需要强撤销能力 | Session 或 JWT + 黑名单 |
| 单点登录 SSO | JWT |
现代系统常采用混合方案:核心认证使用 JWT,敏感操作增加二次验证,使用 Refresh Token 机制管理会话生命周期。
小结
本章学习了 JWT 的核心概念和实践:
- 结构:Header + Payload + Signature,Base64Url 编码
- 原理:服务端签名验证,无需存储会话
- 算法:对称(HMAC)和非对称(RSA/ECDSA)
- 流程:登录获取令牌,请求携带令牌
- 管理:双令牌机制、令牌黑名单
- 安全:算法限制、密钥管理、存储策略
练习
- 实现一个完整的 JWT 登录接口,包含令牌生成和验证
- 实现 Refresh Token 刷新机制
- 使用 Redis 实现令牌黑名单功能
- 对比测试 HS256 和 RS256 的性能差异