欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

SpringBoot實現(xiàn)JWT 認證的項目實踐

 更新時間:2025年08月13日 09:33:00   作者:未必完美  
本文介紹了Spring Boot中實現(xiàn)JWT認證,并介紹了擴展JWT認證功能,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧

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 認證的基本流程如下:

  1. 用戶調(diào)用登錄接口,后端生成令牌并返回給前端。
  2. 前端將令牌存儲在本地(如 localStorage 或 cookie)。
  3. 前端發(fā)起其他請求時,將令牌添加到請求頭或請求體中。
  4. 后端解析令牌,驗證用戶身份和權(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)鍵方法是 getPasswordgetUsername,用于獲取系統(tǒng)的登錄憑證。getAuthorities 方法獲取權(quán)限信息,如果不涉及到權(quán)限管理,返回空集合即可。isAccountNonExpiredisAccountNonLocked、isCredentialsNonExpiredisEnabled 方法獲取用戶的狀態(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)換:

  1. 校驗 Token 是否有效
  2. 從 Token 中獲取用戶名,用 UserDetailsService 獲取對應(yīng)的 UserDetails,構(gòu)建為認證信息(Authentication)
  3. 將認證信息保存進安全上下文。默認以線程變量方式存儲在 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.Userorg.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)文章

最新評論