SpringBoot集成JWT实现无状态登录认证:从原理到实战避坑指南
1. 项目概述与核心价值
最近在重构一个老的后台管理系统,用户登录认证这块一直用的是传统的Session-Cookie方案。随着微服务拆分和前后端分离架构的普及,每次请求都要带着Session ID去查Redis,不仅增加了网络开销,在跨域、分布式环境下更是麻烦不断。正好借着这次重构的机会,我决定把认证方案彻底换成JWT。SpringBoot集成JWT听起来是个老生常谈的话题,网上教程一抓一大把,但真到自己动手时,你会发现从依赖选择、密钥管理到Token刷新策略,每一步都有不少细节和“坑”需要仔细琢磨。这篇文章,我就结合自己这次从零到一的实践,把SpringBoot快速集成JWT实现登录认证的完整链路、核心配置以及那些教程里不会写的“踩坑”经验,给你一次性讲透。无论你是正在搭建第一个SpringBoot项目的初学者,还是想优化现有认证体系的老手,这篇内容都能给你提供一份可直接“抄作业”的实操指南。
JWT,全称JSON Web Token,本质上是一个经过数字签名或加密的、包含声明信息的JSON对象。它由三部分组成:头部、载荷和签名。在用户登录成功后,服务端生成一个JWT Token返回给前端;此后,前端在每次请求需要认证的接口时,只需在HTTP请求头(通常是Authorization: Bearer <token>)中携带这个Token。服务端收到后,验证Token的签名是否有效、是否过期,即可确认用户身份,无需再去查询数据库或缓存。这种无状态的特性能轻松应对水平扩展和API网关等场景,是构建现代Web应用,尤其是SPA和移动端应用的理想选择。
2. 技术选型与环境准备
2.1 核心依赖库选择
在Java生态中,处理JWT的库有不少,比如jjwt、auth0 java-jwt、nimbus-jose-jwt等。经过一番对比,我最终选择了jjwt,原因很简单:它由Okta维护,API设计清晰直观,文档齐全,社区活跃,并且同时支持JWS(签名)和JWE(加密),能满足绝大多数场景。对于SpringBoot项目,我们通常只需要引入其核心库即可。
在你的pom.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> </application>这里有个关键点:jjwt-impl和jjwt-jackson被设置为runtime作用域。这是因为jjwt-api已经定义了所有我们编码时需要调用的接口,而具体实现和JSON处理在运行时才需要。这样设计能让你的编译依赖更干净,也符合依赖隔离的最佳实践。版本号0.11.5是一个经过长期验证的稳定版本,建议使用。
注意:千万不要去网上随便搜一个教程,就引入一个老版本的
jjwt依赖,比如0.9.x。新旧版本的API差异巨大,老版本的很多用法在新版本中已经废弃,盲目复制粘贴会导致代码完全跑不起来。
2.2 配置文件与密钥管理
JWT的安全性核心在于签名密钥。密钥的生成和管理绝对不能马虎。对于HMAC-SHA算法(如HS256),我们需要一个足够强壮的密钥。我强烈建议将密钥作为配置项,放在application.yml中,并且绝不能将真实的密钥提交到代码仓库。
# application.yml jwt: secret: your-256-bit-secret # 生产环境务必从环境变量或配置中心读取,例如 ${JWT_SECRET:defaultFallbackSecret} expiration: 7200 # Token过期时间,单位秒,这里设2小时 issuer: your-app-name # 签发者,可用于校验这里的secret要求是一个至少256位(32字节)的字符串。你可以用任何方式生成一个强随机字符串。在生产环境中,这个值必须通过环境变量注入(如${JWT_SECRET}),或者从云服务商的密钥管理服务中获取,绝对禁止硬编码。
为什么是HS256?它是一种对称加密算法,使用同一个密钥进行签名和验证,性能好,实现简单,适合单服务或密钥分发安全的场景。如果你的服务是多实例部署,且共享同一个密钥配置,用HS256没问题。如果你的架构是多个独立的、互不信任的服务需要互相验证Token,那么可能需要考虑非对称加密算法(如RS256),使用公私钥对。不过对于大多数初创项目或内部系统,HS256完全够用,先跑起来再优化。
3. JWT工具类设计与核心实现
3.1 构建JWT工具类
有了依赖和配置,接下来我们封装一个JwtUtil工具类。这个类将负责Token的生成、解析和验证。我把它设计成Spring的@Component,方便注入配置值。
import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; @Component public class JwtUtil { // 签名密钥,从配置读取 @Value("${jwt.secret}") private String secret; // Token有效期 @Value("${jwt.expiration}") private Long expiration; // 签发者 @Value("${jwt.issuer}") private String issuer; // 生成安全的密钥对象 private SecretKey getSigningKey() { // 将配置的字符串转换为字节,并确保长度足够 byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); // 使用jjwt提供的Keys工具类生成HMAC-SHA密钥 return Keys.hmacShaKeyFor(keyBytes); } /** * 生成JWT Token * @param subject 主题,通常放用户ID或用户名 * @param claims 自定义声明,可以存放角色、权限等信息 * @return 生成的Token字符串 */ public String generateToken(String subject, Map<String, Object> claims) { // 1. 设置Token的过期时间 Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration * 1000); // 2. 使用Jwts.builder()流畅地构建Token return Jwts.builder() .setClaims(claims) // 设置自定义声明(载荷) .setSubject(subject) // 设置主题 .setIssuer(issuer) // 设置签发者 .setIssuedAt(now) // 设置签发时间 .setExpiration(expiryDate) // 设置过期时间 .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用HS256算法和密钥签名 .compact(); // 生成最终的字符串 } /** * 从Token中解析出所有声明(Claims) * @param token JWT Token字符串 * @return Claims对象,包含载荷中的所有信息 */ public Claims parseToken(String token) { // 使用相同的密钥构建解析器,并解析Token return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 验证Token是否有效(未过期且签名正确) * @param token JWT Token字符串 * @return 是否有效 */ public boolean validateToken(String token) { try { parseToken(token); // 如果能成功解析,说明签名有效且未过期(过期会抛出ExpiredJwtException) return true; } catch (JwtException | IllegalArgumentException e) { // 捕获所有JWT相关异常(签名无效、过期、格式错误等) return false; } } /** * 从Token中获取主题(通常是用户ID) */ public String getSubjectFromToken(String token) { return parseToken(token).getSubject(); } /** * 检查Token是否即将过期(例如在到期前30分钟内),用于触发刷新逻辑 * @param token JWT Token字符串 * @param minutes 提前多少分钟视为“即将过期” * @return 是否即将过期 */ public boolean isTokenExpiringSoon(String token, int minutes) { try { Claims claims = parseToken(token); Date expiration = claims.getExpiration(); Date now = new Date(); // 计算距离过期还有多少毫秒 long timeUntilExpiry = expiration.getTime() - now.getTime(); // 如果剩余时间小于指定的分钟数,则认为即将过期 return timeUntilExpiry > 0 && timeUntilExpiry < (minutes * 60 * 1000L); } catch (JwtException e) { // 如果Token本身无效,那肯定不是“即将过期”的问题了 return false; } } }这个工具类涵盖了核心操作。generateToken方法中,我特意将claims参数放在subject之前设置。这是因为setSubject方法内部会往claims里写一个sub字段,如果先setSubject再setClaims,后者的claims会覆盖掉前者,导致sub丢失。这个顺序是很多新手容易踩的坑。
3.2 自定义载荷(Claims)的设计策略
JWT的载荷部分可以存放一些公开的信息。标准预定义字段如sub(主题)、exp(过期时间)、iat(签发时间)等很有用。除此之外,我们通常会添加一些业务字段。我的经验是:放必要且不敏感的信息。
一个典型的自定义claims可能像这样:
Map<String, Object> claims = new HashMap<>(); claims.put(“userId”, user.getId()); // 用户唯一ID claims.put(“username”, user.getUsername()); // 用户名 claims.put(“roles”, user.getRoles()); // 用户角色列表,如 [“ROLE_ADMIN”, “ROLE_USER”] // 注意:不要存放密码、手机号等敏感信息!Token本身只是Base64编码,可以被轻易解码查看。为什么要把角色信息放进Token?这样在验证Token有效后,我们可以直接从Token中提取用户角色进行授权判断,无需再次查询数据库,这就是所谓的“无状态授权”,能极大提升接口性能。当然,这要求角色信息不能频繁变动,或者你有配套的Token强制失效机制。
4. 集成Spring Security实现认证拦截
4.1 引入Spring Security依赖
单纯有JWT工具类还不够,我们需要一个机制来拦截HTTP请求,验证其中的Token。Spring Security是Spring生态中处理安全性的不二之选。在pom.xml中添加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>引入后,默认所有接口都会被保护,访问任何地址都会跳转到一个自带的登录页面。这显然不是我们想要的。我们需要自定义配置。
4.2 自定义安全配置类
创建一个继承WebSecurityConfigurerAdapter的配置类(注意:在Spring Security 5.7+,更推荐使用基于组件的配置,但为了兼容性和教程清晰,这里仍使用旧版方式,原理相通)。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private JwtAuthenticationEntryPoint unauthorizedHandler; @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public PasswordEncoder passwordEncoder() { // 用于加密用户密码,BCrypt是当前推荐的安全哈希算法 return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 禁用CSRF,因为JWT是无状态的,不依赖Cookie,CSRF攻击的前提不存在 .csrf().disable() // 设置异常处理器,处理认证失败(如Token无效)的情况 .exceptionHandling().authenticationEntryPoint(unauthorizedHandler) .and() // 因为使用JWT,所以不需要Session .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() // 配置请求授权规则 .authorizeRequests() // 登录、注册、公开API等接口允许匿名访问 .antMatchers(“/api/auth/login”, “/api/auth/register”, “/public/**”).permitAll() // 管理员接口需要ADMIN角色 .antMatchers(“/api/admin/**”).hasRole(“ADMIN”) // 其他所有请求都需要认证(即携带有效Token) .anyRequest().authenticated(); // 将我们自定义的JWT过滤器加到UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这个配置是核心。SessionCreationPolicy.STATELESS声明了我们的应用是无状态的,Spring Security不会创建和使用HttpSession。JwtAuthenticationFilter是我们接下来要写的自定义过滤器,它负责从请求头中提取Token并验证。JwtAuthenticationEntryPoint则是一个异常处理器,当认证失败(比如没带Token或Token无效)时,它会返回一个结构化的错误JSON,而不是跳转到登录页。
4.3 实现JWT认证过滤器
这个过滤器是连接HTTP请求和JWT验证的桥梁。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private CustomUserDetailsService userDetailsService; // 这是一个自定义的服务,用于根据用户名加载用户信息 @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 1. 从请求头中提取JWT Token String jwt = parseJwt(request); if (jwt != null && jwtUtil.validateToken(jwt)) { // 2. 从有效的Token中解析出用户名(subject) String username = jwtUtil.getSubjectFromToken(jwt); // 3. 检查Security上下文中是否已存在该用户的认证信息(避免重复认证) if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 4. 根据用户名加载用户详细信息(包括权限) UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 5. 构建一个已认证的Authentication对象 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, // 凭证(密码)在这里不需要,因为JWT已证明身份 userDetails.getAuthorities()); // 用户的权限集合 // 6. 将请求的详细信息(如IP、Session ID)设置到Authentication对象中 authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 7. 将Authentication对象设置到Security上下文中,代表用户已认证 SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // 记录日志,但不要在这里抛出异常,交给后面的EntryPoint处理 logger.error(“Cannot set user authentication: {}”, e); } // 8. 继续执行过滤器链 filterChain.doFilter(request, response); } private String parseJwt(HttpServletRequest request) { String headerAuth = request.getHeader(“Authorization”); if (StringUtils.hasText(headerAuth) && headerAuth.startsWith(“Bearer “)) { // 提取“Bearer ”之后的部分,即真正的Token return headerAuth.substring(7); } return null; } }这个过滤器的逻辑很清晰:检查每个请求的Authorization头,如果有有效Token,就根据Token中的用户名加载用户权限,并完成Spring Security的认证流程。这样,在后续的Controller中,你就可以通过@AuthenticationPrincipal注解或SecurityContextHolder直接获取到当前登录用户的信息。
注意:这里我调用了
userDetailsService.loadUserByUsername。对于完全无状态的场景,你可以选择将权限也编码在JWT的claims里,在过滤器中直接从Token还原出权限列表,从而避免这次数据库查询。但这意味着一旦用户权限变更,必须等到其Token过期或主动重新登录才能生效。你需要根据业务对“权限实时性”的要求来做权衡。我的建议是,对于后台管理系统,权限变更后立即生效是刚需,所以这里保留一次查询是值得的。
5. 构建登录与用户详情服务
5.1 实现自定义UserDetailsService
CustomUserDetailsService是Spring Security用于加载用户核心信息的接口。我们需要实现它,将数据库中的用户实体转化为Spring Security能理解的UserDetails对象。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; // 假设你有一个访问用户表的Repository @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 从数据库查找用户,这里假设username是唯一标识 User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException(“User not found with username: “ + username)); // 将数据库中的角色字符串(如“ROLE_ADMIN,ROLE_USER”)转换为GrantedAuthority集合 List<GrantedAuthority> authorities = user.getRoles().stream() .map(role -> new SimpleGrantedAuthority(role.getName())) // 假设Role实体有getName方法 .collect(Collectors.toList()); // 返回Spring Security的User对象(它是UserDetails的实现) // 注意:这里返回的User是org.springframework.security.core.userdetails.User return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), // 数据库存储的应该是加密后的密码 authorities); } }这里的关键是,数据库里存储的用户密码必须是加密后的,比如用前面配置的BCryptPasswordEncoder加密的字符串。Spring Security在认证时会自动用相同的编码器进行比对。
5.2 创建登录认证接口
最后,我们需要一个接收用户名密码、验证成功后颁发JWT Token的接口。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping(“/api/auth”) public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; @PostMapping(“/login”) public ResponseEntity<?> login(@Valid @RequestBody LoginRequest loginRequest) { // 1. 使用Spring Security的AuthenticationManager进行认证 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证成功,将Authentication对象设置到上下文中(非必须,但符合规范) SecurityContextHolder.getContext().setAuthentication(authentication); // 3. 获取当前认证的用户信息 UserDetails userDetails = (UserDetails) authentication.getPrincipal(); // 4. 准备JWT的载荷(Claims) Map<String, Object> claims = new HashMap<>(); // 你可以放入任何不敏感的业务信息,比如用户ID、昵称、角色等 // 假设我们有一个方法能从userDetails中获取用户ID String userId = getUserIdFromUserDetails(userDetails); claims.put(“userId”, userId); claims.put(“roles”, userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); // 5. 生成JWT Token,主题(subject)通常设为用户名 String jwtToken = jwtUtil.generateToken(userDetails.getUsername(), claims); // 6. 构建响应体 Map<String, String> response = new HashMap<>(); response.put(“token”, jwtToken); response.put(“type”, “Bearer”); response.put(“expiresIn”, String.valueOf(jwtUtil.getExpiration())); // 返回过期时间秒数 return ResponseEntity.ok(response); } // 一个简单的登出接口,由于JWT无状态,服务端只需让客户端丢弃Token即可 @PostMapping(“/logout”) public ResponseEntity<?> logout() { // 理论上服务端无法主动让一个JWT失效,除非使用黑名单机制。 // 这里只是返回成功,实际失效操作在客户端(删除存储的Token)。 SecurityContextHolder.clearContext(); // 清除当前线程的Security上下文 return ResponseEntity.ok(“Logout successful”); } }登录接口的核心是authenticationManager.authenticate(),它会委托给我们在CustomUserDetailsService中实现的逻辑,进行用户名密码的校验。校验成功后,我们利用JwtUtil生成Token并返回。前端收到后,通常会将这个Token存储在localStorage或sessionStorage中,并在后续请求的Authorization头中携带。
6. 高级话题与生产环境考量
6.1 Token刷新机制的设计
JWT一旦签发,在过期前无法修改。如果Token有效期设得太长,不安全;设得太短,用户体验差(频繁要求重新登录)。折中的方案是使用“刷新Token”。具体流程通常是:
- 登录时,服务端返回两个Token:一个访问Token(Access Token,有效期短,如2小时),一个刷新Token(Refresh Token,有效期长,如7天或30天)。
- 访问Token过期后,客户端不是让用户重新登录,而是用一个专门的接口,凭刷新Token来获取新的访问Token(和可选的新的刷新Token)。
- 刷新Token本身也应有过期时间,并且一旦使用过,旧的刷新Token应失效(单次使用),防止被盗用。
实现刷新Token机制,你需要:
- 在数据库中或一个独立的缓存中(如Redis)存储刷新Token与用户ID、设备信息的关联关系,并设置过期时间。
- 提供一个
/api/auth/refresh接口,接收刷新Token,验证其有效性和单次性,然后颁发新的Token对。 - 刷新Token的生成可以使用JWT,但验证时除了签名和过期时间,还必须去存储中检查其状态。
6.2 安全性增强与常见攻击防护
- 密钥管理:重申一遍,生产环境的JWT密钥必须通过环境变量或密钥管理服务注入,严禁硬编码。
- Token存储:前端不应将Token存放在容易被XSS攻击读取的
localStorage中。对于能保证子域安全的场景,可考虑存在HttpOnly的Cookie中(需妥善处理CSRF防护)。更常见的做法是存在sessionStorage中,并确保代码没有XSS漏洞。 - 注销与黑名单:JWT无法在服务端直接作废。对于需要立即吊销Token的场景(如用户修改密码、管理员封禁用户),可以维护一个“黑名单”(如Redis,Key为Token的jti声明或Token本身,设置一个较短的过期时间等于原Token剩余有效期)。在过滤器中验证Token时,增加一步黑名单检查。
- 防止重放攻击:可以为每个Token加入一个随机数(
jti声明)并记录,但这会引入状态,与无状态初衷相悖。一个更实用的方法是缩短Token有效期,并配合刷新机制。
6.3 性能优化与监控
- 避免频繁查库:在
JwtAuthenticationFilter中,每次请求都调用userDetailsService.loadUserByUsername查库可能会成为性能瓶颈。如果用户权限不常变,可以考虑将UserDetails对象(不含密码)在验证Token后缓存一段时间(如几分钟),使用用户名作为Key。 - 监控Token使用:记录Token的签发、验证失败(过期、签名无效)、刷新等事件,有助于发现异常行为和安全攻击。
- 选择合适的算法:HS256性能很好。如果担心密钥泄露风险,可以考虑RS256(非对称),但验证签名时的计算开销会稍大。
7. 踩坑实录与排查技巧
在实际集成过程中,我遇到了几个典型问题,这里分享出来帮你避坑。
问题一:SignatureException: JWT signature does not match locally computed signature.
- 现象:登录成功拿到Token,但访问其他接口时报签名不匹配。
- 排查:
- 检查
JwtUtil中getSigningKey()方法,确保生成密钥的secret字符串前后没有空格或换行符。最好在配置读取后打印一下长度或进行trim()。 - 确认生成Token和验证Token使用的是完全相同的密钥。在微服务架构中,所有服务必须共享同一个密钥配置。
- 检查Token在传输过程中是否被修改。可以用在线工具(如 jwt.io )解码你的Token,手动验证签名。
- 检查
- 解决:在我的案例中,是因为在
application.yml里写secret时不小心在值后面加了一个空格。YAML解析会包含这个空格,导致密钥不一致。去掉空格后问题解决。
问题二:ExpiredJwtException但前端明明刚登录。
- 现象:登录后立即操作,偶尔会提示Token过期。
- 排查:
- 检查服务器时间是否正确。JWT的
exp是基于服务器系统时间的。如果服务器时间比实际时间快,Token就会“提前”过期。 - 检查
JwtUtil中计算过期时间expiryDate的逻辑,确保单位换算正确(配置是秒,Date构造函数需要毫秒)。 - 在前端和后端打印Token的签发时间(
iat)和过期时间(exp),进行对比。
- 检查服务器时间是否正确。JWT的
- 解决:我们有一台测试服务器时间漂移了5分钟。使用NTP服务同步所有服务器时间后问题消失。
问题三:Spring Security配置了放行路径,但仍被拦截。
- 现象:在
SecurityConfig中明明配置了.antMatchers(“/public/**”).permitAll(),但访问/public/test还是要求认证。 - 排查:
- 检查路径匹配是否正确。
/public/**可以匹配/public/test和/public/sub/path。如果你的路径是/api/public/test,则需要配置/api/public/**。 - 检查过滤器链的顺序。自定义的
JwtAuthenticationFilter是否在UsernamePasswordAuthenticationFilter之前?如果顺序不对,可能先被其他过滤器拦截了。 - 使用Spring Boot Actuator的
/actuator/mappings端点,查看所有接口的真实映射路径,确保和你配置的一致。
- 检查路径匹配是否正确。
- 解决:我的问题是Controller类上的
@RequestMapping(“/api”)和方法上的@GetMapping(“public/test”)组合成了/api/public/test,而安全配置只写了/public/**,自然不匹配。将安全配置改为/api/public/**后解决。
问题四:跨域(CORS)请求时,前端无法携带Token。
- 现象:前端是独立域名,发起登录请求成功,但后续带Token的请求被浏览器拦截,提示CORS错误。
- 排查:
- 确保服务端正确配置了CORS。Spring Boot可以通过
@CrossOrigin注解或全局WebMvcConfigurer配置。 - 重点检查CORS配置是否暴露了
Authorization头。浏览器对于跨域请求中的自定义头(包括Authorization)有特殊要求,服务端必须在Access-Control-Expose-Headers响应头中明确列出,前端才能读取到。
- 确保服务端正确配置了CORS。Spring Boot可以通过
- 解决:在全局CORS配置中添加如下设置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(“/**“) .allowedOrigins(“https://your-frontend-domain.com“) .allowedMethods(“GET”, “POST”, “PUT”, “DELETE”, “OPTIONS”) .allowedHeaders(“*“) .exposedHeaders(“Authorization”) // 关键!暴露Authorization头 .allowCredentials(true); // 如果使用Cookie,需要这个 } }
集成JWT到SpringBoot项目,从工具类编写、Spring Security配置到过滤器实现,每一步都需要对框架和协议有清晰的理解。我的体会是,不要急于求成,先把最小链路跑通(登录->生成Token->携带Token访问一个受保护接口),然后再逐步添加刷新机制、黑名单、监控等高级特性。对于大多数应用,本文提供的方案已经足够稳健。最后记住,安全无小事,密钥管理、Token有效期、传输安全这些细节,值得你花额外的时间去仔细打磨。
