iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0

昨天我們為驗證異常指定了入口點,讓我們的登入功能在驗證失敗時能夠正確回傳 401 狀態碼。

既然登入後取得 Token 的功能已經完成了,今天想來實作 JWT 驗證的功能。也就是當客戶端帶著 Token 來請求受保護的資源時,伺服器該如何驗證這個 Token。

前端如何在請求中夾帶 Token 資訊?

當後端將 JWT Token 回傳給前端後,前端在之後的請求需要夾帶這個 Token 來進行驗證。因此本節想稍微說明前端是如何在請求中夾帶 JWT Token 資訊,好讓我們在實作驗證功能時知道從哪裡取得 Token 資訊。

Authorization Header

Authorization Header 是 HTTP Request Header的一種類型,它的作用就是讓前端用來夾帶憑證 (Credentials),向後端伺服器證明自己的身分。將 Token 放在 Authorization Header 中有幾個好處:

  • 關注點分離:Header 本身就用於傳遞 Metadata,如身份驗證、內容類型等。
  • RESTful 合規性:RESTful API 中某些方法不帶 RequestBody,放在 Header 才不會破壞合規性。
  • 遵循業界標準:這是業界常見的做法,代理伺服器、API 閘道等都會去 Authorization Header 取得 Token 資訊。
  • 快取友好:讓GET 請求的 URL 保持乾淨,不會因為包含 Body 內容不同破壞快取機制。同時,Authorization Header 也明確告知快取,這個回應是針對特定用戶的私密內容。
  • 日誌檔安全:伺服器常可能會記錄請求的 Body 用於除錯,但較少會預設記錄所有 Header。如果伺服器有記錄請求 Body 的習慣,Token 就有可能經由日誌檔外洩。

Bearer

Authorization Header 的標準格式為:Authorization: <type> <credentials>。其中, type 代表憑證的類型,等於是在告訴伺服器:「要使用哪種證件進行驗證」。

對於使用 JWT 進行驗證的系統,通常會選擇 Bearer 作為憑證的類型。
Bearer Token 有不記名的含意,代表任何人只要持有這個 Token,就被視為合法的授權使用者,可以直接存取對應的資源。有點像大部分的現金交易,只認鈔票不認人(?)

但也因為將 Token 本身視為身分識別的概念,要特別小心外洩問題,也要適當的加入過期、Refresh 的機制來避免身分遭盜用的問題。

在 JwtUtils 中加入驗證 JWT 的方法

因為與 JWT 相關,因此將驗證的方法加入 JwtUtils 這個類別中。
除了驗證 JWT Token 的方法外,還新增了一個取得 JWT 中 Subject 的方法,這是為了後續在自訂的 Filter 中建立 Authentication 物件先準備的一個方法。


public String getUserSubjectFromJwtToken(String authToken) {
    // 使用公鑰解析 Token,並獲取 Subject (用戶ID)
    return Jwts.parser()
            .verifyWith(publicKey)
            .build()
            .parseSignedClaims(authToken) 
            .getPayload()
            .getSubject();
}

public boolean validateJwtToken(String authToken) {
    try {
        Jwts.parser()
            .verifyWith(publicKey)
            .build()
            .parseSignedClaims(authToken);

        return true; // 如果解析成功,則表示 Token 有效
    } catch (SignatureException e) {
        logger.error("Invalid JWT signature: {}", e.getMessage());
    } catch (MalformedJwtException e) {
        logger.error("Invalid JWT token: {}", e.getMessage());
    } catch (ExpiredJwtException e) {
        logger.error("JWT token is expired: {}", e.getMessage());
    }
    return false;
}

兩個方法很類似,都傳入公鑰來建構 JWT 解析器(JwtParser),並在 parseSignedClaims 方法內比對了header, paylod 與 Signature 是否匹配,若不匹配會拋出幾種例外:

  • SignatureException:表示 JWT 的數位簽名無效,Token 可能被篡改。
  • MalformedJwtException:表示 JWT 的格式不正確,無法解析。
  • ExpiredJwtException:表示 JWT 已經過期,不再有效。

