Spring Security OAuth2 完全解析 (流程/原理/實戰定製) —— Client / ResourceServer 篇

一、前言

本文假設讀者對 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 復現。

  1. 後端在 .yml 配置中做好相關配置
  2. 訪問受限資源”/user”,後端鑒權異常後,由LoginUrlAuthenticationEntryPoint重定向到登錄頁。
  3. 登錄頁則由DefaultLoginPageGeneratingFilter根據相關配置自動構造頁面String返回。
  4. 頁面點擊<a "href"="/oauth2/authorization/gitee">發出授權請求。
    後端OAuth2AuthorizationRequestRedirectFilter匹配響應該模板路徑,返回實際授權碼請求的重定向響應,轉入三方授權頁面:
  5. 同意授權後,Gitee會向遊覽器返回重定向響應。
    遊覽器向 “redirect-uri” 發起訪問,此時被後端OAuth2LoginAuthenticationFilter匹配處理,其會用請求攜帶的 code 向配置的 “token-uri、user-info-uri” 發起一系列請求,最後構造出認證後的身份放入SecurityContext,以SESSION持久化等。再將先前保存在SESSION中的的受限資源訪問請求拿出,重定向重新訪問。

三、oauth2Login、oauth2Client 解析

1. 兩者區別

這兩者都是在SpringSecurity中整合OAuth2的入口方法(例http.oauth2Login()),對應OAuth2LoginConfigurerOAuth2ClientConfigurer,只是引入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

  1. OAuth2LoginAuthenticationToken
    用以給Provider認證過渡用,最初僅含code,最終包含access_token、user等。
  2. OAuth2AuthorizationCodeAuthenticationToken
    用以給Provider認證過渡用,未填充時僅含code,經填充後包含access_token等。
  3. OAuth2AuthenticationToken
    authenticated=true 認證後安全上下文實際保存的OAuth2用戶認證,由convert將填充後的OAuth2LoginAuthenticationToken轉換而來。

5. 相關 Filter

  1. OAuth2AuthorizationRequestRedirectFilter

    1. 通過調用OAuth2AuthorizationRequestResolver用於判斷是否為授權請求(默認為 “/oauth2/authorization/{registrationId}”,可通過.oauth2Login().authorizationEndpoint().baseUri()配置) ,並且請求包裝為OAuth2AuthorizationRequest後由authorizationRequestRepository(默認基於SESSION實現)將授權請求保存(後有他用)
    2. 隨後重定向到追加了參數(client-id、response_type)的真實授權碼請求。
  2. OAuth2LoginAuthenticationFilter
    繼承自AbstractAuthenticationProcessingFilter,即負責身份認證的Filter。

    1. 當是loginProcessingUrl(默認為/login/oauth2/code/*)請求且帶了code和state時,嘗試以這倆參數構建OAuth2LoginAuthenticationToken且調用AuthenticationManager去進行認證。
    2. 認證通過後,調用authenticationResultConverter將認證後完全填充的OAuth2LoginAuthenticationToken轉為authenticated=true的OAuth2AuthenticationToken,用以代表認證後的身份。(該converter默認就是直接提取填充後的”principal、authorities、clientid”直接new)
    3. 將先前得到 “token、refreshToken” 等信息包裝為OAuth2AuthorizedClient調用 OAuth2AuthorizedClientRepository#saveAuthorizedClient保存起來(默認是基於內存實現的ClientId和Principal為key的Map)
  3. 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

  1. OAuth2LoginAuthenticationProvider

    1. OAuth2LoginAuthenticationToken嘗試認證,其內會進一步構造OAuth2AuthorizationCodeAuthenticationToken,然後調用 OAuth2AuthorizationCodeAuthenticationProvider 對其進行認證。
    2. 經過上述認證後拿到填充了 “access_token” 的OAuth2AuthorizationCodeAuthenticationToken,會構造成OAuth2UserRequest後傳給OAuth2UserService負責進行實際的 “user-info-uri” 請求,並將結果包裝成DefaultOAuth2User返回。(該User擁有兩類authorities,一個是ROLE_USER(Spring在經過oauth2UserService時手動添加的),一類是Token中的SCOPE_{sopces})
  2. 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:在oauth2ResourceServerConfigurer::init時,會構建JwtAuthenticationProvider,它就需要decorder以提供對”token”校驗解析。
  • JwtEncoder:雖然不是必須的,但我們自己系統登錄有令牌分發的需要。

3. 相關 Filter

  • BearerTokenAuthenticationFilter
    (雖沒繼承AbstractAuthenticationProcessingFilter但卻幹着認證的事)
    首先通過DefaultBearerTokenResolver::resolve判斷是否含”token”,然後構建BearerTokenAuthenticationToken並調用AuthenticationManager嘗試認證。
    將認證後的結果JwtAuthenticationToken設置到安全上下文中。如果中途出現了異常,則以該filter的authenticationEntryPoint(可通過.oauth2ResourceServer().authenticationEntryPoint配置) 處理。

4. 相關 Authentication

  1. BearerTokenAuthenticationToken
    代表原始token的一個過渡身份。
  2. JwtAuthenticationToken
    authenticated=true,進行實際系統訪問的身份。由BearerTokenAuthenticationToken認證後,通過JwtAuthenticationConverter轉換而來。

5. 相關 Provider

  • JwtAuthenticationProvider
    對 BearerTokenAuthenticationToken(帶access_token)進行認證。

    1. 內部會調用JwtDecoder::decode(可通過.bearerTokenResolver().jwt().decode配置)對 “token” 進行解析&驗證為Jwt對象。
    2. 調用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等等,後面有時間的話也會慢慢更。