SpringBoot實現(xiàn)JWT 認證的項目實踐
JWT 是 JSON Web Token 的縮寫,用于實現(xiàn)無狀態(tài)的認證系統(tǒng)。無狀態(tài)指后端不需要存儲用戶 Session,前端與后端之間只通過令牌(Token) 進行通信。無狀態(tài)的好處是易于擴展,適合分布式系統(tǒng)。而且,相比另一種常用的無狀態(tài)認證方案分布式 Session,JWT 更契合 RESTful API 的設(shè)計理念。
JWT 的令牌(Token)是由后端生成的一串字符串,前端發(fā)起 HTTP 請求時攜帶令牌一起發(fā)送給后端,后端通過解析令牌來驗證用戶的身份。整個過程,后端不需要存儲令牌,也不需要維護 Session,所以 JWT 非常適合用于分布式系統(tǒng)。
JWT 認證的基本流程如下:
- 用戶調(diào)用登錄接口,后端生成令牌并返回給前端。
- 前端將令牌存儲在本地(如 localStorage 或 cookie)。
- 前端發(fā)起其他請求時,將令牌添加到請求頭或請求體中。
- 后端解析令牌,驗證用戶身份和權(quán)限。
前端攜帶 Token,較為常用的是 Bearer 身份認證,具體做法是在 HTTP 請求頭中添加 Authorization 字段,值為 Bearer <token>,Bearer 和 token 之間是一個空格。
Authorization: Bearer <token>
因此,如果想要在 Spring Boot 中實現(xiàn) JWT 認證,需要實現(xiàn)以下功能:
- 生成令牌
- 解析令牌
- 驗證令牌
生成、解析、驗證
令牌格式
JWT 需要遵循 JWT 標(biāo)準(zhǔn),生成的 Token 字符串具備固定的格式:
- 令牌頭(Header):包含令牌的類型(JWT)和使用的簽名算法,默認使用 HMAC SHA-256 簽名算法。
- 載荷(Payload):有關(guān)用戶和令牌的信息,是 Token 的主體部分。
- 簽名(Signature):用 Header 中的簽名算法生成的摘要碼,用于驗證令牌的完整性。
一個典型的 JWT Token 為 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c,用 . 可以分為三部分,分別對應(yīng) Header、Payload 和 Signature。在 jwt.io 網(wǎng)站上可以查看解析后的內(nèi)容。

