【Spring Boot】第17.4課-從 Security Context 取得 API 存取方的認證資訊 #2024 年更新


https://unsplash.com/photos/LmyPLbbUWhA

在開發商業邏輯時,我們經常需要知道 API 存取方的身份。舉例來說,在網路發表文章、按讚和留言,理論上都要在資料庫中紀錄「誰發的文」、「誰按的讚」和「誰留的言」,亦即資料的建立者。

本文將以 HTTP Basic 認證方式為例,示範如何從「Security Context」取得 API 存取方的使用者資料。接著準備自定義的使用者類別,以便在程式邏輯中能有更多的資料可運用。

最後透過觀察原始碼,認識 Spring Security 進行 HTTP Basic 認證的原理,讓讀者知道該認證資訊從何而來。

一、配置 Spring Security

(一)一般配置

本節進行 Spring Security 的配置,請確認 pom.xml 檔案已經添加以下依賴。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

以下是進行 Spring Security 相關配置的類別。

呼叫 httpBasic 方法,可啟用「HTTP Basic」的認證方式。這樣在發送 request 時,就能在「Authorization」這個 header 攜帶帳密,讓 Spring Security 知道我們的身份。詳情可參考第 17.3 課

呼叫 csrf 方法,可進一步停用對於 CSRF 攻擊的保護機制,讓 Postman 工具能順利存取 API。

呼叫 authorizeHttpRequests 方法,可進一步定義各個 RESTful API 的授權規則。此處設定為 API 不需通過認證也可存取。

(二)準備使用者

以下準備了 1 個測試使用者,帳號為「user1」,密碼為「111」,權限為「學生」與「助理」。

附帶一提,InMemoryUserDetailsManager 本身就有實作 UserDetailsService 介面。若讀者想改為串接真實的資料庫,只要基於此介面進行抽換即可,做法可參考第 17.2 課


二、取得 Security Context 的認證資訊

以下是在 Controller 中準備一支 RESTful API,用途是存取時,能得到自身的使用者資料。

在 API 的程式邏輯中,第一行呼叫了 SecurityContextHolder 類別的靜態方法,取得了 SecurityContext 物件。第二行從 SecurityContext 物件取得 Authentication 物件。

呼叫 Authentication 物件的 getPrincipal 方法,可得到 API 存取方的使用者資料。但該值會隨著認證情況的不同而有差異,所以回傳值型態是 Object

若尚未通過認證,principal 的值固定會是「anonymousUser」字串。若通過認證,則為 UserDetails 物件。在範例程式中,我們取出帳號與權限的值進行運用。

下圖是使用 Postman,以 HTTP Basic 認證方式在 request header 攜帶帳密後存取 API。

spring-security-context-postman-get-simple-info

而下圖是以未認證的身份,匿名存取 API。

spring-security-context-postman-get-anonymous-user

上面見到了許多類別與介面,到了本文第五節,筆者會介紹相關的原始碼,讓讀者了解原理。

三、自定義使用者資料

(一)設計使用者類別

上一節的範例程式,取用了使用者的帳號與權限這兩項資料。然而在實務開發上,「使用者」這種資料通常還會有更多欄位,例如 id、名字或信箱等。

接下來讓我們準備自定義的使用者類別,取名為「Member」。它除了基本的帳號、密碼與權限,還包含了 id、暱稱與信箱,共 6 個欄位。

在 Spring Security 中,會以 UserDetails 介面來傳遞使用者資料。這也是為什麼在第二節的範例程式中,我們是呼叫該介面的方法,來取得使用者資料。

現在為了攜帶自定義的資料,我們也需準備一個實作該介面的類別。

這個「MemberUserDetails」類別實作了 UserDetails 介面,並完成介面所規範的方法。此外還提供其他自定義的方法,以取得使用者的 id、暱稱與信箱。這些資料都源自於從建構子傳入的 Member 物件。

(二)實作認證程式

本文第一節的 InMemoryUserDetailsManager 所回傳的 UserDetails,它的實作類別固定是內建的 User,我們無法使它回傳先前設計好的 MemberUserDetails。

因此請讀者準備另一個實作 UserDetailsService 介面的類別來代替,讓它在 Spring Security 進行認證時被使用。

接著在 Spring Security 的配置類別中,將它建立為元件。

此處同時將自定義的 Member 物件傳入,模擬出在資料庫中儲存使用者。如此就能把 UserDetails 介面的實作類別,換成自定義的 MemberUserDetails 了。

另外也需建立 PasswordEncoder 元件,以指定密碼的加密方式,筆者選擇不加密。


四、封裝取得認證資訊的邏輯

