【Spring Boot】第17.6課-實作 Spring Security 的認證 Filter(以 JWT 為例)#2024 年更新


https://unsplash.com/photos/LmyPLbbUWhA

上一篇實作出建立與解析 JWT 的程式。JWT 經常被攜帶於 request header 中,用來表明自己的身份,與 HTTP Basic 認證需攜帶帳密的 Base64 編碼有異曲同工之妙。

本文將以 JWT 為基礎,實作一個性質與 HTTP Basic 相似的認證 Filter。首先會說明練習用專案大致有哪些程式。接著開發 Filter,將 header 中的 JWT 轉化為認證後的使用者資料。最後在 Controller 中取用。

一、程式專案概觀

本文的範例程式,是基於上一篇的完成後專案繼續實作。筆者僅展示比較重要的部份,而讀者也能下載專案,一邊對照著看。

(一)使用者資料

以下是自定義的使用者類別,可假想成對應到資料庫的表。它包含 id、帳號、密碼、暱稱與權限,共 5 個欄位。其中權限包含「學生」與「助理」這 2 種。

以下是自定義的 UserDetails 類別。它在建構子接收了上述的 Member 物件,將當中的值都複製過來。這些值會在後面存取「Security Context」時被使用。

以下是自定義的 UserDetailsService 類別,它在建構子可接收多個 Member 物件。但本文並未串接真實的資料庫,故以 Java 的 HashMap 資料結構來儲存。

(二)解析 JWT 的程式

以下是處理 JWT 相關事務的類別,其中用來解析 JWT 的方法名稱為「parseToken」。

該方法所回傳的「Claims」介面物件,包含了當初放進 JWT 的各種資料,在本文第二節實作認證 Filter 時會取用它們。

(三)Spring Security 配置

以下是將 UserDetailsService 與 JwtService 建立成元件。

此處也建立一筆使用者資料,帳號為「user1」,密碼為「111」。我們會在本文第三節進行測試時,用來呼叫登入 API。

二、實作認證 Filter

(一)前言

當 request 到達後端,Spring Security 背後會有許多 Filter 依序執行不同的工作。多個 Filter 合稱為「過濾鏈」(filter chain),其中也包含了認證與授權的流程。當 request 得到授權,才能進入 Controller。

第 17.4 課,筆者介紹了 HTTP Basic 認證流程的原始碼,也就是 BasicAuthenticationFilter。讓我們先回顧一下該 Filter 的邏輯。

它獲取了「Authorization」這個 request header 的值,其為帳號與密碼的 Base64 編碼。經由解碼得到帳密的值後,隨即進行身份認證。

認證成功後,會得到內含使用者資料的 UserDetails 介面物件。將其放入 Security Context 後,即可供其他程式存取。

而本節也要實作相同目的的 Filter。我們會接收 JWT,進行解析後,將裡頭的使用者資料放入 Security Context,相當於完全取代 HTTP Basic 認證。到了本文第三節,將在 Controller 進行測試。

(二)建立 Filter

首先請建立一個 Filter 類別。

該類別繼承自 OncePerRequestFilter,確保後端每次收到 request,該 Filter 只會執行一次。否則 Spring Security 執行第一次後,因為該 Filter 剛好是個元件(bean),於是 Spring Boot 又執行第二次。

此處特別覆寫了 shouldNotFilter 方法。範例程式的 Controller 中,有「POST /login」與「GET /who-am-i」這兩支 API。我們不預期在存取登入或測試用途的 API 時,還得經過身份認證。藉由在該方法回傳 true,可避免 Filter 處理特定的 request。

(三)處理收到的 JWT

依照同樣的思路,以下會逐步實作出一個接收 header,並處理 JWT 的 Filter。為了進行解析,需注入前面提到的 JwtService。

首先從 Authorization 這個 request header 取出值。由於攜帶 JWT 的格式,是以「Bearer」加一個半形空格做為前綴,因此要進行字串的擷取,才能得到 JWT 的值。將其傳入 JwtService 進行解析後,得到 Claims 介面的物件。

