1. 为什么我们需要JWT?
在传统的Web应用中,我们通常使用Session机制来维护用户的登录状态。服务器会为每个登录的用户创建一个Session,并将Session ID通过Cookie返回给客户端。这种方式在单体应用中工作良好,但在现代分布式系统和微服务架构中却暴露出诸多问题:
- 服务器内存压力:每个活跃用户的Session都会占用服务器内存
- 扩展性问题:当应用需要水平扩展时,Session的同步会成为瓶颈
- 跨域限制:在前后端分离的SPA应用中,Cookie的跨域限制会导致开发复杂度增加
- CSRF风险:基于Cookie的认证容易受到跨站请求伪造攻击
JWT(JSON Web Token)正是为了解决这些问题而诞生的。它是一种开放标准(RFC 7519),定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息作为JSON对象。与Session不同,JWT不需要在服务端存储任何状态,这使得它成为无状态认证的理想选择。
提示:JWT特别适合前后端分离的SPA应用、移动应用和微服务架构,但在某些需要即时撤销令牌的场景下可能不是最佳选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JWT的结构与工作原理
2.1 JWT的三大组成部分
一个标准的JWT由三部分组成,用点号(.)分隔:
code复制header.payload.signature
让我们拆解一个实际的JWT示例:
code复制eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
2.1.1 Header(头部)
Header通常由两部分组成:
- 令牌类型(typ):固定为"JWT"
- 签名算法(alg):如HS256、RS256等
经过Base64Url编码后的Header:
json复制{
"alg": "HS256",
"typ": "JWT"
}
2.1.2 Payload(负载)
Payload包含声明(claims),声明是关于实体(通常是用户)和附加数据的语句。有三种类型的声明:
- 注册声明(Registered claims):预定义的声明,如iss(签发者)、exp(过期时间)、sub(主题)等
- 公开声明(Public claims):可以自定义,但为避免冲突应在IANA JSON Web Token Registry中定义
- 私有声明(Private claims):自定义声明,用于在同意使用它们的各方之间共享信息
示例Payload:
json复制{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
2.1.3 Signature(签名)
签名用于验证消息在传输过程中没有被篡改。创建签名需要:
- 编码后的Header
- 编码后的Payload
- 一个密钥(只有服务器知道)
- Header中指定的算法
对于HS256算法,签名是这样生成的:
code复制HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
2.2 JWT的工作流程
- 用户使用凭据(如用户名密码)登录
- 服务器验证凭据,生成JWT并返回给客户端
- 客户端存储JWT(通常在localStorage或Cookie中)
- 客户端在后续请求的Authorization头中携带JWT
- 服务器验证JWT并处理请求
3. 在Spring Security中实现JWT
3.1 环境准备
首先添加必要的依赖到pom.xml:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
3.2 JWT工具类实现
创建一个JwtTokenUtil类来处理JWT的生成和验证:
java复制import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
// 生成密钥
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
// 生成token
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, userDetails.getUsername());
}
private String doGenerateToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// 验证token
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
// 从token中提取用户名
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// 检查token是否过期
public Boolean isTokenExpired(String token) {
final Date expiration = extractExpiration(token);
return expiration.before(new Date());
}
// 提取过期时间
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
3.3 配置Spring Security
创建一个JwtAuthenticationFilter来拦截请求并验证JWT:
java复制public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
final String requestTokenHeader = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
// JWT Token格式为 "Bearer token"。去掉Bearer前缀
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtTokenUtil.extractUsername(jwtToken);
} catch (IllegalArgumentException e) {
logger.error("无法获取JWT Token");
} catch (ExpiredJwtException e) {
logger.warn("JWT Token已过期");
}
} else {
logger.warn("JWT Token格式不正确");
}
// 验证token
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
然后在Security配置类中添加这个过滤器:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/authenticate").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
4. JWT的存储与安全最佳实践
4.1 客户端存储方案
在客户端,JWT通常有以下几种存储方式:
-
localStorage:
- 优点:简单易用,容量较大
- 缺点:易受XSS攻击,无法设置HttpOnly标志
-
sessionStorage:
- 类似localStorage,但标签页关闭后自动清除
- 同样存在XSS风险
-
HttpOnly Cookie:
- 优点:防止XSS攻击
- 缺点:可能受到CSRF攻击,需要额外防护
-
内存存储:
- 最安全的方式,但页面刷新后会丢失
提示:对于安全性要求高的应用,推荐使用HttpOnly Cookie + CSRF防护的组合方案。如果必须使用localStorage,务必确保应用有严格的XSS防护措施。
4.2 服务端验证注意事项
- 验证签名:确保使用正确的密钥验证签名
- 检查过期时间:验证exp声明
- 验证签发者:检查iss声明是否符合预期
- 检查受众:验证aud声明是否包含你的服务
- 防止重放攻击:可以使用jti声明和短期有效的令牌
4.3 令牌刷新机制
由于JWT是无状态的,一旦签发就无法撤销(除非更改密钥)。常见的刷新机制有:
-
短期访问令牌+长期刷新令牌:
- 访问令牌有效期短(如15分钟)
- 刷新令牌有效期长(如7天),存储在数据库或Redis中
- 当访问令牌过期时,使用刷新令牌获取新的访问令牌
-
滑动过期:
- 每次请求都刷新令牌过期时间
- 需要维护一个黑名单来撤销令牌
实现刷新令牌的示例:
java复制public class JwtTokenUtil {
// ... 其他方法
@Value("${jwt.refreshExpiration}")
private Long refreshExpiration;
public String generateRefreshToken(UserDetails userDetails) {
return doGenerateToken(new HashMap<>(), userDetails.getUsername(), refreshExpiration);
}
private String doGenerateToken(Map<String, Object> claims, String subject, Long expiration) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
}
5. 常见问题与解决方案
5.1 JWT令牌过长问题
当JWT包含大量声明时,可能会导致令牌过长(超过8KB),这可能会遇到以下问题:
- 某些服务器对Header大小有限制
- 增加网络传输开销
- 可能超出Cookie大小限制
解决方案:
- 只存储必要信息在JWT中
- 使用引用令牌模式(Reference Token):在JWT中只存储一个唯一ID,详细信息存储在服务端
- 压缩JWT(虽然效果有限)
5.2 令牌撤销问题
由于JWT是无状态的,撤销未过期的令牌是一个挑战。常见解决方案:
- 黑名单机制:
- 维护一个已撤销令牌的黑名单
- 每次验证令牌时检查黑名单
- 可以使用Redis等内存数据库实现
java复制// Redis黑名单检查示例
public Boolean isTokenRevoked(String token) {
return redisTemplate.opsForValue().get(token) != null;
}
public void revokeToken(String token) {
Date expiration = jwtTokenUtil.extractExpiration(token);
long ttl = expiration.getTime() - System.currentTimeMillis();
redisTemplate.opsForValue().set(token, "revoked", ttl, TimeUnit.MILLISECONDS);
}
-
短期令牌:
- 设置很短的过期时间(如5-15分钟)
- 配合刷新令牌使用
-
版本控制:
- 在JWT中添加版本号
- 需要撤销时递增版本号
5.3 多设备登录管理
当用户在多设备登录时,可能需要:
- 限制同时登录的设备数量
- 显示活跃会话列表
- 允许用户远程注销特定设备
实现方案:
- 在用户记录中存储设备信息和签发令牌的jti
- 每次验证令牌时检查设备是否被允许
- 提供管理接口让用户查看和注销设备
java复制// 多设备管理示例
public class DeviceAwareJwtTokenUtil extends JwtTokenUtil {
public String generateToken(UserDetails userDetails, String deviceId) {
Map<String, Object> claims = new HashMap<>();
claims.put("deviceId", deviceId);
return doGenerateToken(claims, userDetails.getUsername());
}
public void invalidateDeviceTokens(String username, String deviceId) {
// 将该用户在该设备上的所有令牌加入黑名单
}
}
5.4 性能优化
JWT验证虽然是无状态的,但在高并发场景下仍可能成为瓶颈。优化建议:
- 缓存公钥:如果使用RS256算法,缓存公钥避免重复解析
- 并行验证:对于包含多个验证步骤的,可以并行执行
- 预验证:先快速检查令牌格式和过期时间,再验证签名
- 异步日志:将验证失败的日志记录异步化
6. 实际项目中的经验分享
在实际项目中使用JWT时,我积累了一些宝贵的经验教训:
-
不要存储敏感信息:JWT虽然可以加密,但最佳实践是不要在Payload中存储任何敏感信息,因为客户端可以解码Base64看到内容。
-
合理设置过期时间:访问令牌建议15-30分钟,刷新令牌建议7天。对于金融等高安全场景,访问令牌可以缩短至5分钟。
-
实现完善的令牌刷新流程:刷新令牌应当是一次性的,使用后立即作废并签发新的刷新令牌。这可以防止令牌被截获后长期有效。
-
考虑时钟偏移问题:不同服务器之间可能存在时钟不同步,验证exp和nbf时要留有余量(如±30秒)。
-
记录令牌使用情况:虽然JWT是无状态的,但记录令牌的签发、使用和撤销情况有助于安全审计和问题排查。
-
防范CSRF攻击:如果使用Cookie存储JWT,务必实施CSRF防护措施,如SameSite Cookie属性、CSRF令牌等。
-
密钥管理:签名密钥应当定期轮换(如每3个月),旧密钥应在所有签发的令牌过期后再移除。
-
错误处理:验证失败时应返回适当的HTTP状态码(401未认证或403禁止),但不要透露过多细节以防信息泄露。
-
测试各种边界情况:包括但不限于过期令牌、篡改的签名、缺失的声明、空令牌等。
-
监控和告警:监控JWT验证失败率、异常签名尝试等,设置适当的告警阈值。
