Springboot整合SpringSecurity实现登录认证和鉴权全过程
作者:德鲁伊的扛把子
一、Springboot整合SpringSecurity实现登录认证
1、springsecurity是通过在web一系列原生filter拦截器中增加自己的过滤器链来拦截web请求,然后请求会在经过过滤器链的过程中会完成认证与授权,如果中间发现这条请求未认证或者未授权,会根据被保护API的权限去抛出异常,然后由异常处理器去处理这些异常。
2、SpringSecurity通过FilterChainProxy管理众多SecurityFilterChain, 而FilterChainProxy则被DelegatingFilterProxy管理并被DelegatingFilterProxy放入web原生的过滤器链中;
每个SecurityFilterChain下则是具体的拥有拦截规则的filter,这些filter由SpringSecurity进行代理操作,可以理解为他是"Security Filter",而不是原生的"Web Filter";
总结就是:
【DelegatingFilterProxy】——管理——>【FilterChainProxy】——管理——>【SecurityFilterChain】——管理——>【Security Filter】
3、springboot整合springsecurity,springboot会通过一系列xxxAutoConfiguration进行自动配置默认的Spring Security的一系列底层组件,如WebSecurityConfigurerAdapter和一些默认组件,有些"Security Filter"会自动开启,有些则不会;
整个认证的过程其实一直在围绕图中过滤链的绿色部分,而动态鉴权主要是围绕其橙色部分;
Spring Security配置中有两个叫formLogin和httpBasic的配置项,这两个配置项就分别对应着图中分的两个过滤器
- formLogin对应着你form表单认证方式,即UsernamePasswordAuthenticationFilter。
- httpBasic对应着Basic认证方式,即BasicAuthenticationFilter。
4、我使用的就是UsernamePasswordAuthenticationFilter这个过滤器,springboot整合springsecurity时会自动加载这个过滤器;
Spring Security 在自动装配后,会有默认的拦截策略,未登陆的请求都会被拦截并跳转到login登录页,此时输入账号密码登录就会被这个UsernamePasswordAuthenticationFilter拦截,并验证账号是否存在,密码是否正确
进入formlogin,发现有个**FormLoginConfigurer()**方法
进入FormLoginConfigurer()方法,在这里用户输入账号密码就会被这个UsernamePasswordAuthenticationFilter拦截,并验证进行认证
发送登陆请求后,UsernamePasswordAuthenticationFilter会调用attemptAuthentication() 方法进行认证,失败则抛出异常,成功则返回带有用户信息的Authentication对象
"Security Filter"中,认证过程是由 " 主角 " AuthenticationManager(接口)去管理AuthenticationProvider(接口)去实现的,AuthenticationManager可以有多个,他们如果认证失败就会调用父亲也就是全局的AuthenticationManager再去认证看看,一般只用一个全局的.
一个AuthenticationProvider代表一种认证方法,只要其中一个AuthenticationProvider认证通过就算登陆成功,记住两个主角的实现类ProviderManager和DaoAuthenticationProvider
回到attemptAuthentication()方法,调用拿到全局的AuthenticationMananger去执行*authenticate()*方法,拿到ProviderManager中所有的AuthenticationProvider,交给他们去认证
在遍历provider这个过程中,调用了provider(DaoAuthenticationProvider)的authenticate方法,由provider去认证,AuthenticationProvider的实现类DaoAuthenticationProvider继承了AbstractUserDetailsAuthenticationProvider,所以自然也有父类方法的*authenticate()方法,因为没有重写他,所以在源码debug阶段会进入了他的父类的authenticate()方法,他的父类AbstractUserDetailsAuthenticationProvider实现了AuthenticationProvider
在provider(DaoAuthenticationProvider)的authenticate()方法中,先调用retrieveUser()通过用户名来获取我们存储中是否有该用户,如果有就封装到UserDetail中,后面再拿请求中的密码跟UserDetail用户信息中的密码进行比较,如果没有,密码都不用比较了,因为用户根本不存在.provider中有个叫UserDetailService的接口,通过用户名可以获取我们的用户数据(他功能相当于一个service层去调用dao层最终返回用户数据),在自动装配中,默认配了个基于内存存储的InMemoryUserDetailsManager,他是UserDetailService的实现类;
所以在使用springsecurity进行登录认证的时候,除了要创建配置类进行相关内容的配置,还要创建UserDetailService的实现类用于到数据库中查询登录认证所需要的信息;
并且还要创建UserDetail的实现类用于封装查询出来的数据,并把数据交给springsecurity框架拿去用于认证
最后通过additionalAuthenticationChecks()方法进行密码比较
认证失败抛异常,认证成功则将用户详细信息封装进Authentication返回
二、Springboot整合SpringSecurity实现鉴权
1、整个认证的过程其实一直在围绕图中过滤链的绿色部分,而现在要说的动态鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor
2、想知道怎么动态鉴权首先我们要搞明白SpringSecurity的鉴权逻辑,从上图中我们也可以看出:一个请求完成了认证,且没有抛出异常之后就会到达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方法,FilterSecurityInterceptor的doFilter方法出奇的简单,总共只有两行:
- 第一行是创建了一个FilterInvocation对象,这个FilterInvocation对象你可以当作它封装了request,它的主要工作就是拿请求里面的信息,比如请求的URI和method
- 第二行就调用了自身的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的代码也可以概括为两部分:
- 调用了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,其实里面是通过我们在配置文件中配置的过滤规则获取到请求需要的角色权限。
public SecurityMetadataSource obtainSecurityMetadataSource() { return this.securityMetadataSource; }
拿到了Authentication,这里是调用authenticateIfRequired方法拿到了,其实里面是通过SecurityContextHolder拿到的
Authentication authenticated = authenticateIfRequired();
调用了accessDecisionManager.decide(authenticated, object, attributes),前两步都是对decide方法做参数的准备,第三步才是正式去到鉴权的逻辑,既然这里面才是真正鉴权的逻辑,那也就是说鉴权其实是accessDecisionManager在做。
// 鉴权需要调用的接口 this.accessDecisionManager.decide(authenticated, object, attributes);
AccessDecisionManager是一个接口,它声明了三个方法,除了第一个decide()鉴权方法以外,还有两个是辅助性的方法,其作用都是甄别 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默认的鉴权逻辑就是根据配置文件中的配置进行鉴权,这是符合我们现有认知的
3、总结一下就是:
FilterSecurityInterceptor执行doFilter 方法创建FilterInvocation(req,resp,chain)对象;然后调用自身invoke方法,传入对象
invoke方法中,在 chain().doFilter 前有 super.beforeInvocation(fi),调用 AbstractSecurityInterceptor 的beforeInvocation方法
beforeInvocation方法中
- 通过调用请求过滤接口obtainSecurityMetadataSource() 的getAttributes()方法获取一个Collection对象,这个对象是一个list,里面封装了请求所需要的角色权限
- 调用authenticateIfRequired方法拿到Authentication对象
- 调用了accessDecisionManager.decide(authenticated, object, attributes)正式进行鉴权
4、在使用springsecurity进行鉴权操作的时候,根据具体业务需求去自定义请求过滤器obtainSecurityMetadataSource()和投票器accessDecisionManager()
- 自定义请求过滤器,重写getAttributes()方法
@Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { // 修改接口角色关系后重新加载 if (CollectionUtils.isEmpty(resourceRoleList)) { this.loadDataSource(); } //Spring Security 通过FilterInvocation对object进行封装,可以安全的拿到其HttpServletRequest 和 HttpServletResponse对象 FilterInvocation fi = (FilterInvocation) object; // 获取用户请求方式 String method = fi.getRequest().getMethod(); // 获取用户请求Url String url = fi.getRequest().getRequestURI(); //new一个工具类AntPathMatcher的实例化对象,把路径匹配委托给AntPathMatcher实现 AntPathMatcher antPathMatcher = new AntPathMatcher(); // 获取接口角色信息,若为匿名接口则放行,若无对应角色则禁止 for (ResourceRoleDTO resourceRoleDTO : resourceRoleList) { //判断resourceRoleList中是否有和参数对象的URL和method完全相同的对象 if (antPathMatcher.match(resourceRoleDTO.getUrl(), url) && resourceRoleDTO.getRequestMethod().equals(method)) { //如果有对象匹配成功,则获取该对象的角色列表RoleList List<String> roleList = resourceRoleDTO.getRoleList(); if (CollectionUtils.isEmpty(roleList)) { return SecurityConfig.createList("disable"); } return SecurityConfig.createList(roleList.toArray(new String[]{})); //rolelist集合转换成String数组,通过SecurityConfig.createList(str)对结果进行封装,然后return } } return null; }
自定义投票器
@Override public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException { // 获取用户权限列表 List<String> permissionList = authentication.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); for (ConfigAttribute item : collection) { //item.getAttribute()获取当前用户访问资源所需要的权限 //如果用户权限列表中包含该权限,则return,否则最后会提示没有操作权限 if (permissionList.contains(item.getAttribute())) { return; } } throw new AccessDeniedException("没有操作权限"); }
Config文件中,调用 postProcess 方法将自定义的请求过滤器和投票器注册到 Spring 容器中去
http.authorizeRequests() .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() { @Override public <O extends FilterSecurityInterceptor> O postProcess(O fsi) { fsi.setSecurityMetadataSource(securityMetadataSource()); //设置请求拦截规则 fsi.setAccessDecisionManager(accessDecisionManager()); //设置访问决策管理器,真正的鉴权操作在这里完成 return fsi; } })
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。