Spring Security整合JWT:从Session认证到无状态API的实战指南
1. 从“登录态”到“无状态”:为什么我们需要JWT?
在构建Web应用时,身份认证和授权是绕不开的核心议题。传统的做法,比如Spring Security默认提供的基于Session的认证,其工作流程大家都很熟悉:用户登录成功后,服务器创建一个Session,将用户信息存入其中,并生成一个Session ID通过Cookie返回给浏览器。后续的每次请求,浏览器都会带上这个Cookie,服务器通过Session ID找到对应的Session,从而确认用户身份。
这套机制成熟、稳定,但在微服务、前后端分离的架构下,它的短板开始显现。最核心的问题是状态。Session本身是服务器端维护的状态信息。这意味着,要么你的应用是单体的,所有请求都打到同一台服务器;要么你就得引入Session共享方案(如Redis),这增加了架构的复杂度和运维成本。更重要的是,它违背了RESTful架构倡导的“无状态”原则,使得服务器的横向扩展变得不那么纯粹。
这时,JWT(JSON 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 SHA256(HS256)或RSA SHA256(RS256)。它会被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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JJWT (Java JWT Library) --> <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> <!-- Lombok (可选,用于简化代码) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里重点说明一下JJWT库。我们引入了三个部分:jjwt-api(API接口)、jjwt-impl(运行时实现)、jjwt-jackson(JSON处理器)。这种拆分是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 // 禁用CSRF(Cross-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过滤器先于默认的表单登录过滤器执行。 // 如果请求头中有有效的JWT,JWT过滤器会直接完成认证,后续的登录过滤器就不会再执行。 .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) { Map<String, Object> claims = new HashMap<>(); // 可以将用户角色、额外信息放入claims中 claims.put("roles", userDetails.getAuthorities()); return createToken(claims, userDetails.getUsername()); } // 创建Token的私有方法 private String createToken(Map<String, 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, Function<Claims, 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时会抛出异常(如SignatureException,ExpiredJwtException,MalformedJwtException)。这些异常需要在过滤器中被捕获并转换为合适的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)的校验,可能还是需要查询最新的用户信息。这是一个需要在性能和数据一致性之间做出的权衡。
- 将核心的、不常变的权限信息(如角色)直接放在Token的
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 ResponseEntity<LoginResponse> 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 List<String> customPermissions; public CustomUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities, Long departmentId, List<String> 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.security=DEBUG,可以清晰地看到过滤器链的执行过程、认证和授权的决策流程。 - 在过滤器中打印关键信息:在
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应用。
