SpringSecurity的鉴权原理

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

1.FilterSecurityInterceptor

想知道怎么动态鉴权首先我们要搞明白SpringSecurity的鉴权逻辑,从上图中我们也可以看出:FilterSecurityInterceptor是这个过滤链的最后一环,而认证之后就是鉴权,所以我们的FilterSecurityInterceptor主要是负责鉴权这部分。
一个请求完成了认证,且没有抛出异常之后就会到达FilterSecurityInterceptor所负责的鉴权部分,也就是说鉴权的入口就在FilterSecurityInterceptor。
我们先来看看FilterSecurityInterceptor的定义和主要方法:

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implementsFilter {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方法,FilterSecurityInterceptor的doFilter方法出奇的简单,总共只有两行:
第一行是创建了一个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 checkingfi.getChain().doFilter(fi.getRequest(), fi.getResponse());}else {// first time this request being called, so perform security checkingif (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的代码也可以概括为两部分:

调用了super.beforeInvocation(fi)。调用完之后过滤器继续往下走。
第二步可以不看,每个过滤器都有这么一步,所以我们主要看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;}}

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

拿到了一个Collection对象,这个对象是一个List,其实里面就是我们在配置文件中配置的过滤规则。拿到了Authentication,这里是调用authenticateIfRequired方法拿到了,其实里面还是通过SecurityContextHolder拿到的,上一篇文章我讲过如何拿取。调用了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 abstainedcheckAllowIfAllAbstainDecisions();}
}

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就是我们需要自定义的投票器。