在第二節的 Controller 範例程式中,我們呼叫 SecurityContextHolder 的方法取得 SecurityContext,接著再取得 Authentication,最後取得認證資訊 principal。

由於太繁瑣了,因此本節的目的是將這段過程封裝成一個元件。筆者取名為「UserIdentity」,代表用途是取得身份。

類別中宣告了「getMemberUserDetails」方法,將取得認證資訊 principal 的過程封裝起來。若認證成功,便轉型為 MemberUserDetails,供其他方法呼叫。

若為匿名存取 API,則回傳空值的物件。此處運用了「Null Object Pattern」設計模式,避免其他方法取用 MemberUserDetails 物件時,意外導致「Null Pointer Exception」例外。

此元件提供了取得 id、帳號、信箱、權限以及是否匿名等使用者資料的方法。讓我們將其注入到 Controller 中,在程式邏輯取得這些資料做運用。

下圖是以「user1」的使用者身份存取 API。

spring-security-context-postman-get-customized-info

如此一來,讀者在開發商業邏輯時,不論是要在資料庫儲存文章的建立者,還是將暱稱寫入訊息文字,又或者是寄送郵件,都能更輕鬆地取得當前 API 存取方的使用者資料。

五、HTTP Basic 認證的原理

(一)Filter 原始碼

我們已經知道如何取用 SecurityContext 中的認證資訊,而本節將介紹該內容是從何而來。

Spring Security 是透過 Java Servlet 的「Filter」,才能在 request 到達 Controller 前進行認證與授權。其中 BasicAuthenticationFilter 是專門處理 HTTP Basic 認證的 Filter。

以下是它的部份原始碼,供讀者參考。

本節會提到該 Filter 中的 3 個重要元件:AuthenticationConverterAuthenticationManagerSecurityContextHolderStrategy。接下來筆者將搭配 UML 的類別圖,分別進行說明。

(二)AuthenticationConverter 解析帳密

HTTP Basic 是一種經由在 request header 攜帶帳號與密碼,讓後端進行身份認證的方式。

Filter 接收到 request 後,首先 AuthenticationConverter 會從 HttpServletRequest 取出「Authorization」這個 header 的值,進行 Base64 解碼。解析出帳密後,封裝成 Authentication 物件。

spring-boot-security-authentication-converter-class-diagram

AuthenticationConverter 介面的實作類別是 BasicAuthenticationConverter,它會回傳 Authentication 介面的實作類別 UsernamePasswordAuthenticationToken

解析出的帳號,會放在 UsernamePasswordAuthenticationToken 物件的 principal 欄位,而密碼放在 credentials 欄位。

(三)AuthenticationManager 進行認證

將帳號與密碼的值封裝成 Authentication 介面的物件後,接下來 AuthenticationManager 會進行身份認證,並回傳另一個新的 Authentication 物件。兩個物件的差別在於,新物件會包含認證後的結果,即使用者資料。

這個 AuthenticationManager 介面,有個實作類別叫做 ProviderManager,它擁有多個 AuthenticationProvider 介面的物件,讀者可理解成「認證功能的提供者」。

spring-boot-security-authentication-manager-class-diagram

在 HTTP Basic 認證的情況下,會由 DaoAuthenticationProvider 提供認證功能。它接收 Authentication 物件後,使用了 UserDetailsServicePasswordEncoder 進行帳號與密碼的認證。

認證成功後,DaoAuthenticationProvider 會將 UserDetailsService 回傳的 UserDetails 放入 UsernamePasswordAuthenticationToken 物件的 principal 欄位中,並以 Authentication 介面回傳。

(四)SecurityContextHolderStrategy 管理認證資訊

Spring Security 會將認證後的使用者資料儲存於記憶體,讓我們在程式邏輯中能使用。就像在 Controller 的範例程式取出 UserDetails 物件那樣。

負責管理認證資訊的是 SecurityContextHolder 中的 SecurityContextHolderStrategy。後者是一個介面,代表管理的「策略」,Spring Security 內建數種實作好的策略。

而前者是一個類別,會在啟動程式時選擇其中一種管理策略。除此之外,就只是對外提供存取認證資訊的方法罷了。

spring-boot-security-context-holder-class-diagram

預設的管理策略是 ThreadLocalSecurityContextHolderStrategy,它採用了「ThreadLocal」的技術,讓每個執行緒只能取得屬於自己的資料。因此,即便 Spring Boot 同時處理許多 request,但並不會意外地取得他人的認證資訊。

回到 Filter 的邏輯。接下來會產生一個 SecurityContext 介面的物件,將認證後的 Authentication 物件封裝起來。

