公司动态
Spring Security整合JWT:从Session认证到无状态API的实战指南
1. 从“登录态”到“无状态”为什么我们需要JWT在构建Web应用时身份认证和授权是绕不开的核心议题。传统的做法比如Spring Security默认提供的基于Session的认证其工作流程大家都很熟悉用户登录成功后服务器创建一个Session将用户信息存入其中并生成一个Session ID通过Cookie返回给浏览器。后续的每次请求浏览器都会带上这个Cookie服务器通过Session ID找到对应的Session从而确认用户身份。这套机制成熟、稳定但在微服务、前后端分离的架构下它的短板开始显现。最核心的问题是状态。Session本身是服务器端维护的状态信息。这意味着要么你的应用是单体的所有请求都打到同一台服务器要么你就得引入Session共享方案如Redis这增加了架构的复杂度和运维成本。更重要的是它违背了RESTful架构倡导的“无状态”原则使得服务器的横向扩展变得不那么纯粹。这时JWTJSON Web Token作为一种无状态的认证方案其价值就凸显出来了。JWT的本质是一个经过数字签名或加密的、自包含的JSON对象。所谓“自包含”是指令牌本身Token就携带了认证所需的全部信息比如用户ID、角色、过期时间等。服务器在验证了Token的签名有效性后就可以直接信任其中的内容而无需再去查询数据库或缓存。这完美解决了Session方案的状态依赖问题使得任何一台拥有相同密钥的服务实例都可以独立验证请求的合法性非常适合分布式场景。所以当我们在Spring Security中整合JWT时我们实际上是在做一件“升级”工作将Spring Security强大的认证授权框架与JWT这种现代化的、适合分布式系统的令牌机制结合起来。我们保留Spring Security对URL访问控制、方法级安全、角色权限管理的所有能力只是将其默认的“状态存储与查找”环节替换为“令牌解析与验证”。接下来我们就一步步拆解这个整合过程并深入那些容易踩坑的细节。2. JWT的核心结构、安全机制与选型考量在动手编码之前我们必须彻底理解JWT这把“锁”的构造。一个JWT令牌由三部分组成以点号.分隔Header.Payload.Signature。Header头部通常由两部分组成令牌类型typ固定为JWT和所使用的签名算法alg如HMAC SHA256HS256或RSA SHA256RS256。它会被Base64Url编码。{ alg: HS256, typ: JWT }Payload负载是令牌的核心包含了一系列声明Claims。声明分为三种类型注册声明预定义的一些有特定含义的声明如iss签发者、exp过期时间、sub主题等。非强制但推荐使用。公共声明可以添加任何自定义信息但为避免冲突应使用已注册的声明名或在命名空间下定义。私有声明供消费方和提供方共同定义的声明用于在双方之间传递信息。一个典型的Payload可能如下{ sub: 1234567890, name: John Doe, admin: true, iat: 1516239022, exp: 1516242622 }注意JWT的Payload仅是Base64Url编码并非加密。这意味着任何人都可以解码并看到其中的内容。绝对不要在Payload中存放任何敏感信息如密码、信用卡号等。Signature签名是确保令牌不被篡改的关键。签名的生成方式如下HMACSHA256( base64UrlEncode(header) . base64UrlEncode(payload), secret)签名部分将编码后的Header和Payload加上一个只有服务器知道的密钥secret通过Header中指定的算法计算得出。任何对Header或Payload的修改都会导致签名验证失败。算法选型HS256 vs RS256这是两个最常用的算法选择哪一个至关重要。HS256对称加密使用同一个密钥进行签名和验证。速度快实现简单。但密钥必须安全地存储在服务器端并且如果需要在多个服务间共享验证能力分发和管理这个密钥会带来安全风险。RS256非对称加密使用私钥签名公钥验证。私钥由认证服务器严格保管用于签发令牌公钥可以安全地分发给所有需要验证令牌的资源服务器。这更符合微服务架构下的安全最佳实践即使公钥泄露攻击者也无法伪造令牌。对于大多数内部微服务或中小型项目HS256因其简单性可能是首选。但对于面向公众或安全要求更高的系统强烈建议使用RS256。在本文的示例中为了演示的通用性我们将使用HS256但会重点说明密钥管理的重要性。3. 工程搭建与核心依赖引入我们从一个标准的Spring Boot项目开始。假设你已通过 start.spring.io 或IDE创建了一个项目至少需要包含Spring Web依赖。接下来我们需要在pom.xml中添加几个关键的依赖。dependencies !-- Spring Boot Starter Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JJWT (Java JWT Library) -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency !-- Lombok (可选用于简化代码) -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里重点说明一下JJWT库。我们引入了三个部分jjwt-apiAPI接口、jjwt-impl运行时实现、jjwt-jacksonJSON处理器。这种拆分是JJWT 0.10.x版本后的推荐方式可以避免将不必要的实现库打包到你的API模块中。版本0.11.5是一个广泛使用且稳定的版本。依赖添加完成后启动应用你会看到Spring Security自动生成的默认密码打印在控制台并且访问任何端点都会跳转到它的默认登录页。这说明Spring Security已经生效。我们的目标就是接管这个流程用JWT来替代它。4. 定制Spring Security配置核心过滤器链的改造Spring Security的核心是一系列过滤器Filter组成的过滤器链FilterChain。默认的登录、会话管理等行为都由特定的过滤器处理。我们要整合JWT就需要定制这个链条主要做两件事1. 禁用默认的Session管理2. 插入我们自己的JWT认证过滤器。首先创建一个配置类SecurityConfig。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.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; import lombok.RequiredArgsConstructor; Configuration RequiredArgsConstructor public class SecurityConfig { // 我们稍后会创建的JWT认证过滤器 private final JwtAuthenticationFilter jwtAuthenticationFilter; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 禁用CSRFCross-Site Request Forgery保护。 // 在基于Token的无状态API中通常可以禁用CSRF因为攻击者无法通过第三方站点轻易获取有效的JWT。 // 但如果你同时服务Web页面使用Cookie则需要重新评估。 .csrf().disable() // 关键配置设置会话创建策略为STATELESS无状态。 // 这告诉Spring Security不要创建和使用HttpSession。 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() // 配置请求授权规则 .authorizeHttpRequests(authz - authz // 允许所有人访问登录接口用于获取Token .requestMatchers(/api/auth/login).permitAll() // 允许所有人访问公开接口如健康检查 .requestMatchers(/public/**).permitAll() // 任何其他请求都需要认证 .anyRequest().authenticated() ) // 在UsernamePasswordAuthenticationFilter之前添加我们的JWT过滤器。 // 这个位置很重要JWT过滤器先于默认的表单登录过滤器执行。 // 如果请求头中有有效的JWTJWT过滤器会直接完成认证后续的登录过滤器就不会再执行。 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } // 密码编码器Bean。用于对用户密码进行加密存储和比对。 // 这里使用BCrypt它是目前最推荐的安全哈希算法。 Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 暴露AuthenticationManager Bean供登录认证服务使用。 Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } }这段配置是整个安全体系的骨架。SessionCreationPolicy.STATELESS是宣告进入“无状态”模式的关键。addFilterBefore则是将我们自定义的JwtAuthenticationFilter注入到Spring Security的核心处理流程中。接下来我们就来实现这个核心过滤器以及相关的工具类。5. JWT工具类令牌的生成、解析与验证逻辑封装为了让代码清晰且易于维护我们创建一个专门的JWT工具类。这个类负责所有与JWT令牌本身相关的操作生成、解析、验证、提取信息。我们将密钥、过期时间等配置放在application.yml中。首先在application.yml中添加配置jwt: secret: your-256-bit-secret-your-256-bit-secret-your-256-bit-secret # 用于HS256签名的密钥至少32字符 expiration: 86400000 # Token过期时间毫秒这里设置24小时 token-prefix: Bearer # Token在请求头中的前缀通常为Bearer header: Authorization # 携带Token的请求头名称然后创建JWT工具类import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; Component public class JwtTokenProvider { // 从配置文件中注入密钥 Value(${jwt.secret}) private String secretString; // 从配置文件中注入过期时间 Value(${jwt.expiration}) private long expiration; // 生成安全的密钥对象。使用Keys.hmacShaKeyFor方法可以确保密钥长度符合HS256算法要求。 private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secretString.getBytes()); } // 核心方法根据UserDetails生成JWT令牌 public String generateToken(UserDetails userDetails) { MapString, Object claims new HashMap(); // 可以将用户角色、额外信息放入claims中 claims.put(roles, userDetails.getAuthorities()); return createToken(claims, userDetails.getUsername()); } // 创建Token的私有方法 private String createToken(MapString, Object claims, String subject) { Date now new Date(); Date expiryDate new Date(now.getTime() expiration); return Jwts.builder() .setClaims(claims) // 设置自定义声明 .setSubject(subject) // 设置主题通常是用户名 .setIssuedAt(now) // 设置签发时间 .setExpiration(expiryDate) // 设置过期时间 .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用密钥和算法签名 .compact(); // 生成最终的字符串 } // 从Token中解析所有声明 private Claims extractAllClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) // 设置用于验证签名的密钥 .build() .parseClaimsJws(token) // 解析JWS已签名的JWT .getBody(); // 获取负载Claims } // 通用方法从Token中解析特定的声明 public T T extractClaim(String token, FunctionClaims, T claimsResolver) { final Claims claims extractAllClaims(token); return claimsResolver.apply(claims); } // 从Token中提取用户名Subject public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } // 从Token中提取过期时间 public Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } // 验证Token是否过期 private Boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } // 核心验证方法验证Token是否对应用户且未过期 public Boolean validateToken(String token, UserDetails userDetails) { final String username extractUsername(token); return (username.equals(userDetails.getUsername()) !isTokenExpired(token)); } }这个工具类封装了JJWT库的核心操作。有几个关键点需要注意密钥安全secretString在生产环境中绝不能硬编码在代码或配置文件中。应该通过环境变量、配置中心或密钥管理服务如Vault注入。一个简单的your-256-bit-secret会带来巨大的安全风险。异常处理extractAllClaims和validateToken方法在遇到非法、过期或篡改的Token时会抛出异常如SignatureExceptionExpiredJwtExceptionMalformedJwtException。这些异常需要在过滤器中被捕获并转换为合适的HTTP响应。信息存储我们在generateToken中将用户的权限Authorities存入了Token的claims。这样在后续的授权判断时可以直接从Token中读取无需再次查询数据库这是JWT无状态优势的体现。6. 实现JWT认证过滤器请求拦截与身份上下文的建立这是整个流程中最关键的一环。JwtAuthenticationFilter将拦截每一个HTTP请求检查是否携带合法的JWT并据此为当前请求建立安全上下文Security Context。import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; 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; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; Component RequiredArgsConstructor Slf4j 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 { // 1. 从请求头中获取Authorization final String authHeader request.getHeader(Authorization); final String jwt; final String username; // 2. 检查Authorization头格式是否正确以Bearer 开头 if (authHeader null || !authHeader.startsWith(Bearer )) { // 如果没有Token直接放行到下一个过滤器。 // Spring Security的后续过滤器如AnonymousAuthenticationFilter会将其视为匿名用户。 filterChain.doFilter(request, response); return; } // 3. 提取纯粹的Token字符串去掉Bearer 前缀 jwt authHeader.substring(7); // Bearer .length() 7 try { // 4. 从Token中解析用户名 username jwtTokenProvider.extractUsername(jwt); // 5. 如果用户名不为空且当前安全上下文中尚未有认证信息 if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { // 6. 根据用户名加载用户详情从数据库或缓存 UserDetails userDetails this.userDetailsService.loadUserByUsername(username); // 7. 验证Token是否对该用户有效且未过期 if (jwtTokenProvider.validateToken(jwt, userDetails)) { // 8. 创建认证令牌Authentication Token UsernamePasswordAuthenticationToken authToken new UsernamePasswordAuthenticationToken( userDetails, null, // 凭证Credentials设为null因为JWT本身已是凭证 userDetails.getAuthorities() // 从UserDetails中获取权限 ); // 9. 将请求的详细信息设置到认证令牌中 authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 10. 将认证令牌设置到安全上下文中。至此该请求被视为已认证。 SecurityContextHolder.getContext().setAuthentication(authToken); log.debug(Authenticated user: {}, username); } } } catch (Exception e) { // 非常重要捕获所有JWT解析或验证过程中的异常 // 例如Token过期、签名无效、格式错误等。 // 这里可以选择记录日志但不要抛出异常而是让请求继续。 // 因为一个无效的Token等同于没有Token应该被当作匿名请求处理。 log.error(JWT authentication failed: {}, e.getMessage()); // 可以选择在响应头中设置提示信息但不要中断过滤器链。 // response.setHeader(X-Authentication-Error, Invalid token); } // 11. 无论认证成功与否都继续执行过滤器链 filterChain.doFilter(request, response); } }这个过滤器的逻辑是Spring Security整合JWT的经典模式。有几个极易踩坑的细节需要特别注意OncePerRequestFilter的使用确保这个过滤器在一次请求中只执行一次避免在转发Forward或包含Include时重复执行。SecurityContextHolder的检查在设置新的认证信息前一定要检查当前上下文是否已存在认证SecurityContextHolder.getContext().getAuthentication() null。如果不检查可能会覆盖掉其他认证机制如OAuth2设置的上下文。异常处理策略在catch块中我们选择了记录日志并让请求继续。另一种常见的做法是直接返回401状态码。选择哪种取决于你的业务需求。让请求继续意味着无效Token的请求会被后续的授权过滤器判定为“匿名用户”如果该端点需要认证则会返回403。直接返回401更明确地告诉客户端Token有问题。我个人倾向于前者因为它更符合“过滤器”的职责——只负责认证不负责响应将授权失败的响应交给Spring Security的AccessDeniedHandler或AuthenticationEntryPoint统一处理会更清晰。UserDetailsService.loadUserByUsername的调用这一步是有状态的它需要查询数据库或缓存。这与JWT“无状态”的理念似乎矛盾。实际上JWT的无状态指的是认证状态的无状态服务器不保存会话但授权信息用户的角色、权限如果发生变化在Token过期前是无法更新的。为了解决这个问题常见的实践是将核心的、不常变的权限信息如角色直接放在Token的claims里这样验证时就不需要查库。在过滤器中可以尝试先从Token的claims中恢复Authentication对象如果权限足够比如只是角色判断可以跳过loadUserByUsername。但对于需要细粒度权限如基于ACL的校验可能还是需要查询最新的用户信息。这是一个需要在性能和数据一致性之间做出的权衡。7. 构建认证入口登录接口的实现现在我们需要一个端点来接收用户的登录凭证如用户名密码验证成功后颁发JWT令牌。这个接口本身应该是公开的在SecurityConfig中已配置为permitAll()。首先定义登录请求和响应的DTO数据传输对象import lombok.Data; Data public class LoginRequest { private String username; private String password; } Data public class LoginResponse { private String token; private String type Bearer; // 令牌类型 private Long expiresIn; // 过期时间秒 // 可以添加其他信息如用户基本信息 }然后创建一个认证服务类AuthService来处理登录逻辑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.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; Service RequiredArgsConstructor public class AuthService { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; // 你的自定义UserDetailsService public LoginResponse authenticateUser(LoginRequest loginRequest) { // 1. 使用AuthenticationManager进行认证 // 它会调用我们配置的UserDetailsService和PasswordEncoder Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证成功后将认证信息设置到安全上下文可选但对于本次请求后续可能有的逻辑有用 SecurityContextHolder.getContext().setAuthentication(authentication); // 3. 获取UserDetails认证成功后authentication.getPrincipal()返回的就是UserDetails UserDetails userDetails (UserDetails) authentication.getPrincipal(); // 4. 生成JWT令牌 String jwt jwtTokenProvider.generateToken(userDetails); // 5. 构建响应 LoginResponse response new LoginResponse(); response.setToken(jwt); // 可以从jwtTokenProvider或配置中获取过期时间 // response.setExpiresIn(jwtTokenProvider.getExpirationFromToken(jwt) / 1000); return response; } }最后创建一个REST控制器AuthController暴露登录接口import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; RestController RequestMapping(/api/auth) RequiredArgsConstructor public class AuthController { private final AuthService authService; PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest loginRequest) { LoginResponse response authService.authenticateUser(loginRequest); return ResponseEntity.ok(response); } }至此一个完整的“密码登录换JWT”的流程就实现了。用户调用POST /api/auth/login传入用户名密码成功后即可拿到一个JWT令牌。后续访问受保护的API时只需在请求头中添加Authorization: Bearer 你的JWT令牌即可。8. 权限控制实战从接口到方法的细粒度管控拿到Token只是第一步更重要的是如何利用Token中的信息进行授权控制。Spring Security提供了多层次、细粒度的权限控制方式。8.1 基于URL的访问控制这是在SecurityConfig中通过authorizeHttpRequests配置的最简单直接。我们可以根据角色进行控制.authorizeHttpRequests(authz - authz .requestMatchers(/api/admin/**).hasRole(ADMIN) // 需要ADMIN角色 .requestMatchers(/api/user/**).hasAnyRole(USER, ADMIN) // 需要USER或ADMIN角色 .requestMatchers(/api/profile/**).authenticated() // 只需要认证不限角色 .anyRequest().permitAll() )这里hasRole方法会自动添加前缀ROLE_。也就是说你的UserDetails中返回的权限字符串应该是ROLE_ADMIN、ROLE_USER。如果你使用hasAuthority方法则直接使用权限字符串本身如ADMIN。8.2 基于方法的注解控制对于更细粒度的控制比如服务层的方法可以使用注解。 首先在配置类或主应用类上启用全局方法安全Configuration EnableGlobalMethodSecurity(prePostEnabled true) // 启用PreAuthorize等注解 public class MethodSecurityConfig { // 配置可以留空 }然后在Service方法上使用注解Service public class SomeService { // 只有拥有ADMIN角色的用户才能调用此方法 PreAuthorize(hasRole(ADMIN)) public void adminOperation() { // ... } // 更复杂的SpEL表达式允许用户操作自己的资源 PreAuthorize(#userId authentication.principal.username or hasRole(ADMIN)) public void getUserProfile(String userId) { // authentication.principal 这里就是UserDetails对象 // ... } // 方法执行后验证返回值 PostAuthorize(returnObject.owner authentication.principal.username) public Resource getResource(Long id) { // ... } }PreAuthorize和PostAuthorize提供了强大的基于Spring Expression Language (SpEL)的访问控制能力。8.3 从JWT中提取自定义信息进行授权有时我们存储在JWTclaims中的不仅仅是角色可能还有部门ID、租户信息等。如何在授权时使用这些信息呢我们需要让Spring Security认识这些信息。一种方法是在JWT过滤器中将claims中的信息提取出来设置为Authentication对象的details或自定义的principal。首先可以创建一个自定义的UserDetails实现类继承自org.springframework.security.core.userdetails.User并添加额外字段public class CustomUserDetails extends org.springframework.security.core.userdetails.User { private Long departmentId; private ListString customPermissions; public CustomUserDetails(String username, String password, Collection? extends GrantedAuthority authorities, Long departmentId, ListString customPermissions) { super(username, password, authorities); this.departmentId departmentId; this.customPermissions customPermissions; } // getters... }然后在UserDetailsService的loadUserByUsername方法中查询数据库并返回这个自定义对象。同时在JWT的generateToken方法中将这些额外信息如departmentId放入claims。最后在过滤器中验证Token后创建Authentication对象时使用这个CustomUserDetails作为principal。这样在PreAuthorize注解或代码中就可以通过authentication.principal.departmentId来访问这些信息了。// 在过滤器中 if (jwtTokenProvider.validateToken(jwt, userDetails)) { CustomUserDetails customUserDetails (CustomUserDetails) userDetails; // 也可以从token claims中直接读取并设置 // Long deptId jwtTokenProvider.extractClaim(jwt, claims - claims.get(deptId, Long.class)); // customUserDetails.setDepartmentId(deptId); UsernamePasswordAuthenticationToken authToken new UsernamePasswordAuthenticationToken( customUserDetails, // 使用自定义的UserDetails作为principal null, customUserDetails.getAuthorities() ); // ... }现在你就可以在安全表达式中使用这些属性了PreAuthorize(securityService.canAccessDepartment(authentication.principal.departmentId, #deptId)) public void someDepartmentalOperation(Long deptId) { // ... }9. 令牌的生命周期管理刷新、黑名单与安全增强JWT一旦签发在过期前理论上都是有效的。这带来了便利也带来了安全挑战如何让一个有效的令牌提前失效常见的场景是用户注销、修改密码或怀疑令牌泄露。9.1 令牌刷新机制为了平衡安全性和用户体验通常会采用“访问令牌Access Token 刷新令牌Refresh Token”的双令牌机制。访问令牌生命周期短如15分钟用于访问业务API。即使泄露影响窗口也较小。刷新令牌生命周期长如7天仅用于获取新的访问令牌单独存储于服务端如数据库或Redis。当访问令牌过期后客户端使用刷新令牌调用一个特定的/refresh端点来获取新的访问令牌。服务端会校验刷新令牌的有效性检查数据库并颁发新的访问令牌。同时可以使旧的刷新令牌失效单次使用或维持其有效性。实现此机制需要对登录响应和认证流程进行扩展并维护一个刷新令牌的存储与验证逻辑。这增加了复杂度但对于需要高安全性的应用是值得的。9.2 令牌黑名单/白名单即使使用短期的访问令牌有时我们也需要立即撤销它。这就需要引入一个“黑名单”机制。最简单的实现是将需要失效的令牌的标识如JTI - JWT ID一个唯一标识符或令牌本身哈希值存入一个缓存如Redis并设置其过期时间与令牌本身的exp一致。在JWT过滤器中在验证令牌签名和过期时间之后增加一步检查查询该令牌是否在黑名单中。如果在则拒绝请求。// 在JwtAuthenticationFilter的doFilterInternal中验证token后 if (jwtTokenProvider.validateToken(jwt, userDetails)) { // 新增检查令牌是否在黑名单中 if (tokenBlacklistService.isBlacklisted(jwt)) { log.warn(Token is blacklisted for user: {}, username); // 可以抛出特定异常或直接返回 response.sendError(HttpServletResponse.SC_UNAUTHORIZED, Token revoked); return; } // ... 后续认证逻辑 }同理也可以实现“白名单”只允许存在于白名单中的令牌有效。这实际上就退化成了服务端存储会话的状态化方案失去了JWT的部分优势需谨慎使用。9.3 其他安全增强措施密钥轮换定期更换JWT签名密钥。旧密钥签发的令牌在轮换后的一小段宽限期内仍可接受之后完全失效。这需要精细的密钥版本管理。绑定信息在生成Token时将用户的部分不可变信息如用户ID的哈希或客户端指纹如IP地址、User-Agent的哈希放入claims。验证时除了校验签名和过期时间再校验这些绑定信息是否与当前请求匹配。这可以防止令牌在另一台设备或地点被使用。设置合理的过期时间访问令牌的过期时间不宜过长根据业务敏感度设定在几分钟到几小时之间。刷新令牌可以稍长但也要有上限。10. 实战中的坑点、调试技巧与最佳实践总结整合过程看似顺畅但实际落地时总会遇到各种问题。以下是我从多个项目中总结出的常见坑点和应对策略。10.1 常见问题与排查403 Forbidden而不是401 Unauthorized现象携带Token访问接口返回403。排查403表示认证成功但授权失败。首先检查过滤器日志确认用户是否已成功认证SecurityContextHolder中是否有Authentication对象。然后检查该用户的权限Authorities是否满足接口要求。使用调试工具或在过滤器中打印userDetails.getAuthorities()的内容确保角色/权限前缀正确如ROLE_ADMIN。Authentication对象在控制器中为null现象在RestController中注入Authentication参数发现是null。排查确保你的JWT过滤器被正确添加到了过滤器链中并且位置在UsernamePasswordAuthenticationFilter之前。检查过滤器是否因为异常而提前返回没有执行到SecurityContextHolder.setAuthentication()。确保请求头格式是Authorization: Bearer token注意Bearer后面有一个空格。跨域CORS问题导致请求头被屏蔽现象前端请求能发出去但后端收不到Authorization头。解决在Spring Security配置中显式配置CORS。HttpSecurity的cors()配置需要配合一个CorsConfigurationSource的Bean。Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList(https://your-frontend.com)); // 允许的源 configuration.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); configuration.setAllowedHeaders(Arrays.asList(Authorization, Content-Type, X-Requested-With)); configuration.setAllowCredentials(true); // 如果前端需要传Cookie等凭证设为true configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, configuration); return source; } // 在SecurityConfig的filterChain方法中调用 .cors().configurationSource(corsConfigurationSource())Token过期后的友好处理现象Token过期后接口直接返回401前端不知道具体原因。优化在JWT过滤器的catch块中捕获ExpiredJwtException然后可以在响应头中添加特定信息response.setHeader(Token-Expired, true)。或者返回一个结构化的错误响应但这会改变过滤器的职责更推荐使用自定义的AuthenticationEntryPoint来统一处理认证失败。10.2 调试技巧开启Spring Security Debug日志在application.yml中设置logging.level.org.springframework.securityDEBUG可以清晰地看到过滤器链的执行过程、认证和授权的决策流程。在过滤器中打印关键信息在doFilterInternal方法的关键节点如提取到的用户名、验证结果、设置的认证信息添加log.debug语句。使用Autowired注入Authentication在控制器方法中可以添加AuthenticationPrincipal注解来直接获取UserDetails或者直接声明Authentication参数方便在调试时查看当前用户详情。GetMapping(/me) public ResponseEntity? getCurrentUser(AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(userDetails); }10.3 最佳实践总结密钥管理是生命线生产环境的JWT密钥必须通过安全的方式注入环境变量、密钥管理服务绝对不要提交到代码仓库。考虑使用RS256非对称加密将私钥妥善保管。Payload只放必要信息不要存储敏感数据。用户ID、角色等非敏感信息是安全的。如果需要传递更多信息可以考虑加密部分声明JWE但这会增加复杂度。拥抱“无状态”但要管理状态理解JWT的无状态是针对认证会话的。对于令牌撤销、权限实时更新等需求你仍然需要引入有状态的组件如Redis黑名单、权限缓存。这是一个权衡。使用成熟的库像JJWT这样的库经过了安全审计比自己手写签名/验证要可靠得多。保持库的更新。定义清晰的Token过期策略结合业务设计访问令牌和刷新令牌的过期时间。对于后台管理系统访问令牌可以稍长对于金融类应用访问令牌应非常短。前端安全存储指导前端将JWT存储在HttpOnly的Cookie中防XSS或安全的客户端存储中并在每次请求时正确携带。对于SPA应用存储在内存或sessionStorage中也是常见做法但需注意XSS风险。整合Spring Security与JWT本质是将一个强大的、有状态的认证授权框架适配到无状态的API世界中。这个过程需要你深刻理解两者各自的原理与边界。希望这篇详尽的拆解能帮你不仅搭起这个架子更能理解每一行配置、每一段代码背后的考量从而构建出真正安全、健壮的Web应用。