如果 JWT 因為過期、格式錯誤等原因而導致解析失敗,則回傳 HTTP 401 的狀態碼。

接下來筆者將 Claims 物件中所包含的使用者資料,轉換為 MemberUserDetails 類別的規格。好處是透過「getId」、「getNickname」等設計好的方法來取值,會讓我們更加直覺。

呼叫 Claims.get 方法,傳入欄位名稱與要轉換成的型態,即可得到欄位值。要注意的是,該方法僅支援轉換成簡單的 String、Integer、Date 等型態。

若想轉換成其他型態,例如 List、陣列或自定義的類別等,請參考文件,實作反序列化(deserialize)的方式。或者先透過開發工具的 debug 模式確認資料型態,再像範例程式中的 authorities 欄位那樣,直接強制轉換。

Claims 中的資料包裝成 MemberUserDetails 後,最後就是要放入 Security Context 了。放入的方式,是提供 Authentication 介面的物件。

此處選擇 UsernamePasswordAuthenticationToken。它是一個方便的物件,能夠以 Object 型態攜帶使用者資料。也能以 Collection<? extends GrantedAuthority> 介面,攜帶權限資料給 Spring Security。

(四)配置到過濾鏈

實作完 JWT 的認證 Filter 後,我們需要添加到 Spring Security 的 filter chain 中,認證的效果才能生效。

Spring Security 的 filter chain 會比其他 Filter 還優先執行。而裡頭最後一個執行的 Filter 叫做 AuthorizationFilter,正是負責 API 的授權。

因此,若未將自定義的認證 Filter 添加到 Spring Security 中,那麼它就會比 AuthorizationFilter 還晚執行。即便邏輯中有提供權限資料給 Security Context,但早就先被判定為不允許授權了。

此處呼叫 addFilterBefore 方法,將 JwtAuthenticationFilter 放置在 BasicAuthenticationFilter 的前面。選擇相鄰位置的考量,是因為它們的性質相同,筆者認為較不會影響 filter chain 的運作。


三、在 Controller 測試

上一節實作出認證的 Filter 後,本節我們會在 Controller 取出 API 存取方的使用者資料進行運用。

上面的範例程式中,從 Security Context 取出了 principal 的資料。如果存取 API 時未攜帶 JWT,principal 的值固定會是「anonymousUser」字串。針對此情形,僅回傳簡單的字串。

若 JwtAuthenticationFilter 有將解析成功的結果轉換為 MemberUserDetails,並放入 Security Context,則 principal 的值將會是這份使用者資料。

如果讀者對 Security Context 不太了解,可參考第 17.4 課。或者想將存取 Security Context 的過程封裝成元件,該文亦有範例程式。

下圖是使用 Postman 進行測試的結果。

spring-security-postman-send-jwt-to-get-context

可看到 API 回傳了 id、帳號、暱稱與權限,成功以 JWT 取代 HTTP Basic 的認證方式。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch17-6

上一篇:【Spring Boot】第17.5課-將 Spring Security 與 JWT 結合,實作登入 API

下一篇:【Spring Boot】第20課-切面導向程式設計(AOP)

留言

  1. 提醒一下,如果有人在寫完generateToken之後想寫單元測試去測,很大的機率會出錯,然後如果又用debugger去找,很有可能會發現AuthenticationManager會是空的,所以會一直出錯,我個人猜測是因為如果是用單元測試,程式不會將AuthenticationManager這個物件注入進去(雖然有AutoWired),但是用PostMan是可以的
    另外如果有人有出現implementation for interface io.jsonwebtoken.io.Serializer using java.util.ServiceLoader.的錯誤

    可以把這個依賴加進去

    io.jsonwebtoken
    jjwt-jackson
    0.11.1
    runtime

    回覆刪除

張貼留言