2021-03-12

Tags: 程式語言 , java , spring

前言

要實作 spring security 裡面認証(authentication) & 援權(authorization) 功能時,大多數文件只會說要實作 UserDetailsService、UserDetails、WebSecurityConfigurerAdapter 這儿個 class 或 inteface 就可以搞定一切。但這儿個 class 間到底怎麼交互作用通常都不提,讓人覺得實作參考的範例程式碼很簡單易懂、但背後原理卻不知怎麼回事。程式這樣改一改就可以跑讓人覺得很像黑魔法,哪天要微調時很可能因為不知背後原理就改不動了。

因此我決定把背後原理搞清楚並整理出重點摘要。既然是摘要只會挑關鍵重點,建議搭配 spring security 裡的 source code 一起看,比較好理解內容。

認証與援權功能的程式碼如何運作

程式碼依照下列順序進行認証動作

  1. 用戶使用帳號和密碼進行登錄。
  2. 在 Filter 裡將獲取到的用戶名和密碼封裝成一個實現了 Authentication interface 的 UsernamePasswordAuthenticationToken。
  3. 將上述產生的 token 傳遞給 AuthenticationManager 進行登錄認證。
  4. AuthenticationManager 認證成功後將會返回一個 封裝了用戶權限等資料 的 Authentication 實例。而且這個實例內容裡 不帶用戶憑証或密碼
  5. 通過呼叫 SecurityContextHolder.getContext().setAuthentication(...) method 將 AuthenticationManager 在認證成功後產生的 Authentication 實例賦予給當前的 SecurityContext。

上述流程項次 1,2,3,5 實作程式碼可參考 BasicAuthenticationFilter.doFilterInternal(...) method,source code 與關鍵說明如下

    @Override
    protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain chain)
                    throws IOException, ServletException {
        ...略

        try {
            String[] tokens = extractAndDecodeHeader(header, request);

            String username = tokens[0];

            if (authenticationIsRequired(username)) {
                // step1. 將用戶輸入的帳號密碼轉換成 UsernamePasswordAuthenticationToken DTO
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, tokens[1]);
                authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
                
                // step2. 利用 AuthenticationManager.authenticate(...) method 進行用戶認証
                //        認証失敗時丟出 Exception,認証成功時將用戶資料(指 UserDetails)、權限存
                //        成一個新的 Authentication DTO
                //
                //        AuthenticationManager.authenticate(...) method 裡的 "尋找用戶資料" 程式片段
                //        通常是呼叫 UserDetailsService.loadUserByUsername(...) method
                Authentication authResult = this.authenticationManager.authenticate(authRequest);

                // step3. SecurityContextHolder 存放授權成功的 Authentication DTO
                SecurityContextHolder.getContext().setAuthentication(authResult);
            }

        }
        catch (AuthenticationException failed) {
            // 授權失敗時,要把 SecurityContextHolder 裡的 Authentication DTO 清除
            SecurityContextHolder.clearContext();
        }

        ...略
    }

上述流程項次 4 實作程式碼可參考 AbstractUserDetailsAuthenticationProvider.authenticate(...) method,source code 與關鍵說明如下

    public Authentication authenticate(Authentication authentication)throws AuthenticationException {
        ...略

        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED": authentication.getName();

        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);

        if (user == null) {
            cacheWasUsed = false;

            try {
                // step1. retrieveUser(...) method 呼叫 UserDetailsService.loadUserByUsername(...) method 
                //        查出用戶資料(指 UserDetails)
                user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException notFound) {
                ...略

                throw notFound;
            }

        }

        try {
            ...略

            // step2. 用戶資料(指 UserDetails) 跟 UsernamePasswordAuthenticationToken 內容比對
            //        進行用戶認証,認証失敗時丟出 Exception。
            additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException exception) {
            ...略

            throw exception;
        }

        ...略

        // step3. 認証成功時將用戶資料(指 UserDetails)、權限存成一個新的 Authentication DTO
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

認証與援權功能對應的關鍵 class 與 interface

所有 Filter 簡述

Spring Security 底層是通過一系列的 Filter 來管理,每個 Filter 都有其自身的功能,而且各個 Filter 在功能上還有關聯關係,所以它們的順序也是非常重要的。Spring Security 對 FilterChain 中 Filter 順序有嚴格的規定的,Spring Security 對那些預設的 Filter 指定了它們的位置。

Spring Security 已經定義了一些 Filter,不管實際應用中你用到了哪些,它們應當保持如下順序。

  1. ChannelProcessingFilter

    如果你訪問的 channel 錯了,那首先就會在 channel 之間進行跳轉,如 http 變為 https。

  2. SecurityContextPersistenceFilter

    開始進行 request 的時候就可以在 SecurityContextHolder 中建立一個 SecurityContext,然後在請求結束的時候,任何對 SecurityContext 的改變都可以被 copy 到 HttpSession。

  3. ConcurrentSessionFilter

    它使用 SecurityContextHolder 的功能,而且更新對應 session 的最後更新時間,以及通過 SessionRegistry 獲取當前的 SessionInformation 以檢查當前的 session 是否已經過期,過期則會呼叫 LogoutHandler。

  4. 認證處理機制

    如 UsernamePasswordAuthenticationFilter,CasAuthenticationFilter,BasicAuthenticationFilter 等,以至於 SecurityContextHolder 可以被更新為包含一個有效的 Authentication 請求。

  5. SecurityContextHolderAwareRequestFilter

    它將會把 HttpServletRequest 封裝成一個繼承自 HttpServletRequestWrapper 的 SecurityContextHolderAwareRequestWrapper,同時使用 SecurityContext 實現了 HttpServletRequest 中與安全相關的方法。

  6. JaasApiIntegrationFilter

    如果 SecurityContextHolder 中擁有的 Authentication 是一個 JaasAuthenticationToken,那麼該 Filter 將使用包含在 JaasAuthenticationToken 中的 Subject 繼續執行 FilterChain。

  7. RememberMeAuthenticationFilter

    如果之前的認證處理機制沒有更新 SecurityContextHolder,並且用戶請求包含了一個 Remember-Me 對應的 cookie,那麼一個對應的 Authentication 將會設給 SecurityContextHolder。

  8. AnonymousAuthenticationFilter

    如果之前的認證機制都沒有更新 SecurityContextHolder 擁有的 Authentication,那麼一個 AnonymousAuthenticationToken 將會設給 SecurityContextHolder。

  9. ExceptionTransactionFilter

    用於處理在 FilterChain 範圍內拋出的 AccessDeniedException 和 AuthenticationException,並把它們轉換為對應的 Http 錯誤碼返回或者對應的頁面。

  10. FilterSecurityInterceptor,保護 Web URI,並且在訪問被拒絕時拋出異常。

reference document