@Beanpublic AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {return new AccessDecisionProcessor();}@Beanpublic AccessDecisionManager accessDecisionManager() {// 构造一个新的AccessDecisionManager 放入两个投票器List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());return new UnanimousBased(decisionVoters);}

定义完AccessDecisionManager之后,我们将它放入启动配置:

@Overrideprotected 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> {@Autowiredprivate Cache caffeineCache;@Overridepublic int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {assert authentication != null;assert object != null;// 拿到当前请求uriString 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;}}@Overridepublic boolean supports(ConfigAttribute attribute) {return true;}@Overridepublic boolean supports(Class<?> clazz) {return true;}
}

大致逻辑是这样:我们以URI+METHOD为key去缓存中查找权限相关的信息,如果没有找到此URI,则证明这个URI没有被保护,投票器可以直接弃权。
如果找到了这个URI相关权限信息,则用其与用户自带的角色信息做一个对比,根据对比结果返回ACCESS_GRANTED或ACCESS_DENIED。
当然这样做有一个前提,那就是我在系统启动的时候就把URI权限数据都放到缓存中了,系统一般在启动的时候都会把热点数据放入缓存中,以提高系统的访问效率。

@Component
public class InitProcessor {@Autowiredprivate PermissionService permissionService;@Autowiredprivate Cache caffeineCache;@PostConstructpublic 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风格的权限定义方式,可以用一个工具类来进行比较:

@Testpublic void match() {AntPathMatcher antPathMatcher = new AntPathMatcher();// trueSystem.out.println(antPathMatcher.match("/user/**", "/user/get/1"));}

这是我是为了测试直接new了一个AntPathMatcher,实际中你可以将它注册成Bean,注入到AccessDecisionProcessor中进行使用。
它也可以比较RESTFUL风格的URI,比如:

@Testpublic void match() {AntPathMatcher antPathMatcher = new AntPathMatcher();// trueSystem.out.println(antPathMatcher.match("/user/{id}", "/user/1"));}

在面对真正的系统的时候,往往是根据系统设计进行组合使用这些工具类和设计思想。
注:ACCESS_GRANTED,ACCESS_DENIED和ACCESS_ABSTAIN是AccessDecisionVoter接口中带有的常量。

原文链接

SpringSecurity实现动态鉴权相关推荐

  1. springSecurity源码之鉴权原理

    请求的过滤器链 整个认证的过程其实一直在围绕图中过滤链的绿色部分,而动态鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor. FilterSecurityIn ...

  2. Spring Security 鉴权流程

    如果对该文章感兴趣欢迎关注我的小博客 Forest,我会分享更多学习时遇到的知识 (●'◡'●) 参考资料 SpringSecurity原理剖析与权限系统设计 SpringSecurity动态鉴权流程 ...

  3. SpringSecurity动态加载用户角色权限实现登录及鉴权

    本文来说下SpringSecurity如何动态加载用户角色权限实现登录及鉴权 文章目录 概述 动态数据登录验证的基础知识 UserDetails与UserDetailsService接口 实现User ...

  4. 快速搭建一个网关服务,动态路由、鉴权看完就会(含流程图)

    [文章来源]https://sourl.cn/tcbSPi 前 言 本文记录一下我是如何使用Gateway搭建网关服务及实现动态路由的,帮助大家学习如何快速搭建一个网关服务,了解路由相关配置,鉴权的流 ...

  5. vue router 路由鉴权(非动态路由)

    概述 角色:超级管理员.主题管理员.数据服务管理员 权限: 超级管理员:所有页面 主题管理员:基础公共页面+主题设置页 数据服务管理员:基础公共页面+数据服务设置页+数据服务审批页 需求:角色菜单来自 ...

  6. No6-6.从零搭建spring-cloud-alibaba微服务框架,添加用户鉴权逻辑,动态数据权限(使用AOP实现)等(六,no6-6)

    代码地址与接口看总目录:[学习笔记]记录冷冷-pig项目的学习过程,大概包括Authorization Server.springcloud.Mybatis Plus~~~_清晨敲代码的博客-CSDN ...

  7. 六、SpringSecurity OAuth2 + SpringCloud Gateway实现统一鉴权管理

    代码 代码仓库:地址 代码分支:lesson6 博客:地址 简介 在先前文章中,我们使用SpringSecurity OAuth2搭建了一套基于OAuth2协议的授权系统,并扩展了手机验证码授权模式. ...

  8. SpringSecurity给用户授权,一个用户能同时拥有多种身份Role,及权限鉴权注解方法hasRole及hasAuthority的使用区别

    文章目录 1.SpringSecurity一般分为两个重点 2.实际上用户存在一般就等于认证成功,认证成功之后就存在授权的问题 3.一个用户可以有多个身份 4.看图 5.hasRole及hasAuth ...

  9. java造轮子:快速搭建一个网关服务,动态路由、鉴权看完就会(含流程图)

    前言 本文记录一下我是如何使用Gateway搭建网关服务及实现动态路由的,帮助大家学习如何快速搭建一个网关服务,了解路由相关配置,鉴权的流程及业务处理,有兴趣的一定看到最后,非常适合没接触过网关服务的 ...

最新文章

  1. 折半查找函数(from 《The C Programming Language》)
  2. Android动态方式破解apk进阶篇(IDA调试so源码)
  3. git通过authorized_keys来管理用户的权限(二)
  4. MVC, MVP, MVVM比较以及区别(下)
  5. Checking battery state… ubuntu
  6. 不错的html学习网址。
  7. mysql5.5java安装_配置非安装版的mysql 5.5
  8. Xna游戏编辑器开发(WinForm内嵌Xna)
  9. 深入了解前端开发中的高度、宽度问题
  10. 深入理解BGP的几个路由特性(试读连载六)
  11. STM32程序烧录软件设计
  12. STM8L SPI使用过程记录
  13. 推荐一个好看且实用的火狐浏览器新标签页插件【火狐浏览器新标签页自定义美化】
  14. Win10-C盘清理方法全攻略(从剩余9G到剩49G)
  15. Chatopera 张凯:创业的信念,为了小家和大家
  16. Windows 10正式版官方原版ISO镜像下载
  17. vuepress-theme-reco自动生成侧边栏
  18. 人工智能全局概览:通用智能的当前困境和未来可能
  19. AccessKey泄露利用
  20. 约瑟夫问题(c语言)

热门文章

  1. 机器人视觉引导定位介绍
  2. matlab fpga 移植,使用MATLAB,Simulink以及基于模型的设计实现把电机控制算法移植到Zynq平台...
  3. Android秀翻天的操作——使用协程进行网络请求
  4. 基于JavaWEB实现的学校健康码管理系统
  5. 学完java基础语法之后用来练习的不依赖框架的小项目
  6. http超大文件上传方案
  7. 怎么设置邮箱收发服务器
  8. 新手入门:ST-Link和J-Link仿真器的使用
  9. HTML常用字体样式设置(加粗、下划线、斜体)
  10. 3大利器推荐,帮你写出规范漂亮的python代码