当前位置: 首页 > news >正文

Spring Security 深度学习(六): RESTful API 安全与 JWT

在这里插入图片描述

目录

    • 1. 引言:无状态认证的崛起
    • 2. JWT (JSON Web Token) 核心概念
      • 2.1 什么是JWT?
      • 2.2 JWT的组成:Header, Payload, Signature
      • 2.3 JWT的工作原理
      • 2.4 JWT的优缺点与适用场景
    • 3. Spring Security中的JWT集成策略
      • 3.1 禁用Session管理与CSRF防护
      • 3.2 JWT认证流程概述
    • 4. 实战演练:构建JWT认证系统
      • 4.1 引入JWT库依赖
      • 4.2 JWT工具类:生成与解析Token
      • 4.3 自定义 JwtAuthenticationToken
      • 4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider)
      • 4.5 自定义 JwtAuthenticationFilter
      • 4.6 更新 SecurityFilterChain 配置,集成JWT过滤器
      • 4.7 改造登录接口,返回JWT
      • 4.8 认证失败与权限不足的自定义处理
      • 4.9 测试JWT认证流程
    • 5. JWT的安全性与挑战
      • 5.1 Token过期与刷新机制
      • 5.2 JWT注销/黑名单机制
      • 5.3 密钥管理
      • 5.4 防止令牌盗用
    • 6. 常见陷阱与注意事项
    • 7. 阶段总结

1. 引言:无状态认证的崛起

传统的Web应用通常依赖于服务器端的HTTP Session来维护用户状态。每次用户登录后,服务器会创建一个Session并将其Session ID通过Cookie发送给客户端。客户端在后续请求中携带这个Cookie,服务器通过Session ID查找对应的Session,从而识别用户身份。

然而,这种基于Session的方式在以下场景中面临挑战:

  • 前后端分离: 前端(React, Vue, Angular)和后端(Spring Boot API)是独立的,它们之间可能存在跨域请求。Cookie通常受同源策略限制,且在前端应用中直接操作Cookie不方便。
  • 微服务架构: 用户请求可能需要经过多个微服务,Session的共享和管理(例如使用Sticky Session或Redis共享Session)变得复杂且增加了系统耦合度。
  • 移动应用/第三方应用: 移动客户端不能很好地支持Cookie,更倾向于通过Authorization Header传递凭证。
  • 水平扩展: 当服务器集群需要水平扩展时,Session共享成为瓶颈。

无状态认证应运而生。它意味着服务器不再存储用户会话信息,每次请求都携带完整的认证凭证。JWT (JSON Web Token) 是实现无状态认证的主流方案之一。

2. JWT (JSON Web Token) 核心概念

2.1 什么是JWT?

JWT是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。这些信息以JSON对象的形式传输,可以被数字签名,从而可以验证其真实性和完整性。

  • 紧凑: JWT的体积很小,可以通过URL、POST参数或HTTP头轻松传输。
  • 自包含: JWT包含了所有必要的用户信息(通常是用户ID、角色、权限等),服务器无需查询数据库即可获取这些信息。
  • 安全: JWT可以通过数字签名进行验证,确保其未被篡改。

2.2 JWT的组成:Header, Payload, Signature

一个JWT通常由三部分组成,用.分隔:Header.Payload.Signature

A. Header (头部)
通常包含两个信息:

  • alg (algorithm):签名算法,如HMAC SHA256 (HS256) 或 RSA (RS256)。
  • typ (type):Token类型,通常是JWT
{"alg": "HS256","typ": "JWT"
}

Header会被Base64Url编码。

B. Payload (载荷)
包含声明 (claims),是关于实体(通常是用户)和附加数据的断言。声明分为三类:

  • Registered claims (注册声明): 预定义的一些声明,非强制,但推荐使用,例如:
    • iss (issuer):颁发者
    • exp (expiration time):过期时间
    • sub (subject):主题(通常是用户ID)
    • aud (audience):受众
    • iat (issued at):签发时间
  • Public claims (公共声明): 可以在JWT中自由定义的声明,但为了避免冲突,应该在IANA JWT Registry中注册,或者将其定义为URI。
  • Private claims (私有声明): 约定俗成的声明,用于在特定方之间共享信息,既不是注册声明也不是公共声明。例如,可以包含用户角色、权限列表等业务信息。
{"sub": "1234567890","name": "John Doe","iat": 1516239022,"exp": 1516242622, // 签发时间 + 有效期"roles": ["USER", "ADMIN"] // 私有声明
}

Payload也会被Base64Url编码。

C. Signature (签名)
用于验证Token的发送者,并确保Token在传输过程中没有被篡改。
签名是使用Header中指定的算法(例如HS256),将Base64Url编码后的Header、Base64Url编码后的Payload和密钥(secret)进行加密计算得到。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

2.3 JWT的工作原理

  1. 用户登录: 用户使用用户名和密码向认证服务器(应用后端)发送登录请求。
  2. 生成JWT: 认证服务器验证用户凭证。如果验证成功,根据用户ID、角色、权限等信息生成一个JWT,并用一个密钥进行签名。
  3. 返回JWT: 服务器将生成的JWT返回给客户端(通常在HTTP响应体中)。
  4. 客户端存储JWT: 客户端接收到JWT后,通常将其存储在本地存储(如LocalStorage或SessionStorage)中。
  5. 访问受保护资源: 客户端在后续每次访问受保护的API时,都会在HTTP请求头的Authorization字段中携带JWT,格式为Authorization: Bearer <JWT>
  6. 验证JWT: 资源服务器(应用后端)接收到请求后,从Authorization头中提取JWT。然后,它使用之前用于签名的密钥验证JWT的签名、检查Token是否过期,以及解析其中的声明(如用户ID、权限)。
  7. 授权与响应: 如果JWT有效且用户具有所需权限,服务器处理请求并返回数据。如果JWT无效或过期,或者用户权限不足,则返回错误(如401 Unauthorized或403 Forbidden)。

2.4 JWT的优缺点与适用场景

优点:

  • 无状态: 服务器无需存储Session,易于水平扩展,适用于微服务。
  • 紧凑自包含: 包含了所有必要的用户信息,减少了数据库查询。
  • 跨域友好: 不依赖Cookie,易于跨域请求。
  • 移动兼容性: 广泛应用于移动应用。

缺点:

  • Token无法实时注销: JWT一旦签发,在其有效期内都是有效的,服务器端无法强制使其失效(除非引入黑名单机制)。
  • Token过大: 如果Payload中包含太多信息,Token会变大,增加请求头大小。
  • 安全性考量:
    • 密钥安全: 签名密钥一旦泄露,攻击者可以伪造Token。
    • 传输安全: JWT应始终通过HTTPS传输,防止Token被截获。
    • XSS风险: 如果存储在LocalStorage,容易受到XSS攻击。
    • 无CSRF防护: 因为不依赖Session Cookie,JWT本身不提供CSRF防护,因此无需特别开启CSRF。

适用场景:

  • 前后端分离的Web应用。
  • 微服务架构中的API认证。
  • 移动应用和桌面应用。
  • 第三方OAuth2/OpenID Connect认证。

3. Spring Security中的JWT集成策略

在Spring Security中集成JWT,通常需要进行以下调整:

3.1 禁用Session管理与CSRF防护

由于JWT是无状态的,我们不再需要Spring Security的Session管理和CSRF防护功能。

            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 设置为无状态).csrf(csrf -> csrf.disable()) // 禁用CSRF防护

3.2 JWT认证流程概述

  1. JWT生成: 在用户登录成功后,后端生成JWT并返回。
  2. JWT传输: 客户端将JWT存储起来,并在每次请求时通过Authorization: Bearer <JWT>请求头发送。
  3. JWT解析与验证: Spring Security过滤器链中会插入一个自定义的JWT过滤器:
    • 它拦截所有请求,从Authorization头中提取JWT。
    • 使用预设的密钥解析并验证JWT的签名和有效期。
    • 如果验证成功,从JWT中提取用户ID和权限,创建Authentication对象。
    • Authentication对象设置到SecurityContextHolder中。
  4. 授权: 后续的Spring Security授权过滤器(如FilterSecurityInterceptor)会根据SecurityContextHolder中的认证信息进行授权决策。

4. 实战演练:构建JWT认证系统

我们将改造之前的项目,实现JWT认证。

4.1 引入JWT库依赖

我们将使用jjwt库来处理JWT。

        <!-- JJWT (JWT Library) --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.12.5</version> </dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.12.5</version><scope>runtime</scope></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.12.5</version><scope>runtime</scope></dependency>

4.2 JWT工具类:生成与解析Token

创建一个工具类来处理JWT的生成、解析和验证。

package com.example.springsecuritystage1.util;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.GrantedAuthority;
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.List;
import java.util.Map;
import java.util.stream.Collectors;@Component
public class JwtUtil {// 密钥。生产环境务必从安全通道获取,不能硬编码。@Value("${jwt.secret:thisismyjwtsecretkeythatiwilluseforsigningandvalidatingtokensanditshouldbeverylongandcomplex}")private String secret;// JWT有效期 (毫秒),这里设置为1小时@Value("${jwt.expiration:3600000}")private long expiration; // 1 hourprivate SecretKey getSigningKey() {// 使用 HS256 算法生成密钥return Keys.hmacShaKeyFor(secret.getBytes());}// 生成Tokenpublic String generateToken(UserDetails userDetails) {Map<String, Object> claims = new HashMap<>();// 将用户权限添加到claims中List<String> authorities = userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());claims.put("authorities", authorities);return createToken(claims, userDetails.getUsername());}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中获取所有声明public Claims extractAllClaims(String token) {return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).getBody();}// 从Token中获取用户名public String extractUsername(String token) {return extractAllClaims(token).getSubject();}// 从Token中获取过期时间public Date extractExpiration(String token) {return extractAllClaims(token).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));}// 额外的:从Token中获取权限@SuppressWarnings("unchecked")public List<String> extractAuthorities(String token) {return (List<String>) extractAllClaims(token).get("authorities");}
}

application.yml中添加JWT配置:

jwt:secret: your_jwt_secret_key_that_is_very_long_and_complex_and_should_be_kept_secure_in_production # 至少32位,生产环境务必使用更长更随机的密钥expiration: 3600000 # 1小时,单位毫秒

4.3 自定义 JwtAuthenticationToken

ApiKeyAuthenticationToken类似,我们需要一个Authentication实现来承载从JWT解析出的认证信息。

package com.example.springsecuritystage1.security.token;import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;import java.util.Collection;public class JwtAuthenticationToken extends AbstractAuthenticationToken {private final Object principal; // 用户名或UserDetails对象private String credentials; // JWT字符串本身public JwtAuthenticationToken(String jwtToken) {super(null);this.principal = null; // 初始时principal是nullthis.credentials = jwtToken; // JWT Token作为凭证setAuthenticated(false);}public JwtAuthenticationToken(Object principal, String jwtToken, Collection<? extends GrantedAuthority> authorities) {super(authorities);this.principal = principal;this.credentials = jwtToken;setAuthenticated(true);}@Overridepublic Object getCredentials() {return credentials;}@Overridepublic Object getPrincipal() {return principal;}
}

4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider)

Spring Security 6.x 推荐使用BearerTokenAuthenticationConverterReactiveJwtDecoder等用于OAuth2 Resource Server,但对于自定义的JWT,我们可以继续使用AuthenticationProvider

package com.example.springsecuritystage1.security.provider;import com.example.springsecuritystage1.security.token.JwtAuthenticationToken;
import com.example.springsecuritystage1.service.CustomUserDetailsService; // 你的UserDetailsService
import com.example.springsecuritystage1.util.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {private final JwtUtil jwtUtil;private final CustomUserDetailsService userDetailsService; // 用于加载用户详情public JwtAuthenticationProvider(JwtUtil jwtUtil, CustomUserDetailsService userDetailsService) {this.jwtUtil = jwtUtil;this.userDetailsService = userDetailsService;}@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;String jwt = (String) jwtAuthenticationToken.getCredentials();try {String username = jwtUtil.extractUsername(jwt);List<String> authoritiesStrings = jwtUtil.extractAuthorities(jwt); // 从JWT中提取权限// 可以选择从数据库再次加载UserDetails,以确保用户状态最新// 或者仅仅使用JWT中的信息构建User对象UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(jwt, userDetails)) {Set<SimpleGrantedAuthority> authorities = authoritiesStrings.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());return new JwtAuthenticationToken(userDetails, jwt, authorities);} else {throw new BadCredentialsException("Invalid JWT token");}} catch (ExpiredJwtException e) {throw new BadCredentialsException("JWT Token has expired", e);} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {throw new BadCredentialsException("Invalid JWT Token", e);}}@Overridepublic boolean supports(Class<?> authentication) {return JwtAuthenticationToken.class.isAssignableFrom(authentication);}
}