令牌頭(Header)和載荷(Payload)兩部分都是 JSON 對象,使用特殊的 Base64Url 編碼。Base64Url 編碼是對 Base64 編碼的改進,將 URL 中存在特殊意義的 + 和 / 替換為 - 和 _,從而在 URL 中使用。
簽名部分則是對 Header 和 Payload 兩部分進行簽名生成的摘要碼,singature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)。secret 是后端使用的密鑰,用于驗證令牌的完整性,不能泄露,需要妥善保管。
在 Payload 中,默認包含的字段有:
- iss:issuer,簽發(fā)人
- exp:expiration time,過期時間
- sub:subject,主題
- aud:audience,受眾
- nbf:not before,生效時間
- iat:issued at,簽發(fā)時間
- jti:JWT ID,令牌 ID
不過,并不需要提供以上所有信息,一個典型的 Payload 可能只包含以下內(nèi)容:
- sub:主題,如用戶 ID
- exp:過期時間,可以取 Token 生成時間的一個小時后
- iat:簽發(fā)時間,取 Token 生成時間
同時,還可以根據(jù)需求,增加自定義字段,但需要注意,添加的字段越多,Token 的體積就越大,在傳輸過程中就需要占用更多的網(wǎng)絡(luò)帶寬,也會影響性能。
在安全性方面,JWT 存在一下問題:
- Base64Url 編碼無加密,Header 和 Payload 部分是公開的,不應(yīng)該包含敏感信息。
- 必須配合 HTTPS 使用,否則存在被篡改的安全隱患。
- 基于令牌的認證方式無法主動撤銷,只能等到令牌過期。不過可以通過黑名單的方式來實現(xiàn)令牌的撤銷。
生成 JWT 令牌
生成令牌時,可以按照 JWT 規(guī)范手動實現(xiàn),也可以使用現(xiàn)成工具庫。在 Java 中,可以使用 jjwt 庫,也可以使用 com.auth0:java-jwt 庫。本文使用 jjwt 庫作為例子。
private static final String YOUR_SECRET = "your-secret-key";
private static final SecretKey SECRET_KEY = getSigningKey();
public String buildToken(String username, long expirationMills,
Map<String, Object> extraClaims) {
return Jwts.builder()
.claims()
.subject(username)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMills))
.add(extraClaims)
.and()
.signWith(SECRET_KEY, Jwts.SIG.HS256)
.compact();
}
private static SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(YOUR_SECRET.getBytes(StandardCharsets.UTF_8));
}
參數(shù)中的 extraClaims 是自定義的字段,可以添加一些額外的信息,但不能添加敏感信息。
YOUR_SECRET 字符串,是用于生成簽名的密鑰,使用 HMAC SHA-256 算法時,要求長度至少 32 字節(jié)(256 位)。
SecretKey 是對密鑰的封裝,考慮到構(gòu)建成本,可以復(fù)用對象。生成 SecretKey 時,可以從配置文件中獲取密鑰字符串,也可以用代碼生成。
private SecretKey generateSecurityKey() {
return Jwts.SIG.HS256.key().build();
}
解析 JWT 令牌
jjwt 庫也提供了解析令牌的工具類,可以直接使用。
private static final SecretKey SECRET_KEY = ...;
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(token)
.getBody();
}
從 Claims 對象中可以獲取到令牌中的所有信息,包括自定義字段。
解析操作中,也包含了驗證 Token 完整性的步驟,如果 Token 被篡改,會拋出 JwtException異常。
驗證 JWT 令牌
驗證令牌時,主要檢查令牌是否過期。
public boolean isTokenValid(String token) {
Claims claims = parseToken(token);
Date now = new Date();
return !claims.getIssuedAt().after(now)
&& !claims.getExpiration().before(now);
}
與 Spring Security 集成
Spring Security 沒有內(nèi)置 JWT 認證的功能,需要自己實現(xiàn)。
Spring Security 內(nèi)部結(jié)構(gòu)
Spring Security 主要基于 Filter 來實現(xiàn)認證和授權(quán)。Filter 是 Servlet 規(guī)范中的概念,在請求(HTTP Request)進入 Servlet 容器時,會經(jīng)過一列長長的 Filter 鏈,鏈中每一個 Filter 都會對請求進行處理,至到最后到達 Servlet。在 Servlet 返回響應(yīng)(HTTP Response)時,也會經(jīng)過相同的 Filter 鏈,只不過順序與請求時相反。Filter 鏈就像一個洋蔥,請求數(shù)據(jù)從外向內(nèi)穿過洋蔥,而響應(yīng)數(shù)據(jù)從內(nèi)向外穿過洋蔥。

除了 Filter,Spring Security 還有兩個重要組件,AuthenticationManager 和 AuthenticationProvider。前者用于提供統(tǒng)一的認證功能,后者用于提供用戶信息來源。一個 AuthenticationManager 可以包含多個 AuthenticationProvider,每個 AuthenticationProvider 對應(yīng)一種用戶來源,最常用的是 DaoAuthenticationProvider,用于從數(shù)據(jù)庫中查詢用戶信息。

