目錄
- 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響應。