最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

SpringBoot實(shí)現(xiàn)JWT 認(rèn)證的項(xiàng)目實(shí)踐

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

JWT 是 JSON Web Token 的縮寫,用于實(shí)現(xiàn)無(wú)狀態(tài)的認(rèn)證系統(tǒng)。無(wú)狀態(tài)指后端不需要存儲(chǔ)用戶 Session,前端與后端之間只通過(guò)令牌(Token) 進(jìn)行通信。無(wú)狀態(tài)的好處是易于擴(kuò)展,適合分布式系統(tǒng)。而且,相比另一種常用的無(wú)狀態(tài)認(rèn)證方案分布式 Session,JWT 更契合 RESTful API 的設(shè)計(jì)理念。

JWT 的令牌(Token)是由后端生成的一串字符串,前端發(fā)起 HTTP 請(qǐng)求時(shí)攜帶令牌一起發(fā)送給后端,后端通過(guò)解析令牌來(lái)驗(yàn)證用戶的身份。整個(gè)過(guò)程,后端不需要存儲(chǔ)令牌,也不需要維護(hù) Session,所以 JWT 非常適合用于分布式系統(tǒng)。

JWT 認(rèn)證的基本流程如下:

  1. 用戶調(diào)用登錄接口,后端生成令牌并返回給前端。
  2. 前端將令牌存儲(chǔ)在本地(如 localStorage 或 cookie)。
  3. 前端發(fā)起其他請(qǐng)求時(shí),將令牌添加到請(qǐng)求頭或請(qǐng)求體中。
  4. 后端解析令牌,驗(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,過(guò)期時(shí)間
  • sub:subject,主題
  • aud:audience,受眾
  • nbf:not before,生效時(shí)間
  • iat:issued at,簽發(fā)時(shí)間
  • jti:JWT ID,令牌 ID

不過(guò),并不需要提供以上所有信息,一個(gè)典型的 Payload 可能只包含以下內(nèi)容:

  • sub:主題,如用戶 ID
  • exp:過(guò)期時(shí)間,可以取 Token 生成時(shí)間的一個(gè)小時(shí)后
  • iat:簽發(fā)時(shí)間,取 Token 生成時(shí)間

同時(shí),還可以根據(jù)需求,增加自定義字段,但需要注意,添加的字段越多,Token 的體積就越大,在傳輸過(guò)程中就需要占用更多的網(wǎng)絡(luò)帶寬,也會(huì)影響性能。

在安全性方面,JWT 存在一下問(wèn)題:

  • Base64Url 編碼無(wú)加密,Header 和 Payload 部分是公開(kāi)的,不應(yīng)該包含敏感信息。
  • 必須配合 HTTPS 使用,否則存在被篡改的安全隱患。
  • 基于令牌的認(rèn)證方式無(wú)法主動(dòng)撤銷,只能等到令牌過(guò)期。不過(guò)可以通過(guò)黑名單的方式來(lái)實(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í),主要檢查令牌是否過(guò)期。

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 沒(méi)有內(nèi)置 JWT 認(rèn)證的功能,需要自己實(shí)現(xiàn)。

Spring Security 內(nèi)部結(jié)構(gòu)

Spring Security 主要基于 Filter 來(lái)實(shí)現(xiàn)認(rèn)證和授權(quán)。Filter 是 Servlet 規(guī)范中的概念,在請(qǐng)求(HTTP Request)進(jìn)入 Servlet 容器時(shí),會(huì)經(jīng)過(guò)一列長(zhǎng)長(zhǎng)的 Filter 鏈,鏈中每一個(gè) Filter 都會(huì)對(duì)請(qǐng)求進(jìn)行處理,至到最后到達(dá) Servlet。在 Servlet 返回響應(yīng)(HTTP Response)時(shí),也會(huì)經(jīng)過(guò)相同的 Filter 鏈,只不過(guò)順序與請(qǐng)求時(shí)相反。Filter 鏈就像一個(gè)洋蔥,請(qǐng)求數(shù)據(jù)從外向內(nèi)穿過(guò)洋蔥,而響應(yīng)數(shù)據(jù)從內(nèi)向外穿過(guò)洋蔥。

除了 Filter,Spring Security 還有兩個(gè)重要組件,AuthenticationManager 和 AuthenticationProvider。前者用于提供統(tǒng)一的認(rèn)證功能,后者用于提供用戶信息來(lái)源。一個(gè) AuthenticationManager 可以包含多個(gè) AuthenticationProvider,每個(gè) AuthenticationProvider 對(duì)應(yīng)一種用戶來(lái)源,最常用的是 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)鍵方法是 getPasswordgetUsername,用于獲取系統(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ù)泄露,黑客也無(wú)法知道用戶的原始密碼,也就無(wú)法用泄露的賬號(hào)和密碼登錄系統(tǒng),也無(wú)法根據(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);
    }
}