DaoAuthenticationProvider 會調(diào)用 UserDetailsService 接口,根據(jù)用戶名獲取用戶信息。當(dāng)系統(tǒng)使用數(shù)據(jù)庫保管用戶信息時,需要實現(xiàn) UserDetailsService 接口,從數(shù)據(jù)庫中查詢用戶信息,轉(zhuǎn)換為 UserDetails 對象。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
loadUserByUsername 方法的返回類型是 UserDetails 接口,這是 Spring Security 定義的類型,包含了需要的用戶信息。
public interface UserDetails {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
UserDetails 接口的關(guān)鍵方法是 getPassword 和 getUsername,用于獲取系統(tǒng)的登錄憑證。getAuthorities 方法獲取權(quán)限信息,如果不涉及到權(quán)限管理,返回空集合即可。isAccountNonExpired、isAccountNonLocked、isCredentialsNonExpired、isEnabled 方法獲取用戶的狀態(tài)信息,默認返回 true,如果不需要更細粒度的控制,可以不用實現(xiàn)。
Spring Security 提供了一個 UserDetails 的實現(xiàn)類 org.springframework.security.core.userdetails.User 和 GrantedAuthority 接口的實現(xiàn)類 org.springframework.security.core.SimpleGrantedAuthority,可以直接使用。
實現(xiàn) JWT 認證
為了更好地與 Spring Security 集成,我們應(yīng)該將 JWT 認證的邏輯放在一個 Filter 中,并復(fù)用 Spring Security 的 AuthenticationManager 和 AuthenticationProvider。
首先,需要提供 JWT Token 的生成、解析、驗證功能,這部分代碼與前文一致,封裝在 JwtTokenService 中。
其次,定義 PasswordEncoder、AuthenticationManager、UserDetailsService,前兩者可以直接使用系統(tǒng)提供的實現(xiàn)類,UserDetailsService 需要自己實現(xiàn)。
@EnableWebSecurity() // 啟用 WebSecurityConfiguration
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final UserMapper userMapper;
/**
* 啟用 BCrypt 哈希算法處理密碼,避免明文存儲密碼
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 提供 UserDetailsService 接口的實現(xiàn)
*/
@Bean
public UserDetailsService userDetailsService() {
return username -> userMapper.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
/**
* 提供 AuthenticationManager 接口的實現(xiàn)
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
這里使用 Lambda 表達式實現(xiàn) UserDetailsService,直接返回 UserMapper 返回值的前提是返回值是 UserDetails 接口的實現(xiàn)類。
PasswordEncoder 接口用于處理密碼。為了保證安全性,不能直接存儲明文密碼,需要用密碼學(xué)哈希算法進行單向映射。登錄時對用戶輸入的密碼進行相同操作,再進行比較。這樣即使數(shù)據(jù)庫泄露,黑客也無法知道用戶的原始密碼,也就無法用泄露的賬號和密碼登錄系統(tǒng),也無法根據(jù)用戶習(xí)慣用相同密碼嘗試登錄其他應(yīng)用。
使用 DaoAuthenticationProvider 的authenticate 方法進行身份認證時,會自動調(diào)用 PasswordEncoder 對明文密碼編碼后再匹配。因此,如果使用 Spring Security 提供的認證機制,不需要手動調(diào)用 PasswordEncoder,系統(tǒng)會自動處理。但注冊用戶時,必須用 PasswordEncoder 對明文密碼進行編碼。
BCryptPasswordEncoder 是 Spring Security 提供的一種 PasswordEncoder 實現(xiàn)類,使用 BCrypt 哈希算法,這是安全性較高的算法,可以有效防止彩虹表攻擊。
接著實現(xiàn) JwtTokenFilter,內(nèi)部調(diào)用 JwtTokenService,實現(xiàn) JWT 認證邏輯。
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
@Nonnull HttpServletResponse response,
@Nonnull FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// "Bearer ".length() == 7
String token = authorization.substring(7);
String username = jwtTokenService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtTokenService.isTokenValid(token, user)) {
// 創(chuàng)建一個新的認證令牌,并將其設(shè)置為當(dāng)前的安全上下文
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
// 為認證令牌綁定 Request 信息
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
log.warn("Invalid JWT token of user: {}", username);
}
}
filterChain.doFilter(request, response);
// 在 SecurityContextHolderFilter 中自動清除,不需要手動清除
// SecurityContextHolder.getContext().setAuthentication(null);
}
}
在上述代碼中,沒有調(diào)用 AuthenticationManager 來認證用戶,只是實現(xiàn)了 Token -> UserDetails 的轉(zhuǎn)換:
- 校驗 Token 是否有效
- 從 Token 中獲取用戶名,用 UserDetailsService 獲取對應(yīng)的 UserDetails,構(gòu)建為認證信息(Authentication)
- 將認證信息保存進安全上下文。默認以線程變量方式存儲在 ThreadLocal 對象中。
即使 Token 校驗沒通過,或者根據(jù)用戶名查不到用戶信息,或者根本就沒提供 Token,JwtTokenFilter 也不會拋出異常,只是不會設(shè)置認證信息。
真正負責(zé)認證的是 AuthorizationFilter 類,這是 Spring Security 內(nèi)置的 Filter,會根據(jù)認證信息進行認證。從安全上下文獲取認證信息(Authentication),并調(diào)用 AuthenticationManager 進行身份認證。認證時會結(jié)合 URL 判斷,如果 URL 需要身份認證,但無法從安全上下文獲取對應(yīng) Authentication,就判定為沒通過認證,拋出 AuthenticationException 異常。通過復(fù)用 AuthorizationFilter,而不是自己實現(xiàn)相關(guān)邏輯,可以更好地與 Spring Security 集成,復(fù)用其強大的認證機制。
我們還需要提供一個登錄接口,根據(jù)用戶的登錄憑證,比如用戶名密碼,生成 Token 并返回。
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenService jwtTokenService;
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtTokenService.buildToken(authentication.getName());
return ResponseEntity.ok(token);
}
}
有了上述類,就可以組裝 JWT 認證的邏輯。在 Spring Security 中,最為核心的配置就是 SecurityFilterChain 類型的定義。通過 SecurityFilterChain,可以開啟和關(guān)閉相關(guān) Filter,可以指定哪些 URL 需要認證,哪些 URL 不需要認證,以及認證失敗時的處理方式。
在實現(xiàn) JWT 認證時,需要調(diào)整一下配置:
- 禁用 CSRF 保護。JWT 認證基于 Token,不需要 CSRF 保護。
- 禁用 Session。
- 配置 Cors,支持跨域。需要 注冊一個 CorsFilter 類型的 Bean 才會生效。
- 配置 AuthenticationEntryPoint,指定認證失敗時的處理方式。
- 注冊 JwtTokenFilter,實現(xiàn) Token -> UserDetails 的轉(zhuǎn)換。
- 配置路徑的認證規(guī)則,哪些路徑需要認證,哪些路徑不需要認證。
相關(guān)代碼如下:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtTokenFilter jwtTokenFilter) throws Exception {
return http
.cors(Customizer.withDefaults()) // 啟用 CORS,配合注冊 CorsFilter Bean 才會生效
.csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF
.sessionManagement(manager ->
manager.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 禁用 Session
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/api/auth/**").permitAll(); // 開放登錄接口
auth.anyRequest().authenticated(); // 其他接口需要認證
})
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) // 注冊 JwtTokenFilter
.build();
}
@Bean
public JwtTokenFilter jwtTokenFilter(@Qualifier("handlerExceptionResolver")
HandlerExceptionResolver exceptionResolver) {
return new JwtTokenFilter(jwtTokenService, userDetailsService, exceptionResolver);
}
// 用于支持 CORS
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
AuthenticationEntryPoint 涉及到錯誤處理,我們稍后再介紹。
基于目前的配置,就可以實現(xiàn) JWT 認證。訪問 login 之外的接口,如果沒有提供 Token,會返回 401 錯誤碼。通過在請求頭添加 Token 后,就可以正常訪問。對應(yīng)的 Filter 鏈包含 13 個 Filter,從外向內(nèi)依次是:
DisableEncodeUrlFilter (1/13) WebAsyncManagerIntegrationFilter (2/13) SecurityContextHolderFilter (3/13) HeaderWriterFilter (4/13) CorsFilter (5/13) LogoutFilter (6/13) JwtTokenFilter (7/13) RequestCacheAwareFilter (8/13) SecurityContextHolderAwareRequestFilter (9/13) AnonymousAuthenticationFilter (10/13) SessionManagementFilter (11/13) ExceptionTranslationFilter (12/13) AuthorizationFilter (13/13)
擴展功能
錯誤處理
上文提及,真正執(zhí)行認證的是 AuthorizationFilter。這個類會根據(jù)用戶提供的登錄憑證進行認證,當(dāng)認證失敗時,會拋出 AuthenticationException 異常。此外,如果在定義 SecurityFilterChain 時,指定了某個路徑需要鑒權(quán),AuthorizationFilter 也會執(zhí)行鑒權(quán)操作。當(dāng)用戶權(quán)限不足,會拋出 AuthenticationException 異常。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> {
auth.requestMatchers("/api/admin/**").hasRole("ADMIN"); // 需要管理員權(quán)限
auth.anyRequest().authenticated(); // 其他接口需要認證
})
// 其他配置
// ...
.build();
}
這兩個異常無法由 Sping Boot 的 ExceptionHandler 處理。因為通用的異常處理機制,也就是基于 ExceptionHandler 的異常處理邏輯,生效于 DispatcherServlet,這是一個 Servlet,位于上面洋蔥圖的最里面,無法處理外層 Filter 中的異常,只能處理 Interceptor 和 Controller 中的異常。
為了處理 AuthorizationFilter 拋出的兩種異常,Spring Security 在 AuthorizationFilter 之前注冊了一個 ExceptionTranslationFilter,專門捕獲這兩種異常。
- 對于 AuthenticationException,會調(diào)用
AuthenticationEntryPoint接口處理。默認的 AuthenticationEntryPoint 實現(xiàn)是 BasicAuthenticationEntryPoint,直接返回 401 錯誤碼。 - 對于 AccessDeniedException,會調(diào)用
AccessDeniedHandler接口處理。默認的 AccessDeniedHandler 實現(xiàn)是 AccessDeniedHandlerImpl,直接返回 403 錯誤碼。
這兩種默認處理實現(xiàn),都存在一個明顯的問題,即內(nèi)部使用了 HttpServletResponse::sendError 返回錯誤信息。當(dāng) sendError() 被調(diào)用時,Servlet 容器(如 Tomcat)會捕獲這個錯誤狀態(tài),然后會查找是否有為該錯誤狀態(tài)配置的錯誤頁面。由于 Spring Boot 默認為所有錯誤配置了 /error 路徑作為錯誤頁面,因此,容器會將請求轉(zhuǎn)發(fā)到 /error 路徑。這個轉(zhuǎn)發(fā)操作會再經(jīng)歷一遍 Filter 鏈,包括 AuthorizationFilter,如果沒有將 /error 配置為公開路徑,會導(dǎo)致 AuthenticationException(只會拋出一次)。這就導(dǎo)致了錯誤信息會被覆蓋掉,比如鑒權(quán)失敗 403 錯誤碼會被認證失敗 401 錯誤碼覆蓋。
有兩種解決辦法:
- 不啟用 Spring Boot 的 /error 錯誤頁面路徑,需要排除掉
ErrorMvcAutoConfiguration配置類。 - 自定義 AuthenticationEntryPoint 和 AccessDeniedHandler,在拋出異常時,不調(diào)用 sendError(),而是將錯誤信息寫入響應(yīng)體。
下面是利用系統(tǒng)提供的 HandlerExceptionResolver 組件來處理異常,這還帶來另外的好處,可以集中異常處理,便于統(tǒng)一管理。
@Component
public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {
@Resource(name = "handlerExceptionResolver")
private HandlerExceptionResolver exceptionResolver;
@Override
public void afterPropertiesSet() throws Exception {
Assert.notNull(this.exceptionResolver, "exceptionResolver must be specified");
}
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
log.debug("handle AuthenticationException with HandlerExceptionResolver, reason: {}",
authException.getMessage());
exceptionResolver.resolveException(request, response, null, authException);
}
}
@Slf4j
@Component
public class DelegatedAccessDeniedHandler implements AccessDeniedHandler {
@Resource(name = "handlerExceptionResolver")
private HandlerExceptionResolver exceptionResolver;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.debug("handle AccessDeniedException with HandlerExceptionResolver", accessDeniedException);
exceptionResolver.resolveException(request, response, null, accessDeniedException);
}
}
還需要在 SecurityFilterChain 中配置 DelegatedAuthenticationEntryPoint 和 DelegatedAccessDeniedHandler:
http.exceptionHandling(exceptionHanding ->
exceptionHanding.authenticationEntryPoint(entryPoint)
.accessDeniedHandler(accessDeniedHandler))
//... 其他配置
.build();
對應(yīng)的 ExceptionHandler 處理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleAuthenticationException(AuthenticationException exception,
HttpServletRequest request,
HttpServletResponse response) {
log.debug("occur AuthenticationException: ", exception);
log.warn("AuthenticationException in path {}: {}", request.getRequestURI(), exception.getMessage());
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
ProblemDetail errorDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, exception.getMessage());
errorDetail.setProperty("description", "Full authentication is required to access this resource");
return errorDetail;
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDeniedException(AccessDeniedException exception,
HttpServletRequest request) {
log.debug("occur AccessDeniedException: ", exception);
log.warn("AccessDeniedException in path {} : {}", request.getRequestURI(), exception.getMessage());
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, exception.getMessage());
problemDetail.setProperty("description", "You are not authorized to access this resource");
return problemDetail;
}
}
為 UserDetails 添加緩存
在 JwtTokenFilter 中,每次請求都會調(diào)用 UserDetailsService 獲取 UserDetails,相當(dāng)于一次數(shù)據(jù)庫查詢。JwtTokenFilter 在每次請求時都會執(zhí)行,如果系統(tǒng)用戶量較多,頻繁調(diào)用 UserDetailsService 會影響性能??梢钥紤]將 UserDetails 緩存起來,比如使用 Redis 緩存。
Spring Boot 有很多集成 Redis 的方案,最簡單的是直接使用 Spirng Cache,需要引入 spring-boot-starter-data-redis 庫。
使用 Redis Cache,需要配置序列化方案:
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60)) // 默認緩存 60 分鐘
.disableCachingNullValues() // 不緩存 null
.computePrefixWith(cacheName -> "lu:" + cacheName + ":") // 添加 lu: 前綴,并用單冒號替換調(diào)默認雙冒號
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())); // 使用 JSON 序列化
}
}
配置好之后,就可以直接通過 @Cacheable 注解在 UserDetailsService 中使用緩存:
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomCachingUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
private final RoleMapper roleMapper;
@Override
@Cacheable(value = "users", key = "#username") // 定義一個名為 users 的緩存,以參數(shù)中的 username 作為 key
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.findByUsername(username);
if (user == null) {
log.info("User {} not found", username);
throw new UsernameNotFoundException("User '" + username + "' not found");
}
user.setRoles(roleMapper.listRolesByUserId(user.getId()));
return user.asSecurityUser();
}
}
有一個容易出錯的地方,JSON 序列化不支持 Spring Security 提供的實現(xiàn)類 org.springframework.security.core.userdetails.User 和 org.springframework.security.core.SimpleGrantedAuthority,需要使用自定義的 UserDetails 實現(xiàn)和 GrantedAuthority 實現(xiàn)。
用上述 CustomCachingUserDetailsService 替換掉原來的 UserDetailsService,就可以實現(xiàn) UserDetails 的緩存。JwtTokenFilter 每次處理請求,只有緩存無法命中時,才會調(diào)用 UserDetailsService 查詢數(shù)據(jù)庫。
禁用令牌
由于后端不會存儲 Token,只有 Token 過期后才會失效,無法主動讓一個 Token 失效。不過可以借助黑名單功能,實現(xiàn)類似的效果。
具體思路是維護一個黑名單,記錄需要失效的用戶,在進行 Token 認證時,查詢用戶是否在黑名單中。簡單起見,可以借助 Redis 實現(xiàn)黑名單功能。
在 JwtTokenService 中,添加一個方法,用于將 Token 添加到黑名單中:
public void blacklistAccessToken(String token) {
if (!StringUtils.hasText(token)) {
return;
}
String username = extractUsername(token);
long ttl = extractExpiration(token).getTime() - System.currentTimeMillis();
if (ttl > 0) {
log.info("Access token blacklisted for user: {}", username);
redisTemplate.opsForValue().set("lu:blacklist:" + username, token, ttl, TimeUnit.MILLISECONDS);
}
}
在檢驗 Token 時,添加對黑名單的檢查:
public boolean isTokenValid(String token) {
Claims claims = extractClaims(token);
Date now = new Date();
return !claims.getIssuedAt().after(now)
&& !claims.getExpiration().before(now)
&& !isTokenBlacklisted(token);
}
private boolean isTokenBlacklisted(String token) {
String username = extractUsername(token);
String blacklistedToken = redisTemplate.opsForValue().get("lu:blacklist:" + username);
return token.equals(blacklistedToken);
}
當(dāng)調(diào)用 blacklistAccessToken 后,相關(guān) Token 無法通過校驗,達到失效的效果。基于這個功能,可以實現(xiàn)登出 logout 功能。
@PostMapping("/logout")
public void logout(@RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
// "Bearer ".length() == 7
String token = authHeader.substring(7);
authenticationService.logout(token, command.getRefreshToken());
}
直接從請求頭獲取 Token,因此 logout 接口需要認證。
密鑰輪換
在 JwtTokenService 中,無論采用配置文件提供 JWT 加密密鑰,還是直接生成隨機密鑰,都存在一個問題:密鑰固定不變,一旦泄露,就無法保證安全性。
解決辦法是實現(xiàn)密鑰輪換。定期更換 JWT 密鑰,比如每 24 小時更換一次,將密鑰泄露的影響降到最低。
實現(xiàn)密鑰輪換最簡單的辦法是利用定時任務(wù)定期更換。值得注意的是,密鑰輪換時,需要確保新舊密鑰都能用于解密,因此需要保存舊的密鑰。
/**
* 管理 JWT 使用的 SecretKey,提供密鑰輪轉(zhuǎn)功能,提高安全性
*/
@Slf4j
@Component
public class RotatingSecretKeyManager implements InitializingBean {
private static final int MAX_KEYS = 2;
private final Deque<SecretKey> keys = new ConcurrentLinkedDeque<>();
@Override
public void afterPropertiesSet() throws Exception {
// 有必要預(yù)熱
rotateKeys();
}
@Scheduled(cron = "${security.jwt.key.rotation.cron:0 0 0 * * ?}")
public void rotateKeys() {
log.info("Rotating JWT signing keys");
keys.offerFirst(generateSecurityKey());
while (keys.size() > MAX_KEYS) {
keys.pollLast();
}
log.info("JWT signing keys rotated. Current number of active keys: {}", keys.size());
// jwtMetrics.incrementKeyRotationCount();
}
private SecretKey generateSecurityKey() {
return Jwts.SIG.HS256.key().build();
}
public SecretKey getCurrentKey() {
if (keys.isEmpty()) {
rotateKeys();
}
return keys.peek();
}
public Iterable<SecretKey> secretKeys() {
if (keys.isEmpty()) {
rotateKeys();
}
return keys;
}
}
這里運用了雙端隊列保管 SecretKey,每次輪換時,將新密鑰添加到隊列頭部。這樣,隊頭總是最新的密鑰,隊尾總是最舊的密鑰。
修改 JwtTokenService 中生成 Token 的方法,從 RotatingSecretKeyManager 獲取 SecretKey:
public String buildToken(UserDetails userDetails, long expirationMills, Map<String, Object> extraClaims) {
return Jwts.builder()
.claims()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMills))
.add(extraClaims)
.and()
.signWith(keyManager.getCurrentKey(), Jwts.SIG.HS256)
.compact();
}
private Claims extractClaims(String token) {
JwtException exception = null;
// 密鑰會自動切換,token 對應(yīng)的密鑰可能被換掉了
for (SecretKey secretKey : keyManager.secretKeys()) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
exception = e;
}
}
assert exception != null;
throw exception;
}
上述實現(xiàn)有一個小問題,當(dāng)應(yīng)用重啟后,所有舊的 Token 會失效。在開發(fā)環(huán)境中,因為頻繁重啟,總是要經(jīng)常重新獲取 Token,十分不方便。一個比較好的實踐是,結(jié)合配置文件提供的加密密鑰。啟動時,如果配置文件中提供了加密密鑰,則使用配置文件中的密鑰,否則,生成一個隨機密鑰。
修改 RotatingSecretKeyManager,增加相關(guān)邏輯:
@Value("${security.jwt.key.secret}")
private String secret;
@Override
public void afterPropertiesSet() throws Exception {
// 支持配置文件中的密鑰,可以避免開發(fā)時重啟后 Token 失效
if (StringUtils.hasText(secret)) {
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
log.warn("The secret key is too short, it should be at least 32 characters long.");
throw new IllegalArgumentException("The secret key is too short, it should be at least 32 characters long.");
}
keys.offerFirst(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)));
} else {
rotateKeys();
}
}
開發(fā)環(huán)境中,可以使用配置文件提供的密鑰來避免 Token 失效。生產(chǎn)環(huán)境,則不配置加密密鑰,使用隨機生成的密鑰,保證最大安全性。
總結(jié)
本文介紹了如何在 Spring Boot 中實現(xiàn) JWT 認證,并介紹了如何擴展 JWT 認證功能,包括錯誤處理、用戶信息緩存、令牌失效、密鑰輪換。重點是通過 JwtTokenFilter 將 JWT 令牌與 Spring Security 的認證功能結(jié)合起來,直接在 Spring Security 的 Filter 鏈中完成認證。
相關(guān)代碼已上傳到 GitHub,xioshe/less-url,這是一個基于 Spring Boot 3 實現(xiàn)的短鏈服務(wù),其中包含了 JWT 認證。
參考資料
[1] JSON Web Token 入門教程 - 阮一峰的網(wǎng)絡(luò)日志
[2] Get Started with JSON Web Tokens
[3] JWT authentication in Spring Boot 3 with Spring Security 6
到此這篇關(guān)于SpringBoot實現(xiàn)JWT 認證的項目實踐的文章就介紹到這了,更多相關(guān)SpringBoot JWT 認證內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
小議Java的源文件的聲明規(guī)則以及編程風(fēng)格
這篇文章主要介紹了小議Java的源文件的聲明規(guī)則以及編程風(fēng)格,僅給Java初學(xué)者作一個簡單的示范,需要的朋友可以參考下2015-09-09
Java并發(fā)編程之CountDownLatch的使用
CountDownLatch是一個倒數(shù)的同步器,常用來讓一個線程等待其他N個線程執(zhí)行完成再繼續(xù)向下執(zhí)行,本文主要介紹了CountDownLatch的具體使用方法,感興趣的可以了解一下2023-05-05
Java?IO流與NIO技術(shù)綜合應(yīng)用詳細實例代碼
這篇文章主要給大家介紹了關(guān)于Java?IO流與NIO技術(shù)綜合應(yīng)用的相關(guān)資料,文中包括了字節(jié)流和字符流,以及它們的高級特性如緩沖區(qū)、序列化和反序列化,同時還介紹了NIO中的通道和緩沖區(qū),以及選擇器的使用,需要的朋友可以參考下2024-12-12
Spring?Boot緩存實戰(zhàn)之Redis?設(shè)置有效時間和自動刷新緩存功能(時間支持在配置文件中配置)
這篇文章主要介紹了Spring?Boot緩存實戰(zhàn)?Redis?設(shè)置有效時間和自動刷新緩存,時間支持在配置文件中配置,需要的朋友可以參考下2023-05-05
Java可重入鎖的實現(xiàn)原理與應(yīng)用場景
今天小編就為大家分享一篇關(guān)于Java可重入鎖的實現(xiàn)原理與應(yīng)用場景,小編覺得內(nèi)容挺不錯的,現(xiàn)在分享給大家,具有很好的參考價值,需要的朋友一起跟隨小編來看看吧2019-01-01

