一、Spring Security介紹
Spring Security的前身Acegi,其配置及使用相對來說複雜一些,因爲要配置的東西比較多,Sprng Security簡化了以前的配置。大家有興趣可以多多瞭解以前的版本,因爲很多細節在前面的版本可以看得比較清楚,後面的版本需要看源代碼才知道其實現的原理了。
基於角色的設計與實現是絕大部分系統中比較常見的權限管理方式,對權限進行分組進行管理有助於減少權限管理的複雜程度。
Spring Security目前提供了三種權限的管理方式:
一、 基於URL的攔截方式
二、 基於方法的AOP攔截方式
三、 基於數據的權限攔截方式
第一種是常見的權限管理方式,第二種有時也可以通過第一種去進行實現,方法級的攔截容易實現,不過要以友好的方式顯示權限不足設計及實現就有些囉嗦。基於數據級的安全攔截實現上得更麻煩,在此不作介紹,我們只是針對第一種方式作設計。Spring Security在URL上已經提供了比較好的管理,不過其是以類似以下這種方式進行配置的。
- <http auto-config='true'>
- <intercept-url pattern="/login.jsp*" filters="none"/>
- <intercept-url pattern="/**" access="ROLE_USER" />
- <form-login login-page='/login.jsp'/>
- </http>
這種要動態實現角色與權限的管理就顯得有些不足了,因此需要進行擴展實現,我們可以把一些不需要進行安全攔截的url放在Spring的以上配置中,設置filters=”none”,如images,css,js等,提高訪問的速度。
在擴展Spring Security之前,我們需要了解一下Spring Security的相關術語。
Authentication (認證)對象
其實就是一個可以通過Spring Security的認證的身份證明。如實現該接口的類UsernamePasswordAuthenticationToken,表示可以通過username及password作爲身份驗證。
Authentication對象包含了
- Principal 標識是哪一個對象,可以認爲是用戶
- Credentials 信任的對象,如密碼。
- Authorities 權限的集合,在我們的系統中可以認爲是角色的集合 (authorities要賦予給principal的)
SecurityContextHolder
是Spring Security的核心對象,是安全上下文的訪問的入口。如取得當前的登錄用戶可以從該類中的相應的方法取得。該類中包含ThreadLocal私有屬性用於存取SecurityContext, SecurityContext包含Authentication私有屬性。如實現彈出窗口登錄功能的時候,輸入的用戶名及密碼並沒有最終經過SPRING SECURITY的filter,那麼如何使用得當前用戶可以成功登錄呢,其就是利用到這一點,通過該類拿到SecurityContext,然後設置一個認證的對象給它,SPRING
SECURITY在看到該認證對象的時候,就會成功經過了身份的認證了。
其實也可以這樣理解,爲了處理Http請求間認證,Spring Security使用HttpSessionIntegrationFilter,HttpSessionIntegrationFilter用於在HttpSession存儲Http請求間的SecurityContext。不過我們可以通過SecurityContextHolder去拿到這個SecurityContext
AuthenticationManager
通過Providers 驗證 在當前 ContextHolder中的Authentication對象是否合法。
AccessDecissionManager
經過投票機制來審批是否批准操作
Interceptors(攔截器)
攔截器(如FilterSecurityInterceptor,JoinPoint,MethodSecurityInterceptor等)用於協調授權,認證等操作。
Spring Security是Spring中一個強大的安全管理框架,不過目前在我們系統中使用的僅是其中一部分的功能,則權限過濾安全檢查的功能。如果拋開這個框架,我們實現權限管理的時候,可能使用最多的方案還是使用Filter來進行過濾,在Filter裏判斷當前的用戶是否爲登錄用戶,若是登錄用戶,則看是否有權限訪問當前的資源,若爲未登錄用戶,則跳至登錄頁面。
二、擴展Spring Security
擴展Spring Security基於角色的管理策略,通過角色分配,保證系統的安全。其安全的手段包括以下:
1. 登錄時需要加上驗證碼
2. 所有的數據展示及訪問頁需要登錄後才能訪問
3. 用戶的數據庫密碼存儲時使用Sha-256的加密算法
4. 登錄後的所有系統的訪問URL均需要授權
5. 登錄多少次失敗後,可鎖定IP,約20分鐘後才能自動解鎖。(尚未實現)
權限設計目前是採用基於角色控制的方式,用戶需要訪問系統的資源,首先必須要授予一個角色,而該角色具有訪問系統資源的權限的能力,也可以認爲是權限的集合。因此,一個用戶要訪問系統的某個資源(如產品列表),則首先要授予一個能夠訪問產品列表資源的角色(如productAdmin)。只要任一個用戶擁有了該角色,即可以訪問該資源。
系統的安全涉及到兩個不同的概念,認證和授權。前者是關於確認用戶是否確實是他們所宣稱的身份。用戶進入系統的時候,首先要進行第一個操作就是進行身份認證,即Authentication。在系統中一般表現爲用戶用賬號跟密碼登錄。如果都正確了,則可以登錄系統。在現實中你可以這樣理解,員工在進入公司之前,需要進行身份的確認。身份確認通過後,則可以進入公司。進入公司後,並不代表可以隨便進入公司的每個辦公室。這時就需要每個看當前員工具有哪些角色,即授權。授權則是關於確認用戶是否有允許執行一個特定的操作。如當前員工是總經理,則可以進入總經理辦公室,並且可以進入普通員工的辦公區域。是因爲總經理已經授權可以出入這些地方。
在本系統中,權限表現爲功能菜單及系統訪問的URL。
如:
添加用戶,其訪問的url爲 /system/saveAppUser.do
刪除用戶,其訪問的url爲/system/deleteAppUser.do
查詢用戶,其訪問的url爲/system/listAppUser.do
因而用戶、角色、權限之間的關係可以用如下的圖描述:
表設計
一個用戶可以有多個角色,每個角色有多個功能菜單,每個功能菜單會對應多個系統訪問的URL
表設計如下所示:
表說明:
1. app_user系統用戶表,放置系統的所有用戶
2. user_role用戶角色,放置系統的所有角色
3. app_role角色表,放置用戶角色
4. role_fun角色對應的功能表,放置角色擁有的功能
5. app_function系統的功能表,放置系統參與授權的所有功能
6. fun_url系統的功能對應的權限URL表
目前我們需要擴展Spring Security的以下兩部分功能
1. 身份認證
2. 授權
Spring Security是由一組的filter來進行統一的過濾,不同的filter進行相應的權限過濾功能。不過在Security跟spring集成的過程中,其是由一個代理的類進行這些filter的統一管理。可以在web.xml中進行了查看,如下所示:
- <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>*.do</url-pattern>
- </filter-mapping>
- <filter-mapping>
- <filter-name>springSecurityFilterChain</filter-name>
- <url-pattern>/index.jsp</url-pattern>
- </filter-mapping>
- <filter-mapping>
- <filter-name>springSecurityFilterChain</filter-name>
- <url-pattern>/file-upload</url-pattern>
- </filter-mapping>
所有經過springSecurityFilterChain的url,都會轉到DelegatingFilterProxy類的bean去處理。而該Bean在Spring Security 2.0中,已經內置於安全管理的缺省的配置當中,我們只需要把app-security.xml加入我們系統管理中來即可。如下:
- <?xml version="1.0" encoding="UTF-8"?>
- <b:beans xmlns="http://www.springframework.org/schema/security" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns:b="http://www.springframework.org/schema/beans"
- xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
- http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-2.0.4.xsd">
- <http auto-config="true" access-denied-page="/403.jsp" lowercase-comparisons="true"
- access-decision-manager-ref="accessDecisionManager">
- <intercept-url pattern="/images/**" filters="none"/>
- <intercept-url pattern="/css/**" filters="none"/>
- <intercept-url pattern="/js/**" filters="none"/>
- <intercept-url pattern="/403*" filters="none"/>
- <intercept-url pattern="/404*" filters="none"/>
- <intercept-url pattern="/500*" filters="none"/>
- <intercept-url pattern="/ext3/**" filters="none"/>
- <intercept-url pattern="/fckeditor/**" filters="none"/>
- <intercept-url pattern="/jsonStruts**" filters="none"/>
- <form-login default-target-url="/index.jsp" login-page="/login.jsp" authentication-failure-url="/login.jsp?error=true" login-processing-url="/j_security_check" />
- <logout logout-url="/j_logout.do" logout-success-url="/login.jsp"/>
- <remember-me key="RememberAppUser"/>
- </http>
- <b:bean id="accessDecisionManager" class="org.springframework.security.vote.AffirmativeBased">
- <b:property name="allowIfAllAbstainDecisions" value="false"/>
- <b:property name="decisionVoters">
- <b:list>
- <b:bean class="org.springframework.security.vote.RoleVoter">
- <b:property name="rolePrefix" value="" />
- </b:bean>
- <b:bean class="org.springframework.security.vote.AuthenticatedVoter" />
- </b:list>
- </b:property>
- </b:bean>
- <authentication-manager alias="authenticationManager"/>
- <authentication-provider user-service-ref="appUserDao">
- <password-encoder hash="sha-256" base64="true"/>
- </authentication-provider>
- <b:bean id="securityInterceptorFilter" class="com.htsoft.core.web.filter.SecurityInterceptorFilter" >
- <custom-filter after="FILTER_SECURITY_INTERCEPTOR" />
- <b:property name="securityDataSource" ref="securityDataSource"/>
- </b:bean>
- <b:bean id="securityDataSource" class="com.htsoft.core.security.SecurityDataSource">
- <b:property name="appRoleService" ref="appRoleService"/>
- <b:property name="anonymousUrls">
- <b:set>
- <b:value>/login.do</b:value>
- <b:value>/check.do</b:value>
- </b:set>
- </b:property>
- <b:property name="publicUrls">
- <b:set>
- <b:value>/modelsMenu.do</b:value>
- <b:value>/itemsMenu.do</b:value>
- <b:value>/file-upload</b:value>
- <b:value>/index.jsp</b:value>
- <b:value>/communicate/listPhoneBook.do</b:value>
- <b:value>/communicate/listPhoneGroup.do</b:value>
- <b:value>/communicate/moveMail.do</b:value>
- <b:value>/communicate/listMailFolder.do</b:value>
- <b:value>/communicate/removeMailFolder.do</b:value>
- <b:value>/communicate/searchMail.do</b:value>
- <b:value>/system/getAppUser.do</b:value>
- <b:value>/system/checkDiary.do</b:value>
- <b:value>/system/selectDepartment.do</b:value>
- <b:value>/system/listAppRole.do</b:value>
- </b:set>
- </b:property>
- </b:bean>
- </b:beans>
身份認證
說明:當用戶登錄時,會根據用戶賬號及密碼進行驗證,驗證由authenticationManager來進,其會調用實現UserDetailsService接口實現類完成,在本系統,是由appUserDaoImpl類來實現。
而我們的用戶及角色實體要成爲安全框架識別的安全實體,需要相應實現不同的接口,如下所示:
訪問授權
授權的管理是通過Filter來進行的,用戶訪問URL時,均需要經過Spring Security的URL進行授權。在本系統中,這個功能是通過SecurityInterceptorFilter來進行。
系統啓動時,會把所有的權限以[角色—URL列表]的形式放置在一個全局的Map中,用戶訪問系統的url時,就會根據當前用戶所擁有的角色是否包含此URL。這個全局的權限匹配源則由SecurityDataSource來提供。由於登錄用戶在進入系統後,都會具備一些常用的功能,所以每個用戶均有一個PUBLIC_ROLE的角色,代表可以訪問系統的公告資源。該角色對應的可訪問的URL,則配置在SecurityDataSource Bean中的publicUrls屬性中。
SecurityInterceptorFilter代碼如下所示:
- package com.htsoft.core.web.filter;
- /*
- * 廣州宏天軟件有限公司 OA辦公管理系統 -- http://www.jee-soft.cn
- * Copyright (C) 2008-2009 GuangZhou HongTian Software Company
- */
- import java.io.IOException;
- import java.util.HashMap;
- import java.util.Set;
- import javax.servlet.FilterChain;
- import javax.servlet.ServletException;
- import javax.servlet.http.HttpServletRequest;
- import javax.servlet.http.HttpServletResponse;
- import org.springframework.security.AccessDeniedException;
- import org.springframework.security.Authentication;
- import org.springframework.security.GrantedAuthority;
- import org.springframework.security.context.SecurityContextHolder;
- import org.springframework.web.filter.OncePerRequestFilter;
- import com.htsoft.core.security.SecurityDataSource;
- /**
- * 權限攔載器
- * @author csx
- */
- public class SecurityInterceptorFilter extends OncePerRequestFilter {
- /**
- * 角色權限映射列表源,用於權限的匹配
- */
- private HashMap<String, Set<String>> roleUrlsMap=null;
- private SecurityDataSource securityDataSource;
- public void setSecurityDataSource(SecurityDataSource securityDataSource) {
- this.securityDataSource = securityDataSource;
- }
- @Override
- protected void doFilterInternal(HttpServletRequest request,
- HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
- if(logger.isDebugEnabled()){
- logger.debug("...enter the SecurityInterceptorFilter doFilterInternal here...");
- }
- String url=request.getRequestURI();
- //若有contextPath,則切出來
- if(org.springframework.util.StringUtils.hasLength(request.getContextPath())){
- String contextPath=request.getContextPath();
- int index=url.indexOf(contextPath);
- if(index!=-1){
- url=url.substring(index+contextPath.length());
- }
- }
- Authentication auth= SecurityContextHolder.getContext().getAuthentication();//取得認證器
- boolean isSuperUser=false;
- for(int i=0;i<auth.getAuthorities().length;i++){
- //logger.info("角色名稱:"+auth.getAuthorities()[i].getAuthority());
- if("超級管理員".equals(auth.getAuthorities()[i].getAuthority())){
- isSuperUser=true;
- }
- }
- if(!isSuperUser){//非超級管理員
- if(!isUrlGrantedRight(url,auth)){//如果未授權
- logger.info("ungranted url:" + url);
- throw new AccessDeniedException("Access is denied! Url:" + url + " User:" + SecurityContextHolder.getContext().getAuthentication().getName());
- }
- }
- if(logger.isInfoEnabled()){
- logger.info("pass the url:" + url);
- }
- //進行下一個Filter
- chain.doFilter(request, response);
- }
- /**
- * 檢查該URL是否授權訪問
- * @param url
- * @return
- */
- private boolean isUrlGrantedRight(String url,Authentication auth){
- //遍歷該用戶下所有角色對應的URL,看是否有匹配的
- for(GrantedAuthority ga:auth.getAuthorities()){
- Set<String> urlSet=roleUrlsMap.get(ga.getAuthority());
- //TODO AntPathMatcher here
- if(urlSet!=null && urlSet.contains(url)){
- return true;
- }
- }
- return false;
- }
- public void loadDataSource(){
- roleUrlsMap=securityDataSource.getDataSource();
- }
- @Override
- public void afterPropertiesSet() throws ServletException {
- loadDataSource();
- if(roleUrlsMap==null){
- throw new RuntimeException("沒有進行設置系統的權限匹配數據源");
- }
- }
- }
三、EXT的擴展實現
至此,我們完成了對Spring Security的權限擴展,但是EXT訪問的時候,我們的應用程序都是在一個頁面上進行,也就是我們之前說的One Application One Page,幾乎所有的請求都是通過Ajax的請求來時行,頁面沒有刷新,當權限不足的時候,我們如何提示用戶呢?另外我們的功能菜單又是如何來根據用戶的角色來顯示出來呢?在此,我們把需要把角色、功能、權限URL需要進行統一管理。我們從以下幾個方面來進行擴展。
當用戶權限不足時,我們需要提示用戶無限訪問該URL,在app-sercurity.xml中,我們配置了以下:
- <http auto-config="true" access-denied-page="/403.jsp" lowercase-comparisons="true"
- access-decision-manager-ref="accessDecisionManager">
- <intercept-url pattern="/images/**" filters="none"/>
- <intercept-url pattern="/css/**" filters="none"/>
- <intercept-url pattern="/js/**" filters="none"/>
- <intercept-url pattern="/403*" filters="none"/>
- <intercept-url pattern="/404*" filters="none"/>
- <intercept-url pattern="/500*" filters="none"/>
- <intercept-url pattern="/ext3/**" filters="none"/>
- <intercept-url pattern="/fckeditor/**" filters="none"/>
- <intercept-url pattern="/jsonStruts**" filters="none"/>
- <form-login default-target-url="/index.jsp" login-page="/login.jsp" authentication-failure-url="/login.jsp?error=true" login-processing-url="/j_security_check" />
- <logout logout-url="/j_logout.do" logout-success-url="/login.jsp"/>
- <remember-me key="RememberAppUser"/>
- </http>
即當用戶權限不足時,會跳至403頁,因此我們可以在此上作文章,當跳至403頁時,我們往response的頭寫一個標識,在ext的connection中獲取返回結果時,我們根據這個標識來給用戶提示一個友好的信息,如:
403.jsp的代碼實現:
- <%@ page pageEncoding="UTF-8"%><%
- response.addHeader("__forbidden","true");
- String basePath=request.getContextPath();
- %>
- <html>
- <head>
- <title>訪問拒絕</title>
- <style type="text/css">
- <!--
- .STYLE10 {
- font-family: "黑體";
- font-size: 36px;
- }
- -->
- </style>
- </head>
- <body>
- <table width="510" border="0" align="center" cellpadding="0" cellspacing="0">
- <tr>
- <td><img src="<%=basePath%>/images/error_top.jpg" width="510" height="80" /></td>
- </tr>
- <tr>
- <td height="200" align="center" valign="top" background="<%=basePath%>/images/error_bg.jpg">
- <table width="80%" border="0" cellspacing="0" cellpadding="0">
- <tr>
- <td width="34%" align="right"><img src="<%=basePath%>/images/error.gif" width="128" height="128"></td>
- <td width="66%" valign="bottom" align="center">
- <span class="STYLE10">訪問被拒絕</span>
- <div style="text-align: left;line-height: 22px;">
- <font size="2">對不起,您的當前角色沒有查看此頁面的權限。請聯繫您的系統管理員,以獲得相應的權限。點擊這裏返回主頁。如果需要技術支持,點擊這裏發送郵件。</font>
- </div>
- <a href="#" onclick="javascript:document.location.href='<%=basePath%>/j_logout.do';">重 新 登 錄</a>
- <a href="#" onclick="javascript:history.back(-1);">後 退</a>
- </td>
- </table>
- </td>
- </tr>
- <tr>
- <td><img src="<%=basePath%>/images/error_bootom.jpg" width="510" height="32" /></td>
- </tr>
- </table>
- </body>
- </html>
處理該標識:
- App.init = function() {
- Ext.util.Observable.observeClass(Ext.data.Connection);
- Ext.data.Connection.on('requestcomplete', function(conn, resp,options ){
- if (resp && resp.getResponseHeader){
- if(resp.getResponseHeader('__timeout')) {
- Ext.ux.Toast.msg('操作提示:','操作已經超時,請重新登錄!');
- window.location.href=__ctxPath+'/index.jsp?randId=' + parseInt(1000*Math.random());
- }
- if(resp.getResponseHeader('__forbidden')){
- Ext.ux.Toast.msg('系統訪問權限提示:','你目前沒有權限訪問:{0}',options.url);
- }
- }
- });
- …
- }
Connection的這個requestcomplete事件是所有的Ajax請求都必須觸發的,所以把它作爲總的入口。
另外,用戶登錄後,其功能的菜單如何來配置呢,因此應用程序是通過一個全局的menu.xml文件來進行功能菜單的管理,同時也包括其功能與URL的配置。
- <?xml version="1.0" encoding="UTF-8"?>
- <Menus>
- <Items id="SystemSetting" text="系統設置" iconCls="menu-system">
- ...
- <Item id="AppRoleView" iconCls="menu-role" text="角色設置">
- <Function id="_AppRoleList" text="查看角色" iconCls="menu-list">
- <url>/system/listAppRole.do</url>
- </Function>
- <Function id="_AppRoleAdd" text="添加角色" iconCls="menu-add">
- <url>/system/listAppRole.do</url>
- <url>/system/saveAppRole.do</url>
- </Function>
- <Function id="_AppRoleEdit" text="編輯角色" iconCls="menu-add">
- <url>/system/listAppRole.do</url>
- <url>/system/saveAppRole.do</url>
- </Function>
- <Function id="_AppRoleDel" text="刪除角色" iconCls="menu-del">
- <url>/system/listAppRole.do</url>
- <url>/system/mulDelAppRole.do</url>
- </Function>
- <Function id="_AppRoleGrant" text="授權角色">
- <url>/system/listAppRole.do</url>
- <url>/system/grantAppRole.do</url>
- </Function>
- </Item>
- ...
- <Item id="ReportTemplateView" iconCls="menu-report" text="報表管理">
- ...
- </Item>
- </Items>
- </Menus>
這個XML文件會在應用程序啓動添加至系統的全局變量中,以“角色”對應“URL”的Map提供數據源來進行。
那麼角色對應的URL是如何來構造的,這個相對簡單一些,以上的功能及菜單,其均存在一個Id,如角色設置(AppRoleView),添加角色“_AppRoleAdd”。
每個角色就保存這些ID,所以加載這些ID,就有辦法把其下的URL全部加載出來,從而形成角色與URL的映射關係。
另外,我們還可以把用戶所擁有的權限,通過該用戶擁有哪些角色,每個角色包括哪些權限的ID,從而構造出該用戶的權限集合。如下所示,當用戶登錄後,我們把所有的ID集中放在用戶的rights字段中,這樣就可以通過ID來決定用戶是否有權限訪問某個功能按鈕,從而達到功能級別的控制,如:
- //加載權限
- Ext.Ajax.request({
- url:__ctxPath+'/system/getCurrentAppUser.do',
- method:'Get',
- success:function(response,options){
- var object=Ext.util.JSON.decode(response.responseText);
- //取得當前登錄用戶的相關信息,包括權限
- curUserInfo=new UserInfo(object.user.userId,object.user.fullname,object.user.rights);
- }
- });
以下爲user.rights的構造,是在用戶登錄的時候進行配置實現,爲AppUserDaoImpl.java的部分代碼
- public UserDetails loadUserByUsername(final String username)
- throws UsernameNotFoundException, DataAccessException {
- return (UserDetails) getHibernateTemplate().execute(
- new HibernateCallback() {
- public Object doInHibernate(Session session)
- throws HibernateException, SQLException {
- String hql = "from AppUser ap where ap.username=? and ap.delFlag = ?";
- Query query = session.createQuery(hql);
- query.setString(0, username);
- query.setShort(1, Constants.FLAG_UNDELETED);
- AppUser user = null;
- try {
- user = (AppUser) query.uniqueResult();
- if (user != null) {
- Hibernate.initialize(user.getRoles());
- Hibernate.initialize(user.getDepartment());
- //進行合併權限的處理
- Set<AppRole> roleSet=user.getRoles();
- Iterator<AppRole> it=roleSet.iterator();
- while(it.hasNext()){
- AppRole role=it.next();
- if(role.getRoleId().equals(AppRole.SUPER_ROLEID)){//具有超級權限
- user.getRights().clear();
- user.getRights().add(AppRole.SUPER_RIGHTS);
- break;
- }else{
- if(StringUtils.isNotEmpty(role.getRights())){
- String[]items=role.getRights().split("[,]");
- for(int i=0;i<items.length;i++){
- if(!user.getRights().contains(items[i])){
- user.getRights().add(items[i]);
- }
- }
- }
- }
- }
- }
- } catch (Exception ex) {
- logger.warn("user:" + username
- + " can't not loding rights:"
- + ex.getMessage());
- }
- return user;
- }
- });
- }
而其最終的實現效果可以參見我另一篇博客: