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的工作原理
- 用户登录: 用户使用用户名和密码向认证服务器(应用后端)发送登录请求。
- 生成JWT: 认证服务器验证用户凭证。如果验证成功,根据用户ID、角色、权限等信息生成一个JWT,并用一个密钥进行签名。
- 返回JWT: 服务器将生成的JWT返回给客户端(通常在HTTP响应体中)。
- 客户端存储JWT: 客户端接收到JWT后,通常将其存储在本地存储(如LocalStorage或SessionStorage)中。
- 访问受保护资源: 客户端在后续每次访问受保护的API时,都会在HTTP请求头的
Authorization
字段中携带JWT,格式为Authorization: Bearer <JWT>
。 - 验证JWT: 资源服务器(应用后端)接收到请求后,从
Authorization
头中提取JWT。然后,它使用之前用于签名的密钥验证JWT的签名、检查Token是否过期,以及解析其中的声明(如用户ID、权限)。 - 授权与响应: 如果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认证流程概述
- JWT生成: 在用户登录成功后,后端生成JWT并返回。
- JWT传输: 客户端将JWT存储起来,并在每次请求时通过
Authorization: Bearer <JWT>
请求头发送。 - JWT解析与验证: Spring Security过滤器链中会插入一个自定义的JWT过滤器:
- 它拦截所有请求,从
Authorization
头中提取JWT。 - 使用预设的密钥解析并验证JWT的签名和有效期。
- 如果验证成功,从JWT中提取用户ID和权限,创建
Authentication
对象。 - 将
Authentication
对象设置到SecurityContextHolder
中。
- 它拦截所有请求,从
- 授权: 后续的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 推荐使用BearerTokenAuthenticationConverter
和ReactiveJwtDecoder
等用于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中提取了权限信息。但为了确保用户状态(如enabled
,accountNonLocked
)是最新的,我们仍然通过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
中添加JwtAuthenticationProvider
到AuthenticationManager
,并将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本身没有什么副作用,可以保留}
}
重要的更新点:
- JWT相关注入:
JwtAuthenticationProvider
和JwtUtil
被注入,并JwtAuthenticationProvider
添加到ProviderManager
中。 - 禁用CSRF和Session:
csrf(csrf -> csrf.disable())
和sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
是实现无状态的关键。 - 移除Session相关配置:
formLogin()
和rememberMe()
配置被移除,因为它们依赖于Session。 - JWT过滤器添加:
JwtAuthenticationFilter
通过addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), ApiKeyAuthenticationFilter.class)
添加到过滤器链中,它将在ApiKeyAuthenticationFilter
之前尝试处理JWT认证。你可以自行调整顺序。 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认证流程
- 启动应用。
- 获取JWT: 使用Postman向
http://localhost:8080/api/auth/login
发送POST
请求,Content-Type: application/json
。
Body:
成功后,你应该会收到一个包含JWT的JSON响应,例如:{"username": "user","password": "password" }
{"token": "eyJhbGc...","type": "Bearer","username": "user","roles": ["ROLE_USER", "PRODUCT_READ", "USER_VIEW"] }
- 使用JWT访问受保护资源:
- 复制得到的
token
。 - 向
http://localhost:8080/user/profile
发送GET
请求,在请求头中添加Authorization: Bearer <你的JWT>
。 - 你应该会收到
200 OK
响应,表示访问成功。
- 复制得到的
- 访问无权限资源:
- 继续使用同一个JWT(
user
用户的),尝试访问http://localhost:8080/admin/dashboard
。 - 你应该收到
403 Forbidden
响应,内容为我们自定义的JSON错误。
- 继续使用同一个JWT(
- 访问需要API Key的资源:
- 尝试使用JWT访问
http://localhost:8080/api/v2/secret-data
。 - 由于这个路径需要
API_KEY_AUTH
权限,而JWT中可能没有,所以还是会收到403 Forbidden
。 - 此时,如果你在请求头中同时提供正确的
X-API-KEY
,API Key认证会优先触发,导致最终成功。这展示了多认证机制的协同工作。
- 尝试使用JWT访问
- 无效/过期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 Token
和Refresh 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长。
- 异常处理: 务必为
AuthenticationEntryPoint
和AccessDeniedHandler
提供友好的JSON响应。 AuthenticationManager
的构建: 确保ProviderManager
包含了所有你需要的AuthenticationProvider
。
7. 阶段总结
至此,你已经完成了Spring Security深度学习的第六阶段!你现在已经能够:
- 理解JWT的核心概念、组成和工作原理。
- 使用
jjwt
库生成、解析和验证JWT。 - 在Spring Security中禁用Session和CSRF防护,构建一个无状态的API认证系统。
- 设计
JwtAuthenticationToken
、JwtAuthenticationProvider
和JwtAuthenticationFilter
,并将其集成到Spring Security过滤器链中。 - 改造登录接口,使其返回JWT。
- 定制API认证失败和权限不足的JSON响应。