在 Web 項目中應用 Apache Shiro 開源權限框架
Apache Shiro 是功能強大并且容易集成的開源權限框架,它能夠完成認證、授權、加密、會話管理等功能。認證和授權為權限控制的核心,簡單來說,“認證”就是證明你是誰? Web 應用程序一般做法通過表單提交用戶名及密碼達到認證目的。“授權”即是否允許已認證用戶訪問受保護資源。關于 Shiro 的一系列特征及優點,很多文章已有列舉,這里不再逐一贅述,本文重點介紹 Shiro 在 Web Application 中如何實現驗證碼認證以及如何實現單點登錄。
用戶權限模型
在揭開 Shiro 面紗之前,我們需要認知用戶權限模型。本文所提到用戶權限模型,指的是用來表達用戶信息及用戶權限信息的數據模型。即能證明“你是誰?”、“你能訪問多少 受保護資源?”。為實現一個較為靈活的用戶權限數據模型,通常把用戶信息單獨用一個實體表示,用戶權限信息用兩個實體表示。
- 用戶信息用 LoginAccount 表示,最簡單的用戶信息可能只包含用戶名 loginName 及密碼 password 兩個屬性。實際應用中可能會包含用戶是否被禁用,用戶信息是否過期等信息。
- 用戶權限信息用 Role 與 Permission 表示,Role 與 Permission 之間構成多對多關系。Permission 可以理解為對一個資源的操作,Role 可以簡單理解為 Permission 的集合。
- 用戶信息與 Role 之間構成多對多關系。表示同一個用戶可以擁有多個 Role,一個 Role 可以被多個用戶所擁有。
認證與授權
Shiro 認證與授權處理過程
- 被 Shiro 保護的資源,才會經過認證與授權過程。使用 Shiro 對 URL 進行保護可以參見“與 Spring 集成”章節。
- 用戶訪問受 Shiro 保護的 URL;例如 http://host/security/action.do。
- Shiro 首先檢查用戶是否已經通過認證,如果未通過認證檢查,則跳轉到登錄頁面,否則進行授權檢查。認證過程需要通過 Realm 來獲取用戶及密碼信息,通常情況我們實現 JDBC Realm,此時用戶認證所需要的信息從數據庫獲取。如果使用了緩存,除第一次外用戶信息從緩存獲取。
- 認證通過后接受 Shiro 授權檢查,授權檢查同樣需要通過 Realm 獲取用戶權限信息。Shiro 需要的用戶權限信息包括 Role 或 Permission,可以是其中任何一種或同時兩者,具體取決于受保護資源的配置。如果用戶權限信息未包含 Shiro 需要的 Role 或 Permission,授權不通過。只有授權通過,才可以訪問受保護 URL 對應的資源,否則跳轉到“未經授權頁面”。
Shiro Realm
在 Shiro 認證與授權處理過程中,提及到 Realm。Realm 可以理解為讀取用戶信息、角色及權限的 DAO。由于大多 Web 應用程序使用了關系數據庫,因此實現 JDBC Realm 是常用的做法,后面會提到 CAS Realm,另一個 Realm 的實現。
清單 1. 實現自己的 JDBC Realmpublic class MyShiroRealm extends AuthorizingRealm{ // 用于獲取用戶信息及用戶權限信息的業務接口 private BusinessManager businessManager; // 獲取授權信息 protected AuthorizationInfo doGetAuthorizationInfo( PrincipalCollection principals) { String username = (String) principals.fromRealm( getName()).iterator().next(); if( username != null ){ // 查詢用戶授權信息 Collection<String> pers=businessManager.queryPermissions(username); if( pers != null && !pers.isEmpty() ){ SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); for( String each:pers ) info.addStringPermissions( each ); return info; } } return null; } // 獲取認證信息 protected AuthenticationInfo doGetAuthenticationInfo( AuthenticationToken authcToken ) throws AuthenticationException { UsernamePasswordToken token = (UsernamePasswordToken) authcToken; // 通過表單接收的用戶名 String username = token.getUsername(); if( username != null && !"".equals(username) ){ LoginAccount account = businessManager.get( username ); if( account != null ){ return new SimpleAuthenticationInfo( account.getLoginName(),account.getPassword(),getName() ); } } return null; } }
代碼說明:
- businessManager 表示從數據庫獲取用戶信息及用戶權限信息的業務類,實際情況中可能因用戶權限模型設計不同或持久化框架選擇不同,這里沒給出示例代碼。
- doGetAuthenticationInfo 方法,取用戶信息。對照用戶權限模型來說,就是取 LoginAccount 實體。最終我們需要為 Shiro 提供 AuthenticationInfo 對象。
- doGetAuthorizationInfo 方法,獲取用戶權限信息。代碼給出了獲取用戶 Permission 的示例,獲取用戶 Role 的代碼類似。為 Shiro 提供的用戶權限信息以 AuthorizationInfo 對象形式返回。
為何對 Shiro 情有獨鐘
或許有人要問,我一直在使用 Spring,應用程序的安全組件早已選擇了 Spring Security,為什么還需要 Shiro ?當然,不可否認 Spring Security 也是一款優秀的安全控制組件。本文的初衷不是讓您必須選擇 Shiro 以及必須放棄 Spring Security,秉承客觀的態度,下面對兩者略微比較:
- 簡單性,Shiro 在使用上較 Spring Security 更簡單,更容易理解。
- 靈活性,Shiro 可運行在 Web、EJB、IoC、Google App Engine 等任何應用環境,卻不依賴這些環境。而 Spring Security 只能與 Spring 一起集成使用。
- 可插拔,Shiro 干凈的 API 和設計模式使它可以方便地與許多的其它框架和應用進行集成。Shiro 可以與諸如 Spring、Grails、Wicket、Tapestry、Mule、Apache Camel、Vaadin 這類第三方框架無縫集成。Spring Security 在這方面就顯得有些捉衿見肘。
與 Spring 集成
在 Java Web Application 開發中,Spring 得到了廣泛使用;與 EJB 相比較,可以說 Spring 是主流。Shiro 自身提供了與 Spring 的良好支持,在應用程序中集成 Spring 十分容易。
有了前面提到的用戶權限數據模型,并且實現了自己的 Realm,我們就可以開始集成 Shiro 為應用程序服務了。
Shiro 的安裝
Shiro 的安裝非常簡單,在 Shiro 官網下載 shiro-all-1.2.0.jar、shiro-cas-1.2.0.jar(單點登錄需要),及 SLF4J 官網下載 Shiro 依賴的日志組件 slf4j-api-1.6.1.jar。Spring 相關的 JAR 包這里不作列舉。這些 JAR 包需要放置到 Web 工程 /WEB-INF/lib/ 目錄。至此,剩下的就是配置了。
配置過濾器
首先,配置過濾器讓請求資源經過 Shiro 的過濾處理,這與其它過濾器的使用類似。
web.xml
<filter> <filter-name>shiroFilter</filter-name> <filter-class> org.springframework.web.filter.DelegatingFilterProxy </filter-class> </filter> <filter-mapping> <filter-name>shiroFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
Spring 配置
接下來僅僅配置一系列由 Spring 容器管理的 Bean,集成大功告成。各個 Bean 的功能見代碼說明。
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <property name="securityManager" ref="securityManager"/> <property name="loginUrl" value="/login.do"/> <property name="successUrl" value="/welcome.do"/> <property name="unauthorizedUrl" value="/403.do"/> <property name="filters"> <util:map> <entry key="authc" value-ref="formAuthenticationFilter"/> </util:map> </property> <property name="filterChainDefinitions"> <value> /=anon /login.do*=authc /logout.do*=anon # 權限配置示例 /security/account/view.do=authc,perms[SECURITY_ACCOUNT_VIEW] /** = authc </value> </property> </bean> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="realm" ref="myShiroRealm"/> </bean> <bean id="myShiroRealm" class="xxx.packagename.MyShiroRealm"> <!-- businessManager 用來實現用戶名密碼的查詢 --> <property name="businessManager" ref="businessManager"/> <property name="cacheManager" ref="shiroCacheManager"/> </bean> <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/> <bean id="shiroCacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager"> <property name="cacheManager" ref="cacheManager"/> </bean> <bean id="formAuthenticationFilter" class="org.apache.shiro.web.filter.authc.FormAuthenticationFilter"/>
代碼說明:
- shiroFilter 中 loginUrl 為登錄頁面地址,successUrl 為登錄成功頁面地址(如果首先訪問受保護 URL 登錄成功,則跳轉到實際訪問頁面),unauthorizedUrl 認證未通過訪問的頁面(前面提到的“未經授權頁面”)。
- shiroFilter 中 filters 屬性,formAuthenticationFilter 配置為基于表單認證的過濾器。
- shiroFilter 中 filterChainDefinitions 屬性,anon 表示匿名訪問(不需要認證與授權),authc 表示需要認證,perms[SECURITY_ACCOUNT_VIEW] 表示用戶需要提供值為“SECURITY_ACCOUNT_VIEW”Permission 信息。由此可見,連接地址配置為 authc 或 perms[XXX] 表示為受保護資源。
- securityManager 中 realm 屬性,配置為我們自己實現的 Realm。關于 Realm,參見前面“Shiro Realm”章節。
- myShiroRealm 為我們自己需要實現的 Realm 類,為了減小數據庫壓力,添加了緩存機制。
- shiroCacheManager 是 Shiro 對緩存框架 EhCache 的配置。
實現驗證碼認證
驗證碼是有效防止暴力破解的一種手段,常用做法是在服務端產生一串隨機字符串與當前用戶會話關聯(我們通常說的放入 Session),然后向終端用戶展現一張經過“擾亂”的圖片,只有當用戶輸入的內容與服務端產生的內容相同時才允許進行下一步操作。
產生驗證碼
作為演示,我們選擇開源的驗證碼組件 kaptcha。這樣,我們只需要簡單配置一個 Servlet,頁面通過 IMG 標簽就可以展現圖形驗證碼。
<!-- captcha servlet--> <servlet> <servlet-name>kaptcha</servlet-name> <servlet-class> com.google.code.kaptcha.servlet.KaptchaServlet </servlet-class> </servlet> <servlet-mapping> <servlet-name>kaptcha</servlet-name> <url-pattern>/images/kaptcha.jpg</url-pattern> </servlet-mapping>
擴展 UsernamePasswordToken
Shiro 表單認證,頁面提交的用戶名密碼等信息,用 UsernamePasswordToken 類來接收,很容易想到,要接收頁面驗證碼的輸入,我們需要擴展此類:
public class CaptchaUsernamePasswordToken extends UsernamePasswordToken{ private String captcha; // 省略 getter 和 setter 方法 public CaptchaUsernamePasswordToken(String username, char[] password, boolean rememberMe, String host,String captcha) { super(username, password, rememberMe, host); this.captcha = captcha; } }
擴展 FormAuthenticationFilter
接下來我們擴展 FormAuthenticationFilter 類,首先覆蓋 createToken 方法,以便獲取 CaptchaUsernamePasswordToken 實例;然后增加驗證碼校驗方法 doCaptchaValidate;最后覆蓋 Shiro 的認證方法 executeLogin,在原表單認證邏輯處理之前進行驗證碼校驗。
public class CaptchaFormAuthenticationFilter extends FormAuthenticationFilter{ public static final String DEFAULT_CAPTCHA_PARAM = "captcha"; private String captchaParam = DEFAULT_CAPTCHA_PARAM; public String getCaptchaParam() { return captchaParam; } public void setCaptchaParam(String captchaParam) { this.captchaParam = captchaParam; } protected String getCaptcha(ServletRequest request) { return WebUtils.getCleanParam(request, getCaptchaParam()); } // 創建 Token protected CaptchaUsernamePasswordToken createToken( ServletRequest request, ServletResponse response) { String username = getUsername(request); String password = getPassword(request); String captcha = getCaptcha(request); boolean rememberMe = isRememberMe(request); String host = getHost(request); return new CaptchaUsernamePasswordToken( username, password, rememberMe, host,captcha); } // 驗證碼校驗 protected void doCaptchaValidate( HttpServletRequest request ,CaptchaUsernamePasswordToken token ){ String captcha = (String)request.getSession().getAttribute( com.google.code.kaptcha.Constants.KAPTCHA_SESSION_KEY); if( captcha!=null && !captcha.equalsIgnoreCase(token.getCaptcha()) ){ throw new IncorrectCaptchaException ("驗證碼錯誤!"); } } // 認證 protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { CaptchaUsernamePasswordToken token = createToken(request, response); try { doCaptchaValidate( (HttpServletRequest)request,token ); Subject subject = getSubject(request, response); subject.login(token); return onLoginSuccess(token, subject, request, response); } catch (AuthenticationException e) { return onLoginFailure(token, e, request, response); } } }
代碼說明:
- 添加 captchaParam 變量,為的是頁面表單提交驗證碼的參數名可以進行靈活配置。
- doCaptchaValidate 方法中,驗證碼校驗使用了框架 KAPTCHA 所提供的 API。
添加 IncorrectCaptchaException
前面驗證碼校驗不通過,我們拋出一個異常 IncorrectCaptchaException,此類繼承 AuthenticationException,之所以需要擴展一個新的異常類,為的是在頁面能更精準顯示錯誤提示信息。
public class IncorrectCaptchaException extends AuthenticationException{ public IncorrectCaptchaException() { super(); } public IncorrectCaptchaException(String message, Throwable cause) { super(message, cause); } public IncorrectCaptchaException(String message) { super(message); } public IncorrectCaptchaException(Throwable cause) { super(cause); } }頁面展現驗證碼錯誤提示信息
Object obj=request.getAttribute( org.apache.shiro.web.filter.authc.FormAuthenticationFilter .DEFAULT_ERROR_KEY_ATTRIBUTE_NAME); AuthenticationException authExp = (AuthenticationException)obj; if( authExp != null ){ String expMsg=""; if(authExp instanceof UnknownAccountException || authExp instanceof IncorrectCredentialsException){ expMsg="錯誤的用戶賬號或密碼!"; }else if( authExp instanceof IncorrectCaptchaException){ expMsg="驗證碼錯誤!"; }else{ expMsg="登錄異常 :"+authExp.getMessage() ; } out.print("<div class=\"error\">"+expMsg+"</div>"); }
前面章節,我們認識了 Shiro 的認證與授權,并結合 Spring 作了集成實現。現實中,有這樣一個場景,我們擁有很多業務系統,按照前面的思路,如果訪問每個業務系統,都要進行認證,這樣是否有點難讓人授受。有沒有一 種機制,讓我們只認證一次,就可以任意訪問目標系統呢?
上面的場景,就是我們常提到的單點登錄 SSO。Shiro 從 1.2 版本開始對 CAS 進行支持,CAS 就是單點登錄的一種實現。
Shiro CAS 認證流程
- 用戶首次訪問受保護的資源;例如 http://casclient/security/view.do
- 由于未通過認證,Shiro 首先把請求地址(http://casclient/security/view.do)緩存起來。
- 然后跳轉到 CAS 服務器進行登錄認證,在 CAS 服務端認證完成后需要返回到請求的 CAS 客戶端,因此在請求時,必須在參數中添加返回地址 ( 在 Shiro 中名為 CAS Service)。 例如 http://casserver/login?service=http://casclient/shiro-cas
- 由 CAS 服務器認證通過后,CAS 服務器為返回地址添加 ticket。例如 http://casclient/shiro-cas?ticket=ST-4-BWMEnXfpxfVD2jrkVaLl-cas
- 接下來,Shiro 會校驗 ticket 是否有效。由于 CAS 客戶端不提供直接認證,所以 Shiro 會向 CAS 服務端發起 ticket 校驗檢查,只有服務端返回成功時,Shiro 才認為認證通過。
- 認證通過,進入授權檢查。Shiro 授權檢查與前面提到的相同。
- 最后授權檢查通過,用戶正常訪問到 http://casclient/security/view.do。
CAS Realm
Shiro 提供了一個名為 CasRealm 的類,與前面提到的 JDBC Realm 相似,該類同樣包括認證和授權兩部分功能。認證就是校驗從 CAS 服務端返回的 ticket 是否有效;授權還是獲取用戶權限信息。
實現單點登錄功能,需要擴展 CasRealm 類。
public class MyCasRealm extends CasRealm{ // 獲取授權信息 protected AuthorizationInfo doGetAuthorizationInfo( PrincipalCollection principals) { //... 與前面 MyShiroRealm 相同 } public String getCasServerUrlPrefix() { return "http://casserver/login"; } public String getCasService() { return "http://casclient/shiro-cas"; } }
代碼說明:
- doGetAuthorizationInfo 獲取授權信息與前面章節“實現自己的 JDBC Realm”相同。
- 認證功能由 Shiro 自身提供的 CasRealm 實現。
- getCasServerUrlPrefix 方法返回 CAS 服務器地址,實際使用一般通過參數進行配置。
- getCasService 方法返回 CAS 客戶端處理地址,實際使用一般通過參數進行配置。
- 認證過程需 keystore,否則會出現異常。可以通過設置系統屬性的方式來指定,例如 System.setProperty("javax.net.ssl.trustStore","keystore-file");
CAS Spring 配置
實現單點登錄的 Spring 配置與前面類似,不同之處參見代碼說明。
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <property name="securityManager" ref="securityManager"/> <property name="loginUrl" value="http://casserver/login?service=http://casclient/shiro-cas"/> <property name="successUrl" value="/welcome.do"/> <property name="unauthorizedUrl" value="/403.do"/> <property name="filters"> <util:map> <entry key="authc" value-ref="formAuthenticationFilter"/> <entry key="cas" value-ref="casFilter"/> </util:map> </property> <property name="filterChainDefinitions"> <value> /shiro-cas*=cas /logout.do*=anon /casticketerror.do*=anon # 權限配置示例 /security/account/view.do=authc,perms[SECURITY_ACCOUNT_VIEW] /** = authc </value> </property> </bean> <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager"> <property name="realm" ref="myShiroRealm"/> </bean> <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/> <!-- CAS Realm --> <bean id="myShiroRealm" class="xxx.packagename.MyCasRealm"> <property name="cacheManager" ref="shiroCacheManager"/> </bean> <bean id="shiroCacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager"> <property name="cacheManager" ref="cacheManager"/> </bean> <bean id="formAuthenticationFilter" class="org.apache.shiro.web.filter.authc.FormAuthenticationFilter"/> <!-- CAS Filter --> <bean id="casFilter" class="org.apache.shiro.cas.CasFilter"> <property name="failureUrl" value="casticketerror.do"/> </bean>
代碼說明:
- shiroFilter 中 loginUrl 屬性,為登錄 CAS 服務端地址,參數 service 為服務端的返回地址。
- myShiroRealm 為上一節提到的 CAS Realm。
- casFilter 中 failureUrl 屬性,為 Ticket 校驗不通過時展示的錯誤頁面。
總結
至此,我們對 Shiro 有了較為深入的認識。Shiro 靈活,功能強大,幾乎能滿足我們實際應用中的各種情況,還等什么呢?讓我開始使用 Shiro 為應用程序護航吧!
文章出處: IBM developerWorks