news 2026/8/24 4:47:44

SpringBoot集成JWT实现无状态登录认证:从原理到实战避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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的库有不少,比如jjwtauth0 java-jwtnimbus-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-impljjwt-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字段,如果先setSubjectsetClaims,后者的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存储在localStoragesessionStorage中,并在后续请求的Authorization头中携带。

6. 高级话题与生产环境考量

6.1 Token刷新机制的设计

JWT一旦签发,在过期前无法修改。如果Token有效期设得太长,不安全;设得太短,用户体验差(频繁要求重新登录)。折中的方案是使用“刷新Token”。具体流程通常是:

  1. 登录时,服务端返回两个Token:一个访问Token(Access Token,有效期短,如2小时),一个刷新Token(Refresh Token,有效期长,如7天或30天)。
  2. 访问Token过期后,客户端不是让用户重新登录,而是用一个专门的接口,凭刷新Token来获取新的访问Token(和可选的新的刷新Token)。
  3. 刷新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,但访问其他接口时报签名不匹配。
  • 排查
    1. 检查JwtUtilgetSigningKey()方法,确保生成密钥的secret字符串前后没有空格或换行符。最好在配置读取后打印一下长度或进行trim()
    2. 确认生成Token和验证Token使用的是完全相同的密钥。在微服务架构中,所有服务必须共享同一个密钥配置。
    3. 检查Token在传输过程中是否被修改。可以用在线工具(如 jwt.io )解码你的Token,手动验证签名。
  • 解决:在我的案例中,是因为在application.yml里写secret时不小心在值后面加了一个空格。YAML解析会包含这个空格,导致密钥不一致。去掉空格后问题解决。

问题二:ExpiredJwtException但前端明明刚登录。

  • 现象:登录后立即操作,偶尔会提示Token过期。
  • 排查
    1. 检查服务器时间是否正确。JWT的exp是基于服务器系统时间的。如果服务器时间比实际时间快,Token就会“提前”过期。
    2. 检查JwtUtil中计算过期时间expiryDate的逻辑,确保单位换算正确(配置是秒,Date构造函数需要毫秒)。
    3. 在前端和后端打印Token的签发时间(iat)和过期时间(exp),进行对比。
  • 解决:我们有一台测试服务器时间漂移了5分钟。使用NTP服务同步所有服务器时间后问题消失。

问题三:Spring Security配置了放行路径,但仍被拦截。

  • 现象:在SecurityConfig中明明配置了.antMatchers(“/public/**”).permitAll(),但访问/public/test还是要求认证。
  • 排查
    1. 检查路径匹配是否正确。/public/**可以匹配/public/test/public/sub/path。如果你的路径是/api/public/test,则需要配置/api/public/**
    2. 检查过滤器链的顺序。自定义的JwtAuthenticationFilter是否在UsernamePasswordAuthenticationFilter之前?如果顺序不对,可能先被其他过滤器拦截了。
    3. 使用Spring Boot Actuator的/actuator/mappings端点,查看所有接口的真实映射路径,确保和你配置的一致。
  • 解决:我的问题是Controller类上的@RequestMapping(“/api”)和方法上的@GetMapping(“public/test”)组合成了/api/public/test,而安全配置只写了/public/**,自然不匹配。将安全配置改为/api/public/**后解决。

问题四:跨域(CORS)请求时,前端无法携带Token。

  • 现象:前端是独立域名,发起登录请求成功,但后续带Token的请求被浏览器拦截,提示CORS错误。
  • 排查
    1. 确保服务端正确配置了CORS。Spring Boot可以通过@CrossOrigin注解或全局WebMvcConfigurer配置。
    2. 重点检查CORS配置是否暴露了Authorization头。浏览器对于跨域请求中的自定义头(包括Authorization)有特殊要求,服务端必须在Access-Control-Expose-Headers响应头中明确列出,前端才能读取到。
  • 解决:在全局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有效期、传输安全这些细节,值得你花额外的时间去仔细打磨。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/24 4:47:22

OpenSpeedy 游戏变速实战:单机游戏的节奏自己说了算

OpenSpeedy 游戏变速实战&#xff1a;单机游戏的节奏自己说了算 【免费下载链接】OpenSpeedy &#x1f3ae; An open-source game speed modifier. 项目地址: https://gitcode.com/gh_mirrors/op/OpenSpeedy 想把重复关卡快进掉、或者把卡住的动作放慢三遍再研究的时候&…

作者头像 李华
网站建设 2026/8/24 4:45:34

Java面试核心指南:并发、JVM、MySQL与Spring系统化备战

对于 Java 开发者来说&#xff0c;面试准备是一个系统性工程&#xff0c;它不仅仅是背几道题&#xff0c;而是对知识体系、项目经验和问题解决能力的综合考察。很多人在准备时容易陷入两个极端&#xff1a;要么只盯着八股文死记硬背&#xff0c;遇到场景题就束手无策&#xff1…

作者头像 李华
网站建设 2026/8/24 4:45:14

大厂LLM面试核心:Transformer注意力机制QKV详解

1. 大厂LLM面试核心考察点解析最近辅导了几位准备大厂LLM相关岗位面试的候选人&#xff0c;发现大家对Transformer底层机制的理解普遍存在知识盲区。本文将以典型二面题为切入点&#xff0c;深度剖析注意力机制中QKV的运作原理与工程实现细节。2. QKV三元组本质解析2.1 数学形式…

作者头像 李华
网站建设 2026/8/24 4:44:16

LLM损失函数核心原理与面试高频考点解析

1. 为什么LLMs损失函数是面试必考点&#xff1f;在大型语言模型(LLM)的面试中&#xff0c;损失函数问题几乎从不缺席。这背后有三个深层原因&#xff1a;首先&#xff0c;损失函数直接决定了模型的学习方向&#xff0c;就像导航系统决定行车路线一样关键。其次&#xff0c;不同…

作者头像 李华
网站建设 2026/8/24 4:42:02

2026年软件测试面试趋势与AI自动化测试实战

1. 为什么2026年的软件测试面试题值得关注&#xff1f;2026年的软件测试领域正在经历一场深刻变革。随着AI测试工具普及率突破60%&#xff0c;测试工程师的岗位要求已经从单纯的功能验证转向质量保障全流程掌控。我最近面试了三十多位候选人&#xff0c;发现80%的人还在用五年前…

作者头像 李华