在上述代碼中,沒(méi)有調(diào)用 AuthenticationManager 來(lái)認(rèn)證用戶,只是實(shí)現(xiàn)了 Token -> UserDetails 的轉(zhuǎn)換:

  1. 校驗(yàn) Token 是否有效
  2. 從 Token 中獲取用戶名,用 UserDetailsService 獲取對(duì)應(yīng)的 UserDetails,構(gòu)建為認(rèn)證信息(Authentication)
  3. 將認(rèn)證信息保存進(jìn)安全上下文。默認(rèn)以線程變量方式存儲(chǔ)在 ThreadLocal 對(duì)象中。

即使 Token 校驗(yàn)沒(méi)通過(guò),或者根據(jù)用戶名查不到用戶信息,或者根本就沒(méi)提供 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)證,但無(wú)法從安全上下文獲取對(duì)應(yīng) Authentication,就判定為沒(méi)通過(guò)認(rèn)證,拋出 AuthenticationException 異常。通過(guò)復(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 類型的定義。通過(guò) SecurityFilterChain,可以開(kāi)啟和關(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(); // 開(kāi)放登錄接口
                    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)證。訪問(wèn) login 之外的接口,如果沒(méi)有提供 Token,會(huì)返回 401 錯(cuò)誤碼。通過(guò)在請(qǐng)求頭添加 Token 后,就可以正常訪問(wèn)。對(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è)異常無(wú)法由 Sping Boot 的 ExceptionHandler 處理。因?yàn)橥ㄓ玫漠惓L幚頇C(jī)制,也就是基于 ExceptionHandler 的異常處理邏輯,生效于 DispatcherServlet,這是一個(gè) Servlet,位于上面洋蔥圖的最里面,無(wú)法處理外層 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è)明顯的問(wèn)題,即內(nèi)部使用了 HttpServletResponse::sendError 返回錯(cuò)誤信息。當(dāng) sendError() 被調(diào)用時(shí),Servlet 容器(如 Tomcat)會(huì)捕獲這個(gè)錯(cuò)誤狀態(tài),然后會(huì)查找是否有為該錯(cuò)誤狀態(tài)配置的錯(cuò)誤頁(yè)面。由于 Spring Boot 默認(rèn)為所有錯(cuò)誤配置了 /error 路徑作為錯(cuò)誤頁(yè)面,因此,容器會(huì)將請(qǐng)求轉(zhuǎn)發(fā)到 /error 路徑。這個(gè)轉(zhuǎn)發(fā)操作會(huì)再經(jīng)歷一遍 Filter 鏈,包括 AuthorizationFilter,如果沒(méi)有將 /error 配置為公開(kāi)路徑,會(huì)導(dǎo)致 AuthenticationException(只會(huì)拋出一次)。這就導(dǎo)致了錯(cuò)誤信息會(huì)被覆蓋掉,比如鑒權(quán)失敗 403 錯(cuò)誤碼會(huì)被認(rèn)證失敗 401 錯(cuò)誤碼覆蓋。

