在现代Web应用开发中,访问认证是保障系统安全的第一道防线。作为一名Java开发者,我经历过从传统的Cookie/Session到JWT的完整演进过程。记得2016年做第一个分布式项目时,Session共享问题让我们团队熬了整整两周的夜,最终是JWT拯救了我们。
JWT(JSON Web Token)本质上是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。与传统的认证方式相比,它的核心优势在于:
但JWT也不是银弹,我在实际项目中总结出它的适用场景:
Java生态中有多个JWT实现库,经过对比测试,我最终选择了jjwt(Java JWT),原因如下:
在Spring Boot项目中引入依赖时,需要注意这三个组件必须同时引入:
xml复制<!-- JWT核心依赖 -->
<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>
重要提示:jjwt-impl和jjwt-jackson必须设为runtime范围,这是为了避免依赖冲突,同时保证API的纯净性。
新手最容易犯的错误就是随意设置密钥。根据HS256算法的要求,密钥必须满足:
我推荐两种安全的密钥管理方式:
方案一:配置文件注入(推荐)
java复制@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {
private String secret;
private long expire;
// getters & setters
}
application.yml配置示例:
yaml复制jwt:
secret: "dGhpcyBpcyBhIHZlcnkgc2VjdXJlIHNlY3JldCBrZXkK"
expire: 7200000 # 2小时
方案二:环境变量注入
java复制private String secret = System.getenv("JWT_SECRET");
JWT的生成过程就像制作一份防伪合同,需要包含三个关键部分:
java复制public static String generateToken(String subject, Map<String, Object> claims, long expireTime) {
Date expireDate = new Date(System.currentTimeMillis() + expireTime);
return Jwts.builder()
.setClaims(claims) // 自定义数据
.setSubject(subject) // 用户标识
.setIssuedAt(new Date()) // 签发时间
.setExpiration(expireDate) // 过期时间
.signWith(getSecretKey(), SignatureAlgorithm.HS256) // 签名
.compact(); // 压缩为字符串
}
经验之谈:claims中不要存放敏感信息(如密码),因为Payload只是Base64编码而非加密。我曾见过有团队把用户权限列表全放进去,结果令牌长度超过了8KB!
令牌解析就像验钞,需要检查三个关键点:
java复制public static Claims parseToken(String token) {
JwtParser parser = Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build();
return parser.parseClaimsJws(token).getBody();
}
异常处理是重点,不同异常对应不同问题:
| 异常类型 | 含义 | 处理建议 |
|---|---|---|
| ExpiredJwtException | 令牌过期 | 提示用户重新登录 |
| MalformedJwtException | 令牌格式错误 | 记录日志,可能遭受攻击 |
| SignatureException | 签名验证失败 | 立即报警,密钥可能泄露 |
| IllegalArgumentException | 非法参数 | 检查令牌是否为空或格式错误 |
java复制// 示例:增强版令牌生成
public static String generateEnhancedToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", user.getRole());
claims.put("fingerprint", getFingerprint(user));
return generateToken(user.getId(), claims);
}
private static String getFingerprint(User user) {
String ip = ServletUtil.getClientIP();
return DigestUtils.md5Hex(ip.substring(0, 6) + user.getDeviceId());
}
在Spring Boot中最优雅的方式是实现HandlerInterceptor:
java复制public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
throw new UnauthorizedException("缺少认证令牌");
}
try {
Claims claims = JwtUtil.parseToken(token);
request.setAttribute("USER_ID", claims.getSubject());
return true;
} catch (ExpiredJwtException e) {
throw new UnauthorizedException("令牌已过期");
} catch (Exception e) {
throw new UnauthorizedException("无效令牌");
}
}
}
注册拦截器配置:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
}
统一处理认证相关异常:
java复制@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<Result> handleUnauthorized(UnauthorizedException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(401, e.getMessage()));
}
}
单纯的JWT无法防止重放攻击,我的解决方案是:
java复制// 增强版令牌生成
public static String generateTokenWithJTI(String subject, Map<String, Object> claims) {
claims.put("jti", UUID.randomUUID().toString());
return generateToken(subject, claims);
}
// 校验示例
public boolean checkReplayAttack(String token) {
Claims claims = parseToken(token);
String jti = (String) claims.get("jti");
return redisTemplate.opsForValue().setIfAbsent("jti:"+jti, "1", 5, TimeUnit.MINUTES);
}
java复制// 智能刷新实现示例
public void checkAndRefreshToken(HttpServletResponse response, String token) {
Claims claims = parseToken(token);
long remainTime = claims.getExpiration().getTime() - System.currentTimeMillis();
if (remainTime < 30 * 60 * 1000) { // 剩余30分钟
String newToken = generateToken(claims.getSubject(), claims);
response.setHeader("New-Token", newToken);
}
}
以下是我在项目中遇到的典型问题及解决方案:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 签名验证失败 | 密钥不一致 | 检查各服务jwt.secret配置 |
| 令牌解析报MalformedJwt | 令牌被截断或篡改 | 检查Nginx的header大小限制 |
| 获取不到Authorization头 | 跨域配置问题 | 确保前端设置了withCredentials |
| 偶尔出现IllegalArgumentException | 并发环境下密钥加载问题 | 改用静态密钥或双重检查锁 |
| 令牌过期时间不生效 | 时区问题 | 确保服务器时间同步 |
血泪教训:曾经因为测试环境服务器时间比实际快10分钟,导致所有令牌"提前"过期,这个bug让我们排查了整整一天!
虽然JWT很流行,但在某些场景下这些方案可能更合适:
我的经验法则是:
最后分享一个真实案例:在某金融项目中,我们最终采用了JWT+短期Session的混合方案,既保证了接口性能,又满足了严格的合规要求。技术选型从来不是非此即彼,理解原理才能灵活运用。