Spring Security,沒有看起來那麼複雜(附原始碼)

【雨歌】發表於2021-01-27

許可權管理是每個專案必備的功能,只是各自要求的複雜程度不同,簡單的專案可能一個 Filter 或 Interceptor 就解決了,複雜一點的就可能會引入安全框架,如 Shiro, Spring Security 等。
其中 Spring Security 因其涉及的流程、類過多,看起來比較複雜難懂而被詬病。但如果能捋清其中的關鍵環節、關鍵類,Spring Security 其實也沒有傳說中那麼複雜。本文結合腳手架框架的許可權管理實現(jboost-auth 模組,原始碼獲取見文末),對 Spring Security 的認證、授權機制進行深入分析。

使用 Spring Security 認證、鑑權機制

Spring Security 主要實現了 Authentication(認證——你是誰?)、Authorization(鑑權——你能幹什麼?)

認證(登入)流程

Spring Security 的認證流程及涉及的主要類如下圖,

SpringSecurity認證

認證入口為 AbstractAuthenticationProcessingFilter,一般實現有 UsernamePasswordAuthenticationFilter

  1. filter 解析請求引數,將客戶端提交的使用者名稱、密碼等封裝為 Authentication,Authentication 一般實現有 UsernamePasswordAuthenticationToken
  2. filter 呼叫 AuthenticationManager 的 authenticate() 方法對 Authentication 進行認證,AuthenticationManager 的預設實現是
    ProviderManager
  3. ProviderManager 認證時,委託給一個 AuthenticationProvider 列表,呼叫列表中 AuthenticationProvider 的 authenticate()
    方法來進行認證,只要有一個通過,則認證成功,否則丟擲 AuthenticationException 異常(AuthenticationProvider 還有一個 supports() 方法,用來判斷該 Provider
    是否對當前型別的 Authentication 進行認證)
  4. 認證完成後,filter 通過 AuthenticationSuccessHandler(成功時) 或 AuthenticationFailureHandler(失敗時)來對認證結果進行處理,如返回 token 或 認證錯誤提示

認證涉及的關鍵類

  1. 登入認證入口 UsernamePasswordAuthenticationFilter

專案中 RestAuthenticationFilter 繼承了 UsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter 將客戶端提交的引數封裝為
UsernamePasswordAuthenticationToken,供 AuthenticationManager 進行認證。

RestAuthenticationFilter 覆寫了 UsernamePasswordAuthenticationFilter 的 attemptAuthentication(request,response) 方法邏輯,根據
loginType 的值來將登入引數封裝到認證資訊 Authentication 中,(loginType 為 USER 時為 UsernameAuthenticationToken,
loginType 為 Phone 時為 PhoneAuthenticationToken),供下游 AuthenticationManager 進行認證。

  1. 認證資訊 Authentication

使用 Authentication 的實現來儲存認證資訊,一般為 UsernamePasswordAuthenticationToken,包括

  • principal:身份主體,通常是使用者名稱或手機號
  • credentials:身份憑證,通常是密碼或手機驗證碼
  • authorities:授權資訊,通常是角色 Role
  • isAuthenticated:認證狀態,表示是否已認證

本專案中的 Authentication 實現:

  • UsernameAuthenticationToken: 使用使用者名稱登入時封裝的 Authentication

    • principal => username
    • credentials => password
    • 擴充套件了兩個屬性: uuid, code,用來驗證圖形驗證碼
  • PhoneAuthenticationToken: 使用手機驗證碼登入時封裝的 Authentication

    • principal => phone(手機號)
    • credentials => code(驗證碼)

兩者都繼承了 UsernamePasswordAuthenticationToken。

  1. 認證管理器 AuthenticationManager

認證管理器介面 AuthenticationManager,包含一個 authenticate(authentication) 方法。
ProviderManager 是 AuthenticationManager 的實現,管理一個 AuthenticationProvider(具體認證邏輯提供者)列表。在其 authenticate(authentication ) 方法中,對 AuthenticationProvider 列表中每一個 AuthenticationProvider,呼叫其 supports(Class<?> authentication) 方法來判斷是否採用該
Provider 來對 Authentication 進行認證,如果適用則呼叫 AuthenticationProvider 的 authenticate(authentication)
來完成認證,只要其中一個完成認證,則返回。

  1. 認證提供者 AuthenticationProvider

