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

相关文章
相关标签/搜索