若匹配則代表身分驗證成功,會回傳 Jws<Claims> 物件,我們可以透過 getPayloadgetSubject 等方式取得 claims 的內容。

實作 JwtAuthenticationFilter

不同於使用帳號密碼有預設的 Filter ,為了處理 JWT 驗證我們需要建立一個自訂過濾器 (Custom Filter)。這個 Filter 主要負責在每個傳入的請求中,檢查是否存在有效的 JWT,如果有的話,就解析它並完成身份驗證。

我們可以將這個 Filter 內要完成的任務流程劃分成以下幾點:

  1. 攔截請求:作為一個 Filter,他要攔截使用 JWT Token 進行驗證存取資源的 HTTP 請求。(後續透過F ilter Chain 中加入此過濾器達到這個目的)
  2. 查找 Token:從 Request 的 Authorization Header 中,查找以 Bearer 開頭的憑證。
  3. 驗證 Token:如果 Token 存在,就調用 JwtUtils 的方法進行驗證,確認 Token 簽章是否正確、有無過期或 Claim 是否合法等情況。
  4. 建立 Authentication 物件:Token 驗證成功後,從 Token 的 Payload 中取得 Subject(對應到我們上方建立的getUserSubjectFromJwtToken方法),建立一個 Authentication 物件。
  5. 設定Security Context:將 Authentication 物件,設定到 Security Context中,即在本次請求的範圍內,將該使用者標記為已登入。
  6. 放行請求:呼叫 chain.doFilter(request, response),將請求交給過濾器鏈中的下一個 Filter 繼續處理。
  7. 此時,因為安全上下文中已經有了使用者資訊,後續的授權檢查就能正常運作了。

知道大概的流程後,對照實作的內容應該就很清楚:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    @Autowired
    private  JwtUtils jwtUtils;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException{
        try {
            String jwt = parseJwt(request);

            if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
                String userSubject = jwtUtils.getUserSubjectFromJwtToken(jwt); 

                UserDetails userDetails = userDetailsService.loadUserByUsername(userSubject); 

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null, // JWT 認證時不傳入密碼
                                userDetails.getAuthorities());

 SecurityContextHolder.getContext().setAuthentication(authentication);

            }
        } catch (Exception e) {
		        // 有什麼驗證錯誤先交由 ExceptionTranslationFilter 統一處理
            logger.error("Cannot set user authentication: {}", e.getMessage(), e);
        }

        filterChain.doFilter(request, response);
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");

        //注意 Bearer 後有一個空格,所以應該取得第7個後的字串才是我們要的Token
        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7);
        }
        return null;
    }
}

繼承 OncePerRequestFilter 這個類別,可以確保在同一次請求中,這個 Filter 只會被執行一次。不會因為發生錯誤,內部轉發又觸發這個Filter。

雖然理論上我們成功驗證 JWT Token 後就可以信任 Payload 中的角色資訊,不用再查資料庫,但透過再呼叫一次 loadUserByUsername ,可以總是取得使用者最新的權限狀態,增加系統的安全性。

JwtAuthenticationFilter 加入 Filter Chain

透過 addFilterBefore 方法將這個過濾器加在 UsernamePasswordAuthenticationFilter 之前,這樣以 JWT 進行驗證的請求(應該占多數)再到 UsernamePasswordAuthenticationFilter 時就已經是認證狀態,避免經過不必要的的驗證流程。

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .exceptionHandling(exception -> exception.authenticationEntryPoint(authEntryPoint))
                .authorizeHttpRequests(auth -> auth
											  .requestMatchers(HttpMethod.POST,"/users/**").permitAll()
                        .anyRequest().authenticated()
                )
                // 新增這一行
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

今天,我們建立了 JWT 的驗證機制,從前端如何夾帶 Token、後端 JwtUtils 的驗證邏輯,到核心的 JwtAuthenticationFilter,最後完成了過濾器設定。

明天,讓我們來建立一個測試的 API 端點,透過幾個測試案例來驗證這個流程是否已順利運作吧~


上一篇
Day16:Auth Service - AuthEntryPoint
下一篇
Day 18:JWT 驗證功能測試與 Filter Chain 的簡易說明
系列文
吃出一個SideProject!20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言