由3可知認證的真正邏輯由 AuthenticationProvider 提供,本專案的認證邏輯提供者包括

  • UsernameAuthenticationProvider: 支援對 UsernameAuthenticationToken 型別的認證資訊進行認證。同時使用 PasswordRetryUserDetailsChecker
    來對密碼錯誤次數超過5次的使用者,在10分鐘內限制其登入操作
  • PhoneAuthenticationProvider: 支援對 PhoneAuthenticationToken 型別的認證資訊進行認證

兩者都繼承了 DaoAuthenticationProvider —— 通過 UserDetailsService 的 loadUserByUsername(String username) 獲取儲存的使用者資訊
UserDetails,再與客戶端提交的認證資訊 Authentication 進行比較(如與 UsernameAuthenticationToken 的密碼進行比對),來完成認證。

  1. 使用者資訊獲取 UserDetailsService

UserDetailsService 提供 loadUserByUsername(username) 方法,可獲取已儲存的使用者資訊(如儲存在資料庫中的使用者賬號資訊)。

本專案的 UserDetailsService 實現包括

  • UsernameUserDetailsService:通過使用者名稱從資料庫獲取賬號資訊
  • PhoneUserDetailsService:通過手機號碼從資料庫獲取賬號資訊
  1. 認證結果處理

認證成功,呼叫 AuthenticationSuccessHandler 的 onAuthenticationSuccess(request, response, authentication) 方法,在 SecurityConfiguration 中注入 RestAuthenticationFilter 時進行了設定。 本專案中認證成功後,生成 jwt token返回客戶端。

認證失敗(賬號校驗失敗或過程中丟擲異常),呼叫 AuthenticationFailureHandler 的 onAuthenticationFailure(request, response, exception) 方法,在 SecurityConfiguration 中注入 RestAuthenticationFilter 時進行了設定,返回錯誤資訊。

以上關鍵類及其關聯基本都在 SecurityConfiguration 進行配置。

  1. 工具類

SecurityContextHolder 是 SecurityContext 的容器,預設使用 ThreadLocal 儲存,使得在相同執行緒的方法中都可訪問到 SecurityContext。
SecurityContext 主要是儲存應用的 principal 資訊,在 Spring Security 中用 Authentication 來表示。在
AbstractAuthenticationProcessingFilter 中,認證成功後,呼叫 successfulAuthentication() 方法使用 SecurityContextHolder 來儲存
Authentication,並呼叫 AuthenticationSuccessHandler 來完成後續工作(比如返回token等)。

使用 SecurityContextHolder 來獲取使用者資訊示例:

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
    String username = ((UserDetails)principal).getUsername();
} else {
    String username = principal.toString();
}

鑑權流程

Spring Security 的鑑權(授權)有兩種實現機制:

  • FilterSecurityInterceptor:通過 Filter 對 HTTP 資源的訪問進行鑑權
  • MethodSecurityInterceptor:通過 AOP 對方法的呼叫進行鑑權。在 GlobalMethodSecurityConfiguration 中注入,
    需要在配置類上新增註解 @EnableGlobalMethodSecurity(prePostEnabled = true) 使 GlobalMethodSecurityConfiguration 配置生效。

鑑權流程及涉及的主要類如下圖,

springsecurity鑑權

  1. 登入完成後,一般返回 token 供下次呼叫時攜帶進行身份認證,生成 Authentication
  2. FilterSecurityInterceptor 攔截器通過 FilterInvocationSecurityMetadataSource 獲取訪問當前資源需要的許可權
  3. FilterSecurityInterceptor 呼叫鑑權管理器 AccessDecisionManager 的 decide 方法進行鑑權
  4. AccessDecisionManager 通過 AccessDecisionVoter 列表的鑑權投票,確定是否通過鑑權,如果不通過則丟擲 AccessDeniedException 異常
  5. MethodSecurityInterceptor 流程與 FilterSecurityInterceptor 類似

