SpringSecurity動態鑒權流程解析 | 部落格園新人第二彈

如果不能談情說愛,我們可以自憐自愛。

楔子

上一篇文我們講過了SpringSecurity的認證流程,相信大家認真讀過了之後一定會對SpringSecurity的認證流程已經明白個七八分了,本期是我們如約而至的動態鑒權篇,看這篇並不需要一定要弄懂上篇的知識,因為講述的重點並不相同,你可以將這兩篇看成兩個獨立的章節,從中擷取自己需要的部分。

祝有好收穫。

此文是我從我的掘金搬運而來,所以裡面一些文章鏈接指向了掘金,但是在我的部落格園也可以找到對應的文章。

本文程式碼: 碼雲地址GitHub地址

1. 📖SpringSecurity的鑒權原理

上一篇文我們講認證的時候曾經放了一個圖,就是下圖:

9329806-8eb5612b9ba8bb2a.jpeg

整個認證的過程其實一直在圍繞圖中過濾鏈的綠色部分,而我們今天要說的動態鑒權主要是圍繞其橙色部分,也就是圖上標的:FilterSecurityInterceptor

1. FilterSecurityInterceptor

想知道怎麼動態鑒權首先我們要搞明白SpringSecurity的鑒權邏輯,從上圖中我們也可以看出:FilterSecurityInterceptor是這個過濾鏈的最後一環,而認證之後就是鑒權,所以我們的FilterSecurityInterceptor主要是負責鑒權這部分。

一個請求完成了認證,且沒有拋出異常之後就會到達FilterSecurityInterceptor所負責的鑒權部分,也就是說鑒權的入口就在FilterSecurityInterceptor

我們先來看看FilterSecurityInterceptor的定義和主要方法:

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
  Filter {

            public void doFilter(ServletRequest request, ServletResponse response,
                    FilterChain chain) throws IOException, ServletException {
                FilterInvocation fi = new FilterInvocation(request, response, chain);
                invoke(fi);
            }
}

上文程式碼可以看出FilterSecurityInterceptor是實現了抽象類AbstractSecurityInterceptor的一個實現類,這個AbstractSecurityInterceptor中預先寫好了一段很重要的程式碼(後面會說到)。

FilterSecurityInterceptor的主要方法是doFilter方法,過濾器的特性大家應該都知道,請求過來之後會執行這個doFilter方法,FilterSecurityInterceptordoFilter方法出奇的簡單,總共只有兩行:

第一行是創建了一個FilterInvocation對象,這個FilterInvocation對象你可以當作它封裝了request,它的主要工作就是拿請求裡面的資訊,比如請求的URI。

第二行就調用了自身的invoke方法,並將FilterInvocation對象傳入。

所以我們主要邏輯肯定是在這個invoke方法裡面了,我們來打開看看:

public void invoke(FilterInvocation fi) throws IOException, ServletException {
        if ((fi.getRequest() != null)
                && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
                && observeOncePerRequest) {
            // filter already applied to this request and user wants us to observe
            // once-per-request handling, so don't re-do security checking
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        else {
            // first time this request being called, so perform security checking
            if (fi.getRequest() != null && observeOncePerRequest) {
                fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
            }

            // 進入鑒權
            InterceptorStatusToken token = super.beforeInvocation(fi);

            try {
                fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
            }
            finally {
                super.finallyInvocation(token);
            }

            super.afterInvocation(token, null);
        }
    }

invoke方法中只有一個if-else,一般都是不滿足if中的那三個條件的,然後執行邏輯會來到else

else的程式碼也可以概括為兩部分:

  1. 調用了super.beforeInvocation(fi)
  2. 調用完之後過濾器繼續往下走。

第二步可以不看,每個過濾器都有這麼一步,所以我們主要看super.beforeInvocation(fi),前文我已經說過,
FilterSecurityInterceptor實現了抽象類AbstractSecurityInterceptor
所以這個裡super其實指的就是AbstractSecurityInterceptor
那這段程式碼其實調用了AbstractSecurityInterceptor.beforeInvocation(fi)
前文我說過AbstractSecurityInterceptor中有一段很重要的程式碼就是這一段,
那我們繼續來看這個beforeInvocation(fi)方法的源碼:

protected InterceptorStatusToken beforeInvocation(Object object) {
        Assert.notNull(object, "Object was null");
        final boolean debug = logger.isDebugEnabled();

        if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
            throw new IllegalArgumentException(
                    "Security invocation attempted for object "
                            + object.getClass().getName()
                            + " but AbstractSecurityInterceptor only configured to support secure objects of type: "
                            + getSecureObjectClass());
        }

        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
                .getAttributes(object);

        Authentication authenticated = authenticateIfRequired();

        try {
            // 鑒權需要調用的介面
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException accessDeniedException) {
            publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
                    accessDeniedException));

            throw accessDeniedException;
        }

    }

