30分鐘學會如何使用Shiro
一、架構
要學習如何使用Shiro必須先從它的架構談起,作為一款安全框架Shiro的設計相當精妙。Shiro的應用不依賴任何容器,它也可以在JavaSE下使用。但是最常用的環(huán)境還是JavaEE。下面以用戶登錄為例:
1、使用用戶的登錄信息創(chuàng)建令牌
- UsernamePasswordToken token = new UsernamePasswordToken(username, password);
token可以理解為用戶令牌,登錄的過程被抽象為Shiro驗證令牌是否具有合法身份以及相關權限。
2、執(zhí)行登陸動作
- SecurityUtils.setSecurityManager(securityManager); // 注入SecurityManager
- Subject subject = SecurityUtils.getSubject(); // 獲取Subject單例對象
- subject.login(token); // 登陸
Shiro的核心部分是SecurityManager,它負責安全認證與授權。Shiro本身已經實現(xiàn)了所有的細節(jié),用戶可以完全把它當做一個黑盒來使用。SecurityUtils對象,本質上就是一個工廠類似Spring中的ApplicationContext。
Subject是初學者比較難于理解的對象,很多人以為它可以等同于User,其實不然。Subject中文翻譯:項目,而正確的理解也恰恰如此。它是你目前所設計的需要通過Shiro保護的項目的一個抽象概念。通過令牌(token)與項目(subject)的登陸(login)關系,Shiro保證了項目整體的安全。
我把歷史發(fā)布過的實戰(zhàn)文章整理成了 PDF ,關注微信公眾號「Java后端」回復 666 下載。
3、判斷用戶
Shiro本身無法知道所持有令牌的用戶是否合法,因為除了項目的設計人員恐怕誰都無法得知。因此Realm是整個框架中為數(shù)不多的必須由設計者自行實現(xiàn)的模塊,當然Shiro提供了多種實現(xiàn)的途徑,本文只介紹最常見也最重要的一種實現(xiàn)方式——數(shù)據庫查詢。
4、兩條重要的英文
我在學習Shiro的過程中遇到的第一個障礙就是這兩個對象的英文名稱:AuthorizationInfo,AuthenticationInfo。不用懷疑自己的眼睛,它們確實長的很像,不但長的像,就連意思都十分近似。
在解釋它們前首先必須要描述一下Shiro對于安全用戶的界定:和大多數(shù)操作系統(tǒng)一樣。用戶具有角色和權限兩種最基本的屬性。例如,我的Windows登陸名稱是learnhow,它的角色是administrator,而administrator具有所有系統(tǒng)權限。這樣learnhow自然就擁有了所有系統(tǒng)權限。那么其他人需要登錄我的電腦怎么辦,我可以開放一個guest角色,任何無法提供正確用戶名與密碼的未知用戶都可以通過guest來登錄,而系統(tǒng)對于guest角色開放的權限極其有限。
同理,Shiro對用戶的約束也采用了這樣的方式。AuthenticationInfo代表了用戶的角色信息集合,AuthorizationInfo代表了角色的權限信息集合。如此一來,當設計人員對項目中的某一個url路徑設置了只允許某個角色或具有某種權限才可以訪問的控制約束的時候,Shiro就可以通過以上兩個對象來判斷。說到這里,大家可能還比較困惑。先不要著急,繼續(xù)往后看就自然會明白了。
二、實現(xiàn)Realm
如何實現(xiàn)Realm是本文的重頭戲,也是比較費事的部分。這里大家會接觸到幾個新鮮的概念:緩存機制、散列算法、加密算法。由于本文不會專門介紹這些概念,所以這里僅僅拋磚引玉的談幾點,能幫助大家更好的理解Shiro即可。
1、緩存機制
Ehcache是很多Java項目中使用的緩存框架,Hibernate就是其中之一。它的本質就是將原本只能存儲在內存中的數(shù)據通過算法保存到硬盤上,再根據需求依次取出。你可以把Ehcache理解為一個Map<String,Object>對象,通過put保存對象,再通過get取回對象。
- <?xml version="1.0" encoding="UTF-8"?>
- <ehcache name="shirocache">
- <diskStore path="java.io.tmpdir" />
- <cache name="passwordRetryCache"
- maxEntriesLocalHeap="2000"
- eternal="false"
- timeToIdleSeconds="1800"
- timeToLiveSeconds="0"
- overflowToDisk="false"
- statistics="true">
- </cache>
- </ehcache>
以上是ehcache.xml文件的基礎配置,timeToLiveSeconds為緩存的最大生存時間,timeToIdleSeconds為緩存的最大空閑時間,當eternal為false時ttl和tti才可以生效。更多配置的含義大家可以去網上查詢。
2、散列算法與加密算法
md5是本文會使用的散列算法,加密算法本文不會涉及。散列和加密本質上都是將一個Object變成一串無意義的字符串,不同點是經過散列的對象無法復原,是一個單向的過程。例如,對密碼的加密通常就是使用散列算法,因此用戶如果忘記密碼只能通過修改而無法獲取原始密碼。但是對于信息的加密則是正規(guī)的加密算法,經過加密的信息是可以通過秘鑰解密和還原。
3、用戶注冊
請注意,雖然我們一直在談論用戶登錄的安全性問題,但是說到用戶登錄首先就是用戶注冊。如何保證用戶注冊的信息不丟失,不泄密也是項目設計的重點。
- public class PasswordHelper {
- private RandomNumberGenerator randomNumberGenerator = new SecureRandomNumberGenerator();
- private String algorithmName = "md5";
- private final int hashIterations = 2;
- public void encryptPassword(User user) {
- // User對象包含最基本的字段Username和Password
- user.setSalt(randomNumberGenerator.nextBytes().toHex());
- // 將用戶的注冊密碼經過散列算法替換成一個不可逆的新密碼保存進數(shù)據,散列過程使用了鹽
- String newnewPassword = new SimpleHash(algorithmName, user.getPassword(),
- ByteSource.Util.bytes(user.getCredentialsSalt()), hashIterations).toHex();
- user.setPassword(newPassword);
- }
- }
如果你不清楚什么叫加鹽可以忽略散列的過程,只要明白存儲在數(shù)據庫中的密碼是根據戶注冊時填寫的密碼所產生的一個新字符串就可以了。經過散列后的密碼替換用戶注冊時的密碼,然后將User保存進數(shù)據庫。剩下的工作就丟給UserService來處理。
那么這樣就帶來了一個新問題,既然散列算法是無法復原的,當用戶登錄的時候使用當初注冊時的密碼,我們又應該如何判斷?答案就是需要對用戶密碼再次以相同的算法散列運算一次,再同數(shù)據庫中保存的字符串比較。
4、匹配
CredentialsMatcher是一個接口,功能就是用來匹配用戶登錄使用的令牌和數(shù)據庫中保存的用戶信息是否匹配。當然它的功能不僅如此。本文要介紹的是這個接口的一個實現(xiàn)類:HashedCredentialsMatcher
- public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher {
- // 聲明一個緩存接口,這個接口是Shiro緩存管理的一部分,它的具體實現(xiàn)可以通過外部容器注入
- private Cache<String, AtomicInteger> passwordRetryCache;
- public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) {
- passwordRetryCache = cacheManager.getCache("passwordRetryCache");
- }
- @Override
- public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
- String username = (String) token.getPrincipal();
- AtomicInteger retryCount = passwordRetryCache.get(username);
- if (retryCount == null) {
- retryCount = new AtomicInteger(0);
- passwordRetryCache.put(username, retryCount);
- }
- // 自定義一個驗證過程:當用戶連續(xù)輸入密碼錯誤5次以上禁止用戶登錄一段時間
- if (retryCount.incrementAndGet() > 5) {
- throw new ExcessiveAttemptsException();
- }
- boolean match = super.doCredentialsMatch(token, info);
- if (match) {
- passwordRetryCache.remove(username);
- }
- return match;
- }
- }
可以看到,這個實現(xiàn)里設計人員僅僅是增加了一個不允許連續(xù)錯誤登錄的判斷。真正匹配的過程還是交給它的直接父類去完成。連續(xù)登錄錯誤的判斷依靠Ehcache緩存來實現(xiàn)。顯然match返回true為匹配成功。
5、獲取用戶的角色和權限信息
說了這么多才到我們的重點Realm,如果你已經理解了Shiro對于用戶匹配和注冊加密的全過程,真正理解Realm的實現(xiàn)反而比較簡單。我們還得回到上文提及的兩個非常類似的對象AuthorizationInfo和AuthenticationInfo。因為Realm就是提供這兩個對象的地方。
- public class UserRealm extends AuthorizingRealm {
- // 用戶對應的角色信息與權限信息都保存在數(shù)據庫中,通過UserService獲取數(shù)據
- private UserService userService = new UserServiceImpl();
- /**
- * 提供用戶信息返回權限信息
- */
- @Override
- protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
- String username = (String) principals.getPrimaryPrincipal();
- SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
- // 根據用戶名查詢當前用戶擁有的角色
- Set<Role> roles = userService.findRoles(username);
- Set<String> roleNames = new HashSet<String>();
- for (Role role : roles) {
- roleNames.add(role.getRole());
- }
- // 將角色名稱提供給info
- authorizationInfo.setRoles(roleNames);
- // 根據用戶名查詢當前用戶權限
- Set<Permission> permissions = userService.findPermissions(username);
- Set<String> permissionNames = new HashSet<String>();
- for (Permission permission : permissions) {
- permissionNames.add(permission.getPermission());
- }
- // 將權限名稱提供給info
- authorizationInfo.setStringPermissions(permissionNames);
- return authorizationInfo;
- }
- /**
- * 提供賬戶信息返回認證信息
- */
- @Override
- protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
- String username = (String) token.getPrincipal();
- User user = userService.findByUsername(username);
- if (user == null) {
- // 用戶名不存在拋出異常
- throw new UnknownAccountException();
- }
- if (user.getLocked() == 0) {
- // 用戶被管理員鎖定拋出異常
- throw new LockedAccountException();
- }
- SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user.getUsername(),
- user.getPassword(), ByteSource.Util.bytes(user.getCredentialsSalt()), getName());
- return authenticationInfo;
- }
- }
根據Shiro的設計思路,用戶與角色之前的關系為多對多,角色與權限之間的關系也是多對多。在數(shù)據庫中需要因此建立5張表,分別是:
用戶表(存儲用戶名,密碼,鹽等)
角色表(角色名稱,相關描述等)
權限表(權限名稱,相關描述等)
用戶-角色對應中間表(以用戶ID和角色ID作為聯(lián)合主鍵)
角色-權限對應中間表(以角色ID和權限ID作為聯(lián)合主鍵)
具體dao與service的實現(xiàn)本文不提供??傊Y論就是,Shiro需要根據用戶名和密碼首先判斷登錄的用戶是否合法,然后再對合法用戶授權。而這個過程就是Realm的實現(xiàn)過程。
6、會話
用戶的一次登錄即為一次會話,Shiro也可以代替Tomcat等容器管理會話。目的是當用戶停留在某個頁面長時間無動作的時候,再次對任何鏈接的訪問都會被重定向到登錄頁面要求重新輸入用戶名和密碼而不需要程序員在Servlet中不停的判斷Session中是否包含User對象。
啟用Shiro會話管理的另一個用途是可以針對不同的模塊采取不同的會話處理。以淘寶為例,用戶注冊淘寶以后可以選擇記住用戶名和密碼。之后再次訪問就無需登陸。但是如果你要訪問支付寶或購物車等鏈接依然需要用戶確認身份。當然,Shiro也可以創(chuàng)建使用容器提供的Session最為實現(xiàn)。
三、與SpringMVC集成
有了注冊模塊和Realm模塊的支持,下面就是如何與SpringMVC集成開發(fā)。有過框架集成經驗的同學一定知道,所謂的集成基本都是一堆xml文件的配置,Shiro也不例外。
1、配置前端過濾器
先說一個題外話,F(xiàn)ilter是過濾器,interceptor是攔截器。前者基于回調函數(shù)實現(xiàn),必須依靠容器支持。因為需要容器裝配好整條FilterChain并逐個調用。后者基于代理實現(xiàn),屬于AOP的范疇。
如果希望在WEB環(huán)境中使用Shiro必須首先在web.xml文件中配置
- <?xml version="1.0" encoding="UTF-8"?>
- <web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xmlns="http://java.sun.com/xml/ns/javaee"
- xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
- id="WebApp_ID" version="3.0">
- <display-name>Shiro_Project</display-name>
- <welcome-file-list>
- <welcome-file>index.jsp</welcome-file>
- </welcome-file-list>
- <servlet>
- <servlet-name>SpringMVC</servlet-name>
- <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
- <init-param>
- <param-name>contextConfigLocation</param-name>
- <param-value>classpath:springmvc.xml</param-value>
- </init-param>
- <load-on-startup>1</load-on-startup>
- <async-supported>true</async-supported>
- </servlet>
- <servlet-mapping>
- <servlet-name>SpringMVC</servlet-name>
- <url-pattern>/</url-pattern>
- </servlet-mapping>
- <listener>
- <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
- </listener>
- <listener>
- <listener-class>org.springframework.web.util.Log4jConfigListener</listener-class>
- </listener>
- <context-param>
- <param-name>contextConfigLocation</param-name>
- <!-- 將Shiro的配置文件交給Spring監(jiān)聽器初始化 -->
- <param-value>classpath:spring.xml,classpath:spring-shiro-web.xml</param-value>
- </context-param>
- <context-param>
- <param-name>log4jConfigLoaction</param-name>
- <param-value>classpath:log4j.properties</param-value>
- </context-param>
- <!-- shiro配置 開始 -->
- <filter>
- <filter-name>shiroFilter</filter-name>
- <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
- <async-supported>true</async-supported>
- <init-param>
- <param-name>targetFilterLifecycle</param-name>
- <param-value>true</param-value>
- </init-param>
- </filter>
- <filter-mapping>
- <filter-name>shiroFilter</filter-name>
- <url-pattern>/*</url-pattern>
- </filter-mapping>
- <!-- shiro配置 結束 -->
- </web-app>
熟悉Spring配置的同學可以重點看有綠字注釋的部分,這里是使Shiro生效的關鍵。由于項目通過Spring管理,因此所有的配置原則上都是交給Spring。DelegatingFilterProxy的功能是通知Spring將所有的Filter交給ShiroFilter管理。
接著在classpath路徑下配置spring-shiro-web.xml文件
- <beans xmlns="http://www.springframework.org/schema/beans"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:p="http://www.springframework.org/schema/p"
- 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.1.xsd
- http://www.springframework.org/schema/context
- http://www.springframework.org/schema/context/spring-context-3.1.xsd
- http://www.springframework.org/schema/mvc
- http://www.springframework.org/schema/mvc/spring-mvc-4.0.xsd">
- <!-- 緩存管理器 使用Ehcache實現(xiàn) -->
- <bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
- <property name="cacheManagerConfigFile" value="classpath:ehcache.xml" />
- </bean>
- <!-- 憑證匹配器 -->
- <bean id="credentialsMatcher" class="utils.RetryLimitHashedCredentialsMatcher">
- <constructor-arg ref="cacheManager" />
- <property name="hashAlgorithmName" value="md5" />
- <property name="hashIterations" value="2" />
- <property name="storedCredentialsHexEncoded" value="true" />
- </bean>
- <!-- Realm實現(xiàn) -->
- <bean id="userRealm" class="utils.UserRealm">
- <property name="credentialsMatcher" ref="credentialsMatcher" />
- </bean>
- <!-- 安全管理器 -->
- <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
- <property name="realm" ref="userRealm" />
- </bean>
- <!-- Shiro的Web過濾器 -->
- <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
- <property name="securityManager" ref="securityManager" />
- <property name="loginUrl" value="/" />
- <property name="unauthorizedUrl" value="/" />
- <property name="filterChainDefinitions">
- <value>
- /authc/admin = roles[admin]
- /authc/** = authc
- /** = anon
- </value>
- </property>
- </bean>
- <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor" />
- </beans>
需要注意filterChainDefinitions過濾器中對于路徑的配置是有順序的,當找到匹配的條目之后容器不會再繼續(xù)尋找。因此帶有通配符的路徑要放在后面。三條配置的含義是:
/authc/admin需要用戶有用admin權限
/authc/**用戶必須登錄才能訪問
/**其他所有路徑任何人都可以訪問
說了這么多,大家一定關心在Spring中引入Shiro之后到底如何編寫登錄代碼呢。
- @Controller
- public class LoginController {
- @Autowired
- private UserService userService;
- @RequestMapping("login")
- public ModelAndView login(@RequestParam("username") String username, @RequestParam("password") String password) {
- UsernamePasswordToken token = new UsernamePasswordToken(username, password);
- Subject subject = SecurityUtils.getSubject();
- try {
- subject.login(token);
- } catch (IncorrectCredentialsException ice) {
- // 捕獲密碼錯誤異常
- ModelAndView mv = new ModelAndView("error");
- mv.addObject("message", "password error!");
- return mv;
- } catch (UnknownAccountException uae) {
- // 捕獲未知用戶名異常
- ModelAndView mv = new ModelAndView("error");
- mv.addObject("message", "username error!");
- return mv;
- } catch (ExcessiveAttemptsException eae) {
- // 捕獲錯誤登錄過多的異常
- ModelAndView mv = new ModelAndView("error");
- mv.addObject("message", "times error");
- return mv;
- }
- User user = userService.findByUsername(username);
- subject.getSession().setAttribute("user", user);
- return new ModelAndView("success");
- }
- }
登錄完成以后,當前用戶信息被保存進Session。這個Session是通過Shiro管理的會話對象,要獲取依然必須通過Shiro。傳統(tǒng)的Session中不存在User對象。
- @Controller
- @RequestMapping("authc")
- public class AuthcController {
- // /authc/** = authc 任何通過表單登錄的用戶都可以訪問
- @RequestMapping("anyuser")
- public ModelAndView anyuser() {
- Subject subject = SecurityUtils.getSubject();
- User user = (User) subject.getSession().getAttribute("user");
- System.out.println(user);
- return new ModelAndView("inner");
- }
- // /authc/admin = user[admin] 只有具備admin角色的用戶才可以訪問,否則請求將被重定向至登錄界面
- @RequestMapping("admin")
- public ModelAndView admin() {
- Subject subject = SecurityUtils.getSubject();
- User user = (User) subject.getSession().getAttribute("user");
- System.out.println(user);
- return new ModelAndView("inner");
- }
- }