鑑權涉及的關鍵類

  1. 認證資訊提取 RestAuthorizationFilter

對於前後端分離專案,登入完成後,接下來我們一般通過登入時返回的 token 來訪問介面。

在鑑權開始前,我們需要將 token 進行驗證,然後生成認證資訊 Authentication 交給下游進行鑑權(授權)。

本專案 RestAuthorizationFilter 將客戶端上報的 jwt token 進行解析,得到 UserDetails, 並對 token 進行有效性校驗,並生成
Authentication(UsernamePasswordAuthenticationToken),通過
SecurityContextHolder 存入 SecurityContext 中供下游使用。

  1. 鑑權入口 AbstractSecurityInterceptor

三個實現:

  • FilterSecurityInterceptor:基於 Filter 的鑑權實現,作用於 Http 介面層級。FilterSecurityInterceptor 從 SecurityMetadataSource 的實現 DefaultFilterInvocationSecurityMetadataSource 獲取要訪問資源所需要的許可權
    Collection,然後呼叫 AccessDecisionManager 進行授權決策投票,若投票通過,則允許訪問資源,否則將禁止訪問。
  • MethodSecurityInterceptor:基於 AOP 的鑑權實現,作用於方法層級。
  • AspectJMethodSecurityInterceptor:用來支援 AspectJ JointPoint 的 MethodSecurityInterceptor
  1. 獲取資源許可權資訊 SecurityMetadataSource

SecurityMetadataSource 讀取訪問資源所需的許可權資訊,讀取的內容,就是我們配置的訪問規則,如我們在配置類中配置的訪問規則:

@Override
protected void configure(HttpSecurity http) throws Exception{
    http.authorizeRequests()
        .antMatchers(excludes).anonymous()
        .antMatchers("/api1").hasAuthority("permission1")
        .antMatchers("/api2").hasAuthority("permission2")
        ...
}

我們可以自定義一個 SecurityMetadataSource 來從資料庫或其它儲存中獲取資源許可權規則資訊。

  1. 鑑權管理器 AccessDecisionManager

AccessDecisionManager 介面的 decide(authentication, object, configAttributes) 方法對本次請求進行鑑權,其中

  • authentication:本次請求的認證資訊,包含 authority(如角色) 資訊
  • object:當前被呼叫的被保護物件,如介面
  • configAttributes:與被保護物件關聯的配置屬性,表示要訪問被保護物件需要滿足的條件,如角色

AccessDecisionManager 介面的實現者鑑權時,最終是通過呼叫其內部 List<AccessDecisionVoter<?>> 列表中每一個元素的 vote(authentication, object, attributes)
方法來進行的,根據決策的不同分為如下三種實現

  • AffirmativeBased:一票通過權策略。只要有一個 AccessDecisionVoter 通過(AccessDecisionVoter.vote 返回 AccessDecisionVoter.
    ACCESS_GRANTED),則鑑權通過。為預設實現
  • ConsensusBased:少數服從多數策略。多數 AccessDecisionVoter 通過,則鑑權通過,如果贊成票與反對票相等,則根據變數 allowIfEqualGrantedDeniedDecisions
    的值來決定,該值預設為 true
  • UnanimousBased:全票通過策略。所有 AccessDecisionVoter 通過或棄權(返回 AccessDecisionVoter.
    ACCESS_ABSTAIN),無一反對則通過,只要有一個反對就拒絕;如果全部棄權,則根據變數 allowIfAllAbstainDecisions 的值來決定,該值預設為 false
  1. 鑑權投票者 AccessDecisionVoter

與 AuthenticationProvider 類似,AccessDecisionVoter 也包含 supports(attribute) 方法(是否採用該 Voter 來對請求進行鑑權投票) 與 vote (authentication, object, attributes) 方法(具體的鑑權投票邏輯)