有兩種解決辦法:

  • 不啟用 Spring Boot 的 /error 錯(cuò)誤頁(yè)面路徑,需要排除掉 ErrorMvcAutoConfiguration 配置類。
  • 自定義 AuthenticationEntryPoint 和 AccessDeniedHandler,在拋出異常時(shí),不調(diào)用 sendError(),而是將錯(cuò)誤信息寫入響應(yīng)體。

下面是利用系統(tǒng)提供的 HandlerExceptionResolver 組件來(lái)處理異常,這還帶來(lái)另外的好處,可以集中異常處理,便于統(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 緩存起來(lái),比如使用 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 序列化
    }
}

配置好之后,就可以直接通過(guò) @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.Userorg.springframework.security.core.SimpleGrantedAuthority,需要使用自定義的 UserDetails 實(shí)現(xiàn)和 GrantedAuthority 實(shí)現(xiàn)。

用上述 CustomCachingUserDetailsService 替換掉原來(lái)的 UserDetailsService,就可以實(shí)現(xiàn) UserDetails 的緩存。JwtTokenFilter 每次處理請(qǐng)求,只有緩存無(wú)法命中時(shí),才會(huì)調(diào)用 UserDetailsService 查詢數(shù)據(jù)庫(kù)。

禁用令牌

由于后端不會(huì)存儲(chǔ) Token,只有 Token 過(guò)期后才會(huì)失效,無(wú)法主動(dòng)讓一個(gè) Token 失效。不過(guò)可以借助黑名單功能,實(shí)現(xiàn)類似的效果。

具體思路是維護(hù)一個(gè)黑名單,記錄需要失效的用戶,在進(jìn)行 Token 認(rèn)證時(shí),查詢用戶是否在黑名單中。簡(jiǎn)單起見(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 無(wú)法通過(guò)校驗(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 中,無(wú)論采用配置文件提供 JWT 加密密鑰,還是直接生成隨機(jī)密鑰,都存在一個(gè)問(wèn)題:密鑰固定不變,一旦泄露,就無(wú)法保證安全性。

解決辦法是實(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è)小問(wèn)題,當(dāng)應(yīng)用重啟后,所有舊的 Token 會(huì)失效。在開(kāi)發(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 {
    // 支持配置文件中的密鑰,可以避免開(kāi)發(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();
    }
}

開(kāi)發(fā)環(huán)境中,可以使用配置文件提供的密鑰來(lái)避免 Token 失效。生產(chǎn)環(huán)境,則不配置加密密鑰,使用隨機(jī)生成的密鑰,保證最大安全性。

總結(jié)

本文介紹了如何在 Spring Boot 中實(shí)現(xiàn) JWT 認(rèn)證,并介紹了如何擴(kuò)展 JWT 認(rèn)證功能,包括錯(cuò)誤處理、用戶信息緩存、令牌失效、密鑰輪換。重點(diǎn)是通過(guò) JwtTokenFilter 將 JWT 令牌與 Spring Security 的認(rèn)證功能結(jié)合起來(lái),直接在 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)文章希望大家以后多多支持腳本之家!

相關(guān)文章

  • Java數(shù)據(jù)庫(kù)連接池連接Oracle過(guò)程詳解

    Java數(shù)據(jù)庫(kù)連接池連接Oracle過(guò)程詳解

    這篇文章主要介紹了Java數(shù)據(jù)庫(kù)連接池連接Oracle過(guò)程詳解,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下
    2020-09-09
  • OpenFeign超時(shí)時(shí)間設(shè)置不生效問(wèn)題排查記錄

    OpenFeign超時(shí)時(shí)間設(shè)置不生效問(wèn)題排查記錄

    文章主要講述了在升級(jí)Spring Boot 3后,發(fā)現(xiàn)配置文件中的OpenFeign超時(shí)時(shí)間設(shè)置不生效的問(wèn)題,通過(guò)查看FeignClientFactoryBean類和FeignClientProperties類的源碼,發(fā)現(xiàn)配置讀取的方式發(fā)生了變化,從而導(dǎo)致超時(shí)時(shí)間設(shè)置不生效
    2024-11-11
  • 基于java語(yǔ)言實(shí)現(xiàn)快遞系統(tǒng)

    基于java語(yǔ)言實(shí)現(xiàn)快遞系統(tǒng)

    這篇文章主要為大家詳細(xì)介紹了基于java語(yǔ)言實(shí)現(xiàn)快遞系統(tǒng),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2022-03-03
  • SpringBoot自定義注解驗(yàn)證枚舉的實(shí)現(xiàn)

    SpringBoot自定義注解驗(yàn)證枚舉的實(shí)現(xiàn)

    本文主要介紹了SpringBoot自定義注解驗(yàn)證枚舉的實(shí)現(xiàn),數(shù)據(jù)校驗(yàn),需要對(duì)枚舉類型的數(shù)據(jù)傳參,進(jìn)行數(shù)據(jù)校驗(yàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2024-01-01
  • 簡(jiǎn)單了解JAVA變量類型及代碼實(shí)例

    簡(jiǎn)單了解JAVA變量類型及代碼實(shí)例

    這篇文章主要介紹了簡(jiǎn)單了解JAVA變量類型及代碼實(shí)例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下
    2020-02-02
  • Java對(duì)象與JSON互相轉(zhuǎn)化的示例詳解

    Java對(duì)象與JSON互相轉(zhuǎn)化的示例詳解

    JSON (JavaScript Object Notation) 是一種輕量級(jí)的數(shù)據(jù)交換格式,易于人閱讀和編寫,同時(shí)也易于機(jī)器解析和生成,下面我們就來(lái)聊聊Java對(duì)象與JSON如何互相轉(zhuǎn)化吧
    2025-07-07
  • 使用idea開(kāi)發(fā)Servlet詳細(xì)圖文教程

    使用idea開(kāi)發(fā)Servlet詳細(xì)圖文教程

    這篇文章主要給大家介紹了關(guān)于使用idea開(kāi)發(fā)Servlet的相關(guān)資料,將idea添加servlet的過(guò)程其實(shí)非常簡(jiǎn)單,只需要按照以下幾個(gè)步驟即可完成,需要的朋友可以參考下
    2023-10-10
  • SpringBoot啟用GZIP壓縮的代碼工程

    SpringBoot啟用GZIP壓縮的代碼工程

    經(jīng)常我們都會(huì)與服務(wù)端進(jìn)行大數(shù)據(jù)量的文本傳輸,例如?JSON?就是常見(jiàn)的一種格式,通過(guò)?REST?API?接口進(jìn)行?GET?和?POST?請(qǐng)求,可能會(huì)有大量的文本格式數(shù)據(jù)提交、返回,壓縮和解壓在提升網(wǎng)絡(luò)帶寬的同時(shí),會(huì)帶來(lái)?CPU?資源的損耗,本文介紹了SpringBoot啟用GZIP壓縮的代碼工程
    2024-08-08
  • Java常用對(duì)象操作工具代碼實(shí)例

    Java常用對(duì)象操作工具代碼實(shí)例

    這篇文章主要介紹了Java常用對(duì)象操作工具代碼實(shí)例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下
    2020-12-12
  • Java?SE之了解泛型

    Java?SE之了解泛型

    這篇文章主要介紹了Java?SE之了解泛型,文章內(nèi)容詳細(xì),簡(jiǎn)單易懂,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧
    2023-01-01

最新評(píng)論

新竹市| 潼关县| 南投市| 宜宾县| 华宁县| 鄯善县| 屏边| 黔西县| 上虞市| 西宁市| 方城县| 阿拉尔市| 福海县| 斗六市| 武定县| 寻甸| 冀州市| 全南县| 岚皋县| 北川| 壶关县| 岳西县| 舞阳县| 丹江口市| 镇雄县| 瑞金市| 古丈县| 梧州市| 阳新县| 原阳县| 广水市| 白沙| 洞口县| 搜索| 综艺| 密云县| 聂拉木县| 和田县| 宁化县| 南宫市| 盐池县|