1. 为什么我们需要JWT?从登录痛点说起
大家好,我是老张,在前后端分离的项目里摸爬滚打了十来年。不知道你有没有遇到过这样的场景:用户登录后,你需要在服务端维护一个Session,每次请求都得去查一下这个Session是否有效。用户量一上来,服务器内存就告急,而且一旦要做集群部署,Session共享又是个让人头疼的大问题。更别提移动端或者跨域请求了,Cookie那一套玩不转。
这时候,JWT(JSON Web Token)就该登场了。你可以把它想象成一张“数字身份证”。用户第一次登录(好比去办证中心),服务器核实了你的身份(用户名密码正确),就会给你签发这张“身份证”。这张身份证上,用加密的方式写明了你是谁(用户ID)、什么时候办的(签发时间)、什么时候过期(有效时间)。之后,你每次去办事(访问其他接口),只需要出示这张身份证就行,服务器一看签名,就知道这是它自己发的、没被伪造过,直接读取上面的信息就知道你是谁,根本不用再去翻它的“户口本”(数据库或Session存储)。
这带来的好处是巨大的:服务端彻底无状态了。它不用再记住谁来过,压力瞬间减轻。无论是扩容成十台服务器,还是对接小程序、APP,这张“身份证”都能畅通无阻。我们今天要聊的,就是如何用Java领域最流行的库——JJWT的最新版0.12.x,亲手打造这套安全、高效的“数字身份证”系统,并封装一个你拿到手就能用的、经过优化的JwtUtils工具类。
2. JJWT 0.12.x:你必须知道的巨变
如果你之前用过JJWT,比如0.9.x甚至更老的版本,那么我要给你提个醒:0.12.x是一个颠覆性的更新。很多网上流传的“祖传代码”直接拿来用会报错,因为API设计理念发生了根本变化。我刚开始迁移的时候也踩了不少坑,但用熟之后发现,新API其实更安全、更清晰。
最大的变化在于密钥(Key)的处理。在老版本里,你可能会直接用字符串secret.getBytes(),然后配合SignatureAlgorithm.HS256来签名。但在0.12.x里,这个做法被彻底废弃了。新版本强制要求你使用io.jsonwebtoken.security.Keys工具类来生成一个标准的java.security.Key对象。这么做的好处是,它强制开发者使用符合密码学规范的密钥长度,避免了因密钥太弱而导致的安全风险。
举个例子,HS256算法要求密钥长度至少达到256位(32字节)。如果你随便用一个短字符串“123456”,老版本可能就让你过了,但新版的Keys.hmacShaKeyFor()方法会帮你检查,并确保生成的密钥是足够强壮的。这就像从“手工打造一把可能生锈的钥匙”,升级到了“必须使用国家认证的保险柜锁芯”,安全性上了一个大台阶。
另一个显著变化是API的链式调用更加流畅。生成Token的Jwts.builder()和解析Token的Jwts.parser(),其方法命名和调用顺序都更加直观。解析后返回的对象也从直接的Claims变成了Jws<Claims>,这其实是一个更严谨的封装,Jws代表了“已签名的JWT”,你从中可以分别获取头部、载荷和签名信息,虽然我们最常用的还是载荷(Payload)里的数据。
3. 从零开始:项目环境与依赖配置
光说不练假把式,我们直接上手。首先,创建一个Spring Boot项目,这个大家应该都很熟了。然后,打开你的pom.xml文件,添加JJWT的依赖。
这里有个关键点:一定要去Maven中央仓库确认最新版本。我写这篇文章时,最新稳定版是0.12.5。依赖项长这样:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- 如果你用Jackson处理JSON -->
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
看到没?和以前一个jjwt包打天下不同,现在它分成了三个模块。jjwt-api提供了所有你在编译时需要的接口和类;jjwt-impl是默认的实现,在运行时才需要;jjwt-jackson是负责JSON序列化和反序列化的处理器(默认就是用它)。这种拆分让依赖更清晰,也给了未来替换底层JSON库的可能性。
依赖加好后,我们还需要一个地方来统一管理JWT的配置,比如密钥和过期时间。我强烈推荐使用Spring Boot的@ConfigurationProperties,把配置写到application.yml里,清晰又容易维护。
我们先创建一个配置类JwtProperties:
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "jwt") // 前缀定为jwt
public class JwtProperties {
/**
* 用于生成和验证Token的密钥。
* 务必保证足够复杂且长度足够(建议32位以上字符),并严格保密。
*/
private String secretKey;
/**
* Token的有效时间,单位是毫秒。
* 例如7200000毫秒 = 2小时。
*/
private Long ttl;
/**
* 前端传递Token时使用的请求头名称。
* 例如:Authorization
*/
private String tokenHeader;
}
然后在application.yml中配置:
jwt:
secret-key: your-super-long-and-ultra-secure-secret-key-at-least-32-chars-here
ttl: 7200000 # 2小时
token-header: Authorization
注意那个secret-key,我强烈建议你使用一个非常长的、无规律的字符串,可以用一些随机字符串生成器来创建。千万别用“123456”、“admin”这种,那等于把家门钥匙放在地毯下面。
3.1 关于密钥安全的最佳实践
这里多聊两句密钥安全。在实际生产环境中,直接把密钥明文写在配置文件里是有风险的。更安全的做法是:
- 环境变量:将密钥设置为服务器的环境变量,在代码中通过
System.getenv("JWT_SECRET")读取。 - 配置中心:使用Spring Cloud Config、Apollo、Nacos等配置中心管理,实现动态更新和加密存储。
- 密钥轮换:定期更换密钥。这意味着新旧Token会有一段时间同时有效,需要在验证逻辑里做兼容,复杂度较高,但对安全性要求极高的系统是必要的。
对于我们当前的学习和中小型项目,放在配置文件里并确保仓库中不提交真实的application.yml(用application.yml.example代替)是一个可行的起点。
4. 核心武器:手把手打造JwtUtils工具类
工具类的好处是“一次编写,到处运行”,把复杂的JJWT API封装成简单的一两个方法,让业务代码清爽无比。下面这个JwtUtils是我在多个项目中打磨出来的,直接复制粘贴就能用,并且已经适配了JJWT 0.12.x。
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Slf4j
@Component
public class JwtUtils {
@Autowired
private JwtProperties jwtProperties;
/**
* 生成JWT Token
*
* @param claims 自定义载荷内容(存放用户ID、用户名等)
* @return 加密后的Token字符串
*/
public String generateToken(Map<String, Object> claims) {
// 1. 计算过期时间点
long expirationMillis = System.currentTimeMillis() + jwtProperties.getTtl();
Date expirationDate = new Date(expirationMillis);
// 2. 根据配置的密钥字符串,生成安全的HMAC密钥对象
// 这是0.12.x的核心变化!不再直接使用字符串字节数组。
SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
// 3. 使用链式调用构建JWT
return Jwts.builder()
.claims(claims) // 设置自定义载荷(claims)
.expiration(expirationDate) // 设置过期时间
.issuedAt(new Date()) // 设置签发时间(iat claim)
.signWith(key) // 设置签名算法和密钥
.compact(); // 生成最终的字符串
}
/**
* 解析并验证JWT Token
*
* @param token 客户端传来的Token字符串
* @return 载荷(Claims)对象,从中可获取存放的数据
* @throws JwtException 如果Token过期、签名无效、格式错误等,会抛出此异常
*/
public Claims parseToken(String token) throws JwtException {
// 1. 同样,先根据密钥生成Key对象
SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
// 2. 构建解析器,并设置验证所需的密钥
JwtParser parser = Jwts.parser()
.verifyWith(key) // 设置验证密钥
.build();
// 3. 解析Token,得到一个已签名的JWT对象(Jws)
Jws<Claims> jws = parser.parseSignedClaims(token);
// 4. 返回载荷部分
return jws.getPayload();
}
/**
* 从Token中获取指定的Claims值(简化操作)
*
* @param token Token字符串
* @param claimKey 要获取的Claims键名
* @return 对应的值,如果不存在或解析失败则返回null
*/
public Object getClaimFromToken(String token, String claimKey) {
try {
Claims claims = parseToken(token);
return claims.get(claimKey);
} catch (JwtException e) {
log.warn("从Token解析Claim失败: {}, claimKey: {}", e.getMessage(), claimKey);
return null;
}
}
/**
* 验证Token是否有效(未过期且签名正确)
*
* @param token Token字符串
* @return 有效返回true,否则返回false
*/
public boolean validateToken(String token) {
try {
parseToken(token); // 如果解析不抛异常,说明Token有效
return true;
} catch (JwtException e) {
log.debug("Token验证失败: {}", e.getMessage());
return false;
}
}
}
我来解释一下这个工具类的几个优化点:
- 自动注入配置:工具类本身是一个Spring Component,直接注入
JwtProperties,不用每次调用方法都传密钥和过期时间。 - 清晰的异常处理:
parseToken方法会抛出JwtException。它的子类很丰富,比如ExpiredJwtException(过期)、MalformedJwtException(格式错误)、SignatureException(签名无效)。我们在上层(如拦截器)可以根据具体异常类型给前端返回更精确的错误码。 - 便捷方法:提供了
getClaimFromToken和validateToken这两个非常常用的方法,让业务代码写起来更顺手。 - 日志记录:使用
@Slf4j记录了必要的警告和调试信息,方便出问题时排查。
5. 实战登录与令牌签发
工具类准备好了,我们来看看怎么在登录流程里用它。假设我们有一个非常简单的UserController。
首先,定义一个登录的DTO(数据传输对象):
@Data
public class LoginDTO {
private String username;
private String password;
}
然后,在Controller里处理登录请求:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private JwtUtils jwtUtils; // 注入我们的工具类
@Autowired
private UserService userService; // 假设有一个服务来验证用户
@PostMapping("/login")
public Result login(@RequestBody LoginDTO loginDTO) {
// 1. 验证用户名密码(这里简化了,实际应从数据库查询比对)
User user = userService.authenticate(loginDTO.getUsername(), loginDTO.getPassword());
if (user == null) {
return Result.error("用户名或密码错误");
}
// 2. 准备存放在Token里的信息(Payload)
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId()); // 用户唯一标识
claims.put("username", user.getUsername());
claims.put("role", user.getRole()); // 用户角色,用于后续权限控制
// 注意:不要存放密码等敏感信息!
// 3. 调用工具类生成JWT Token
String token = jwtUtils.generateToken(claims);
// 4. 构造返回结果
Map<String, String> data = new HashMap<>();
data.put("token", token);
data.put("tokenType", "Bearer"); // 约定好的Token类型
data.put("expiresIn", String.valueOf(jwtProperties.getTtl() / 1000)); // 过期时间(秒)
return Result.success("登录成功", data);
}
}
这样,用户登录成功后,前端就会收到一个类似这样的响应:
{
"code": 200,
"msg": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEyMywidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsInJvbGUiOiJBRE1JTiIsImV4cCI6MTcxMjM0NTYwMCwiaWF0IjoxNzEyMzQyMDAwfQ.some-long-signature-here",
"tokenType": "Bearer",
"expiresIn": "7200"
}
}
前端需要将这个token保存起来,通常放在localStorage、sessionStorage或者Vuex/Pinia(Vue)、Redux(React)的状态管理里。并在后续的每一次请求中,将其放入HTTP请求的Header中。这就是我们之前在配置里定义的Authorization头,格式为:Authorization: Bearer <你的token>。
6. 守卫大门:实现JWT认证拦截器
Token发出去了,接下来的问题就是:其他需要认证的接口,如何验证这个Token?我们不可能在每个Controller方法里都写一遍解析代码。这时,Spring的拦截器(Interceptor)就派上用场了。
我们创建一个JwtAuthenticationInterceptor:
@Component
public class JwtAuthenticationInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 检查请求方法,OPTIONS预检请求直接放行
if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) {
return true;
}
// 2. 从请求头中获取Token
String authHeader = request.getHeader(jwtProperties.getTokenHeader()); // 例如 "Authorization"
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
sendError(response, HttpStatus.UNAUTHORIZED.value(), "缺少有效的认证Token");
return false;
}
// 3. 提取Token(去掉"Bearer "前缀)
String token = authHeader.substring(7);
// 4. 验证并解析Token
try {
Claims claims = jwtUtils.parseToken(token);
// 5. 将解析出的用户信息存入请求上下文,供后续Controller使用
// 通常我们会把userId、username等关键信息放进去
String userId = claims.get("userId", String.class);
request.setAttribute("currentUserId", userId);
// 也可以放入Spring Security的SecurityContextHolder,这里简化处理
return true;
} catch (ExpiredJwtException e) {
sendError(response, HttpStatus.UNAUTHORIZED.value(), "登录凭证已过期,请重新登录");
return false;
} catch (JwtException e) {
// 捕获其他所有JWT异常(签名无效、格式错误等)
sendError(response, HttpStatus.UNAUTHORIZED.value(), "无效的登录凭证");
return false;
}
}
private void sendError(HttpServletResponse response, int status, String msg) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
Result result = Result.error(status, msg);
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
}
}
拦截器写好了,还得把它注册到Spring MVC的配置里:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private JwtAuthenticationInterceptor jwtAuthenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**") // 拦截所有/api开头的请求
.excludePathPatterns(
"/api/auth/login",
"/api/auth/register",
"/doc.html", // 放行Swagger/knife4j文档
"/webjars/**",
"/swagger-resources/**"
); // 排除登录、注册等公开接口
}
}
现在,整个流程就串起来了。用户访问受保护的/api/user/profile接口时,请求会先经过拦截器。拦截器检查Header里的Token,验证通过就把用户ID塞到请求属性里,然后放行。在Controller里,你就可以直接取出来用了:
@GetMapping("/api/user/profile")
public Result getProfile(HttpServletRequest request) {
String currentUserId = (String) request.getAttribute("currentUserId");
// 根据currentUserId去数据库查询用户详情...
return Result.success(userProfile);
}
7. 避坑指南:0.12.x迁移与常见问题
在我从老版本迁移到0.12.x的过程中,遇到了几个典型的“坑”,这里分享给你,希望能帮你节省时间。
第一个大坑:signWith方法参数巨变。 老版本(0.9.x)你可能这样写:.signWith(SignatureAlgorithm.HS256, secretKey.getBytes())。 在新版本(0.12.x),必须分两步:先用Keys.hmacShaKeyFor生成SecretKey对象,再调用.signWith(key)。而且signWith不再接受算法参数,算法由密钥类型自动推断。
第二个坑:setSigningKey已废弃。 解析Token时,老版本用.setSigningKey(secret)。新版本统一改为.verifyWith(key),语义更准确,就是“用这个密钥去验证签名”。
第三个坑:parseClaimsJws方法名变更。 老版本解析签名的JWT用parseClaimsJws。新版本改为parseSignedClaims,返回Jws<Claims>对象。你需要调用jws.getPayload()来获取Claims。
第四个坑:自动验证过期时间。 这是一个好的变化!在老版本,即使Token过期了,如果你只是解析而不手动检查exp字段,程序可能不会报错。但在0.12.x,只要你调用了parser.parseSignedClaims(token),如果Token过期,它会自动抛出ExpiredJwtException,安全性更高了。所以你的异常处理必须考虑到这一点。
第五个注意点:时钟偏差(Clock Skew)。 有时候服务器之间、服务器与客户端之间可能存在几秒钟的时间差。一个刚刚过期的Token,在另一台服务器看来可能还没过期。JJWT提供了容忍度设置:
JwtParser parser = Jwts.parser()
.verifyWith(key)
.clockSkewSeconds(60) // 允许60秒的时钟偏差
.build();
这样,即使Token过期了60秒以内,仍然会被认为是有效的。这个值需要根据你的业务场景谨慎设置。
8. 进阶思考:安全、性能与无状态之踵
JWT用起来很爽,但它也不是银弹,有几个问题需要我们深入思考。
1. 令牌的“失效”问题 这是JWT被问得最多的一点:用户退出登录,或者管理员禁用了某个用户,如何让他的Token立刻失效?因为服务端不存储状态,它无法主动作废一个已签发的、尚未过期的Token。 我常用的解决方案是引入一个短期的黑名单或Token版本号。
- 黑名单:用户退出或禁用时,将该Token的唯一标识(JWT里的
jti字段)或剩余有效时间较长的Token本身,存入Redis,并设置一个较短的过期时间(比如5分钟)。在拦截器验证Token时,增加一步检查Redis黑名单。这只适用于需要“立即失效”的高安全场景,且会增加一点复杂度和Redis开销。 - Token版本号:在用户表中增加一个
tokenVersion字段。生成JWT时,把版本号也放进Payload。当用户退出或密码修改时,递增这个版本号。验证Token时,不仅验证签名和过期时间,还要从数据库或缓存中取出用户当前的tokenVersion,与Token里的对比,不一致则拒绝。这是一个折中的方案。
2. 不要往Payload里塞太多东西 JWT的Payload内容虽然可以自定义,但它是Base64编码的,任何人拿到Token都可以解码看到内容(除非你用JWE加密,但那会复杂很多)。所以,绝对不能存放密码、手机号、身份证号等敏感信息。只存放必要的、非敏感的用户标识和授权信息,比如userId、username、role。
3. 性能考量 每次请求都要进行签名验证,这是一个密码学操作,虽然HS256很快,但在超高并发下仍可能有压力。可以考虑:
- 将
JwtUtils中的SecretKey对象缓存起来,避免每次验证都重新生成。 - 对于解析出的Claims,如果后续多个地方要用,可以将其存入请求属性,避免在拦截器和Controller里重复解析。
4. 关于“无状态”的再认识 JWT让服务端无状态,这既是优点也是限制。它意味着你无法方便地实现“强制某用户下线”、“查看当前在线用户”等功能。如果你的业务强烈需要这些功能,那么纯粹的JWT可能不是最佳选择,可能需要结合服务端的会话存储,或者考虑OAuth 2.0等更复杂的协议。
最后,工具类虽好,但一定要理解其背后的原理和安全考量。我提供的这个JwtUtils是一个坚实的起点,你可以根据自己项目的实际情况,添加令牌刷新机制、多端登录管理(为APP、Web端颁发不同有效期的Token)、或者集成Spring Security等更完整的安全框架。安全无小事,每一步都值得仔细推敲。
451



被折叠的 条评论
为什么被折叠?