注意:JwtAuthenticationProvider中,我们从JWT中提取了权限信息。但为了确保用户状态(如enabledaccountNonLocked)是最新的,我们仍然通过userDetailsService.loadUserByUsername(username)从数据库加载了完整的UserDetails。如果JWT中包含足够的信息且不关心实时状态,可以直接基于JWT信息构建User对象。

4.5 自定义 JwtAuthenticationFilter

这个过滤器负责拦截请求,提取JWT,并将其提交给AuthenticationManager

package com.example.springsecuritystage1.filter;import com.example.springsecuritystage1.security.token.JwtAuthenticationToken;
import com.example.springsecuritystage1.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;import java.io.IOException;// JWT 认证过滤器
public class JwtAuthenticationFilter extends OncePerRequestFilter {private final AuthenticationManager authenticationManager; // 注入 AuthenticationManagerpublic JwtAuthenticationFilter(AuthenticationManager authenticationManager) {this.authenticationManager = authenticationManager;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {// 1. 从 Authorization header 中获取 JWT TokenString authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);String jwt = null;if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {jwt = authorizationHeader.substring(7); // 提取Bearer Token}// 如果没有JWT,或者SecurityContext中已经有认证信息(例如通过Session登录),则跳过if (jwt == null || SecurityContextHolder.getContext().getAuthentication() != null) {filterChain.doFilter(request, response);return;}try {// 2. 创建一个未认证的 JwtAuthenticationTokenJwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(jwt);// 3. 将Token提交给 AuthenticationManager 进行认证Authentication authentication = authenticationManager.authenticate(authenticationToken);// 4. 认证成功,将认证信息存入 SecurityContextHolderSecurityContextHolder.getContext().setAuthentication(authentication);System.out.println("JWT authenticated successfully for: " + authentication.getName());} catch (Exception e) {// 认证失败,清除SecurityContext,并返回401 UnauthorizedSecurityContextHolder.clearContext();response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);response.getWriter().write("JWT authentication failed: " + e.getMessage());return; // 阻止请求继续往下走}// 继续过滤器链filterChain.doFilter(request, response);}
}

4.6 更新 SecurityFilterChain 配置,集成JWT过滤器

现在,我们需要在CustomSecurityConfig中添加JwtAuthenticationProviderAuthenticationManager,并将JwtAuthenticationFilter插入到过滤器链中。同时,禁用Session管理和CSRF防护。

