Spring Security OAuth2 完全解析 (流程/原理/實戰定製) —— Client / ResourceServer 篇
- 2022 年 1 月 11 日
- 筆記
- JAVA, spring security
一、前言
本文假設讀者對 Spring Security 本身原理有一定程度的了解,假設對 OAuth2 規範流程、Jwt 有基礎了解,以此來對 SpringSecurity 整合 OAuth2 有個快速全面的認識。
(關於總體流程,若對SS實在不熟悉可以簡單理解為:Filter構造Authentication-> Provider認證並填充-> 設置到SecurityContext -> 而後用於Filter/AOP鑒權)
要了解一個SpringSecurity模塊,就是了解它如何身份認證、如何自定義、(至於如何鑒權是SpringSecurity通用部分),對應到代碼就是:背後的相關 Configurer、Filter、Authentication、Provider。
本文以最新Spring Boot版本2.6.2 按此邏輯梳理;涉及源碼太多,就不放源碼對照了,可以自行fork查看;斜體表示可配置自定義替換的部分
- 第一部分:先演示默認配置下 spring-boot-starter-oauth2-client 所帶來的流程和效果,建立大概認知。對應代碼 thirdpart-login 項目
- 第二部分:全面解析 oauth2login、oauth2client 原理。
- 第三部分:常見業務下我們自己用戶系統也有token分發需求,因此也解讀下提供JWT服務的 oauth2ResourceServer 模塊
- 第四部分:綜上所述,實戰定製SpringSecurity OAuth2。對應代碼 thirdpart-login-custom
二、默認 spring-boot-starter-oauth2-client 效果預覽
由 OAuth2ClientAutoConfiguration
自動配置類引入的默認配置,可由代碼 thirdpart-login 復現。
- 後端在 .yml 配置中做好相關配置
- 訪問受限資源”/user”,後端鑒權異常後,由
LoginUrlAuthenticationEntryPoint
重定向到登錄頁。
- 登錄頁則由
DefaultLoginPageGeneratingFilter
根據相關配置自動構造頁面String返回。
- 頁面點擊
<a "href"="/oauth2/authorization/gitee">
發出授權請求。
後端OAuth2AuthorizationRequestRedirectFilter
匹配響應該模板路徑,返回實際授權碼請求的重定向響應,轉入三方授權頁面:
- 同意授權後,Gitee會向遊覽器返回重定向響應。
遊覽器向 “redirect-uri” 發起訪問,此時被後端OAuth2LoginAuthenticationFilter
匹配處理,其會用請求攜帶的 code 向配置的 “token-uri、user-info-uri” 發起一系列請求,最後構造出認證後的身份放入SecurityContext
,以SESSION持久化等。再將先前保存在SESSION中的的受限資源訪問請求拿出,重定向重新訪問。
三、oauth2Login、oauth2Client 解析
1. 兩者區別
這兩者都是在SpringSecurity中整合OAuth2的入口方法(例http.oauth2Login()
),對應OAuth2LoginConfigurer
、OAuth2ClientConfigurer
,只是引入Filter有所異同。簡而言之:
oauth2Login
會在授權請求時進行認證(即設置安全上下文SecurityContext),背後會連續訪問acc_token&user-info-url 將獲取的用戶信息構造填充 Authentication。- 而
oauth2Client
也會對授權請求進行處理,但只是獲取到access_token後用repository存起來(要怎麼使用自行處理),不會認證,這也意味着需要自行實現認證邏輯。
2. 從 OAuth2 請求的維度概覽
- 對 授權碼code 的請求:由
OAuth2AuthorizationRequestRedirectFilter
響應”授權請求”向客戶端返回重定向響應,定向到實際 “authorization-uri” - 對 access_token 和 user-info-uri 實際請求:
OAuth2LoginAuthenticationFilter
會對回調地址(攜帶了code和state)進行處理,調用AuthemticationManager
進行認證。背後OAuth2LoginAuthenticationProvider
會進行連續 token-uri、user-info-uri 請求,最後返回完全填充的OAuth2LoginAuthenticationToken
。
3. 必須要配置的屬性:
太長就不貼了 參考Github項目代碼,CommonOAuth2Provider
也內建了一些常見OAuth2提供方,在之內少配幾個字段也沒關係。
- Client屬性:
OAuth2ClientRegistrationRepositoryConfiguration
用以處理application.yml中的相關屬性,並構建代表OAuth2方的一個個ClientRegistration
。根據不同模式,對必須屬性有不同要求。
- provider屬性:除了上圖授權碼模式下必須校驗的 “authorization-uri、token-uri” 外,”user-info-uri”、
userNameAttribute
也是必須的,在後續OAuth2LoginAuthenticationProvider
調用的DefaultOAuth2UserService
內,必須需要這倆屬性才能嘗試訪問 user-info-uri 並包裝為DefaultOAuth2User
。
4. 相關 Authentication
- OAuth2LoginAuthenticationToken
用以給Provider認證過渡用,最初僅含code,最終包含access_token、user等。 - OAuth2AuthorizationCodeAuthenticationToken
用以給Provider認證過渡用,未填充時僅含code,經填充後包含access_token等。 - OAuth2AuthenticationToken
authenticated=true
認證後安全上下文實際保存的OAuth2用戶認證,由convert
將填充後的OAuth2LoginAuthenticationToken
轉換而來。
5. 相關 Filter
-
OAuth2AuthorizationRequestRedirectFilter
- 通過調用
OAuth2AuthorizationRequestResolver
用於判斷是否為授權請求(默認為 “/oauth2/authorization/{registrationId}”,可通過.oauth2Login().authorizationEndpoint().baseUri()
配置) ,並且請求包裝為OAuth2AuthorizationRequest
後由authorizationRequestRepository
(默認基於SESSION實現)將授權請求保存(後有他用) - 隨後重定向到追加了參數(client-id、response_type)的真實授權碼請求。
- 通過調用
-
OAuth2LoginAuthenticationFilter
繼承自AbstractAuthenticationProcessingFilter
,即負責身份認證的Filter。- 當是
loginProcessingUrl
(默認為/login/oauth2/code/*)請求且帶了code和state時,嘗試以這倆參數構建OAuth2LoginAuthenticationToken
且調用AuthenticationManager
去進行認證。 - 認證通過後,調用
authenticationResultConverter
將認證後完全填充的OAuth2LoginAuthenticationToken
轉為authenticated=true的OAuth2AuthenticationToken
,用以代表認證後的身份。(該converter默認就是直接提取填充後的”principal、authorities、clientid”直接new) - 將先前得到 “token、refreshToken” 等信息包裝為
OAuth2AuthorizedClient
調用OAuth2AuthorizedClientRepository#saveAuthorizedClient
保存起來(默認是基於內存實現的ClientId和Principal為key的Map)
- 當是
-
OAuth2AuthorizationCodeGrantFilter
(該Filter,在oauth2Login()
下會永遠被跳過,因為該請求已被OAuth2LoginAuthenticationFilter
處理後通過successHandler
重定向)
匹配帶code與state的請求(表示回調請求)且滿足authorizationRequestRepository.loadAuthorizationRequest
不為空時(表示經過了RedirectFilter,是先前授權請求發起的),會構造OAuth2AuthorizationCodeAuthenticationToken
交由AuthenticationManager
(背後交由OAuth2AuthorizationCodeAuthenticationProvider
)進行認證,並將結果構造為OAuth2AuthorizedClient
交由authorizedClientRepository
保存,然後去除參數再將請求重定向到 “savedRequest 或者 redirect-url”。
【註:不是很能理解該Filter這裡為什麼要重定向,這個重定向真的很惱火。如果API自身需要code,這重定向把參數清除了會報錯;而即便API不要code了依附於它的邏輯使用authorizedClientRepository
,那也是無意義多一次請求。而且其基於SESSION的實現本來沒什麼問題,但非要重定向請求一次就導致單純的多實例時會存在問題】
6. 相關 Provider
-
OAuth2LoginAuthenticationProvider
- 對
OAuth2LoginAuthenticationToken
嘗試認證,其內會進一步構造OAuth2AuthorizationCodeAuthenticationToken
,然後調用 OAuth2AuthorizationCodeAuthenticationProvider 對其進行認證。 - 經過上述認證後拿到填充了 “access_token” 的
OAuth2AuthorizationCodeAuthenticationToken
,會構造成OAuth2UserRequest
後傳給OAuth2UserService
負責進行實際的 “user-info-uri” 請求,並將結果包裝成DefaultOAuth2User
返回。(該User擁有兩類authorities,一個是ROLE_USER(Spring在經過oauth2UserService時手動添加的),一類是Token中的SCOPE_{sopces})
- 對
-
OAuth2AuthorizationCodeAuthenticationProvider
對OAuth2AuthorizationCodeAuthenticationToken
嘗試認證,內部會構造對”token-uri”的實際請求,並調用DefaultAuthorizationCodeTokenResponseClient
進行請求返回,並根據返回結果OAuth2AccessTokenResponse
(內含access_token/refreash_token),新new一個填充了”access_token”的OAuth2AuthorizationCodeAuthenticationToken
返回。
四、oauth2ResourceServer
1. 概述
由 org.springframework.boot:spring-boot-starter-oauth2-resource-server
引入,提供對請求中攜帶token校驗解析、身份認證的服務。
2. 必須的配置
- JwtDecoder:在
oauth2ResourceServer
的Configurer::init
時,會構建JwtAuthenticationProvider
,它就需要decorder
以提供對”token”校驗解析。 - JwtEncoder:雖然不是必須的,但我們自己系統登錄有令牌分發的需要。
3. 相關 Filter
- BearerTokenAuthenticationFilter
(雖沒繼承AbstractAuthenticationProcessingFilter
但卻幹着認證的事)
首先通過DefaultBearerTokenResolver::resolve
判斷是否含”token”,然後構建BearerTokenAuthenticationToken
並調用AuthenticationManager
嘗試認證。
將認證後的結果JwtAuthenticationToken
設置到安全上下文中。如果中途出現了異常,則以該filter的authenticationEntryPoint
(可通過.oauth2ResourceServer().authenticationEntryPoint
配置) 處理。
4. 相關 Authentication
- BearerTokenAuthenticationToken
代表原始token的一個過渡身份。 - JwtAuthenticationToken
其authenticated=true
,進行實際系統訪問的身份。由BearerTokenAuthenticationToken
認證後,通過JwtAuthenticationConverter
轉換而來。
5. 相關 Provider
- JwtAuthenticationProvider
對 BearerTokenAuthenticationToken(帶access_token)進行認證。- 內部會調用
JwtDecoder::decode
(可通過.bearerTokenResolver().jwt().decode
配置)對 “token” 進行解析&驗證為Jwt
對象。 - 調用
JwtAuthenticationConverter
(可通過.bearerTokenResolver().jwt().jwtAuthenticationConverter
配置)嘗試對Jwt
進一步轉換為進行實際系統訪問的(authenticated=true)JwtAuthenticationToken
返回。(默認converter
內部會調用jwtGrantedAuthoritiesConverter
解析Jwt
填充 authorities(將”scpoe”/”scp”聲明中空格分隔的字串轉為SimpleGrantedAuthority
);將”sub”字段作為 principal)
- 內部會調用
五、前後端分離實戰定製
實際情況中,除了OAuth2登錄,我們系統自身也有完整的用戶體系,也有按自己業務定製的token構建分發服務。
三方登錄僅作為綁定手段,而且在初次三方登錄時 往往還需補全信息註冊到我們自己的用戶體系。
最終實現代碼以及效果展示都放在Github上了:spring-security-oauth2-sample
登錄流程,大致API流程:
六、後記
從上文也能看出,不得不提 筆者實際用SpringSecurity很多時候寧願遷出去自己寫套Configuer/Filter/Provider…,官方雖提供了很多服務,而且也能看出在儘可能定製化。但背後還是強制耦合引入了太多邏輯,很難與實際業務契合,即便稍有不同在它基礎上定製也都需要付出很大代價。這代價不僅指新增代碼行數,為了運行穩定 你首先就得徹底清楚它原本引入了哪些邏輯,這就需要大量上手成本。
本文仍存在些許問題,特別是OAuth2AuthorizationCodeGrantFilter
的重定向問題,還有與無狀態相悖的oauth2AuthorizedClientRepository
涉及較少,也沒一張清晰流程圖,時間關係暫且就這樣了。即便要用好也得知其然 知其所以然,筆者撰文也只是盡量往上靠,有什麼問題還希望指正討論。
關於 Spring Security 對 OAuth2 認證服務org.springframework.security:spring-security-oauth2-authorization-server
的實現,以及前言提到的 SpringSecurity原理、JWT等等,後面有時間的話也會慢慢更。