跳到主要内容

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 规范预定义的字段

字段含义说明
issIssuer签发者
subSubject主题(通常是用户 ID)
audAudience接收方
expExpiration Time过期时间(Unix 时间戳)
nbfNot Before生效时间
iatIssued At签发时间
jtiJWT ID令牌唯一标识

公共声明:为避免冲突,在 IANA 注册或使用命名空间的字段。

私有声明:通信双方约定的自定义字段。

示例 Payload:

{
"sub": "1234567890",
"name": "张三",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
安全警告

载荷部分仅经过 Base64 编码,并非加密。任何人都能解码读取其中内容。严禁在载荷中存放密码、银行卡号等敏感信息。

Signature(签名)

签名用于验证令牌是否被篡改。计算方式:

HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)\text{HMACSHA256}(\text{base64UrlEncode(header)} + "." + \text{base64UrlEncode(payload)}, \text{secret})

服务端收到 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 的一个限制是:一旦签发,在过期之前无法主动失效。为解决这个问题,通常采用双令牌方案:

  1. Access Token:短期令牌(15 分钟 - 1 小时),用于日常 API 调用
  2. 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(无状态,易于扩展)
移动端 AppJWT(适合 API 调用)
传统 Web 应用Session(实现简单)
需要强撤销能力Session 或 JWT + 黑名单
单点登录 SSOJWT

现代系统常采用混合方案:核心认证使用 JWT,敏感操作增加二次验证,使用 Refresh Token 机制管理会话生命周期。

小结

本章学习了 JWT 的核心概念和实践:

  1. 结构:Header + Payload + Signature,Base64Url 编码
  2. 原理:服务端签名验证,无需存储会话
  3. 算法:对称(HMAC)和非对称(RSA/ECDSA)
  4. 流程:登录获取令牌,请求携带令牌
  5. 管理:双令牌机制、令牌黑名单
  6. 安全:算法限制、密钥管理、存储策略

练习

  1. 实现一个完整的 JWT 登录接口,包含令牌生成和验证
  2. 实现 Refresh Token 刷新机制
  3. 使用 Redis 实现令牌黑名单功能
  4. 对比测试 HS256 和 RS256 的性能差异

参考资料