源碼較長,這裡我精簡了中間的一部分,這段程式碼大致可以分為三步:

  1. 拿到了一個Collection<ConfigAttribute>對象,這個對象是一個List,其實裡面就是我們在配置文件中配置的過濾規則。
  2. 拿到了Authentication,這裡是調用authenticateIfRequired方法拿到了,其實裡面還是通過SecurityContextHolder拿到的,上一篇文章我講過如何拿取。
  3. 調用了accessDecisionManager.decide(authenticated, object, attributes),前兩步都是對decide方法做參數的準備,第三步才是正式去到鑒權的邏輯,既然這裡面才是真正鑒權的邏輯,那也就是說鑒權其實是accessDecisionManager在做。

2. AccessDecisionManager

前面通過源碼我們看到了鑒權的真正處理者:AccessDecisionManager,是不是覺得一層接著一層,就像套娃一樣,別急,下面還有。先來看看源碼介面定義:

public interface AccessDecisionManager {

    // 主要鑒權方法
    void decide(Authentication authentication, Object object,
                Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
            InsufficientAuthenticationException;

    boolean supports(ConfigAttribute attribute);

    boolean supports(Class<?> clazz);
}

AccessDecisionManager是一個介面,它聲明了三個方法,除了第一個鑒權方法以外,還有兩個是輔助性的方法,其作用都是甄別 decide方法中參數的有效性。

那既然是一個介面,上文中所調用的肯定是他的實現類了,我們來看看這個介面的結構樹:

image.png
image.png

從圖中我們可以看到它主要有三個實現類,分別代表了三種不同的鑒權邏輯:

  • AffirmativeBased:一票通過,只要有一票通過就算通過,默認是它。
  • UnanimousBased:一票反對,只要有一票反對就不能通過。
  • ConsensusBased:少數票服從多數票。

這裡的表述為什麼要用票呢?因為在實現類裡面採用了委託的形式,將請求委託給投票器,每個投票器拿著這個請求根據自身的邏輯來計算出能不能通過然後進行投票,所以會有上面的表述。

也就是說這三個實現類,其實還不是真正判斷請求能不能通過的類,真正判斷請求是否通過的是投票器,然後實現類把投票器的結果綜合起來來決定到底能不能通過。

剛剛已經說過,實現類把投票器的結果綜合起來進行決定,也就是說投票器可以放入多個,每個實現類里的投票器數量取決於構造的時候放入了多少投票器,我們可以看看默認的AffirmativeBased的源碼。

public class AffirmativeBased extends AbstractAccessDecisionManager {

    public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
        super(decisionVoters);
    }

    // 拿到所有的投票器,循環遍歷進行投票
    public void decide(Authentication authentication, Object object,
                       Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
        int deny = 0;

        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, configAttributes);

            if (logger.isDebugEnabled()) {
                logger.debug("Voter: " + voter + ", returned: " + result);
            }

            switch (result) {
                case AccessDecisionVoter.ACCESS_GRANTED:
                    return;

                case AccessDecisionVoter.ACCESS_DENIED:
                    deny++;

                    break;

                default:
                    break;
            }
        }

        if (deny > 0) {
            throw new AccessDeniedException(messages.getMessage(
                    "AbstractAccessDecisionManager.accessDenied", "Access is denied"));
        }

        // To get this far, every AccessDecisionVoter abstained
        checkAllowIfAllAbstainDecisions();
    }
}

AffirmativeBased的構造是傳入投票器List,其主要鑒權邏輯交給投票器去判斷,投票器返回不同的數字代表不同的結果,然後AffirmativeBased根據自身一票通過的策略決定放行還是拋出異常。

AffirmativeBased默認傳入的構造器只有一個->WebExpressionVoter,這個構造器會根據你在配置文件中的配置進行邏輯處理得出投票結果。

所以SpringSecurity默認的鑒權邏輯就是根據配置文件中的配置進行鑒權,這是符合我們現有認知的。

2. ✍動態鑒權實現

通過上面一步步的講述,我想你也應該理解了SpringSecurity到底是什麼實現鑒權的,那我們想要做到動態的給予某個角色不同的訪問許可權應該怎麼做呢?

既然是動態鑒權了,那我們的許可權URI肯定是放在資料庫中了,我們要做的就是實時的在資料庫中去讀取不同角色對應的許可權然後與當前登錄的用戶做個比較。

