昨天我們為驗證異常指定了入口點,讓我們的登入功能在驗證失敗時能夠正確回傳 401 狀態碼。
既然登入後取得 Token 的功能已經完成了,今天想來實作 JWT 驗證的功能。也就是當客戶端帶著 Token 來請求受保護的資源時,伺服器該如何驗證這個 Token。
當後端將 JWT Token 回傳給前端後,前端在之後的請求需要夾帶這個 Token 來進行驗證。因此本節想稍微說明前端是如何在請求中夾帶 JWT Token 資訊,好讓我們在實作驗證功能時知道從哪裡取得 Token 資訊。
Authorization Header 是 HTTP Request Header的一種類型,它的作用就是讓前端用來夾帶憑證 (Credentials),向後端伺服器證明自己的身分。將 Token 放在 Authorization Header 中有幾個好處:
GET
請求的 URL 保持乾淨,不會因為包含 Body 內容不同破壞快取機制。同時,Authorization Header 也明確告知快取,這個回應是針對特定用戶的私密內容。Authorization Header 的標準格式為:Authorization: <type> <credentials>
。其中, type 代表憑證的類型,等於是在告訴伺服器:「要使用哪種證件進行驗證」。
對於使用 JWT 進行驗證的系統,通常會選擇 Bearer 作為憑證的類型。
Bearer Token 有不記名的含意,代表任何人只要持有這個 Token,就被視為合法的授權使用者,可以直接存取對應的資源。有點像大部分的現金交易,只認鈔票不認人(?)
但也因為將 Token 本身視為身分識別的概念,要特別小心外洩問題,也要適當的加入過期、Refresh 的機制來避免身分遭盜用的問題。
因為與 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>
物件,我們可以透過 getPayload
與 getSubject
等方式取得 claims 的內容。
JwtAuthenticationFilter
不同於使用帳號密碼有預設的 Filter ,為了處理 JWT 驗證我們需要建立一個自訂過濾器 (Custom Filter)。這個 Filter 主要負責在每個傳入的請求中,檢查是否存在有效的 JWT,如果有的話,就解析它並完成身份驗證。
我們可以將這個 Filter 內要完成的任務流程劃分成以下幾點:
JwtUtils
的方法進行驗證,確認 Token 簽章是否正確、有無過期或 Claim 是否合法等情況。Authentication
物件:Token 驗證成功後,從 Token 的 Payload 中取得 Subject(對應到我們上方建立的getUserSubjectFromJwtToken
方法),建立一個 Authentication
物件。Authentication
物件,設定到 Security Context中,即在本次請求的範圍內,將該使用者標記為已登入。chain.doFilter(request, response)
,將請求交給過濾器鏈中的下一個 Filter 繼續處理。知道大概的流程後,對照實作的內容應該就很清楚:
@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 端點,透過幾個測試案例來驗證這個流程是否已順利運作吧~