SpringSecurity动态鉴权流程解析-segmentfault新人第二弹

4次阅读

共计 11126 个字符,预计需要花费 28 分钟才能阅读完成。

如果不能谈情说爱,咱们能够自怜自爱。

楔子

上一篇文咱们讲过了 SpringSecurity 的认证流程,置信大家认真读过了之后肯定会对 SpringSecurity 的认证流程曾经明确个七八分了,本期是咱们如约而至的动静鉴权篇,看这篇并不需要肯定要弄懂上篇的常识,因为讲述的重点并不相同,你能够将这两篇看成两个独立的章节,从中撷取本人须要的局部。

祝有好播种。

此文是我从我的掘金搬运而来,所以外面一些文章链接指向了掘金,然而在我的思否也能够找到对应的文章。

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

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 办法,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办法中参数的有效性。

那既然是一个接口,上文中所调用的必定是他的实现类了,咱们来看看这个接口的构造树:

从图中咱们能够看到它次要有三个实现类,别离代表了三种不同的鉴权逻辑:

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

正文完
 0