那我們要做到這一步可以想些方案,比如:

  • 直接重寫一個AccessDecisionManager,將它用作默認的AccessDecisionManager,並在裡面直接寫好鑒權邏輯。
  • 再比如重寫一個投票器,將它放到默認的AccessDecisionManager裡面,和之前一樣用投票器鑒權。
  • 我看網上還有些部落格直接去做FilterSecurityInterceptor的改動。

我一向喜歡小而美的方式,少做改動,所以這裡演示的程式碼將以第二種方案為基礎,稍加改造。

那麼我們需要寫一個新的投票器,在這個投票器裡面拿到當前用戶的角色,使其和當前請求所需要的角色做個對比。

單單是這樣還不夠,因為我們可能在配置文件中也配置的有一些放行的許可權,比如登錄URI就是放行的,所以我們還需要繼續使用我們上文所提到的WebExpressionVoter,也就是說我要自定義許可權+配置文件雙行的模式,所以我們的AccessDecisionManager裡面就會有兩個投票器:WebExpressionVoter和自定義的投票器。

緊接著我們還需要考慮去使用什麼樣的投票策略,這裡我使用的是UnanimousBased一票反對策略,而沒有使用默認的一票通過策略,因為在我們的配置中配置了除了登錄請求以外的其他請求都是需要認證的,這個邏輯會被WebExpressionVoter處理,如果使用了一票通過策略,那我們去訪問被保護的API的時候,WebExpressionVoter發現當前請求認證了,就直接投了贊成票,且因為是一票通過策略,這個請求就走不到我們自定義的投票器了。

註:你也可以不用配置文件中的配置,將你的自定義許可權配置都放在資料庫中,然後統一交給一個投票器來處理。

1. 重新構造AccessDecisionManager

那我們可以放手去做了,首先重新構造AccessDecisionManager
因為投票器是系統啟動的時候自動添加進去的,所以我們想多加入一個構造器必須自己重新構建AccessDecisionManager,然後將它放到配置中去。

而且我們的投票策略已經改變了,要由AffirmativeBased換成UnanimousBased,所以這一步是必不可少的。

並且我們還要自定義一個投票器起來,將它註冊成Bean,AccessDecisionProcessor就是我們需要自定義的投票器。

@Bean
    public AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {
        return new AccessDecisionProcessor();
    }

@Bean
    public AccessDecisionManager accessDecisionManager() {
        // 構造一個新的AccessDecisionManager 放入兩個投票器
        List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());
        return new UnanimousBased(decisionVoters);
    }

定義完AccessDecisionManager之後,我們將它放入啟動配置:

@Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
                // 放行所有OPTIONS請求
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                // 放行登錄方法
                .antMatchers("/api/auth/login").permitAll()
                // 其他請求都需要認證後才能訪問
                .anyRequest().authenticated()
                // 使用自定義的 accessDecisionManager
                .accessDecisionManager(accessDecisionManager())
                .and()
                // 添加未登錄與許可權不足異常處理器
                .exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler())
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                .and()
                // 將自定義的JWT過濾器放到過濾鏈中
                .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class)
                // 打開Spring Security的跨域
                .cors()
                .and()
                // 關閉CSRF
                .csrf().disable()
                // 關閉Session機制
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

這樣之後,SpringSecurity裡面的AccessDecisionManager就會被替換成我們自定義的AccessDecisionManager了。

2. 自定義鑒權實現

上文配置中放入了兩個投票器,其中第二個投票器就是我們需要創建的投票器,我起名為AccessDecisionProcessor

投票其也是有一個介面規範的,我們只需要實現這個AccessDecisionVoter介面就行了,然後實現它的方法。

@Slf4j
public class AccessDecisionProcessor implements AccessDecisionVoter<FilterInvocation> {
    @Autowired
    private Cache caffeineCache;

    @Override
    public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert object != null;

        // 拿到當前請求uri
        String requestUrl = object.getRequestUrl();
        String method = object.getRequest().getMethod();
        log.debug("進入自定義鑒權投票器,URI : {} {}", method, requestUrl);

        String key = requestUrl + ":" + method;
        // 如果沒有快取中沒有此許可權也就是未保護此API,棄權
        PermissionInfoBO permission = caffeineCache.get(CacheName.PERMISSION, key, PermissionInfoBO.class);
        if (permission == null) {
            return ACCESS_ABSTAIN;
        }

        // 拿到當前用戶所具有的許可權
        List<String> roles = ((UserDetail) authentication.getPrincipal()).getRoles();
        if (roles.contains(permission.getRoleCode())) {
            return ACCESS_GRANTED;
        }else{
            return ACCESS_DENIED;
        }
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }
}