最後將 SecurityContext 存回 SecurityContextHolderStrategy,就完成 HTTP Basic 認證的流程。

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

上一篇:【Spring Boot】第17.3課-在 Spring Security 使用 HTTP Basic 認證

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

留言

  1. 您好,想請問一般情況下,專案針對SpringSecurity+Jwt的部分也都是會進行單元測試嗎?
    因為要用mockMvc驗證API權限的情況下,感覺也比較偏向整合測試,
    再來這邊還有一點疑問就是,
    針對權限設置
    http.authorizeHttpRequests(registry ->
    registry.requestMatchers(HttpMethod.POST, "/users").permitAll()
    這邊使用的方式是讀取資料庫權限表做動態加載,
    如果要做測試勢必是不會進資料庫,那有沒有什麼方式做手動的權限配置呢?
    因為預設是用.anyRequest().authenticated(),
    requestMatchers沒有設置路徑會變成所有API都會被擋下

    回覆刪除
    回覆
    1. 嗨嗨,如果只是想測產生或驗證 JWT 的邏輯,單元測試要寫也是可以寫

      但若想測試發送 request 時,是否會被 Spring Security 阻擋,那就是整合測試的範疇了,直接用 MockMvc 即可!
      你會需要建立 HttpHeaders 物件,放置 access token,並將 HttpHeaders 傳入 MockMvc,詳細的用法再稍微查一下

      至於後面「要做測試勢必是不會進資料庫」的問題,我沒有看懂,可以再說明一下 :)

      刪除
    2. 您好,JWT測試的部分,secretKey如果不是固定字串也沒辦法驗證吧?
      secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
      jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
      因為我是用這種方式建,變成每次重啟都會變動,
      沒實戰過JWT,練習單純是不想用固定字段,也不確定一般的做法是怎麼樣或有什麼規範

      至於進資料庫的問題,我是想說一般做單元測試都是塞假資料或是mock,不會連資料庫,那要讀取資料庫做動態加載的部分要怎麼處理,但如果是整合測試這邊想法是直接用H2應該就可行了吧,也不會影響到測試環境的資料,就單純拿來運行測試

      刪除
    3. 我覺得還是用固定字串(Keys.hmacShaKeyFor 方法)來當作 Key 好了,不然單元測試似乎不好寫。感覺要去 mock「secretKey 」的方法,但又不知該如何 mock 才好。

      而後面的問題,意思是你想要寫整合測試,且希望 UserDetailsServiceImpl 能連真實的 DB。但範例程式中的資料卻是 hard code 的,不知道怎麼用自己的測試資料來寫嗎?(你也可以舉例測試情境)

      刪除
    4. 沒事,大致上沒問題了哈哈
      決定先寫controller以外的unitTest
      最後再用整合測試直接測API
      感謝你

      刪除
  2. 作者已經移除這則留言。

    回覆刪除
  3. 您好!感謝您的分享,我從中收穫很多
    這邊想要詢問比較進階的情況,就是使用H2 Databsaes跟Spring Security的時候,雖然可以Permit H2的網址,可以進入H2的登入畫面,但輸入帳密之後會無法進入控制台,都顯示Forbidden的情況,看網路上是說因為iframe被Security靜止的關係,但網路上並沒有人針對新版本的Security教如何開啟iframe,請問您可以開一個教學或者提供建議嗎~?

    回覆刪除
    回覆
    1. 嗨,我沒有用過 H2 這款資料庫,可能沒辦法很好的回答。
      你在 Security 設定 permit H2 的網址時,API 路徑格式是如何寫的呢?記得要用「**」才能套用到底下所有路徑,比方說「/h2-console/**」。

      刪除
    2. 謝謝您的分享,我已經用**套用到所有的路徑,有趣的是H2有一個登入介面,要在登入後才能操作整個資料庫,而在沒有套用/h2-console/**連登入介面都是forbidden,套用後可以進入登入介面,但登入資料庫後卻被forbidden,如果您有興趣可以嘗試引入H2的依賴玩玩看

      刪除
    3. 請問使用 jwt 的方式要如何在使用remember me?

      刪除
  4. 請問使用 jwt 的方式要如何在使用remember me?

    回覆刪除
    回覆
    1. 嗨,你說的「remember me」是指使用者下次回到網站,不必重新登入嗎?我覺得這個需要前端來配合。
      前端透過登入的 API 取得 JWT 後,請瀏覽器存在 Local Storage 之類的地方。當使用者下次回來網站,就取出該 JWT,放在 request header 中即可。
      由於前端不是我的專業,可能沒辦法給太多想法 :(

      刪除

張貼留言