项目标题: "Jwt配置及使用 +外部配置文件"
做过几个带登录鉴权的后台管理系统之后,我对JWT的态度经历过一次明显的转变:早先觉得它不过是个“把用户信息塞进一串字符串”的简单工具,谁不会用?后来在某个项目中把Token密钥直接写死在代码里,上线后被人从反编译的JAR包里翻出了密钥,才被迫花了一整晚去重构配置、轮换密钥、梳理签发逻辑。那次教训之后,我养成了一个习惯——所有跟JWT相关的参数,一律外部化配置,绝不让密钥、过期时间、签发者这些信息躺在代码里。
这篇内容就围绕两件事展开:一是JWT从配置到签发、校验的完整工程落地过程,我把每一步的代码、参数选择和设计理由都写清楚;二是外部配置文件怎么做,包括多环境profile、外置配置文件、环境变量几种方案,以及最容易踩的坑。适合正在做登录鉴权、打算从Session切换到Token方案、或者已经用上JWT但配置管理比较随意的开发者参考。
1. 先从需求说起:接口鉴权为什么要选JWT
1.1 一次典型的登录会话问题
假设你在做一个前后端分离的管理系统,客户端是PC端页面,服务端是Spring Boot接口。用户登录之后,后续每个请求都得让服务端知道“这人是谁”,否则接口没法做权限控制。
传统做法是Session:用户登录成功,服务端生成一个Session ID,存在内存或者Redis里,同时通过Cookie下发给浏览器,之后每次请求带上这个ID,服务端查到对应的Session就知道用户身份了。问题在于,如果服务端是集群部署,Session默认存在单机内存里,用户在A机器登录,下一次请求被负载均衡转发到B机器,B机器没有这个Session,用户就被判定为未登录。解决办法是引入Session共享,把Session集中放到Redis,但这又需要额外维护一套会话存储,还要处理Session过期、序列化、脏数据清理等一系列问题。
1.2 JWT和Session方案的差异
JWT解决的是同一个问题,但思路完全不同:登录成功之后,服务端把用户ID、用户名、过期时间等信息打包进一个经过签名的字符串,直接返回给客户端。客户端后续请求在Header里带上这个字符串,服务端验签通过、确认没过期,就直接信任里面的用户信息,不需要在服务端存任何会话数据。
对比一下两者:
| 对比项 | Session方案 | JWT方案 |
|---|---|---|
| 服务端存储 | 需要存储会话数据 | 无需存储,服务器无状态 |
| 集群扩展 | 需要Session共享或粘滞会话 | 天然支持,任意节点可校验 |
| 客户端载体 | 通常依赖Cookie | Header、Cookie、URL参数均可 |
| 跨域支持 | Cookie有跨域限制,需额外配置 | Header方式对跨域友好 |
| 主动失效 | 删除Session即可 | 无状态,失效依赖过期时间或黑名单 |
| 传输体积 | Session ID很小 | Token较长,含用户信息与签名 |
| 安全关注点 | 防Session固定、防窃取 | 防密钥泄露、防Token窃取 |
没有哪种方案绝对好,但如果你的服务本身是无状态设计、接口要同时被Web端、移动端、第三方系统调用,JWT往往比Session更顺手。开发者也少维护一套会话存储,临时扩容时不用考虑Session共享的问题。
2. JWT核心原理:Token里到底装了什么
2.1 三段式结构拆解
JWT全称是JSON Web Token,本质上是一个经过签名的JSON数据载体。一个标准的JWT长这样:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MTAwMSwiZXhwIjoxNzIwMDAwMDAwfQ.KkU4Vz1fQ5Y4v3XjLm0gZ9yP1fT6dG8wSzQxWnBmV2M用英文句点拆成三段,每一段都是独立的Base64URL编码数据:
- 第一段是Header,声明令牌类型和签名算法,默认可以是
{"alg":"HS256","typ":"JWT"}。 - 第二段是Payload,存放实际传递的数据,比如用户ID、用户名、角色、过期时间等。
- 第三段是Signature,用指定算法对前两段内容加上密钥计算出的签名,用来防止内容被篡改。
要知道一个最基本的事实:JWT的Payload虽然是Base64URL编码的,但它只是编码,不是加密。任何人拿到Token都可以解码看到里面的内容。我见过有人把数据库连接串、手机号甚至密码明文塞进JWT里的,这是非常危险的做法。JWT适合存放非敏感的、用于身份标识和权限判断的数据,比如用户ID、角色Code,敏感信息一律不放。
2.2 签名算法与防篡改原理
签名的作用是保证数据完整性:Token在签发之后,如果有人修改了Payload里的用户ID,服务端验签时会发现签名对不上,直接拒绝。原理不复杂——签名是把Header和Payload拼接后用密钥做HMAC计算,密钥只有服务端知道,客户端无法伪造。
常用的签名算法分两类:
- HS256(HMAC-SHA256):对称算法,签发和校验用同一个密钥,实现简单,单服务或可控服务环境下够用,也是使用最广泛的方案。
- RS256(RSA-SHA256):非对称算法,私钥签发、公钥校验,适合第三方开放平台或微服务间互相校验Token的场景,需要额外管理密钥对。
绝大多数内部系统的管理后台,用HS256就够了。只有当你需要把Token签发权和外系统校验权分离,或者多个服务各自持有校验公钥、不需要共享同一个HMAC密钥时,才考虑RS256。
2.3 密钥选型与有效期设置的背后逻辑
HS256下的密钥长度不是随便定的。如果你用过JJWT库,密钥长度低于32字节(256位)时,很多版本会直接抛异常,比如WeakKeyException。原因是HMAC-SHA256要求密钥至少256位,否则安全性不达标。实际项目里不要卡着32字节用,建议生成一个随机的、长度在64字节以上的字符串,用Base64编码后放到配置文件里。生成方式很简单:
openssl rand -base64 48这行命令会输出一个64字符左右的随机字符串,可以直接作为JWT密钥。
过期时间的选择是个典型的权衡。设置太短,用户每隔一两个小时就要重新登录,体验差;设置太长,Token一旦泄露,攻击者能长时间冒充用户。折中方案是短Token配合刷新Token:访问Token有效期设15分钟到2小时,刷新Token有效期设7天或30天,前端检测到401后自动用刷新Token换取新访问Token。大多数后台管理系统直接设单Token有效期几小时也能接受,但如果你做的是面向大量用户的开放类系统,建议上刷新Token机制。
3. 工程落地:依赖引入与基础配置
3.1 依赖选型
Java生态里JWT库最常见的是JJWT(Java JWT),API设计清晰、文档齐全、社区活跃。以Spring Boot项目为例,Maven依赖如下:
<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官方推荐的模块化做法:jjwt-api提供编译期接口,jjwt-impl是运行时实现,jjwt-jackson负责JSON序列化和反序列化。默认使用Jackson,因为Spring Boot本身就内置了Jackson,不需要额外引入别的JSON库。
需要注意版本差异。JJWT的0.9.x和0.11.x、0.12.x之间API差异很大。0.11.x用Keys.hmacShaKeyFor()构建密钥,用Jwts.parserBuilder().setSigningKey()创建解析器;而0.12.x把parserBuilder改成了parser,setSigningKey改成了verifyWith。网上很多教程混用两代API,直接复制到新项目会编译不过。如果你用的是0.11.5,下面我给的代码可以直接跑。
3.2 配置文件初版:为什么不能让密钥裸奔在代码里
很多新手写JWT工具类时,习惯直接在代码里写死密钥:
private static final String SECRET = "my-secret-key";这种写法的问题在开头说过一遍,但值得再强调:代码里写死密钥,意味着每次改密钥都要改代码、重新编译、重新打包部署。而且一旦代码仓库泄露或者JAR包被反编译,密钥就完全暴露了。密钥泄露的影响不是“Token能被伪造”一句话概括的,而是攻击者可以用你的密钥签发任意用户身份的Token,整个鉴权体系形同虚设。
正确的做法是把密钥、过期时间、签发者都放到配置文件里,通过配置项注入。先看Spring Boot的application.yml初版:
jwt: secret: ${JWT_SECRET:dev-please-change-this-secret-key-64-bytes-long} expire-seconds: 7200 issuer: my-system这里用到了${JWT_SECRET:默认值}语法:优先读环境变量JWT_SECRET,如果没设置,就用冒号后面的默认值。开发环境本地跑的时候可以不设置环境变量,直接用默认值;生产环境必须设置JWT_SECRET环境变量,覆盖默认值。后面讲外部配置时,这个思路会进一步扩展。
3.3 配置类设计:读取配置与初始化密钥
写一个配置类来承载JWT相关配置,避免在工具类里散落魔法值:
@Component @ConfigurationProperties(prefix = "jwt") public class JwtProperties { private String secret; private long expireSeconds; private String issuer; // getter 和 setter 省略 }@ConfigurationProperties会把application.yml里jwt前缀下的配置项自动映射到字段上。相比逐个用@Value注入,这种方式集中、清晰、后续加配置项不用改多个地方。
不过要注意,开启了ConfigurationProperties后必须确保类上有@Component或者在配置类上用@EnableConfigurationProperties(JwtProperties.class),否则Spring不会扫描到这个配置类。我踩过一次这个坑:加了注解但是忘记让Spring管理这个Bean,结果所有JWT配置都是null,排查了半天才发现是Bean没注册。
密钥的初始化有一点需要提醒:建议在配置类中把字符串密钥提前转为SecretKey对象,避免每次签发和校验都做一次转换。可以这样处理:
private SecretKey secretKey; public SecretKey getSecretKey() { if (secretKey == null) { byte[] keyBytes = Decoders.BASE64.decode(secret); secretKey = Keys.hmacShaKeyFor(keyBytes); } return secretKey; }前提是配置里的密钥是用Base64编码的字符串。如果直接放明文密钥,记得用secret.getBytes(StandardCharsets.UTF_8),并确保字节长度够32位。
4. 签发与校验:两个核心方法的完整实现
4.1 生成Token:claims、过期时间、签名
JWT工具类里最核心的是两个方法:一个是根据用户信息生成Token,另一个是从Token解析出用户信息。先看签发逻辑。
@Component public class JwtTokenProvider { private final JwtProperties jwtProperties; private final SecretKey secretKey; public JwtTokenProvider(JwtProperties jwtProperties) { this.jwtProperties = jwtProperties; this.secretKey = jwtProperties.getSecretKey(); } public String createToken(Long userId, String username, List<String> roles) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtProperties.getExpireSeconds() * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("roles", roles) .setIssuer(jwtProperties.getIssuer()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } }几个设计点逐个说:
setSubject存用户ID,这是主流做法,因为用户ID通常不会变,而用户名可能因为修改资料而变化。claim("roles", roles)存放角色列表,服务端后续做权限判断时直接从Token里取,省得每次查数据库。setIssuedAt记录签发时间,setExpiration记录过期时间。signWith(secretKey, SignatureAlgorithm.HS256)指定签名算法和密钥。
也是值得注意的地方:用户名、角色这类信息,改动频率低、不敏感,放进Token合理。但像用户头像URL、手机号这类信息,没必要往Token里塞,Token太大会导致每次请求Header体积膨胀,影响网络传输效率,而且信息更新后Token里的旧值无法同步刷新。
4.2 解析Token:捕获异常、判断有效性
解析是反向过程:
public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); }这个方法只做一件事:验签并取回Claims。但实际使用中必须考虑异常场景。Token可能过期、可能被篡改、可能是乱传的字符串,这些情况都会抛出不同异常,必须在调用层统一处理。我的做法是包装一个更友好的解析方法:
public Claims parseTokenSafely(String token) throws JwtValidationException { try { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { throw new JwtValidationException("Token已过期", e); } catch (JwtException e) { throw new JwtValidationException("Token无效", e); } }这里把ExpiredJwtException单独拎出来是有讲究的:过期Token的Claims其实已经解析出来了,在某些场景下(比如做自动续期时),你可能想在Token过期前几分钟就重新签发新Token,那就要能拿到过期Token里的用户信息。但这个方式不适合直接做刷新Token,因为真正的刷新Token机制需要独立的有效期和黑名单控制,后面常见问题部分我再展开。
4.3 单元测试跑通全流程
配置、签发、解析都写完之后,最好先写个测试类验证全流程,避免把问题带到联调阶段。测试用例覆盖三件事:正常签发能解析出正确的用户ID;篡改Token会被拒绝;过期Token会被拒绝。
@SpringBootTest class JwtTokenProviderTest { @Autowired private JwtTokenProvider jwtTokenProvider; @Test void createAndParseToken_shouldWork() { String token = jwtTokenProvider.createToken(1001L, "admin", List.of("ADMIN")); Claims claims = jwtTokenProvider.parseTokenSafely(token); Assertions.assertEquals("1001", claims.getSubject()); Assertions.assertEquals("admin", claims.get("username", String.class)); } @Test void tamperedToken_shouldThrow() { String token = jwtTokenProvider.createToken(1001L, "admin", List.of("ADMIN")); String tampered = token.substring(0, token.length() - 2) + "xx"; Assertions.assertThrows(JwtValidationException.class, () -> jwtTokenProvider.parseTokenSafely(tampered)); } }第二个用例里,篡改Token末尾的签名部分后,解析器会抛出SignatureException,因为签名验证不通过。这个测试能确保你的异常包装逻辑真的生效,而不是把原始异常抛到接口层,导致客户端收到一堆看不懂的错误堆栈。
5. 集成Spring Security:过滤器把Token变成认证信息
5.1 认证过滤器的工作链路
光有签发和解析还不够,得让JWT和Spring Security接起来,做到“请求进来时自动从Header里取Token、解析出用户身份、填充SecurityContext”。核心是一个OncePerRequestFilter:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null) { try { Claims claims = jwtTokenProvider.parseTokenSafely(token); String username = claims.get("username", String.class); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (JwtValidationException e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }工作链路是:请求进入过滤器,从Authorization头提取Bearer开头的Token,解析出用户名,通过UserDetailsService加载用户完整信息(含角色权限),封装成UsernamePasswordAuthenticationToken放进SecurityContextHolder。后续Controller里用@AuthenticationPrincipal就能拿到当前登录用户,用@PreAuthorize就能做角色控制。
有个细节容易被忽略:解析Token里已经带了角色信息,为什么还要调用UserDetailsService去查库?因为权限判断需要GrantedAuthority对象,而且查库能顺带确认用户账号还是不是正常的(比如被禁用、被删除)。如果完全信任Token里的角色,就可能出现“用户已经被管理员禁用,但Token还没过期,依然能访问接口”的情况。当然,你也可以把角色直接转换成SimpleGrantedAuthority塞进认证对象,省掉一次查询,代价是用户状态变更不是实时生效,看业务场景取舍。
5.2 放行白名单与401处理
所有请求都走过滤器没问题,但登录接口本身、刷新Token接口、静态资源、Swagger文档这些是不需要认证的,必须在SecurityConfig里显式放行:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/refresh", "/doc.html", "/webjars/**").permitAll() .anyRequest().authenticated()) .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token无效\"}"); })) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }重点看最后一行:addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)。加不加这一行差别很大,如果只是把JwtAuthenticationFilter注册成@Component,Spring Boot的自动配置可能会把它加到过滤器链的默认位置,顺序不受控制。明确指定放在UsernamePasswordAuthenticationFilter之前,能保证JWT过滤器先执行,把认证信息放好,后面的授权过滤器才能看到。
SessionCreationPolicy.STATELESS也很关键,表明接口不创建也不使用Session,让Spring Security彻底走无状态模式。很多集成JWT后依然出现“明明设置了Token却还是返回401”的问题,排查后往往是这里用了默认的IF_REQUIRED策略,过滤器链尝试创建Session,行为变得诡异。
5.3 常见坑:过滤器顺序、多次读取Body等
集成过程中有几个坑值得单独提一下。
第一个坑是过滤器执行顺序问题。如果你的项目里还有别的过滤器,比如全局请求日志过滤器、CORS过滤器,要注意它们和JWT过滤器的相对位置。CORS过滤器必须在JWT过滤器之前执行,否则跨域请求的预检(OPTIONS请求)会先被JWT过滤器拦截,导致前端拿到401而不是正常的跨域响应。
第二个坑是OncePerRequestFilter的名字已经说明了它保证每个请求只执行一次,但如果你不小心把它同时注册成@Component又在SecurityConfig里addFilterBefore,在某些版本的Spring Boot里可能会出现过滤器被注册两次的问题。解决办法是二选一:要么只在SecurityConfig里注册,要么只声明@Component且不重复添加。我习惯只在SecurityConfig里用new创建过滤器对象,避免Spring容器和Security配置重复管理同一个Bean。
第三个坑是Controller里拿用户信息的方式。新手容易直接注入HttpServletRequest再手动解析Header里的Token,其实完全没必要,@AuthenticationPrincipal UserDetails直接就能拿:
@GetMapping("/me") public UserInfo getMe(@AuthenticationPrincipal UserDetails userDetails) { return userService.findByUsername(userDetails.getUsername()); }6. 外部配置文件:让JWT配置不再写死
6.1 为什么要把配置外置
聊完JWT本身的实现,回到标题里的重点:“外部配置文件”。为什么这个词值得专门拿出来讲?因为JWT的密钥、过期时间、签发者这些配置,天然就应该是和环境强相关的:开发环境可以用短过期时间方便调试,测试环境可以用独立的密钥,生产环境必须用高强度密钥和合理的过期时间。如果这些信息只写在application.yml里跟着代码包走,每次环境切换都要改配置、重新打包,这既慢又不安全。
外部配置的核心目标是把“配置”从“代码”中剥离:代码包不变,启动时通过外部指定配置文件或者环境变量来覆盖内部配置。同一个JAR包,开发环境用开发配置启动,生产环境用生产配置启动,互不干扰。这也符合十二要素应用的理念,配置要环境化、外置化,而不是和代码耦合在一起。
6.2 方案A:application.yml多环境profile
最轻量的外部化方案是Spring Boot自带的profile机制。你在application.yml里不写死任何环境相关的值,而是定义一个公共配置项,再为不同环境创建profile文件:
# application.yml spring: profiles: active: dev# application-dev.yml jwt: secret: ${JWT_SECRET:dev-only-secret-key-32-bytes-long} expire-seconds: 3600 issuer: my-system-dev# application-prod.yml jwt: secret: ${JWT_SECRET} expire-seconds: 7200 issuer: my-system-prod启动时指定profile即可切换:
java -jar my-app.jar --spring.profiles.active=prod这个方案的好处是零额外依赖、Spring原生支持,很多人就是这么干的。但它有一个隐患:profile文件仍然打包在JAR内部。如果拿到JAR包的人解压出来,依然能看到每个环境下的配置,包括密钥的默认值。所以profile方案更适合管理“非机密配置”的环境差异,机密信息还要靠环境变量或者外置文件来注入。
6.3 方案B:独立配置文件外置
真正意义上的“外部配置文件”,是让配置文件完全脱离JAR包。Spring Boot提供了启动参数来指定外部配置目录:
java -jar my-app.jar --spring.config.additional-location=/etc/my-app/config/启动后,Spring Boot会把这个目录下的application.yml或application-{profile}.yml和JAR包内部的同名配置合并,外部文件的优先级更高,也就是说外部配置会覆盖内部配置。生产环境运维只需要维护/etc/my-app/config/下的配置文件,代码包升级时配置文件不会被动过。
实际操作时有个需要注意的点:目录下放application.yml是覆盖的同名文件,放application-prod.yml只对prod生效。更可控的做法是直接把配置文件名改成application-jwt.yml,然后只用spring.config.additional-location指向这个文件,逻辑上更清晰,专门放JWT配置,和主配置互不干扰。
java -jar my-app.jar \ --spring.profiles.active=prod \ --spring.config.additional-location=/etc/my-app/config/application-jwt.yml这个方案适合中小项目,不引入额外组件,配置分离得干净。缺点是不能动态更新:改完外部配置文件,必须重启应用才能生效。如果项目对配置热更新有要求,就要考虑配置中心了。
6.4 方案C:环境变量与配置中心
环境变量是另一个维度的外部化。比如用Docker部署时,在docker-compose.yml里注入环境变量,应用内部通过占位符引用:
services: app: image: my-app:latest environment: - JWT_SECRET=${JWT_SECRET} - SPRING_PROFILES_ACTIVE=prod这种方法最安全,密钥不落盘、不进代码库、不打包进JAR,只在部署平台(宿主机、容器平台)上维护。如果你已经在用Kubernetes,可以把密钥放到Secret里挂载为环境变量,实现配置和密钥的安全管理。
再往上走是配置中心,比如Nacos、Apollo这类组件,可以做到配置集中管理、动态刷新、版本回溯。团队规模大了之后,几十个服务各自维护配置文件会很难受,配置中心几乎是必经之路。但要注意,引入配置中心是有成本的选择,生产环境要多部署一套配置服务,也多了个强依赖。一个只有两三个服务的中小项目,环境变量加外置配置文件已经足够,不必一上来就上配置中心。
6.5 读取外部配置的代码写法与优先级注意
不管用哪种方案,代码层面读取配置的方式是不变的,还是@ConfigurationProperties或者@Value。以@Value为例:
@Component public class JwtConfigReader { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-seconds:7200}") private long expireSeconds; }@Value的默认值语法是${jwt.expire-seconds:7200},冒号后面是默认值。如果外部配置里没有这个项,Spring会用默认值,不会因为配置缺失直接启动失败。对于密钥这种必填项,建议不要给默认值,让它在缺失时启动报错,避免带着弱密钥跑起来。
外部配置生效之后要验证一件事:应用到底加载了哪个配置。最直接的方式是启动时打印关键配置项(去掉敏感内容)。我习惯在启动日志里输出:
@PostConstruct public void logJwtConfig() { log.info("JWT配置加载完成,issuer={}, expireSeconds={}, secret已配置:{}", jwtProperties.getIssuer(), jwtProperties.getExpireSeconds(), StringUtils.hasText(jwtProperties.getSecret())); }只打印“密钥是否已配置”的布尔值,不要打密钥本身。另外,Spring Boot的ConfigDataEnvironmentPostProcessor会在启动时打印所有加载到的配置源,如果外部配置没生效,看启动日志里有没有对应的配置文件路径是最快的排查手段。
7. 常见问题与排查实录
7.1 一个排查案例:密钥写死引发的安全劫难
前面提到的那个项目,具体情况是:开发同学图省事,把密钥写死在工具类里,发布时用ProGuard做了模糊处理,以为反编译看不到。结果某天监控发现一个管理员的账号在凌晨连续调用了大量导出接口,而这位管理员本人根本没有登录。排查后发现攻击者从Git历史里直接拿到了明文密钥,因为写死密钥的代码提交到过私有仓库,而仓库权限管理并不到位。
那次事件之后,我推动团队做了三件事:密钥全部移出代码仓库,改用环境变量注入;所有端点的Token有效期统一收短,从7天改成2小时;增加登录异常和敏感接口调用告警。这个教训说明,技术上做得多好是一回事,密钥管理是另一回事,两者都不能省。
7.2 高频问题速查表
| 错误现象 | 可能原因 | 解决办法 |
|---|---|---|
启动报WeakKeyException | 密钥长度不足32字节,或密钥不是Base64编码的随机串 | 用openssl rand -base64 48生成密钥,确保解码后字节数≥32 |
| 所有请求都返回401 | 过滤器未注册到Security链路,或SessionCreationPolicy未设置成STATELESS | 确认addFilterBefore生效,检查SecurityConfig |
Token解析报SignatureException | 密钥不一致,或Token被篡改 | 确认签发和校验用同一个SecretKey对象,启动时打印密钥指纹做对比 |
| Token明明没过期却解析失败 | 服务器时间偏差,或不同节点系统时间不同步 | 检查系统时间;如有需要,做时间同步 |
@Value("${jwt.secret}")注入为null | 配置类没有注册为Spring Bean,或配置项前缀写错 | 确认配置类有@Component,检查application.yml里前缀拼写 |
| 外部配置文件不生效 | spring.config.additional-location路径不对,或外部文件优先级低于内部配置 | 确认路径存在,检查启动日志里是否加载了该配置源 |
| 修改外部配置文件后重启不生效 | 配置文件名不匹配profile,或缓存原因 | 确认文件名符合约定,避免使用application.yml覆盖主配置,用独立文件名 |
7.3 关于刷新Token的进阶备注
如果你想做刷新Token,别在JWT本身的编码上折腾,更别想着“让Token自动续期”。JWT是无状态的,Token签发之后就很难在服务端直接控制它的生死。业界通用方案是:
- 访问Token(Access Token)短有效期,比如15分钟到2小时,不存服务端。
- 刷新Token(Refresh Token)长有效期,比如7天,服务端存一份(Redis、数据库均可),支持主动吊销。
- 刷新Token过期或已被吊销时,客户端必须走重新登录流程。
这样既发挥了JWT无状态的优点,又补上了“无法主动踢人”的短板。很多教程把刷新Token的实现写得神神秘秘,但核心逻辑其实不外乎上面这三条。如果你的系统要求不高,单Token方案用着也没问题,先把基础的东西做扎实。
8. 写在最后
JWT本身不是复杂的加密技术,它就是把一个JSON做签名后塞给客户端,再在下一次请求中验签取回。真正的复杂度在工程化:密钥怎么管、配置怎么分离、过期策略怎么定、和Spring Security怎么衔接。把这些落地细节处理好,比单纯会调API重要得多。
就我个人的项目经验来说,外部配置这块值得尽早做。哪怕是一个人维护的小项目,从第一行代码开始就把密钥放在环境变量里,成本几乎为零,后面省掉的是重构的麻烦。等系统跑起来再回头改配置方案,要动的东西就多了:配置类、部署脚本、启动参数、测试用例,四处都要跟着改。
还有一个建议:给JWT工具类写覆盖基本场景的单元测试,包括签发、验签、篡改、过期这四种情况。这个测试花不了多少时间,但排查问题的时候能省下大把时间,至少能立刻排除“是代码Bug还是配置问题”的干扰。这是我踩过几次坑之后总结出来的,希望你能少走这些弯路。