大致邏輯是這樣:我們以URI+METHOD為key去快取中查找許可權相關的資訊,如果沒有找到此URI,則證明這個URI沒有被保護,投票器可以直接棄權。

如果找到了這個URI相關許可權資訊,則用其與用戶自帶的角色資訊做一個對比,根據對比結果返回ACCESS_GRANTEDACCESS_DENIED

當然這樣做有一個前提,那就是我在系統啟動的時候就把URI許可權數據都放到快取中了,系統一般在啟動的時候都會把熱點數據放入快取中,以提高系統的訪問效率。

@Component
public class InitProcessor {
    @Autowired
    private PermissionService permissionService;
    @Autowired
    private Cache caffeineCache;

    @PostConstruct
    public void init() {
        List<PermissionInfoBO> permissionInfoList = permissionService.listPermissionInfoBO();
        permissionInfoList.forEach(permissionInfo -> {
            caffeineCache.put(CacheName.PERMISSION, permissionInfo.getPermissionUri() + ":" + permissionInfo.getPermissionMethod(), permissionInfo);
        });
    }
}

這裡我考慮到許可權URI可能非常多,所以將許可權URI作為key放到快取中,因為一般快取中通過key讀取數據的速度是O(1),所以這樣會非常快。

鑒權的邏輯到底如何處理,其實是開發者自己來定義的,要根據系統需求和資料庫表設計進行綜合考量,這裡只是給出一個思路。

如果你一時沒有理解上面許可權URI做key的思路的話,我可以再舉一個簡單的例子:

比如你也可以拿到當前用戶的角色,查到這個角色下的所有能訪問的URI,然後比較當前請求的URI,有一致的則證明當前用戶的角色下包含了這個URI的許可權所以可以放行,沒有一致的則證明不夠許可權不能放行。

這種方式的話去比較URI的時候可能會遇到這樣的問題:我當前角色許可權是/api/user/**,而我請求的URI是/user/get/1,這種Ant風格的許可權定義方式,可以用一個工具類來進行比較:

@Test
 public void match() {
  AntPathMatcher antPathMatcher = new AntPathMatcher();
  // true
  System.out.println(antPathMatcher.match("/user/**", "/user/get/1"));
 }

這是我是為了測試直接new了一個AntPathMatcher,實際中你可以將它註冊成Bean,注入到AccessDecisionProcessor中進行使用。

它也可以比較RESTFUL風格的URI,比如:

@Test
 public void match() {
  AntPathMatcher antPathMatcher = new AntPathMatcher();
  // true
  System.out.println(antPathMatcher.match("/user/{id}", "/user/1"));
 }

在面對真正的系統的時候,往往是根據系統設計進行組合使用這些工具類和設計思想。

ACCESS_GRANTEDACCESS_DENIEDACCESS_ABSTAINAccessDecisionVoter介面中帶有的常量。

後記

好了,上面就是這期的所有內容了,我從周日就開始肝了。

我寫文章啊,一般要寫三遍:

  • 第一遍是初稿,把思路裡面已有的梳理之後轉化成文字。

  • 第二遍是查漏補缺,看看有哪些原來的思路裡面遺漏的地方可以補上。

  • 第三遍就是對語言結構的重新整理。

經此三遍之後,我才敢發,所以認證和授權分成兩篇了,一是可以分開寫,二是寫到一塊很費時間,我又是第一次寫文,不敢設太大的目標。

這就好比你第一次背單詞就告訴自己一天要背1000個,最後當然背不下來,然後就會自己責怪自己,最終陷入循環。

初期設立太大的目標往往會適得其反,前期一定要挑一些自己力所能及的,先嘗到完成的喜悅,再慢慢加大難度,這個道理是很多做事的道理。

這篇結束後SpringSecurity的認證與授權就都完成了,希望大家有所收穫。

上一篇SpringSecurity的認證流程,大家也可以再回顧一下。

下一篇的話還沒想好,估計會寫一點開發時候常遇到的通用工具或配置的問題,放鬆放鬆,oauth2的東西也有打算,不知道oauth2的東西有人看嗎。

如果覺得寫的還不錯的話,可以抬一手幫我點個贊哈,畢竟我也需要升級啊🚀

你們的每個點贊收藏與評論都是對我知識輸出的莫大肯定,如果有文中有什麼錯誤或者疑點或者對我的指教都可以在評論區下方留言,一起討論。

我是耳朵,一個一直想做知識輸出的人,下期見。

本文程式碼:碼雲地址GitHub地址