FilterSecurityInterceptor 的 AccessDecisionManager 的投票者列表(AbstractInterceptUrlConfigurer.createFilterSecurityInterceptor() 中設定)包括:

  • WebExpressionVoter:驗證 Authentication 的 authenticated。

MethodSecurityInterceptor 的 AccessDecisionManager 的投票者列表(GlobalMethodSecurityConfiguration.accessDecisionManager()
中設定)包括:

  • PreInvocationAuthorizationAdviceVoter: 如果 @EnableGlobalMethodSecurity 註解開啟了 prePostEnabled,則新增該 Voter,對使用了 @PreAuthorize 註解的方法進行鑑權投票
  • Jsr250Voter:如果 @EnableGlobalMethodSecurity 註解開啟了 jsr250Enabled,則新增該 Voter,對 @Secured 註解的方法進行鑑權投票
  • RoleVoter:總是新增, 如果 ConfigAttribute.getAttribute()ROLE_ 開頭,則參與鑑權投票
  • AuthenticatedVoter:總是新增,如果 ConfigAttribute.getAttribute() 值為
    IS_AUTHENTICATED_FULLYIS_AUTHENTICATED_REMEMBEREDIS_AUTHENTICATED_ANONYMOUSLY 其中一個,則參與鑑權投票
  1. 鑑權結果處理

ExceptionTranslationFilter 異常處理 Filter, 對認證鑑權過程中丟擲的異常進行處理,包括:

  • authenticationEntryPoint: 對過濾器鏈中丟擲 AuthenticationException 或 AccessDeniedException 但 Authentication 為
    AnonymousAuthenticationToken 的情況進行處理。如果 token 校驗失敗,如 token 錯誤或過期,則通過 ExceptionTranslationFilter 的 AuthenticationEntryPoint 進行處理,本專案使用 RestAuthenticationEntryPoint 來返回統一格式的錯誤資訊
  • accessDeniedHandler: 對過濾器鏈中丟擲 AccessDeniedException 但 Authentication 不為 AnonymousAuthenticationToken 的情況進行處理,本專案使用 RestAccessDeniedHandler 來返回統一格式的錯誤資訊

如果是 MethodSecurityInterceptor 鑑權時丟擲 AccessDeniedException,並且通過 @RestControllerAdvice 提供了統一異常處理,則將由統一異常處理類處理,因為
MethodSecurityInterceptor 是 AOP 機制,可由 @RestControllerAdvice 捕獲。

本專案中, RestAuthorizationFilter 在 Filter 鏈中位於 ExceptionTranslationFilter 的前面,所以其中丟擲的異常也不能被 ExceptionTranslationFilter 捕獲, 由 cn.jboost.base.starter.web.ExceptionHandlerFilter 捕獲處理。

也可以將 RestAuthorizationFilter 放入 ExceptionTranslationFilter 之後,但在 RestAuthorizationFilter 中需要對 SecurityContextHolder.getContext().getAuthentication() 進行 AnonymousAuthenticationToken 的判斷,因為 AnonymousAuthenticationFilter 位於 ExceptionTranslationFilter 前面,會對 Authentication 為空的請求生成一個
AnonymousAuthenticationToken,放入 SecurityContext 中。

總結

安全框架一般包括認證與授權兩部分,認證解決你是誰的問題,即確定你是否有合法的訪問身份,授權解決你是否有許可權訪問對應資源的問題。Spring Security 使用 Filter 來實現認證,使用 Filter(介面層級) + AOP(方法層級)的方式來實現授權。本文相對偏理論,但也結合了腳手架中的實現,對照檢視,應該更易理解。

本文基於 Spring Boot 腳手架中的許可權管理模組編寫,該腳手架提供了前後端分離的許可權管理實現,效果如下圖,可關注作者公眾號 “半路雨歌”,回覆 “jboost” 獲取原始碼地址。

jboost-admin-login
jboost-admin-main


[轉載請註明出處]
作者:雨歌,可以關注作者公眾號:半路雨歌
qrcode

相關文章