• 第二步可以不看,每个过滤器都有这么一步,所以我们主要看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;
            }
    
        }
    

    源码较长,这里我精简了中间的一部分,这段代码大致可以分为三步:

    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的源码。

    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肯定是放在数据库中了,我们要做的就是实时的在数据库中去读取不同角色对应的权限然后与当前登录的用户做个比较。

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

    我一向喜欢小而美的方式,少做改动,所以这里演示的代码将以第二种方案为基础,稍加改造。

    那么我们需要写一个新的投票器,在这个投票器里面拿到当前用户的角色,使其和当前请求所需要的角色做个对比。

    单单是这样还不够,因为我们可能在配置文件中也配置的有一些放行的权限,比如登录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地址

    07-14 16:00