最近陰差陽錯的搞上了SpringSecurity3,因爲是自己做的小系統,中間遇到了很多坑,基本每個坑都踩過了,網上也查了不少資料,發現有不少錯誤的,更是讓我繞了一圈又一圈,現在把一些基本的東西總結一下。
先從整體上總結一下爲什麼使用SS,一般的,在不使用ss的情況下,我們基本會在每個業務方法執行前,插入一段用於驗證權限的代碼,從而判斷當前用戶是否有相應權限進行操作,這樣做就會讓業務方法和驗證權限有了一個緊密的耦合;如果使用ss,我們就可以通過註解或者XML配置方式代替權限驗證,使得業務和權限代碼徹底分離,通過下圖可以更形象的理解:
目前,權限管理採用最多的技術都是基於角色訪問控制技術RBAC(Role
Based Access Control)。一般來說,提供如下功能:1,角色管理界面,由用戶定義角色,給角色賦權限;2,用戶角色管理界面,由用戶給系統用戶賦予角色。什麼是RBAC,說到底其實就是五張表,權限表-權限角色對應表-角色表-角色用戶對應表-用戶表,比較常見。但是ss3默認支持的並不是這種模式,而是通過XML配置角色及用戶的方式實現的權限驗證等操作,所以需要我們去實現SS中一些接口,讓其支持RBAC,下面開始搭建一套支持RBAC技術的SS框架:
(1)數據庫相關表格:
1.用戶表Users
CREATE TABLE `users` (
`password` varchar(255) default NULL,
`username` varchar(255) default NULL,
`uid` int(11) NOT NULL auto_increment,
PRIMARY KEY (`uid`)
)
2.角色表Roles
CREATE TABLE `roles` (
`rolename` varchar(255) default NULL,
`rid` int(11) NOT NULL auto_increment,
PRIMARY KEY (`rid`)
)
3 用戶_角色表users_roles
CREATE TABLE `users_roles` (
--用戶表的外鍵
`uid` int(11) default NULL,
--角色表的外鍵
`rid` int(11) default NULL,
`urid` int(11) ,
PRIMARY KEY (`urid`),
)
4.資源表resources
CREATE TABLE `resources` (
-- 權限所對應的url地址
`url` varchar(255) default NULL,
--權限所對應的編碼,例201代表發表文章
`resourcename` varchar(255) default NULL,
`rsid` int(11) ,
PRIMARY KEY (`rsid`)
)
5.角色_資源表roles_resources
CREATE TABLE `roles_resources` (
`rsid` int(11) default NULL,
`rid` int(11) default NULL,
`rrid` int(11) NOT NULL ,
PRIMARY KEY (`rrid`),
)
(2)在繼續配置前,需要知道ss是如何通過權限驗證的,實際上ss通過攔截器,攔截髮來的請求,對其進行驗證的。而具體驗證的方式則是通過我們實現相關接口的方法來進行的。既然是攔截器,web.xml勢必是優先配置的。
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >
<web-app>
<display-name>Archetype Created Web Application</display-name>
<!-- Spring Security配置 -->
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Spring MVC配置 -->
<servlet>
<servlet-name>spring</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring-mvc.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>spring</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>
<!-- Spring配置 -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<listener>
<listener-class>org.springframework.web.util.Log4jConfigListener</listener-class>
</listener>
<!-- 指定Spring Bean的配置文件所在目錄。默認配置在WEB-INF目錄下 -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext*.xml,classpath:spring-mybatis.xml</param-value>
</context-param>
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<!-- Spring 刷新Introspector防止內存泄露 -->
<listener>
<listener-class>
org.springframework.web.util.IntrospectorCleanupListener
</listener-class>
</listener>
<!-- 獲取Spring Security session的生命週期-->
<listener>
<listener-class>
org.springframework.security.web.session.HttpSessionEventPublisher
</listener-class>
</listener>
<!-- session超時定義,單位爲分鐘 -->
<session-config>
<session-timeout>20</session-timeout>
</session-config>
</web-app>
接下來是spring security3的一些配置,具體的每一個是什麼意思,網上很多資料,這裏不贅述了。總之,需要根據自己的需求,進行相應的修改。
<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns="http://www.springframework.org/schema/security"
xmlns:beans="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:mvc="http://www.springframework.org/schema/mvc"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd
http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-3.2.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-3.2.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.2.xsd">
<http pattern="/css/**" security="none"></http>
<http pattern="/images/**" security="none"></http>
<http pattern="/img/**" security="none"></http>
<http pattern="/scripts/**" security="none"></http>
<http pattern="/font-awesome/**" security="none"></http>
<http pattern="/system/resources/**" security="none"></http>
<http pattern="/system/login.do" security="none"/>
<http auto-config="true" use-expressions="true">
<form-login login-page="/system/login.do" default-target-url="/system/sysManage.do"/>
<!--
error-if-maximum-exceeded 後登陸的賬號會擠掉第一次登陸的賬號
session-fixation-protection 防止僞造sessionid攻擊,用戶登錄成功後會銷燬用戶當前的session。
-->
<!-- <session-management invalid-session-url="/user/timedout" session-fixation-protection="none">
<concurrency-control max-sessions="1" error-if-maximum-exceeded="true"/>
</session-management> -->
<custom-filter ref="myFilterSecurityInterceptor" before="FILTER_SECURITY_INTERCEPTOR"/>
</http>
<!-- 認證管理器,實現用戶認證的入口,主要實現UserDetailsService接口即可 -->
<authentication-manager alias="authenticationManager">
<authentication-provider
user-service-ref="myUserDetailsServiceImpl">
<!-- <password-encoder hash="md5" /> --> <!--鹽值 [添加這個屬性後,加密密碼明文爲:"密碼明文{鹽值}"] -->
</authentication-provider>
</authentication-manager>
<!-- 配置過濾器 -->
<beans:bean id="myFilterSecurityInterceptor" class="com.product.sys.security.MyFilterSecurityInterceptor">
<!-- 用戶是否擁有所請求資源的權限 -->
<beans:property name="accessDecisionManager" ref="myAccessDescisionManager" />
<!-- 資源與權限對應關係 -->
<beans:property name="fisMetadataSource" ref="mySecurityMetadataSource" />
<!-- 用戶擁有的權限 -->
<beans:property name="authenticationManager" ref="authenticationManager" />
</beans:bean>
<beans:bean id="mySecurityMetadataSource" class="com.product.sys.security.MySecurityMetadataSource"><beans:constructor-arg name="userMapper" ref="userMapper"></beans:constructor-arg></beans:bean>
<beans:bean id="myAccessDescisionManager" class="com.product.sys.security.MyAccessDescisionManager"></beans:bean>
</beans:beans>
到上面的這個配置文件,則是重中之重了,和ss3打交道,主要都是這個文件。簡單說一下,我們需要實現一個自己的filter,在配置中就是myFilterSecurityInterceptor,而這個filter中,還需要我們額外注入三個bean,分別是accessDecisionManager、fisMetadataSource以及authenticationManager,這三個屬性中除了fisMetadataSource可以自定義名稱外,其他兩個都在ss3的父類中定義好了,所以此處需要特別注意,在這裏掉過坑了。另外這裏說一下這三個分別的作用,accessDecisionManager中有decide(Authentication authentication, Object object,Collection<ConfigAttribute> configAttributes)方法,該方法用於判斷當前用戶是否有權限進行操作,參數中authentication包含了當前用戶所擁有的權限,configAttributes中包含了進行該步驟需要的權限,對其進行對比就可以判斷該用戶是否有權限進行操作。
/**
* @description 訪問決策器,決定某個用戶具有的角色,是否有足夠的權限去訪問某個資源 ;做最終的訪問控制決定
*/
public class MyAccessDescisionManager implements AccessDecisionManager{
@Override
public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException {
// TODO Auto-generated method stub
System.out.println("MyAccessDescisionManager.decide()------------------驗證用戶是否具有一定的權限--------");
if(configAttributes==null) return;
Iterator<ConfigAttribute> it = configAttributes.iterator();
while(it.hasNext()){
String needResource = it.next().getAttribute();
//authentication.getAuthorities() 用戶所有的權限
for(GrantedAuthority ga:authentication.getAuthorities()){
if(needResource.equals(ga.getAuthority())){
return;
}
}
}
throw new AccessDeniedException("--------MyAccessDescisionManager:decide-------權限認證失敗!");
}
@Override
public boolean supports(ConfigAttribute attribute) {
// TODO Auto-generated method stub
return true;
}
@Override
public boolean supports(Class<?> clazz) {
// TODO Auto-generated method stub
return true;
}
}
到這裏,可以很自然的想到是權限和用戶數據從哪裏得到的,filterInvocationSecurityMetadataSource在被加載時候,會首先將權限的信息建立起來,這裏我用一個map,key爲url,value爲該權限的名稱,這一步是在構造方法中進行的,也就是服務器啓動時候完成的。而當用戶訪問某一個地址時,ss會到該類中調用getAttributes(Object obj)方法,obj中包含了訪問的url地址,我們需要做的就是將該url對應的權限名稱返回給ss,而ss會將返回的這個對象,其實就是accessDecisionManager的decide方法中的configAttributes對象。
/**
* @description 資源源數據定義,將所有的資源和權限對應關係建立起來,即定義某一資源可以被哪些角色訪問
* @author aokunsang
* @date 2012-8-15
*/
public class MySecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
private UserMapper userMapper;
public UserMapper getUserMapper() {
return userMapper;
}
public void setUserMapper(UserMapper userMapper) {
this.userMapper = userMapper;
}
/* 保存資源和權限的對應關係 key-資源url value-權限 */
private static Map<String,Collection<ConfigAttribute>> resourceMap = null;
private AntPathMatcher urlMatcher = new AntPathMatcher();
public MySecurityMetadataSource(UserMapper userMapper) {
this.userMapper = userMapper;
loadResourcesDefine();
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
private void loadResourcesDefine(){
resourceMap = new HashMap<String,Collection<ConfigAttribute>>();
System.out.println("MySecurityMetadataSource.loadResourcesDefine()--------------開始加載資源列表數據--------");
List<RolePO> roles = userMapper.findAllRoles();
for(RolePO role : roles){
List<ResourcePO> resources = role.getResources();
for(ResourcePO resource : resources){
Collection<ConfigAttribute> configAttributes = null;
ConfigAttribute configAttribute = new SecurityConfig(resource.getResourceName());
if(resourceMap.containsKey(resource.getUrl())){
configAttributes = resourceMap.get(resource.getUrl());
configAttributes.add(configAttribute);
}else{
configAttributes = new ArrayList<ConfigAttribute>() ;
configAttributes.add(configAttribute);
resourceMap.put(resource.getUrl(), configAttributes);
}
}
}
System.out.println("11");
Set<String> set = resourceMap.keySet();
Iterator<String> it = set.iterator();
while(it.hasNext()){
String s = it.next();
System.out.println("key:"+s+"|value:"+resourceMap.get(s));
}
}
/*
* 根據請求的資源地址,獲取它所擁有的權限
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object obj)
throws IllegalArgumentException {
//獲取請求的url地址
String url = ((FilterInvocation)obj).getRequestUrl();
System.out.println("MySecurityMetadataSource:getAttributes()---------------請求地址爲:"+url);
Iterator<String> it = resourceMap.keySet().iterator();
while(it.hasNext()){
String _url = it.next();
if(_url.indexOf("?")!=-1){
_url = _url.substring(0, _url.indexOf("?"));
}
if(urlMatcher.match(_url,url)){
System.out.println("MySecurityMetadataSource:getAttributes()---------------需要的權限是:"+resourceMap.get(_url));
return resourceMap.get(_url);
}
}
Collection<ConfigAttribute> nouse = new ArrayList<ConfigAttribute>();
nouse.add(new SecurityConfig("無相應權限"));
return nouse;
}
@Override
public boolean supports(Class<?> arg0) {
System.out.println("MySecurityMetadataSource.supports()---------------------");
return true;
}
}
到這裏,我們還有一個疑問,就是decide方法中的authentication對象(authentication.getAuthorities()包含當前用戶擁有的權限),用戶的對應角色和權限信息是從哪裏獲得的?其實這裏是通過調用MyUserDetailsServiceImpl來獲取的,該類需要實現UserDetailService接口,更具體一些實際上是通過loadUserByUsername進行獲取用戶權限信息的,這裏注意返回的User不是我們自己定義的PO,而是ss3框架中的User。(這裏說下爲什麼我自己的UserPO沒有繼承ss的User,就是因爲User沒有默認無參構造方法,導致mybatis無法創建對象,具體可能還是有辦法的,比如重寫mybatis的相關接口,比較麻煩,所以這裏是先通過返回我們自己的UserPO後,再組裝成ss需要的User對象進行的)這裏在回到剛纔AccessDescisionManager中的decide方法想一下,authentication.getAuthorities()其實獲得的就是下面的Collection<GrantedAuthority>類型的對象。
最後下面的這段代碼,我沒有直接從username中直接獲得resource,而是通過先獲得role,再通過role獲取resource,我感覺這樣方便一些,sql也簡單,當然有更好的可以替換掉。
@Component("myUserDetailsServiceImpl")
public class MyUserDetailsServiceImpl implements UserDetailsService{
@Resource
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
System.out.println("username is " + username);
UserPO user = userMapper.getUserByUserName(username);
if(user == null) {
throw new UsernameNotFoundException(username);
}
Collection<GrantedAuthority> grantedAuths = obtionGrantedAuthorities(user);
System.out.println(user.getUsername());
return new User(
user.getUsername(),
user.getPassword(),
true,
true,
true,
true,
grantedAuths);
}
//取得用戶的權限
private Set<GrantedAuthority> obtionGrantedAuthorities(UserPO user) {
Set<GrantedAuthority> authSet = new HashSet<GrantedAuthority>();
List<RolePO> roles = user.getRoles();
for(RolePO role : roles) {
RolePO innerRole = userMapper.getRoleByRoleName(role.getRoleName());
List<ResourcePO> tempRes = innerRole.getResources();
for(ResourcePO res : tempRes) {
authSet.add(new GrantedAuthorityImpl(res.getResourceName()));
}
}
return authSet;
}
}
到這裏,所有的權限-角色-用戶信息已經可以串起來了。再來梳理一下流程,啓動服務器時,通過FilterInvocationSecurityMetadataSource獲得用戶的所有角色及權限信息,當用戶登陸時,通過MyUserDetailsServiceImpl中的loadUserByUsername獲得該登陸用戶所有的權限,發出請求時,通過FilterInvocationSecurityMetadataSource的getAttributes(Object url)獲得需要的權限名,最後在AccessDecisionManager中decide方法進行對比,如果用戶擁有的權限名稱和該url需要的權限名相同,那麼放行,否則認證失敗!清楚這些後,我們還需要一個filter,把上述流程串起來,就像提葡萄一樣~
/**
* @description 一個自定義的filter,
* 必須包含authenticationManager,accessDecisionManager,securityMetadataSource三個屬性,
我們的所有控制將在這三個類中實現
*/
public class MyFilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter{
private FilterInvocationSecurityMetadataSource fisMetadataSource;
/* (non-Javadoc)
* @see org.springframework.security.access.intercept.AbstractSecurityInterceptor#getSecureObjectClass()
*/
@Override
public Class<?> getSecureObjectClass() {
return FilterInvocation.class;
}
@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
return fisMetadataSource;
}
@Override
public void destroy() {}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
System.out.println("------------MyFilterSecurityInterceptor.doFilter()-----------開始攔截器了....");
FilterInvocation fi = new FilterInvocation(request, response, chain);
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} catch (Exception e) {
e.printStackTrace();
}finally{
super.afterInvocation(token,null);
}
System.out.println("------------MyFilterSecurityInterceptor.doFilter()-----------攔截器該方法結束了....");
}
@Override
public void init(FilterConfig config) throws ServletException {
}
public void setFisMetadataSource(
FilterInvocationSecurityMetadataSource fisMetadataSource) {
this.fisMetadataSource = fisMetadataSource;
}
public FilterInvocationSecurityMetadataSource getFisMetadataSource() {
return fisMetadataSource;
}
}
如果全部照搬上邊的代碼,到這裏就已經結束了。
但是昨天晚上遇到一個大坑,就是發現如果我在數據庫中配置了該用戶的相關權限url後,用戶可以訪問,如果用戶沒有該url的權限,該用戶依然可以訪問url,這是讓我無比喫驚,因爲大部分都是參考網絡的資料寫的,後來看了一下ss的源碼,才發現可能是其他人寫錯了。這裏簡單說一下,因爲單位電腦沒有ss的源碼,主要問題出在MyFilterSecurityInterceptor中的doFilter方法:InterceptorStatusToken token = super.beforeInvocation(fi); 當ss在未匹配到url的權限時,即MySecurityMetadataSource中的getAttributes返回的對象爲空時,該方法beforeInvocation直接return null,而實際decide方法在下方並未運行。
protected InterceptorStatusToken beforeInvocation(Object object) {
if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
.....
}
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
if (attributes == null || attributes.isEmpty()) {//此處判斷MySecurityMetadataSource中的getAttributes返回的對象
if (rejectPublicInvocations) {
throw new IllegalArgumentException("Secure object invocation " + object +
" was denied as public invocations are not allowed via this interceptor. "
+ "This indicates a configuration error because the "
+ "rejectPublicInvocations property is set to 'true'");
}
if (debug) {
logger.debug("Public object - authentication not attempted");
}
publishEvent(new PublicInvocationEvent(object));
return null; // no further work post-invocation
}
if (debug) {
logger.debug("Secure object: " + object + "; Attributes: " + attributes);
}
if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"), object, attributes);
}
Authentication authenticated = authenticateIfRequired();//實際運行decide方法的地方
// Attempt authorization
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, accessDeniedException));
throw accessDeniedException;
}
if (debug) {
logger.debug("Authorization successful");
}
if (publishAuthorizationSuccess) {
publishEvent(new AuthorizedEvent(object, attributes, authenticated));
}
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
if (runAs == null) {
if (debug) {
logger.debug("RunAsManager did not change Authentication object");
}
// no further work post-invocation
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
} else {
if (debug) {
logger.debug("Switching to RunAs Authentication: " + runAs);
}
SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);
// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
}
在我看的所有BLOG中,當匹配不到時,全部返回了Null,而當我追到 super.beforeInvocation(fi)源碼中時,發現當getAttributes返回null後,ss就會跳過AccessDecisionManager的decide方法,導致未進行判斷!從而ss會讓用戶請求順利的通過。之後,查了一下ss官方英文文檔,如下描述:
Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException
ConfigAttribute
s that apply to a given secure object.- Parameters:
object
- the object being secured- Returns:
- the attributes that apply to the passed in secured object. Should return an empty collection if there are no applicable attributes.
- Throws:
IllegalArgumentException
- if the passed object is not of a type supported by theSecurityMetadataSource
implementation
紅色標出了,應當返回一個空的對象集合如果沒有相應權限的時候。而其他blog文返回的是null,導致後續跳過了decide方法!所以我在MySecurityMetadataSource中的getAttributes中寫的是:
Collection<ConfigAttribute> nouse = new ArrayList<ConfigAttribute>();
nouse.add(new SecurityConfig("無相應權限"));
return nouse;
這樣當沒有權限時,纔可以正常攔截!現在博文抄來抄去,正確的還好,但凡有錯誤。。真是坑死人。
這裏發下幾個幫助比較大的供參考:
http://aokunsang.iteye.com/blog/1638558
http://blog.csdn.net/k10509806/article/details/6369131
和只允許登陸一次的具體方法,需要重寫UserPO中的hashCode和equal方法。
http://flashing.iteye.com/blog/823666