昨天我們完成了 JWT 的驗證功能,也加入了自訂的 Filter:JwtAuthenticationFilter
。 今天,讓我們以一個簡單的 API 端點來測試昨天的設定是否如我們預期的運作:)!
我們目前開出來的 API,一個用於註冊,一個用於登入,這兩個功能都不需要經過身分驗證。因此我們會先建立一個測試的端點,測試 JWT 驗證功能是否正常。
這個端點模擬使用者登入後想查看自己的個人資訊,因此不以 Path Parameter 的形式設計端點(/users/{id}
),而是以 /users/me
取得個人資訊。兩者差別在於,前者比較像以 Admin User 的權限取得使用者資訊,後者則是以 Authenticated 的身分,查詢個人資訊。
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
// 如果 @AuthenticationPrincipal 成功注入 UserDetails,
// 代表我們的 JWT Filter 已經成功驗證 Token 並設定了安全上下文。
if (userDetails != null) {
// 為了測試,我們可以直接回傳使用者的 email (也就是 getUsername() 的回傳值)
// 以及他的權限
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("email", userDetails.getUsername());
userInfo.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
return ResponseEntity.ok(userInfo);
}
// 理論上,如果 SecurityConfig 設定正確,未經驗證的請求進不到這裡
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User not authenticated");
}
@AuthenticationPrincipal 是 Spring Security 提供的註解。作用是直接從 SecurityContext 中,取出當前已經通過驗證的使用者 Principal ,並將其注入到方法參數中。如果這個參數能成功被注入且不為 null,就表示我們的 Filter 有正確地將 authentication 物件設定到 SecurityContext中。
因為接下來的測試我們要在請求中直接帶上 JWT Token,所以我們要先使用先前建立的測試帳號登入,取得 JWT Token 進行下列測試。
打開 API Tester 後,選擇先前建立用於測試登入功能的 Request,於 Body 中輸入 { "email": "test@example.com", "password": "password123" }
,會得到包含 Token 在內的回應,將該 Token 前面加上 “Bearer “,組成待會請求 Header 中的對應內容,如下例:
Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJ...
Bearer
+ 正確的 JWT Token目的:任意修改 JWT Toke Signature 中的內容,驗證 JWT Filter 的簽章校驗功能是否正常
預期結果:HTTP Status Code 回傳 401 Unauthorized
實際結果:
符合預期,在 Token 驗證失敗被指派匿名身分後,對於其無權存取資源的請求回傳未經授權的狀態碼。
log 中的錯誤訊息:
....JwtUtils : Invalid JWT signature: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
修改 Signature 後 JwtUtils 正確判定此 Token 不匹配。
目的:驗證 JWT Filter 的過期時間校驗功能是否正常。
Request Headers:先調整過期時間為收到Token後馬上過期,重新登入取得該 Token 再參考案例一的方法帶入Header進行測試。
預期結果:HTTP Status Code 回傳 401 Unauthorized
實際結果:
符合預期,在 Token 驗證失敗被指派匿名身分後,對於其無權存取資源的請求回傳未經授權的狀態碼。
log 中的錯誤訊息:
...JwtUtils : JWT token is expired: JWT expired 16684 milliseconds ago at 2025-10-02T05:03:41.000Z. Current time: 2025-10-02T05:03:57.684Z. Allowed clock skew: 0 milliseconds.
JwtUtils 正確由 Claims 資訊判定此 Token 已過期。
昨天我們雖然大概知道自訂 Filter 的內容在做什麼,但感覺對於 Filter Chain 的機制不甚了解,為了能知道自己加入了 Filter 到底對整個流程產生了什麼影響,稍微研究了一下Spring Security Servlet Architecture。
圖片來源: spring security 官網
上圖描述了請求從 client 端發出到 SecurityFilterChain 的過程,大致說明如下:
DelegatingFilterProxy
,因為 Servlet 容器本身不認識 Spring 管理的 Bean,所以 Spring 透過這個 Proxy 來為兩者進行關聯。這個 Proxy 本身不處理複雜的業務,主要的職責是將工作委派 (Delegate) 給 FilterChainProxy
。FilterChainProxy
的職責是當一個請求進來時,它會檢查請求的 URL,然後從它所管理的 SecurityFilterChain
清單中,選擇一個最適合的 Filter Chain 套用到這次的請求,以上圖右側為例:
/api/users/1
,它會匹配到圖右上方的 SecurityFilterChain₀
。/dashboard
,它會匹配到圖右下方的 SecurityFilterChainₙ
。SecurityFilterChain
中會包含許多 Security Filters, 每一個 Security Filter 都只專注在一項單一、明確的任務(好比我們昨天自訂的 JwtAuthenticationFilter
,專門用於驗證攜帶 JWT Token 的請求)。換句話說,SecurityFilterChain
像是一套安全準則 SOP,而裡面的 Security Filters 就是執行這套 SOP 的每一個獨立步驟。
想要將自訂的 Filter 加入 Filter Chain ,可以透過 .addFilterBefore
等方法將 Filter 加入到適當的位置,言下之意就是,Filter chain 中的 Filters 是視情況按照順序性進行安排的。例如:我們昨天將JwtAuthenticationFilter
插入 UsernamePasswordAuthenticationFilter
之前,因為處理 API 請求時,我們希望優先用 JWT 的方式來驗證。
今天,我們建立了一個測試端點,驗證了昨天完成的 JWT 驗證功能與 自訂Filter 可以順利運作。
先前我們提到過,為了安全性我們需要為 JWT 設定期限,在目前的案例中我們確實也在設定檔中設定了 15 分的過期時間。但是每當 15 分鐘一到需要重新登入的機制很可能影響使用者體驗。
但也不能因此放棄 JWT 過期時間的安全設定,因此權衡的做法是加入 Refresh Token 的機制,明天我們就來試著實作換發 Token 的功能吧!