
目录
-
- [1. 引言:无状态认证的崛起](#1. 引言:无状态认证的崛起)
- [2. JWT (JSON Web Token) 核心概念](#2. JWT (JSON Web Token) 核心概念)
-
- [2.1 什么是JWT?](#2.1 什么是JWT?)
- [2.2 JWT的组成:Header, Payload, Signature](#2.2 JWT的组成:Header, Payload, Signature)
- [2.3 JWT的工作原理](#2.3 JWT的工作原理)
- [2.4 JWT的优缺点与适用场景](#2.4 JWT的优缺点与适用场景)
- [3. Spring Security中的JWT集成策略](#3. Spring Security中的JWT集成策略)
-
- [3.1 禁用Session管理与CSRF防护](#3.1 禁用Session管理与CSRF防护)
- [3.2 JWT认证流程概述](#3.2 JWT认证流程概述)
- [4. 实战演练:构建JWT认证系统](#4. 实战演练:构建JWT认证系统)
-
- [4.1 引入JWT库依赖](#4.1 引入JWT库依赖)
- [4.2 JWT工具类:生成与解析Token](#4.2 JWT工具类:生成与解析Token)
- [4.3 自定义 JwtAuthenticationToken](#4.3 自定义 JwtAuthenticationToken)
- [4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider)](#4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider))
- [4.5 自定义 JwtAuthenticationFilter](#4.5 自定义 JwtAuthenticationFilter)
- [4.6 更新 SecurityFilterChain 配置,集成JWT过滤器](#4.6 更新 SecurityFilterChain 配置,集成JWT过滤器)
- [4.7 改造登录接口,返回JWT](#4.7 改造登录接口,返回JWT)
- [4.8 认证失败与权限不足的自定义处理](#4.8 认证失败与权限不足的自定义处理)
- [4.9 测试JWT认证流程](#4.9 测试JWT认证流程)
- [5. JWT的安全性与挑战](#5. JWT的安全性与挑战)
-
- [5.1 Token过期与刷新机制](#5.1 Token过期与刷新机制)
- [5.2 JWT注销/黑名单机制](#5.2 JWT注销/黑名单机制)
- [5.3 密钥管理](#5.3 密钥管理)
- [5.4 防止令牌盗用](#5.4 防止令牌盗用)
- [6. 常见陷阱与注意事项](#6. 常见陷阱与注意事项)
- [7. 阶段总结](#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
。
json
{
"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 (私有声明): 约定俗成的声明,用于在特定方之间共享信息,既不是注册声明也不是公共声明。例如,可以包含用户角色、权限列表等业务信息。
json
{
"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防护功能。
java
.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。
xml
<!-- 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的生成、解析和验证。
java
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 hour
private SecretKey getSigningKey() {
// 使用 HS256 算法生成密钥
return Keys.hmacShaKeyFor(secret.getBytes());
}
// 生成Token
public 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配置:
yaml
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解析出的认证信息。
java
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是null
this.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);
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
}
4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider)
Spring Security 6.x 推荐使用BearerTokenAuthenticationConverter
和ReactiveJwtDecoder
等用于OAuth2 Resource Server,但对于自定义的JWT,我们可以继续使用AuthenticationProvider
。
java
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;
}
@Override
public 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);
}
}
@Override
public 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
。
java
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; // 注入 AuthenticationManager
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 从 Authorization header 中获取 JWT Token
String 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. 创建一个未认证的 JwtAuthenticationToken
JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(jwt);
// 3. 将Token提交给 AuthenticationManager 进行认证
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 4. 认证成功,将认证信息存入 SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println("JWT authenticated successfully for: " + authentication.getName());
} catch (Exception e) {
// 认证失败,清除SecurityContext,并返回401 Unauthorized
SecurityContextHolder.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防护。
java
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 Provider
private final JwtUtil jwtUtil; // 注入 JWTUtil
public 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;
}
@Bean
public PasswordEncoder passwordEncoder() { /* ... */ return new BCryptPasswordEncoder(); }
@Bean
public UserDetailsService userDetailsService() { /* ... */ return new CustomUserDetailsService(sysUserMapper); }
@Bean
public PersistentTokenRepository persistentTokenRepository() { /* ... */ return tokenRepository; }
@Bean
public ProviderManager authenticationManager() {
DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
daoProvider.setUserDetailsService(userDetailsService);
daoProvider.setPasswordEncoder(passwordEncoder);
// ProviderManager 现在包含 DaoAuthenticationProvider, ApiKeyAuthenticationProvider 和 JwtAuthenticationProvider
return new ProviderManager(daoProvider, apiKeyAuthenticationProvider, jwtAuthenticationProvider);
}
@Bean
public 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();
}
@Bean
public 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
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 Setters
public 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, Setters
public 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
会被触发。
java
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 {
@Override
public 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
会被触发。
java
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 {
@Override
public 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
,集成异常处理器
java
.exceptionHandling(exception -> exception // <<-- HERE: 集成自定义异常处理器
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 未认证
.accessDeniedHandler(customAccessDeniedHandler) // 权限不足
)
需要注入这两个handler:
java
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:json{ "username": "user", "password": "password" }
成功后,你应该会收到一个包含JWT的JSON响应,例如:
json{ "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响应。