SpringSecurity動態鑑權流程解析 | 掘金新人第二彈

若是不能談情說愛,咱們能夠自憐自愛。git

楔子

上一篇文咱們講過了SpringSecurity的認證流程,相信你們認真讀過了以後必定會對SpringSecurity的認證流程已經明白個七八分了,本期是咱們如約而至的動態鑑權篇,看這篇並不須要必定要弄懂上篇的知識,由於講述的重點並不相同,你能夠將這兩篇當作兩個獨立的章節,從中擷取本身須要的部分。github

祝有好收穫。web

本文代碼: 碼雲地址GitHub地址spring

1. 📖SpringSecurity的鑑權原理

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

9329806-8eb5612b9ba8bb2a.jpeg

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

1. FilterSecurityInterceptor

想知道怎麼動態鑑權首先咱們要搞明白SpringSecurity的鑑權邏輯,從上圖中咱們也能夠看出:FilterSecurityInterceptor是這個過濾鏈的最後一環,而認證以後就是鑑權,因此咱們的FilterSecurityInterceptor主要是負責鑑權這部分。跨域

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

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

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中預先寫好了一段很重要的代碼(後面會說到)。session

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地址

相關文章
相關標籤/搜索