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

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

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

DaoAuthenticationProvider 會(huì)調(diào)用 UserDetailsService 接口,根據(jù)用戶名獲取用戶信息。當(dāng)系統(tǒng)使用數(shù)據(jù)庫(kù)保管用戶信息時(shí),需要實(shí)現(xiàn) UserDetailsService 接口,從數(shù)據(jù)庫(kù)中查詢用戶信息,轉(zhuǎn)換為 UserDetails 對(duì)象。
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)信息,默認(rèn)返回 true,如果不需要更細(xì)粒度的控制,可以不用實(shí)現(xiàn)。
Spring Security 提供了一個(gè) UserDetails 的實(shí)現(xiàn)類 org.springframework.security.core.userdetails.User 和 GrantedAuthority 接口的實(shí)現(xiàn)類 org.springframework.security.core.SimpleGrantedAuthority,可以直接使用。
實(shí)現(xiàn) JWT 認(rèn)證
為了更好地與 Spring Security 集成,我們應(yīng)該將 JWT 認(rèn)證的邏輯放在一個(gè) Filter 中,并復(fù)用 Spring Security 的 AuthenticationManager 和 AuthenticationProvider。
首先,需要提供 JWT Token 的生成、解析、驗(yàn)證功能,這部分代碼與前文一致,封裝在 JwtTokenService 中。
其次,定義 PasswordEncoder、AuthenticationManager、UserDetailsService,前兩者可以直接使用系統(tǒng)提供的實(shí)現(xiàn)類,UserDetailsService 需要自己實(shí)現(xiàn)。
@EnableWebSecurity() // 啟用 WebSecurityConfiguration
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final UserMapper userMapper;
/**
* 啟用 BCrypt 哈希算法處理密碼,避免明文存儲(chǔ)密碼
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 提供 UserDetailsService 接口的實(shí)現(xiàn)
*/
@Bean
public UserDetailsService userDetailsService() {
return username -> userMapper.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
/**
* 提供 AuthenticationManager 接口的實(shí)現(xiàn)
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
這里使用 Lambda 表達(dá)式實(shí)現(xiàn) UserDetailsService,直接返回 UserMapper 返回值的前提是返回值是 UserDetails 接口的實(shí)現(xiàn)類。
PasswordEncoder 接口用于處理密碼。為了保證安全性,不能直接存儲(chǔ)明文密碼,需要用密碼學(xué)哈希算法進(jìn)行單向映射。登錄時(shí)對(duì)用戶輸入的密碼進(jìn)行相同操作,再進(jìn)行比較。這樣即使數(shù)據(jù)庫(kù)泄露,黑客也無法知道用戶的原始密碼,也就無法用泄露的賬號(hào)和密碼登錄系統(tǒng),也無法根據(jù)用戶習(xí)慣用相同密碼嘗試登錄其他應(yīng)用。
使用 DaoAuthenticationProvider 的authenticate 方法進(jìn)行身份認(rèn)證時(shí),會(huì)自動(dòng)調(diào)用 PasswordEncoder 對(duì)明文密碼編碼后再匹配。因此,如果使用 Spring Security 提供的認(rèn)證機(jī)制,不需要手動(dòng)調(diào)用 PasswordEncoder,系統(tǒng)會(huì)自動(dòng)處理。但注冊(cè)用戶時(shí),必須用 PasswordEncoder 對(duì)明文密碼進(jìn)行編碼。
BCryptPasswordEncoder 是 Spring Security 提供的一種 PasswordEncoder 實(shí)現(xiàn)類,使用 BCrypt 哈希算法,這是安全性較高的算法,可以有效防止彩虹表攻擊。
接著實(shí)現(xiàn) JwtTokenFilter,內(nèi)部調(diào)用 JwtTokenService,實(shí)現(xiàn) JWT 認(rèn)證邏輯。
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)建一個(gè)新的認(rèn)證令牌,并將其設(shè)置為當(dāng)前的安全上下文
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
// 為認(rèn)證令牌綁定 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 中自動(dòng)清除,不需要手動(dòng)清除
// SecurityContextHolder.getContext().setAuthentication(null);
}
}
在上述代碼中,沒有調(diào)用 AuthenticationManager 來認(rèn)證用戶,只是實(shí)現(xiàn)了 Token -> UserDetails 的轉(zhuǎn)換:
- 校驗(yàn) Token 是否有效
- 從 Token 中獲取用戶名,用 UserDetailsService 獲取對(duì)應(yīng)的 UserDetails,構(gòu)建為認(rèn)證信息(Authentication)
- 將認(rèn)證信息保存進(jìn)安全上下文。默認(rèn)以線程變量方式存儲(chǔ)在 ThreadLocal 對(duì)象中。
即使 Token 校驗(yàn)沒通過,或者根據(jù)用戶名查不到用戶信息,或者根本就沒提供 Token,JwtTokenFilter 也不會(huì)拋出異常,只是不會(huì)設(shè)置認(rèn)證信息。
真正負(fù)責(zé)認(rèn)證的是 AuthorizationFilter 類,這是 Spring Security 內(nèi)置的 Filter,會(huì)根據(jù)認(rèn)證信息進(jìn)行認(rèn)證。從安全上下文獲取認(rèn)證信息(Authentication),并調(diào)用 AuthenticationManager 進(jìn)行身份認(rèn)證。認(rèn)證時(shí)會(huì)結(jié)合 URL 判斷,如果 URL 需要身份認(rèn)證,但無法從安全上下文獲取對(duì)應(yīng) Authentication,就判定為沒通過認(rèn)證,拋出 AuthenticationException 異常。通過復(fù)用 AuthorizationFilter,而不是自己實(shí)現(xiàn)相關(guān)邏輯,可以更好地與 Spring Security 集成,復(fù)用其強(qiáng)大的認(rèn)證機(jī)制。
我們還需要提供一個(gè)登錄接口,根據(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 認(rèn)證的邏輯。在 Spring Security 中,最為核心的配置就是 SecurityFilterChain 類型的定義。通過 SecurityFilterChain,可以開啟和關(guān)閉相關(guān) Filter,可以指定哪些 URL 需要認(rèn)證,哪些 URL 不需要認(rèn)證,以及認(rèn)證失敗時(shí)的處理方式。
在實(shí)現(xiàn) JWT 認(rèn)證時(shí),需要調(diào)整一下配置:
- 禁用 CSRF 保護(hù)。JWT 認(rèn)證基于 Token,不需要 CSRF 保護(hù)。
- 禁用 Session。
- 配置 Cors,支持跨域。需要 注冊(cè)一個(gè) CorsFilter 類型的 Bean 才會(huì)生效。
- 配置 AuthenticationEntryPoint,指定認(rèn)證失敗時(shí)的處理方式。
- 注冊(cè) JwtTokenFilter,實(shí)現(xiàn) Token -> UserDetails 的轉(zhuǎn)換。
- 配置路徑的認(rèn)證規(guī)則,哪些路徑需要認(rèn)證,哪些路徑不需要認(rèn)證。
相關(guān)代碼如下:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtTokenFilter jwtTokenFilter) throws Exception {
return http
.cors(Customizer.withDefaults()) // 啟用 CORS,配合注冊(cè) CorsFilter Bean 才會(huì)生效
.csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF
.sessionManagement(manager ->
manager.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 禁用 Session
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/api/auth/**").permitAll(); // 開放登錄接口
auth.anyRequest().authenticated(); // 其他接口需要認(rèn)證
})
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) // 注冊(cè) 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 涉及到錯(cuò)誤處理,我們稍后再介紹。
基于目前的配置,就可以實(shí)現(xiàn) JWT 認(rèn)證。訪問 login 之外的接口,如果沒有提供 Token,會(huì)返回 401 錯(cuò)誤碼。通過在請(qǐng)求頭添加 Token 后,就可以正常訪問。對(duì)應(yīng)的 Filter 鏈包含 13 個(gè) 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)
擴(kuò)展功能
錯(cuò)誤處理
上文提及,真正執(zhí)行認(rèn)證的是 AuthorizationFilter。這個(gè)類會(huì)根據(jù)用戶提供的登錄憑證進(jìn)行認(rèn)證,當(dāng)認(rèn)證失敗時(shí),會(huì)拋出 AuthenticationException 異常。此外,如果在定義 SecurityFilterChain 時(shí),指定了某個(gè)路徑需要鑒權(quán),AuthorizationFilter 也會(huì)執(zhí)行鑒權(quán)操作。當(dāng)用戶權(quán)限不足,會(huì)拋出 AuthenticationException 異常。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> {
auth.requestMatchers("/api/admin/**").hasRole("ADMIN"); // 需要管理員權(quán)限
auth.anyRequest().authenticated(); // 其他接口需要認(rèn)證
})
// 其他配置
// ...
.build();
}
這兩個(gè)異常無法由 Sping Boot 的 ExceptionHandler 處理。因?yàn)橥ㄓ玫漠惓L幚頇C(jī)制,也就是基于 ExceptionHandler 的異常處理邏輯,生效于 DispatcherServlet,這是一個(gè) Servlet,位于上面洋蔥圖的最里面,無法處理外層 Filter 中的異常,只能處理 Interceptor 和 Controller 中的異常。
為了處理 AuthorizationFilter 拋出的兩種異常,Spring Security 在 AuthorizationFilter 之前注冊(cè)了一個(gè) ExceptionTranslationFilter,專門捕獲這兩種異常。
- 對(duì)于 AuthenticationException,會(huì)調(diào)用
AuthenticationEntryPoint接口處理。默認(rèn)的 AuthenticationEntryPoint 實(shí)現(xiàn)是 BasicAuthenticationEntryPoint,直接返回 401 錯(cuò)誤碼。 - 對(duì)于 AccessDeniedException,會(huì)調(diào)用
AccessDeniedHandler接口處理。默認(rèn)的 AccessDeniedHandler 實(shí)現(xiàn)是 AccessDeniedHandlerImpl,直接返回 403 錯(cuò)誤碼。
這兩種默認(rèn)處理實(shí)現(xiàn),都存在一個(gè)明顯的問題,即內(nèi)部使用了 HttpServletResponse::sendError 返回錯(cuò)誤信息。當(dāng) sendError() 被調(diào)用時(shí),Servlet 容器(如 Tomcat)會(huì)捕獲這個(gè)錯(cuò)誤狀態(tài),然后會(huì)查找是否有為該錯(cuò)誤狀態(tài)配置的錯(cuò)誤頁面。由于 Spring Boot 默認(rèn)為所有錯(cuò)誤配置了 /error 路徑作為錯(cuò)誤頁面,因此,容器會(huì)將請(qǐng)求轉(zhuǎn)發(fā)到 /error 路徑。這個(gè)轉(zhuǎn)發(fā)操作會(huì)再經(jīng)歷一遍 Filter 鏈,包括 AuthorizationFilter,如果沒有將 /error 配置為公開路徑,會(huì)導(dǎo)致 AuthenticationException(只會(huì)拋出一次)。這就導(dǎo)致了錯(cuò)誤信息會(huì)被覆蓋掉,比如鑒權(quán)失敗 403 錯(cuò)誤碼會(huì)被認(rèn)證失敗 401 錯(cuò)誤碼覆蓋。
有兩種解決辦法:
- 不啟用 Spring Boot 的 /error 錯(cuò)誤頁面路徑,需要排除掉
ErrorMvcAutoConfiguration配置類。 - 自定義 AuthenticationEntryPoint 和 AccessDeniedHandler,在拋出異常時(shí),不調(diào)用 sendError(),而是將錯(cuò)誤信息寫入響應(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();
對(duì)應(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 中,每次請(qǐng)求都會(huì)調(diào)用 UserDetailsService 獲取 UserDetails,相當(dāng)于一次數(shù)據(jù)庫(kù)查詢。JwtTokenFilter 在每次請(qǐng)求時(shí)都會(huì)執(zhí)行,如果系統(tǒng)用戶量較多,頻繁調(diào)用 UserDetailsService 會(huì)影響性能??梢钥紤]將 UserDetails 緩存起來,比如使用 Redis 緩存。
Spring Boot 有很多集成 Redis 的方案,最簡(jiǎn)單的是直接使用 Spirng Cache,需要引入 spring-boot-starter-data-redis 庫(kù)。
使用 Redis Cache,需要配置序列化方案:
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60)) // 默認(rèn)緩存 60 分鐘
.disableCachingNullValues() // 不緩存 null
.computePrefixWith(cacheName -> "lu:" + cacheName + ":") // 添加 lu: 前綴,并用單冒號(hào)替換調(diào)默認(rèn)雙冒號(hà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") // 定義一個(gè)名為 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();
}
}
有一個(gè)容易出錯(cuò)的地方,JSON 序列化不支持 Spring Security 提供的實(shí)現(xiàn)類 org.springframework.security.core.userdetails.User 和 org.springframework.security.core.SimpleGrantedAuthority,需要使用自定義的 UserDetails 實(shí)現(xiàn)和 GrantedAuthority 實(shí)現(xiàn)。
用上述 CustomCachingUserDetailsService 替換掉原來的 UserDetailsService,就可以實(shí)現(xiàn) UserDetails 的緩存。JwtTokenFilter 每次處理請(qǐng)求,只有緩存無法命中時(shí),才會(huì)調(diào)用 UserDetailsService 查詢數(shù)據(jù)庫(kù)。
禁用令牌
由于后端不會(huì)存儲(chǔ) Token,只有 Token 過期后才會(huì)失效,無法主動(dòng)讓一個(gè) Token 失效。不過可以借助黑名單功能,實(shí)現(xiàn)類似的效果。
具體思路是維護(hù)一個(gè)黑名單,記錄需要失效的用戶,在進(jìn)行 Token 認(rèn)證時(shí),查詢用戶是否在黑名單中。簡(jiǎn)單起見,可以借助 Redis 實(shí)現(xiàn)黑名單功能。
在 JwtTokenService 中,添加一個(gè)方法,用于將 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);
}
}
在檢驗(yàn) Token 時(shí),添加對(duì)黑名單的檢查:
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 無法通過校驗(yàn),達(dá)到失效的效果?;谶@個(gè)功能,可以實(shí)現(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());
}
直接從請(qǐng)求頭獲取 Token,因此 logout 接口需要認(rèn)證。
密鑰輪換
在 JwtTokenService 中,無論采用配置文件提供 JWT 加密密鑰,還是直接生成隨機(jī)密鑰,都存在一個(gè)問題:密鑰固定不變,一旦泄露,就無法保證安全性。
解決辦法是實(shí)現(xiàn)密鑰輪換。定期更換 JWT 密鑰,比如每 24 小時(shí)更換一次,將密鑰泄露的影響降到最低。
實(shí)現(xiàn)密鑰輪換最簡(jiǎn)單的辦法是利用定時(shí)任務(wù)定期更換。值得注意的是,密鑰輪換時(shí),需要確保新舊密鑰都能用于解密,因此需要保存舊的密鑰。
/**
* 管理 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;
}
}
這里運(yùn)用了雙端隊(duì)列保管 SecretKey,每次輪換時(shí),將新密鑰添加到隊(duì)列頭部。這樣,隊(duì)頭總是最新的密鑰,隊(duì)尾總是最舊的密鑰。
修改 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;
// 密鑰會(huì)自動(dòng)切換,token 對(duì)應(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;
}
上述實(shí)現(xiàn)有一個(gè)小問題,當(dāng)應(yīng)用重啟后,所有舊的 Token 會(huì)失效。在開發(fā)環(huán)境中,因?yàn)轭l繁重啟,總是要經(jīng)常重新獲取 Token,十分不方便。一個(gè)比較好的實(shí)踐是,結(jié)合配置文件提供的加密密鑰。啟動(dòng)時(shí),如果配置文件中提供了加密密鑰,則使用配置文件中的密鑰,否則,生成一個(gè)隨機(jī)密鑰。
修改 RotatingSecretKeyManager,增加相關(guān)邏輯:
@Value("${security.jwt.key.secret}")
private String secret;
@Override
public void afterPropertiesSet() throws Exception {
// 支持配置文件中的密鑰,可以避免開發(fā)時(shí)重啟后 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)境,則不配置加密密鑰,使用隨機(jī)生成的密鑰,保證最大安全性。
總結(jié)
本文介紹了如何在 Spring Boot 中實(shí)現(xiàn) JWT 認(rèn)證,并介紹了如何擴(kuò)展 JWT 認(rèn)證功能,包括錯(cuò)誤處理、用戶信息緩存、令牌失效、密鑰輪換。重點(diǎn)是通過 JwtTokenFilter 將 JWT 令牌與 Spring Security 的認(rèn)證功能結(jié)合起來,直接在 Spring Security 的 Filter 鏈中完成認(rèn)證。
相關(guān)代碼已上傳到 GitHub,xioshe/less-url,這是一個(gè)基于 Spring Boot 3 實(shí)現(xiàn)的短鏈服務(wù),其中包含了 JWT 認(rèn)證。
參考資料
[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實(shí)現(xiàn)JWT 認(rèn)證的項(xiàng)目實(shí)踐的文章就介紹到這了,更多相關(guān)SpringBoot JWT 認(rèn)證內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- 詳解Spring Boot實(shí)戰(zhàn)之Filter實(shí)現(xiàn)使用JWT進(jìn)行接口認(rèn)證
- Vue+Jwt+SpringBoot+Ldap完成登錄認(rèn)證的示例代碼
- Springboot集成Spring Security實(shí)現(xiàn)JWT認(rèn)證的步驟詳解
- springBoot整合jwt實(shí)現(xiàn)token令牌認(rèn)證的示例代碼
- 詳解SpringBoot如何使用JWT實(shí)現(xiàn)身份認(rèn)證和授權(quán)
- 利用Springboot實(shí)現(xiàn)Jwt認(rèn)證的示例代碼
- springboot+jwt實(shí)現(xiàn)token登陸權(quán)限認(rèn)證的實(shí)現(xiàn)
相關(guān)文章
小議Java的源文件的聲明規(guī)則以及編程風(fēng)格
這篇文章主要介紹了小議Java的源文件的聲明規(guī)則以及編程風(fēng)格,僅給Java初學(xué)者作一個(gè)簡(jiǎn)單的示范,需要的朋友可以參考下2015-09-09
Java并發(fā)編程之CountDownLatch的使用
CountDownLatch是一個(gè)倒數(shù)的同步器,常用來讓一個(gè)線程等待其他N個(gè)線程執(zhí)行完成再繼續(xù)向下執(zhí)行,本文主要介紹了CountDownLatch的具體使用方法,感興趣的可以了解一下2023-05-05
Java手寫簡(jiǎn)易版HashMap的使用(存儲(chǔ)+查找)
這篇文章主要介紹了Java手寫簡(jiǎn)易版HashMap的使用(存儲(chǔ)+查找),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-01-01
Java?IO流與NIO技術(shù)綜合應(yīng)用詳細(xì)實(shí)例代碼
這篇文章主要給大家介紹了關(guān)于Java?IO流與NIO技術(shù)綜合應(yīng)用的相關(guān)資料,文中包括了字節(jié)流和字符流,以及它們的高級(jí)特性如緩沖區(qū)、序列化和反序列化,同時(shí)還介紹了NIO中的通道和緩沖區(qū),以及選擇器的使用,需要的朋友可以參考下2024-12-12
Spring?Boot緩存實(shí)戰(zhàn)之Redis?設(shè)置有效時(shí)間和自動(dòng)刷新緩存功能(時(shí)間支持在配置文件中配置)
這篇文章主要介紹了Spring?Boot緩存實(shí)戰(zhàn)?Redis?設(shè)置有效時(shí)間和自動(dòng)刷新緩存,時(shí)間支持在配置文件中配置,需要的朋友可以參考下2023-05-05
Java可重入鎖的實(shí)現(xiàn)原理與應(yīng)用場(chǎng)景
今天小編就為大家分享一篇關(guān)于Java可重入鎖的實(shí)現(xiàn)原理與應(yīng)用場(chǎng)景,小編覺得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來看看吧2019-01-01
Springboot處理配置CORS跨域請(qǐng)求時(shí)碰到的坑
本篇文章介紹了我在開發(fā)過程中遇到的一個(gè)問題,以及解決該問題的過程及思路,通讀本篇對(duì)大家的學(xué)習(xí)或工作具有一定的價(jià)值,需要的朋友可以參考下2021-09-09
checkpoint 機(jī)制具體實(shí)現(xiàn)示例詳解
這篇文章主要為大家介紹了checkpoint 機(jī)制具體實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-02-02

