如果不能談情說愛,我們可以自憐自愛。
楔子
上一篇文我們講過了SpringSecurity
的認證流程,相信大家認真讀過了之后一定會對SpringSecurity
的認證流程已經明白個七八分了,本期是我們如約而至的動態鑒權篇,看這篇並不需要一定要弄懂上篇的知識,因為講述的重點並不相同,你可以將這兩篇看成兩個獨立的章節,從中擷取自己需要的部分。
祝有好收獲。
此文是我從我的掘金搬運而來,所以里面一些文章鏈接指向了掘金,但是在我的博客園也可以找到對應的文章。
1. 📖SpringSecurity的鑒權原理
上一篇文我們講認證的時候曾經放了一個圖,就是下圖:
整個認證的過程其實一直在圍繞圖中過濾鏈的綠色部分,而我們今天要說的動態鑒權主要是圍繞其橙色部分,也就是圖上標的: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
方法,FilterSecurityInterceptor
的doFilter
方法出奇的簡單,總共只有兩行:
第一行是創建了一個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的代碼也可以概括為兩部分:
-
調用了 super.beforeInvocation(fi)
。 -
調用完之后過濾器繼續往下走。
第二步可以不看,每個過濾器都有這么一步,所以我們主要看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; } }
源碼較長,這里我精簡了中間的一部分,這段代碼大致可以分為三步:
-
拿到了一個 Collection<ConfigAttribute>
對象,這個對象是一個 List,其實里面就是我們在配置文件中配置的過濾規則。 -
拿到了 Authentication
,這里是調用authenticateIfRequired
方法拿到了,其實里面還是通過SecurityContextHolder
拿到的,上一篇文章我講過如何拿取。 -
調用了 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
方法中參數的有效性。
那既然是一個接口,上文中所調用的肯定是他的實現類了,我們來看看這個接口的結構樹:

從圖中我們可以看到它主要有三個實現類,分別代表了三種不同的鑒權邏輯:
-
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_GRANTED
或ACCESS_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_GRANTED
,ACCESS_DENIED
和ACCESS_ABSTAIN
是AccessDecisionVoter
接口中帶有的常量。
后記
好了,上面就是這期的所有內容了,我從周日就開始肝了。
我寫文章啊,一般要寫三遍:
-
第一遍是初稿,把思路里面已有的梳理之后轉化成文字。
-
第二遍是查漏補缺,看看有哪些原來的思路里面遺漏的地方可以補上。
-
第三遍就是對語言結構的重新整理。
經此三遍之后,我才敢發,所以認證和授權分成兩篇了,一是可以分開寫,二是寫到一塊很費時間,我又是第一次寫文,不敢設太大的目標。
這就好比你第一次背單詞就告訴自己一天要背1000個,最后當然背不下來,然后就會自己責怪自己,最終陷入循環。
初期設立太大的目標往往會適得其反,前期一定要挑一些自己力所能及的,先嘗到完成的喜悅,再慢慢加大難度,這個道理是很多做事的道理。
這篇結束后SpringSecurity的認證與授權就都完成了,希望大家有所收獲。
上一篇SpringSecurity的認證流程,大家也可以再回顧一下。
下一篇的話還沒想好,估計會寫一點開發時候常遇到的通用工具或配置的問題,放松放松,oauth2的東西也有打算,不知道oauth2的東西有人看嗎。
如果覺得寫的還不錯的話,可以抬一手幫我點個贊哈,畢竟我也需要升級啊🚀
你們的每個點贊收藏與評論都是對我知識輸出的莫大肯定,如果有文中有什么錯誤或者疑點或者對我的指教都可以在評論區下方留言,一起討論。
我是耳朵,一個一直想做知識輸出的人,下期見。