SpringSecurity动态鉴权流程解析 | 掘金新人第二弹

13,932 阅读14分钟

如果不能谈情说爱,我们可以自怜自爱。

楔子

上一篇文我们讲过了SpringSecurity的认证流程,相信大家认真读过了之后一定会对SpringSecurity的认证流程已经明白个七八分了,本期是我们如约而至的动态鉴权篇,看这篇并不需要一定要弄懂上篇的知识,因为讲述的重点并不相同,你可以将这两篇看成两个独立的章节,从中撷取自己需要的部分。

祝有好收获。

本文代码: 码云地址GitHub地址

1. 📖SpringSecurity的鉴权原理

上一篇文我们讲认证的时候曾经放了一个图,就是下图:

image.png

整个认证的过程其实一直在围绕图中过滤链的绿色部分,而我们今天要说的动态鉴权主要是围绕其橙色部分,也就是图上标的: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

从图中我们可以看到它主要有三个实现类,分别代表了三种不同的鉴权逻辑:

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