一、前言
假設對 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(JWT)
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等等,后面有時間的話也會慢慢更。