package com.example.springsecuritystage1.config;// ... 省略其他 imports
import com.example.springsecuritystage1.filter.ApiKeyAuthenticationFilter;
import com.example.springsecuritystage1.filter.JwtAuthenticationFilter; // 导入 JWT 过滤器
import com.example.springsecuritystage1.security.provider.ApiKeyAuthenticationProvider;
import com.example.springsecuritystage1.security.provider.JwtAuthenticationProvider; // 导入 JWT Provider
import com.example.springsecuritystage1.util.JwtUtil; // 导入 JWT 工具类
import org.springframework.http.HttpMethod; // 导入
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.http.SessionCreationPolicy; // 导入@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class CustomSecurityConfig {private final DataSource dataSource;private final UserDetailsService userDetailsService;private final PasswordEncoder passwordEncoder;private final ApiKeyAuthenticationProvider apiKeyAuthenticationProvider;private final JwtAuthenticationProvider jwtAuthenticationProvider; // 注入 JWT Providerprivate final JwtUtil jwtUtil; // 注入 JWTUtilpublic CustomSecurityConfig(DataSource dataSource,UserDetailsService userDetailsService,PasswordEncoder passwordEncoder,ApiKeyAuthenticationProvider apiKeyAuthenticationProvider,JwtAuthenticationProvider jwtAuthenticationProvider,JwtUtil jwtUtil) {this.dataSource = dataSource;this.userDetailsService = userDetailsService;this.passwordEncoder = passwordEncoder;this.apiKeyAuthenticationProvider = apiKeyAuthenticationProvider;this.jwtAuthenticationProvider = jwtAuthenticationProvider;this.jwtUtil = jwtUtil;}@Beanpublic PasswordEncoder passwordEncoder() { /* ... */ return new BCryptPasswordEncoder(); }@Beanpublic UserDetailsService userDetailsService() { /* ... */ return new CustomUserDetailsService(sysUserMapper); }@Beanpublic PersistentTokenRepository persistentTokenRepository() { /* ... */ return tokenRepository; }@Beanpublic ProviderManager authenticationManager() {DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();daoProvider.setUserDetailsService(userDetailsService);daoProvider.setPasswordEncoder(passwordEncoder);// ProviderManager 现在包含 DaoAuthenticationProvider, ApiKeyAuthenticationProvider 和 JwtAuthenticationProviderreturn new ProviderManager(daoProvider, apiKeyAuthenticationProvider, jwtAuthenticationProvider);}@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.csrf(csrf -> csrf.disable()) // <<-- HERE: 禁用CSRF防护.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // <<-- HERE: 设置为无状态会话策略).authorizeHttpRequests(authorize -> authorize// 允许所有请求,因为我们现在是无状态API,登录获取Token.requestMatchers("/api/auth/**", "/public/**", "/register", "/login").permitAll()// 不需要这些Web页面的权限配置了,因为它们现在应该由前端路由控制// .requestMatchers("/admin/**").hasAnyAuthority("ROLE_ADMIN", "USER_MANAGE")// .requestMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "USER_VIEW").requestMatchers("/api/v2/**").hasAuthority("API_KEY_AUTH").anyRequest().authenticated() // 其他所有 API 请求都需要认证 (JWT 或 API Key))// 移除了 formLogin 和 rememberMe, 因为现在是无状态API.httpBasic(Customizer.withDefaults()) // 可以在测试阶段保留HTTP Basic.addFilterBefore(new ApiKeyAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class)// <<-- HERE: 将 JwtAuthenticationFilter 添加到 ApiKeyAuthenticationFilter 之后,UsernamePasswordAuthenticationFilter 之前// 但因为我们禁用了 Session,UsernamePasswordAuthenticationFilter 实际上不会被用到,可以考虑移除// 这里我们放在 ApiKeyAuthenticationFilter 之后,保证 JWT 认证在 API Key 认证之后尝试.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), ApiKeyAuthenticationFilter.class);// TODO: 为JWT认证添加适当的异常处理器,例如 AuthenticationEntryPoint// .exceptionHandling(exception -> exception//     .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 稍后添加// )return http.build();}@Beanpublic HttpSessionEventPublisher httpSessionEventPublisher() {return new HttpSessionEventPublisher(); // 即使是 STATELESS,这个Bean本身没有什么副作用,可以保留}
}

重要的更新点:

  1. JWT相关注入: JwtAuthenticationProviderJwtUtil被注入,并JwtAuthenticationProvider添加到ProviderManager中。
  2. 禁用CSRF和Session: csrf(csrf -> csrf.disable())sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 是实现无状态的关键。
  3. 移除Session相关配置: formLogin()rememberMe()配置被移除,因为它们依赖于Session。
  4. JWT过滤器添加: JwtAuthenticationFilter通过 addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), ApiKeyAuthenticationFilter.class) 添加到过滤器链中,它将在ApiKeyAuthenticationFilter之前尝试处理JWT认证。你可以自行调整顺序。
  5. UsernamePasswordAuthenticationFilter的去留: 由于我们禁用了Session和表单登录,UsernamePasswordAuthenticationFilter实际上不再具有作用。此处将其保留在addFilterBefore的参考中,但如果你不打算使用HTTP Basic或传统的表单登录,可以完全移除对它的引用,或者直接将其替换。对于纯API,我们通常不会使用UsernamePasswordAuthenticationFilter
    • 更新: 为了清晰,我们将JWT过滤器放在所有认证过滤器之前,让它优先处理Bearer Token。
    • UsernamePasswordAuthenticationFilter.class 如果不使用表单登录,可以将其作为参考位置,或者使用更通用的过滤器,如 BasicAuthenticationFilter.class。这里,我们将API key认证放在它之前,JWT认证放在API key认证之前,形成优先顺序。

