本文主要介绍Spring Security进行自定义权限控制的一次实践。使用的是Spring Security 4.X版本,基于XML配置和自定义Spring Security组件类实现。这次实践比较特殊的地方是需要集成公司内部的单点登录系统,也就是登录验证由该系统完成,而不是Spring Security完成,权限控制才由Spring Security完成。另一个特殊的地方是权限控制粒度需要到表级别,也就是不同人对不同表有多种不同操作权限,而对不同表的操作是由同一个方法抽象完成的,页面按钮显示也是跟不同表关联的,所以也需要一些自定义配置,具体实现见正文。
原理
简单介绍下Spring Security的原理。
核心组件主要有两个:一个是登录验证拦截器AuthenticationProcessingFilter,另一个是权限验证拦截器AbstractSecurityInterceptor。两大核心组件又各自包含一些小组件来完成功能。
现在先大概过一遍整个流程,用户登陆,会被AuthenticationProcessingFilter拦截,调用AuthenticationManager的实现,而且AuthenticationManager会调用AuthenticationProvider的authenticate(Authentication authentication)方法来验证用户登录信息,其中Authentication包含了前段页面输入的账号和密码。(不同的Provider调用的服务不同,因为这些信息可以是在数据库上,可以是在LDAP服务器上,可以是xml配置文件上等)。具体地,authenticate(Authentication authentication)会根据authentication.getName()获取用户名,并调用UserDetailsService的loadUserByUsername(String username)方法,从服务器获取该用户的UserDetails,其中返回的UserDetails包含了服务器已经保存好的用户名、密码和对应的权限。将用户输入的用户名和密码(包含在authentication里)和服务器已经保存好的UserDetails进行对比,如果验证通过后会将用户及其权限信息封装到Authentication对象放到spring的全局缓存SecurityContextHolder中,以备后面访问资源时使用。
访问资源(即授权管理),访问url时,会通过AbstractSecurityInterceptor拦截器拦截,其中会调用FilterInvocationSecurityMetadataSource的方法来获取被拦截url所需的全部权限,在调用授权管理器AccessDecisionManager,这个授权管理器会通过spring的全局缓存SecurityContextHolder获取用户的权限信息,还会获取被拦截的url和被拦截url所需的全部权限,然后根据所配的策略(有:一票决定,一票否定,少数服从多数等),如果权限足够,则返回,权限不够则报错并调用权限不足页面。
设计与实现
下面围绕着Spring Security的两大核心组件以及摘要中的需求来讲如何设计和实现自定义权限控制。
这里先给出XML完整配置,方便后续讲解定位到每项配置的含义。
1 | <beans xmlns="http://www.springframework.org/schema/beans" |
登录验证
XML配置,一般情况下,登录验证使用<form-login>
标签(相当于默认的AuthenticationProcessingFilter)就可以完成。
但是这里由于要使用公司内部的单点登录系统,验证是由该系统完成的,所以这里需要自定义配置AuthenticationProcessingFilter,替换(custom-filter
标签中position表示替换)默认登录验证实现,不添加filterProcessesUrl属性,同时不要再使用<form-login>
标签,否则可能会报冲突。不使用<form-login>
标签则登录入口需要使用authenticationEntryPoint指定,配置登录入口的作用是用户没有登录时自动跳转到登录页。
如下几项XML配置
1 | <security:http auto-config="false" entry-point-ref="authenticationEntryPoint"> |
可以看到上面说的我已经划掉了,因为后来发现其实可以用<form-login>
标签,只需要把标签下的login-processing-url设置为一个没有对应controller处理的url即可达到同样的效果。这样上面的xml配置对应的可以简化为下面:
1 | <security:http auto-config="false"> |
注意其中的/slogin是没有对应的controller处理的。
虽然登录验证不使用spring security,但验证通过后需要用到用户登录及授权信息,而customUserDetailsService就是用来自定义获取用户授权信息的,要调用该类必须先使用AuthenticationManager的authenticate方法进行验证,而我们已经验证通过了,所以这里构造的用户密码设为空,customUserDetailsService获取的用户密码也为空,这样就能通过authenticate方法并获取用户权限信息并设置到SecurityContextHolder中(当前访问线程的ThreadLocal里)。
后端如下操作,/qlogin是用户登录成功后处理的url:
1 | "/qlogin") ( |
注意其中的下面几行,其余部分不用关注。
1 | //构造空密码的用户输入信息 |
然后是customUserDetailsService用来自定义获取用户授权信息,用户的查看、管理员权限 ,以及用户对不同表的权限存在数据库里。
1 | public class CustomUserDetailsService implements UserDetailsService { |
可以看到用户的权限分为系统查看权限、管理员权限以及对各个表的编辑、发布权限,这些信息都是从数据库获得。用户登录认证通过后(authenticate方法通过),会将这些信息放到SecurityContextHolder(访问线程的threadlocal里),以便后面权限认证使用。
权限验证
一般情况下权限验证使用<intercept-url>
标签(相当于默认的AbstractSecurityInterceptor)可以配置访问各个url所需要的权限,或者使用注解配置访问某个方法所需要的权限。
但是这里比较特殊的是用户权限粒度到表级别,为了建立资源(url或方法)——权限的对应关系,将表名放到了url中(尝试过表名放到请求参数中,但spring-security的权限验证器无法获得请求参数,估计spring-security不把请求参数当做资源吧)。这样解析url获得表名使用<intercept-url>
标签就不方便,需要自定义AbstractSecurityInterceptor和它的一些组件,如下XML配置的filterSecurityInterceptor。
下面几项XML配置 负责权限验证:
1 | <security:http auto-config="false" entry-point-ref="authenticationEntryPoint"> |
securityMetadataSource用来自定义访问某资源所需要的权限关系,如下实现(这里实现的是访问某url所需权限集合中只有一个即可):
1 | public class CustomSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { |
accessDecisionManager用来做权限认证,即对比securityMetadataSource获得的访问某url所需权限与SecurityContextHolder中存放到的用户权限,如下
1 | public class CustomAccessDecisionManager implements AccessDecisionManager { |
以上就是spring security整体实现流程,下面是spring security开发过程中解决的问题.
问题解决
后台修改用户权限后,用户再次发送请求立即刷新权限验证,而不需要重新登录
spring security 默认实现是用户登录后获取权限信息并存起来就不变了,后台修改用户权限后需要用户重新登录才能使用新权限。
思路:实现一个刷新权限的工具类,再每次权限验证时调用该工具类刷新,刷新权限逻辑通过向SecurityContextHolder中存放新权限信息完成,跟/qlogin中那三行代码类似。
1 | public class RefreshAuthenticationTools { |
然后在AccessDecisionManager类的decide方法执行开始时加入
1 |
|
ajax请求响应页面跳转
有时ajax请求的url会因权限不够而返回请求重定向,但ajax请求类型是xmlhttprequest,不能响应httpservletrequest请求重定向。需要在后端做针对ajax跳转的特殊处理,同时前端js解析后端响应实现跳转,如下
后端的验证失败处理器:
1 | public class CustomAuthenticationFailureHandler implements AccessDeniedHandler { |
前端加入全局js文件,在ajax结束后解析responseheader:
1 | $.ajaxSetup({ |
权限失败页面给出提示缺少何种权限,需要对中文传参编码
后端对要传递的errMsg使用URLEncoder:
1 | URLEncoder.encode(e.getMessage(), "utf-8"); |
前端解析传递来的参数:
1 | <div class="errMsg content-fluid content" style="vertical-align: middle"> |
其中window.location.search.split("=")[1]
是对url参数截取errMsg,这里只考虑了只有这一个参数的情况,所以这句解析比较粗暴。
(ps:更通用的js解析url参数的方法如下)
1 | //获取页面参数 |
上面相应的一行可以替换为document.getElementById("errMsg").innerText = getQueryString(errMsg);
spring-security标签控制页面显示
本来用标签空值按钮是否显示是很方便的,但前提是该按钮是否显示只依赖于用户具有的权限。
而我做的项目应用中按钮是否显示除了依赖用户权限,还依赖于页面元素的值(下拉框),也就是依赖当前用户操作的是哪张表,这需要由页面下拉框获得,这样就稍微麻烦,因为spring-surity标签得到的权限信息是jsp中的变量(服务端传来),而页面元素值是js中的变量(客户端获得),想综合两者就得动点手脚。
方案一:jsp变量中使用js中的值,这需要客户端发送请求获得,然后在spring-surity标签中使用该变量判断权限,但遗憾的是spring-surity标签不支持变量(经过尝试后失败)。
方案二:将jsp中的权限信息带到js中,由js控制按钮显示,可以使用页面隐藏域来将jsp变量传递到js中,传递到js后判断是否由当前表的操作权限,如下:
jsp页面中:
1 | <%--将权限信息隐藏在页面中,由js获得--%> |
js中:
1 | //选中下拉框并点击查询按钮时,控制页面显示 |
session过期跳转到登录页
xml中http标签下添加如下配置:
1 | <security:session-management invalid-session-url="/sessionTimeout"/> |
其中/sessionTimeout需要对ajax请求做特殊处理,来完成正常跳转,类似上面针对ajax的处理,
在controller中加入如下代码:
1 | "/sessionTimeout") ( |
总结
个人体验spring-security有一定的学习成本,笔者从接触到完成也弄了三五天吧。主要它很灵活,如果不是很典型的应用,需要很多自定义。
通过这几天搜索资料发现很多文章都提到了shiro,这是个更简单易用的框架,不过涵盖功能没spring-security全。下面是网上资料对他的一般性介绍:
-Shiro较之SpringSecurity,Shiro在保持强大功能的同时,还在简单性和灵活性方面拥有巨大优势。Shiro是一个强大而灵活的开源安全框架,能够非常清晰的处理认证、授权、管理会话以及密码加密。
-Spring Security除了不能脱离Spring,shiro的功能它都有。而且Spring Security对Oauth、OpenID也有支持,Shiro则需要自己手动实现。