Shiro 是一個強大、簡單易用的 Java 安全框架,可使認證、授權、加密,會話過程更便捷,並可為應用提供安全保障。本節重點介紹下 Shiro 的認證和授權功能。
1 Shiro 三大核心組件
Shiro 有三大核心組件,即 Subject、SecurityManager 和 Realm。先來看一下它們之間的關系。
可以看到:應用代碼直接交互的對象是 Subject,也就是說 Shiro 的對外 API 核心就是 Subject;其每個 API 的含義:
Subject:主體,代表了當前 “用戶”,這個用戶不一定是一個具體的人,與當前應用交互的任何東西都是 Subject,如網絡爬蟲,機器人等;即一個抽象概念;所有 Subject 都綁定到 SecurityManager,與 Subject 的所有交互都會委托給 SecurityManager;可以把 Subject 認為是一個門面;SecurityManager 才是實際的執行者;
SecurityManager:安全管理器;即所有與安全有關的操作都會與 SecurityManager 交互;且它管理着所有 Subject;可以看出它是 Shiro 的核心,它負責與后邊介紹的其他組件進行交互,如果學習過 SpringMVC,你可以把它看成 DispatcherServlet 前端控制器;
Realm:域,Shiro 從從 Realm 獲取安全數據(如用戶、角色、權限),就是說 SecurityManager 要驗證用戶身份,那么它需要從 Realm 獲取相應的用戶進行比較以確定用戶身份是否合法;也需要從 Realm 得到用戶相應的角色 / 權限進行驗證用戶是否能進行操作;可以把 Realm 看成 DataSource,即安全數據源。
也就是說對於我們而言,最簡單的一個 Shiro 應用:
-
應用代碼通過 Subject 來進行認證和授權,而 Subject 又委托給 SecurityManager;
- 我們需要給 Shiro 的 SecurityManager 注入 Realm,從而讓 SecurityManager 能得到合法的用戶及其權限進行判斷。
從以上也可以看出,Shiro 不提供維護用戶 / 權限,而是通過 Realm 讓開發人員自己注入。
接下來我們來從 Shiro 內部來看下 Shiro 的架構,如下圖所示:
Subject:主體,可以看到主體可以是任何可以與應用交互的 “用戶”;
SecurityManager:相當於 SpringMVC 中的 DispatcherServlet 或者 Struts2 中的 FilterDispatcher;是 Shiro 的心臟;所有具體的交互都通過 SecurityManager 進行控制;它管理着所有 Subject、且負責進行認證和授權、及會話、緩存的管理。
Authenticator:認證器,負責主體認證的,這是一個擴展點,如果用戶覺得 Shiro 默認的不好,可以自定義實現;其需要認證策略(Authentication Strategy),即什么情況下算用戶認證通過了;
Authrizer:授權器,或者訪問控制器,用來決定主體是否有權限進行相應的操作;即控制着用戶能訪問應用中的哪些功能;
Realm:可以有 1 個或多個 Realm,可以認為是安全實體數據源,即用於獲取安全實體的;可以是 JDBC 實現,也可以是 LDAP 實現,或者內存實現等等;由用戶提供;注意:Shiro 不知道你的用戶 / 權限存儲在哪及以何種格式存儲;所以我們一般在應用中都需要實現自己的 Realm;
SessionManager:如果寫過 Servlet 就應該知道 Session 的概念,Session 呢需要有人去管理它的生命周期,這個組件就是 SessionManager;而 Shiro 並不僅僅可以用在 Web 環境,也可以用在如普通的 JavaSE 環境、EJB 等環境;所有呢,Shiro 就抽象了一個自己的 Session 來管理主體與應用之間交互的數據;這樣的話,比如我們在 Web 環境用,剛開始是一台 Web 服務器;接着又上了台 EJB 服務器;這時想把兩台服務器的會話數據放到一個地方,這個時候就可以實現自己的分布式會話(如把數據放到 Memcached 服務器);
SessionDAO:DAO 大家都用過,數據訪問對象,用於會話的 CRUD,比如我們想把 Session 保存到數據庫,那么可以實現自己的 SessionDAO,通過如 JDBC 寫到數據庫;比如想把 Session 放到 Memcached 中,可以實現自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 進行緩存,以提高性能;
CacheManager:緩存控制器,來管理如用戶、角色、權限等的緩存的;因為這些數據基本上很少去改變,放到緩存中后可以提高訪問的性能
Cryptography:密碼模塊,Shiro 提高了一些常見的加密組件用於如密碼加密 / 解密的。
2 Shiro 身份和權限認證
2.1 Shiro 身份認證
我們分析下 Shiro 身份認證的過程,首先看一下官方給出的認證圖。
從圖中可以看到,這個過程包括五步。
Step1:應用程序代碼調用 Subject.login(token) 方法后,傳入代表最終用戶身份的 AuthenticationToken 實例 Token。
Step2:將 Subject 實例委托給應用程序的 SecurityManager(Shiro 的安全管理)並開始實際的認證工作。這里開始了真正的認證工作。
Step3、4、5:SecurityManager 根據具體的 Realm 進行安全認證。從圖中可以看出,Realm 可進行自定義(Custom Realm)。
2.2 Shiro 權限認證
權限認證,也就是訪問控制,即在應用中控制誰能訪問哪些資源。在權限認證中,最核心的三個要素是:權限、角色和用戶。
權限(Permission):即操作資源的權利,比如訪問某個頁面,以及對某個模塊的數據進行添加、修改、刪除、查看操作的權利。
角色(Role):指的是用戶擔任的角色,一個角色可以有多個權限。
用戶(User):在 Shiro 中,代表訪問系統的用戶,即上面提到的 Subject 認證主體。
它們之間的的關系可以用下圖來表示:
一個用戶可以有多個角色,而不同的角色可以有不同的權限,也可有相同的權限。比如說現在有三個角色,1 是普通角色,2 也是普通角色,3 是管理員,角色 1 只能查看信息,角色 2 只能添加信息,管理員對兩者皆有權限,而且還可以刪除信息。
3 Spring Boot 集成 Shiro
因為是demo,沒有引入數據庫表,有需要的可以自己建,也不難,就5張表:用戶表、角色表、權限表、用戶角色關聯表、角色權限關聯表。有需要的話還可以加一張部門表
項目結構如下:
3.1 依賴導入
Spring Boot 2.0.4 集成 Shiro 需要導入如下 starter 依賴:
<!--引入shiro--> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.5.3</version> </dependency>
3.2 自定義 Realm
自定義 Realm 需要繼承 AuthorizingRealm 類,該類封裝了很多方法,且繼承自 Realm 類。
繼承 AuthorizingRealm 類后,我們需要重寫以下兩個方法。
doGetAuthenticationInfo() 方法:用來驗證當前登錄的用戶,獲取認證信息。
doGetAuthorizationInfo() 方法:為當前登錄成功的用戶授予權限和分配角色。
具體實現如下,相關注解請見代碼注釋:
package com.zdyl.springboot_shiro.shiro.realms; import com.zdyl.springboot_shiro.shiro.utils.CurrentCredentialsMatcher; import com.zdyl.springboot_shiro.shiro.utils.JwtToken; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authc.credential.CredentialsMatcher; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; /** * 4 自定義realm */ public class CustomerRealm extends AuthorizingRealm { // 設置realm的名稱 @Override public void setName(String name) { super.setName("customRealm"); } /** * 大坑!,必須重寫此方法,不然Shiro會報錯 */ @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } /** * 授權 * * @param principalCollection * @return */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { System.out.println("進來了授權"); JwtToken jwtToken = (JwtToken) principalCollection.getPrimaryPrincipal(); SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); authorizationInfo.addStringPermission("zdyl:test:1"); return authorizationInfo; } /** * 認證 * * @param authenticationToken * @return * @throws AuthenticationException */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { System.out.println("進來了認證"); JwtToken jwtToken = (JwtToken) authenticationToken; jwtToken.setUsername("1"); String token = jwtToken.getToken(); // 第二步:根據用戶輸入的userCode從數據庫查詢 // .... // 如果查詢不到返回null //數據庫中用戶賬號是zhangsansan /*if(!userCode.equals("zhangsansan")){// return null; }*/ // 模擬從數據庫查詢到密碼 String password = "111112"; // 如果查詢到返回認證信息AuthenticationInfo SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo( jwtToken, password, this.getName()); return simpleAuthenticationInfo; } /** * 自定義密碼匹配器 * @param credentialsMatcher */ @Override public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) { //自定義密碼匹配器 CurrentCredentialsMatcher currentCredentialsMatcher = new CurrentCredentialsMatcher(); super.setCredentialsMatcher(currentCredentialsMatcher); } }
*認證的時候需要返回SimpleAuthenticationInfo,它有3參數構造和4參數構造,第一個參數可以傳用戶名或者用戶,主要是比較密碼的時候從里面取,第二個參數傳數據庫查詢的密碼,第三個參數(如果需要傳的話)傳鹽,至於鹽是什么不知道的可以自行百度,
第四個傳realmName。
3.3 自定義 密碼匹配器
驗證密碼的時候默認走的是SimpleCredentialsMatcher的doCredentialsMatch方法,是明文比較的,需要自定義的可以繼承SimpleCredentialsMatcher重寫doCredentialsMatch方法。
如果需要MD5加密比較可以繼承HashedCredentialsMatcher重寫doCredentialsMatch方法。
package com.zdyl.springboot_shiro.shiro.utils; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.credential.SimpleCredentialsMatcher; /** * 自定義密碼匹配器 */ public class CurrentCredentialsMatcher extends SimpleCredentialsMatcher { @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { JwtToken jwtToken = (JwtToken) token; Object tokenCredentials = jwtToken.getToken(); Object accountCredentials = this.getCredentials(info); return this.equals(tokenCredentials, accountCredentials); } }
3.4 Shiro 配置
自定義 Realm 寫好了,接下來需要配置 Shiro。我們主要配置三個東西:自定義 Realm、安全管理器 SecurityManager、seession管理器sessionManager和 Shiro 過濾器。
首先,配置自定義的 Realm,代碼如下:
package com.zdyl.springboot_shiro.shiro.config; import com.zdyl.springboot_shiro.shiro.realms.CustomerRealm; import com.zdyl.springboot_shiro.shiro.utils.AuthFilter; import org.apache.shiro.realm.Realm; import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.servlet.Filter; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; /** * shiro配置 */ @Configuration public class ShiroConfig { //1創建shiroFilter 負責攔截請求 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); //給filter設置安全管理器 shiroFilterFactoryBean.setSecurityManager(securityManager); Map<String, Filter> filter = new HashMap<>(); filter.put("oauth2", new AuthFilter());
//自定義過濾器 shiroFilterFactoryBean.setFilters(filter); //配置系統受限資源 Map<String, String> filterMap = new LinkedHashMap<>(); // filterMap.put("/test", "anon"); filterMap.put("/**", "oauth2"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap); return shiroFilterFactoryBean; } //2.創建安全管理器 @Bean("securityManager") public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("realm") Realm realm, SessionManager sessionManager) { DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager(); //給安全管理器設置realm defaultWebSecurityManager.setRealm(realm); //給安全管理器設置sessionManager defaultWebSecurityManager.setSessionManager(sessionManager); return defaultWebSecurityManager; } //3.創建自定義realm @Bean("realm") public Realm getRealm() { CustomerRealm customerRealm = new CustomerRealm(); return customerRealm; } //4.創建sessionManager @Bean("sessionManager") public DefaultWebSessionManager sessionManager() { DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager(); //設置session過期時間3600s defaultWebSessionManager.setGlobalSessionTimeout(3600000L); return defaultWebSessionManager; } /** * Shiro生命周期處理器: * 用於在實現了Initializable接口的Shiro bean初始化時調用Initializable接口回調(例如:UserRealm) * 在實現了Destroyable接口的Shiro bean銷毀時調用 Destroyable接口回調(例如:DefaultSecurityManager) */ @Bean public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } /** * 啟用shrio授權注解攔截方式,AOP式方法級權限檢查 */ @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } }
配置 Shiro 過濾器時,我們引入了安全管理器。
至此,我們可以看出,Shiro 配置一環套一環,遵循從 Reaml 到 SecurityManager 再到 Filter 的過程。在過濾器中,我們需要定義一個 shiroFactoryBean,然后將 SecurityManager 引入其中,需要配置的內容主要有以下幾項。
- 默認登錄的 URL:身份認證失敗會訪問該 URL。
- 認證成功之后要跳轉的 URL。
- 權限認證失敗后要跳轉的 URL。
- 需要攔截或者放行的 URL:這些都放在一個 Map 中。
通過上面的代碼,我們也了解到, Map 中針對不同的 URL有不同的權限要求,下表總結了幾個常用的權限。
3.5 自定義AuthenticationToken認證的時候傳參用(用戶名、密碼、token)
package com.zdyl.springboot_shiro.shiro.utils; import org.apache.shiro.authc.AuthenticationToken; public class JwtToken implements AuthenticationToken { String serialVersionUID = "8939244780389542801"; private String token; private String username; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { return null; } @Override public Object getCredentials() { return null; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } }
3.6 自定義過濾器
package com.zdyl.springboot_shiro.shiro.utils; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.web.filter.authc.AuthenticatingFilter; import org.apache.shiro.web.util.WebUtils; import org.springframework.http.HttpStatus; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMethod; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; /** * 自定義過濾器 */ public class AuthFilter extends AuthenticatingFilter { /** * 獲取token * * @param servletRequest * @param servletResponse * @return * @throws Exception */ @Override protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { String requestToken = getRequestToken((HttpServletRequest) servletRequest); JwtToken jwtToken = new JwtToken(requestToken); return jwtToken; } /** * 對跨域提供支持 * * @param request * @param response * @return * @throws Exception */ @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers")); // 跨域時會首先發送一個option請求,這里我們給option請求直接返回正常狀態 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(HttpStatus.OK.value()); return false; } return super.preHandle(request, response); } /** * 驗證token * 當訪問拒絕時是否已經處理了; * 如果返回true表示需要繼續處理; * 如果返回false表示該攔截器實例已經處理完成了,將直接返回即可。 * @param servletRequest * @param servletResponse * @return * @throws Exception */ @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { //完成token登入 //1.檢查請求頭中是否含有token HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; String token = getRequestToken(httpServletRequest); //2. 如果客戶端沒有攜帶token,攔下請求 if (null == token || "".equals(token)) { responseTokenError(servletResponse, "Token為空,您無權訪問該接口"); return false; } //3. 如果有,對進行進行token驗證 return executeLogin(servletRequest, servletResponse); } /** * 執行認證 * * @param request * @param response * @return * @throws Exception */ @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { JwtToken jwtToken = (JwtToken) createToken(request, response); try { SecurityUtils.getSubject().login(jwtToken); } catch (AuthenticationException e) { responseTokenError(response, "Token無效,您無權訪問該接口"); return false; } return true; } /** * 獲取請求的token */ private String getRequestToken(HttpServletRequest httpRequest) { //從header中獲取token String token = httpRequest.getHeader("token"); //如果header中不存在token,則從參數中獲取token if (StringUtils.isEmpty(token)) { token = httpRequest.getParameter("token"); } if (StringUtils.isEmpty(token)) { Cookie[] cks = httpRequest.getCookies(); if (cks != null) { for (Cookie cookie : cks) { if (cookie.getName().equals("yzjjwt")) { token = cookie.getValue(); return token; } } } } return token; } /** * 無需轉發,直接返回Response信息 Token認證錯誤 */ private void responseTokenError(ServletResponse response, String msg) { HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setStatus(HttpStatus.OK.value()); httpServletResponse.setCharacterEncoding("UTF-8"); httpServletResponse.setContentType("application/json; charset=utf-8"); try (PrintWriter out = httpServletResponse.getWriter()) { ObjectMapper objectMapper = new ObjectMapper(); String data = objectMapper.writeValueAsString(new ResponseBean(401, msg, null)); out.append(data); } catch (IOException e) { e.printStackTrace(); } } }
3.7 自定義返回數據類
package com.zdyl.springboot_shiro.shiro.utils; import lombok.Data; /** * 返回數據 */ @Data public class ResponseBean { /** * 200:操作成功 -1:操作失敗 **/ // http 狀態碼 private int code; // 返回信息 private String msg; // 返回的數據 private Object data; public ResponseBean() { } public ResponseBean(int code, String msg, Object data) { this.code = code; this.msg = msg; this.data = data; } public static ResponseBean error(String message) { ResponseBean responseBean = new ResponseBean(); responseBean.setMsg(message); responseBean.setCode(-1); return responseBean; } public static ResponseBean error(int code, String message) { ResponseBean responseBean = new ResponseBean(); responseBean.setMsg(message); responseBean.setCode(code); return responseBean; } public static ResponseBean success(Object data) { ResponseBean responseBean = new ResponseBean(); responseBean.setData(data); responseBean.setCode(200); responseBean.setMsg("成功"); return responseBean; } public static ResponseBean success(String message) { ResponseBean responseBean = new ResponseBean(); responseBean.setData(null); responseBean.setCode(200); responseBean.setMsg(message); return responseBean; } public static ResponseBean success() { ResponseBean responseBean = new ResponseBean(); responseBean.setData(null); responseBean.setCode(200); responseBean.setMsg("Success"); return responseBean; } }
3.8 使用 Shiro 進行認證
至此,我們完成了 Shiro 的准備工作。接下來開始使用 Shiro 進行認證。
使用 http://localhost:8080/test進行身份認證。
請求的時候請求頭攜帶token
@RestController public class TestController { @RequestMapping("/test") public void test() { System.out.println("555555555555"); } }
我們重點分析下用戶帶token訪問。整個處理過程是這樣的。
首先,根據前端傳來的用戶名和密碼,創建一個 Token。
然后,請求頭攜帶token訪問 http://localhost:8080/test。首先被自定義的過濾器攔截,驗證token。
緊接着,調用 subject.login(token) 進行身份認證——注意,這里傳入了剛剛創建的 Token,如注釋所述,這一步會跳轉入自定義的 Realm,訪問 doGetAuthenticationInfo 方法,開始身份認證。
最后,啟動項目,測試一下。在瀏覽器中請求:http://localhost:8080/test, 首先進行身份認證,此時token不正確或者為空,會返回401提示token為空或者不正確。
3.9 使用 Shiro 進行授權
使用 http://localhost:8080/test進行授權。在需要授權的接口是上增加@RequiresPermissions("zdyl:test:1")
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class TestController { @RequiresPermissions("zdyl:test:1") @RequestMapping("/test") public void test() { System.out.println("555555555555"); } }
我們重點分析下授權的過程。整個處理過程是這樣的。
用戶通過了認證環節,請求的資源也就是接口上有@RequiresPermissions("zdyl:test:1")這個注解,就會進到自定義realm中執行doGetAuthorizationInfo方法,該方法有一個入參PrincipalCollection,通俗點說就是用戶信息(用戶名啥的 ),就是認證最后一步返回的SimpleAuthenticationInfo里的第一個參數。通過用戶信息,從數據庫獲取用戶的角色列表和權限列表放進SimpleAuthorizationInfo,最后返回SimpleAuthorizationInfo,后面的工作shiro就幫我們干了。權限列表里如果包含注解上的zdyl:test:1,就可以正常訪問,如果不
包含就會報沒有訪問權限的錯誤。