4.7 改造登录接口,返回JWT

我们需要创建一个新的登录Controller,它接收用户名和密码,并在认证成功后返回JWT。

LoginApiController.java

package com.example.springsecuritystage1.controller;import com.example.springsecuritystage1.model.LoginRequest;
import com.example.springsecuritystage1.model.LoginResponse;
import com.example.springsecuritystage1.util.JwtUtil;
import org.springframework.http.ResponseEntity;
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.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;// 登录请求体
class LoginRequest {private String username;private String password;// Getters and Setterspublic String getUsername() { return username; }public void setUsername(String username) { this.username = username; }public String getPassword() { return password; }public void setPassword(String password) { this.password = password; }
}// 登录响应体 (包含JWT)
class LoginResponse {private String token;private String type = "Bearer";private Long id;private String username;private String email; // 假设有private List<String> roles; // 假设有// Constructors, Getters, Setterspublic LoginResponse(String accessToken, Long id, String username, String email, List<String> roles) {this.token = accessToken;this.id = id;this.username = username;this.email = email;this.roles = roles;}public String getToken() { return token; }public void setToken(String token) { this.token = token; }public String getType() { return type; }public void setType(String type) { this.type = type; }public Long getId() { return id; }public void setId(Long id) { this.id = id; }public String getUsername() { return username; }public void setUsername(String username) { this.username = username; }public String getEmail() { return email; }public void setEmail(String email) { this.email = email; }public List<String> getRoles() { return roles; }public void setRoles(List<String> roles) { this.roles = roles; }
}@RestController
@RequestMapping("/api/auth")
public class LoginApiController {private final AuthenticationManager authenticationManager;private final JwtUtil jwtUtil;public LoginApiController(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {this.authenticationManager = authenticationManager;this.jwtUtil = jwtUtil;}@PostMapping("/login")public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));// 如果上面认证失败,会抛出 AuthenticationException,不会走到这里SecurityContextHolder.getContext().setAuthentication(authentication);UserDetails userDetails = (UserDetails) authentication.getPrincipal();String jwt = jwtUtil.generateToken(userDetails);// 这里仅为了演示,id, email, roles可以从 userDetails 中提取或从数据库查询List<String> roles = userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());return ResponseEntity.ok(new LoginResponse(jwt, null, userDetails.getUsername(), null, roles));}
}

4.8 认证失败与权限不足的自定义处理

由于我们禁用了Session和表单登录,Spring Security默认的重定向行为将不再适用。对于API,我们应该返回JSON格式的错误响应。

A. 未认证 (AuthenticationEntryPoint)
当用户未提供凭证或凭证无效时,AuthenticationEntryPoint会被触发。

package com.example.springsecuritystage1.security.handler;import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;import java.io.IOException;// 处理未认证的请求,返回401 Unauthorized
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)throws IOException, ServletException {System.out.println("Unauthorized error: " + authException.getMessage());response.setContentType("application/json");response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);response.getOutputStream().println("{ \"error\": \"" + authException.getMessage() + "\", \"code\": 401 }");}
}

B. 权限不足 (AccessDeniedHandler)
当用户已认证但没有所需权限时,AccessDeniedHandler会被触发。

package com.example.springsecuritystage1.security.handler;import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;import java.io.IOException;// 处理权限不足的请求,返回403 Forbidden
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)throws IOException, ServletException {System.out.println("Access Denied error: " + accessDeniedException.getMessage());response.setContentType("application/json");response.setStatus(HttpServletResponse.SC_FORBIDDEN);response.getOutputStream().println("{ \"error\": \"" + accessDeniedException.getMessage() + "\", \"code\": 403 }");}
}

C. 更新SecurityFilterChain,集成异常处理器

            .exceptionHandling(exception -> exception // <<-- HERE: 集成自定义异常处理器.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 未认证.accessDeniedHandler(customAccessDeniedHandler) // 权限不足)

需要注入这两个handler:

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;private final CustomAccessDeniedHandler customAccessDeniedHandler;public CustomSecurityConfig(// ... 其他注入JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,CustomAccessDeniedHandler customAccessDeniedHandler) {// ... 初始化this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;this.customAccessDeniedHandler = customAccessDeniedHandler;}

4.9 测试JWT认证流程

  1. 启动应用。
  2. 获取JWT: 使用Postman向 http://localhost:8080/api/auth/login 发送POST请求,Content-Type: application/json
    Body:
    {"username": "user","password": "password"
    }
    
    成功后,你应该会收到一个包含JWT的JSON响应,例如:
    {"token": "eyJhbGc...","type": "Bearer","username": "user","roles": ["ROLE_USER", "PRODUCT_READ", "USER_VIEW"]
    }
    
  3. 使用JWT访问受保护资源:
    • 复制得到的token
    • http://localhost:8080/user/profile 发送GET请求,在请求头中添加 Authorization: Bearer <你的JWT>
    • 你应该会收到 200 OK 响应,表示访问成功。
  4. 访问无权限资源:
    • 继续使用同一个JWT(user用户的),尝试访问 http://localhost:8080/admin/dashboard
    • 你应该收到 403 Forbidden 响应,内容为我们自定义的JSON错误。
  5. 访问需要API Key的资源:
    • 尝试使用JWT访问 http://localhost:8080/api/v2/secret-data
    • 由于这个路径需要API_KEY_AUTH权限,而JWT中可能没有,所以还是会收到403 Forbidden
    • 此时,如果你在请求头中同时提供正确的X-API-KEY,API Key认证会优先触发,导致最终成功。这展示了多认证机制的协同工作。
  6. 无效/过期JWT:
    • 尝试随便修改JWT的某个字符,或者等待JWT过期(如果设置了短有效期)。
    • 再次发送请求,你应该收到 401 Unauthorized 响应。

5. JWT的安全性与挑战

5.1 Token过期与刷新机制

  • 过期目的: JWT的exp声明是其安全性的关键。短有效期可以限制令牌被盗用后的风险。
  • 刷新Token: 通常通过引入Refresh Token机制。
    • 用户登录后,同时获取一个短期的Access Token(JWT)和一个长期的Refresh Token
    • Access Token用于访问资源。
    • Access Token过期时,客户端使用Refresh Token向认证服务器请求新的Access TokenRefresh Token
    • Refresh Token通常存储在更安全的地方(如HttpOnly Cookie),并且只能使用一次,或者有被撤销的机制。

5.2 JWT注销/黑名单机制

JWT无法像Session一样简单地“注销”。一旦签发,只要签名和有效期都没问题,它就是有效的。
为了实现注销功能或禁用被盗用的Token,可以采取:

  • 黑名单机制: 在服务器端维护一个已注销/失效的JWT列表(通常存储在Redis中,设置与JWT有效期相同的过期时间)。每次验证JWT时,除了验证签名和有效期,还需检查其是否在黑名单中。
  • 短有效期结合刷新: 这是更常见的做法。Access Token有效期设置很短,Refresh Token有效期长。当用户登出时,只销毁Refresh Token,Access Token自然很快过期。

5.3 密钥管理

  • 生成与存储: 签名JWT的密钥(secret)至关重要,必须是复杂、随机且妥善保管的。生产环境应通过环境变量、配置文件或密钥管理服务(如Vault)注入,绝不能硬编码。
  • 轮换: 定期轮换密钥是一种良好的安全实践。

5.4 防止令牌盗用

  • Https: 始终通过HTTPS传输JWT,防止中间人攻击窃取Token。
  • HttpOnly: 如果Token存储在Cookie中,应设置为HttpOnly,防止XSS攻击。
  • LocalStorage的风险: 将JWT存储在LocalStorage中虽然方便,但易受XSS攻击。

6. 常见陷阱与注意事项

  • 禁用CSRF与Session的警惕性: 只有当你确定你的应用不再依赖于Session,并且有其他安全措施时,才禁用它们。
  • JWT密钥安全: 生产环境的JWT密钥必须是强随机字符串,且妥善保管。
  • JWT负载信息: 不要在JWT的Payload中存放敏感信息。JWT只是Base64编码,不是加密。
  • JWT有效期: 根据业务需求合理设置JWT有效期。Access Token通常短,Refresh Token长。
  • 异常处理: 务必为AuthenticationEntryPointAccessDeniedHandler提供友好的JSON响应。
  • AuthenticationManager的构建: 确保ProviderManager包含了所有你需要的AuthenticationProvider

7. 阶段总结

至此,你已经完成了Spring Security深度学习的第六阶段!你现在已经能够:

  • 理解JWT的核心概念、组成和工作原理。
  • 使用jjwt库生成、解析和验证JWT。
  • 在Spring Security中禁用Session和CSRF防护,构建一个无状态的API认证系统。
  • 设计JwtAuthenticationTokenJwtAuthenticationProviderJwtAuthenticationFilter,并将其集成到Spring Security过滤器链中。
  • 改造登录接口,使其返回JWT。
  • 定制API认证失败和权限不足的JSON响应。

文章转载自:

http://yit5nRLU.qnzpg.cn
http://TpEcqeLn.qnzpg.cn
http://xUttRkcf.qnzpg.cn
http://wZ5YGmwU.qnzpg.cn
http://rmc5RNlt.qnzpg.cn
http://gkKvlckL.qnzpg.cn
http://k6z7Uvkj.qnzpg.cn
http://C82OFXtP.qnzpg.cn
http://1rWRzGuv.qnzpg.cn
http://ToIiLcKM.qnzpg.cn
http://ADq6dy15.qnzpg.cn
http://tpiMRn7Y.qnzpg.cn
http://2jYMVkOf.qnzpg.cn
http://l5UsIgGX.qnzpg.cn
http://u5l06woJ.qnzpg.cn
http://WNoPWoJW.qnzpg.cn
http://cyTSE65z.qnzpg.cn
http://Ii7f7fKF.qnzpg.cn
http://ROmck1wa.qnzpg.cn
http://544m7QM6.qnzpg.cn
http://Mwy1ammQ.qnzpg.cn
http://A89uqWiA.qnzpg.cn
http://pQ9kQXAT.qnzpg.cn
http://fyyjAYAz.qnzpg.cn
http://o7cRAIbm.qnzpg.cn
http://QJ1fITQQ.qnzpg.cn
http://rCwFlGoW.qnzpg.cn
http://z1doYapU.qnzpg.cn
http://LZUdV2WK.qnzpg.cn
http://0Qnqw0oR.qnzpg.cn
http://www.dtcms.com/a/368938.html

相关文章:

  • 使用CI/CD部署项目(前端Nextjs)
  • Git常用操作(2)
  • LeetCode 刷题【65. 有效数字】
  • Android,jetpack Compose模仿QQ侧边栏
  • 让语言模型自我进化:探索 Self-Refine 的迭代反馈机制
  • Kubernetes(k8s) po 配置持久化挂载(nfs)
  • 支持二次开发的代练App源码:订单管理、代练监控、安全护航功能齐全,一站式解决代练护航平台源码(PHP+ Uni-app)
  • proble1111
  • Ubuntu 24.04.2安装k8s 1.33.4 配置cilium
  • nextcyber——暴力破解
  • Process Explorer 学习笔记(第三章3.2.3):工具栏与参考功能
  • C++两个字符串的结合
  • c51串口通信原理及实操
  • Java垃圾回收算法详解:从原理到实践的完整指南
  • MongoDB 6.0 新特性解读:时间序列集合与加密查询
  • IAR借助在瑞萨RH850/U2A MCU MCAL支持,加速汽车软件开发
  • 状压 dp --- 棋盘覆盖问题
  • 机器学习周报十二
  • 力扣:2322. 从树中删除边的最小分数
  • 人工智能常见分类
  • C++ 音视频开发常见面试题及答案汇总
  • C/C++ Linux系统编程:线程控制详解,从线程创建到线程终止
  • swoole 中 Coroutine\WaitGroup 和channel区别和使用场景
  • HDFS架构核心
  • Python的语音配音软件,使用edge-tts进行文本转语音,支持多种声音选择和语速调节
  • 每周资讯 | 中国游戏市场将在2025年突破500亿美元;《恋与深空》收入突破50亿元
  • 别再手工缝合API了!开源LLMOps神器LMForge,让你像搭积木一样玩转AI智能体!
  • 问卷系统项目自动化测试
  • 事务管理的选择:为何 @Transactional 并非万能,TransactionTemplate 更值得信赖
  • React Fiber 风格任务调度库