Apache Shiro
優點特色
它是一個功能強大、靈活的,優秀開源的安全框架。html
它能夠處理身份驗證、受權、企業會話管理和加密。java
它易於使用和理解,相比Spring Security入門門檻低。web
主要功能
- 驗證用戶身份
- 用戶訪問權限控制
- 支持單點登陸(SSO)功能
- 能夠響應認證、訪問控制,或Session事件
- 支持提供「Remember Me」服務
- 。。。
框架體系
Shiro 的總體框架以下圖所示:redis
Authentication(認證), Authorization(受權), Session Management(會話管理), Cryptography(加密)被 Shiro 框架的開發團隊稱之爲應用安全的四大基石。算法
它們分別是:spring
- Authentication(認證):用戶身份識別,一般被稱爲用戶「登陸」。
- Authorization(受權):訪問控制。好比某個用戶是否具備某個操做的使用權限。
- Session Management(會話管理):特定於用戶的會話管理,甚至在非web 應用程序。
- Cryptography(加密):在對數據源使用加密算法加密的同時,保證易於使用。
除此以外,還有其餘的功能來支持和增強這些不一樣應用環境下安全領域的關注點。特別是對如下的功能支持:數據庫
- Web支持:Shiro 提供的 web 支持 api ,能夠很輕鬆的保護 web 應用程序的安全。
- 緩存:緩存是 Apache Shiro 保證安全操做快速、高效的重要手段。
- 併發:Apache Shiro 支持多線程應用程序的併發特性。
- 測試:支持單元測試和集成測試,確保代碼和預想的同樣安全。
- 「Run As」:這個功能容許用戶假設另外一個用戶的身份(在許可的前提下)。
- 「Remember Me」:跨 session 記錄用戶的身份,只有在強制須要時才須要登陸。
主要流程
在概念層,Shiro 架構包含三個主要的理念:Subject,SecurityManager 和 Realm。下面的圖展現了這些組件如何相互做用,咱們將在下面依次對其進行描述。apache
- Subject:當前用戶,Subject 能夠是一我的,但也能夠是第三方服務、守護進程賬戶、時鐘守護任務或者其它–當前和軟件交互的任何事件。
- SecurityManager:管理全部Subject,SecurityManager 是 Shiro 架構的核心,配合內部安全組件共同組成安全傘。
- Realms:用於進行權限信息的驗證,咱們本身實現。Realm 本質上是一個特定的安全 DAO:它封裝與數據源鏈接的細節,獲得Shiro 所需的相關的數據。在配置 Shiro 的時候,你必須指定至少一個Realm 來實現認證(authentication)和/或受權(authorization)。
咱們須要實現Realms的Authentication 和 Authorization。其中 Authentication 是用來驗證用戶身份,Authorization 是受權訪問控制,用於對用戶進行的操做受權,證實該用戶是否容許進行當前操做,如訪問某個連接,某個資源文件等。json
以上描述摘抄自純潔的微笑博客文章,更多詳情能夠參考:後端
Shiro 官網:http://shiro.apache.org/
純潔的微笑:http://www.ityouknow.com/springboot/2017/06/26/springboot-shiro.html
Shiro 集成
下面就來說解如何在咱們的項目裏集成 Shiro 框架。
引入依賴
首先上 maven 倉庫查找,當前最新的版本是 1.4.0,咱們就用這個版本。
kitty-pom/pom.xml 父POM中添加屬性和 dependencyManagement 依賴
<shiro.version>1.4.0</shiro.version>
<!-- shiro --> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>${shiro.version}</version> </dependency>
kitty-admin/pom.xml 添加 dependencies 依賴
<!-- shiro --> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> </dependency>
同理,把後續要用到的幾個工具包也導入進來。
<!-- fastjson --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>${fastjson.version}</version> </dependency>
<!-- commons -->
<dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> <version>${commons.lang.version}</version> </dependency> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>${commons.fileupload.version}</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>${commons.io.version}</version> </dependency> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>${commons.codec.version}</version> </dependency>
添加配置
1. 添加配置類
添加配置類,注入自定義的認證過濾器(OAuth2Filter)和認證器(OAuth2Realm),並添加請求路徑攔截配置。
ShiroConfig.java
package com.louis.kitty.boot.config; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import javax.servlet.Filter; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.realm.Realm; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import com.louis.kitty.admin.oauth2.OAuth2Filter; import com.louis.kitty.admin.oauth2.OAuth2Realm; /** * Shiro 配置 * @author Louis * @date Sep 1, 2018 */ @Configuration public class ShiroConfig { @Bean public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); // 自定義 OAuth2Filter 過濾器,替代默認的過濾器 Map<String, Filter> filters = new HashMap<>(); filters.put("oauth2", new OAuth2Filter()); shiroFilter.setFilters(filters); // 訪問路徑攔截配置,"anon"表示無需驗證,未登陸也可訪問 Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/webjars/**", "anon"); // 查看SQL監控(druid) filterMap.put("/druid/**", "anon"); // 首頁和登陸頁面 filterMap.put("/", "anon"); filterMap.put("/sys/login", "anon"); // swagger filterMap.put("/swagger-ui.html", "anon"); filterMap.put("/swagger-resources", "anon"); filterMap.put("/v2/api-docs", "anon"); filterMap.put("/webjars/springfox-swagger-ui/**", "anon"); // 其餘全部路徑交給OAuth2Filter處理 filterMap.put("/**", "oauth2"); shiroFilter.setFilterChainDefinitionMap(filterMap); return shiroFilter; } @Bean public Realm getShiroRealm(){ OAuth2Realm myShiroRealm = new OAuth2Realm(); return myShiroRealm; } @Bean public SecurityManager securityManager(){ DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); // 注入 Realm 實現類,實現本身的登陸邏輯 securityManager.setRealm(getShiroRealm()); return securityManager; } }
2. 認證過濾器
攔截除配置成不需認證的請求路徑外的請求,都交由這個過濾器處理,負責接收前臺帶過來的token並封裝成對象,若是請求沒有攜帶token,則提示錯誤。
OAuth2Filter.java
package com.louis.kitty.admin.oauth2; import java.io.IOException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.web.filter.authc.AuthenticatingFilter; import com.alibaba.fastjson.JSONObject; import com.louis.kitty.common.utils.StringUtils; import com.louis.kitty.core.http.HttpResult; import com.louis.kitty.core.http.HttpStatus; /** * Oauth2過濾器 * @author Louis * @date Sep 1, 2018 */ public class OAuth2Filter extends AuthenticatingFilter { @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { // 獲取請求token String token = getRequestToken((HttpServletRequest) request); if(StringUtils.isBlank(token)){ return null; } return new OAuth2Token(token); } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { return false; } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 獲取請求token,若是token不存在,直接返回401 String token = getRequestToken((HttpServletRequest) request); if(StringUtils.isBlank(token)){ HttpServletResponse httpResponse = (HttpServletResponse) response; HttpResult result = HttpResult.error(HttpStatus.SC_UNAUTHORIZED, "invalid token"); String json = JSONObject.toJSONString(result); httpResponse.getWriter().print(json); return false; } return executeLogin(request, response); } @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setContentType("application/json; charset=utf-8"); try { // 處理登陸失敗的異常 Throwable throwable = e.getCause() == null ? e : e.getCause(); HttpResult result = HttpResult.error(HttpStatus.SC_UNAUTHORIZED, throwable.getMessage()); String json = JSONObject.toJSONString(result); httpResponse.getWriter().print(json); } catch (IOException e1) { } return false; } /** * 獲取請求的token */ private String getRequestToken(HttpServletRequest httpRequest){ // 從header中獲取token String token = httpRequest.getHeader("token"); // 若是header中不存在token,則從參數中獲取token if(StringUtils.isBlank(token)){ token = httpRequest.getParameter("token"); } return token; } }
OAuth2Token.java
package com.louis.kitty.admin.oauth2; import org.apache.shiro.authc.AuthenticationToken; /** * 自定義 token 對象 * @author Louis * @date Sep 1, 2018 */ public class OAuth2Token implements AuthenticationToken { private static final long serialVersionUID = 1L; private String token; public OAuth2Token(String token){ this.token = token; } @Override public String getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
3. 邏輯認證器
邏輯認證器是認證和受權的主體邏輯,主要包含兩部分。
doGetAuthenticationInfo:實現本身的登陸驗證邏輯,這裏主要是認證 token。
doGetAuthorizationInfo:實現接口受權邏輯,收集權限標識或角色,用來斷定接口是否能夠訪問
OAuth2Realm.java
package com.louis.kitty.admin.oauth2; import java.util.Set; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.authc.LockedAccountException; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import com.louis.kitty.admin.model.SysUser; import com.louis.kitty.admin.model.SysUserToken; import com.louis.kitty.admin.sevice.SysUserService; import com.louis.kitty.admin.sevice.SysUserTokenService; /** * 認證Realm實現 * @author Louis * @date Sep 1, 2018 */ @Component public class OAuth2Realm extends AuthorizingRealm { @Autowired SysUserService sysUserService; @Autowired SysUserTokenService sysUserTokenService; @Override public boolean supports(AuthenticationToken token) { return token instanceof OAuth2Token; } /** * 受權(接口保護,驗證接口調用權限時調用) */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { SysUser user = (SysUser)principals.getPrimaryPrincipal(); // 用戶權限列表,根據用戶擁有的權限標識與如 @permission標註的接口對比,決定是否能夠調用接口 Set<String> permsSet = sysUserService.findPermissions(user.getUsername()); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setStringPermissions(permsSet); return info; } /** * 認證(登陸時調用) */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { String token = (String) authenticationToken.getPrincipal(); // 根據accessToken,查詢用戶token信息 SysUserToken sysUserToken = sysUserTokenService.findByToken(token); if(sysUserToken == null || sysUserToken.getExpireTime().getTime() < System.currentTimeMillis()){ // token已經失效 throw new IncorrectCredentialsException("token失效,請從新登陸"); } // 查詢用戶信息 SysUser user = sysUserService.findById(sysUserToken.getUserId()); // 帳號被鎖定 if(user.getStatus() == 0){ throw new LockedAccountException("帳號已被鎖定,請聯繫管理員"); } SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, token, getName()); return info; } }
4. 完善登陸接口
完善登陸邏輯,在用戶密碼匹配成功以後,建立並保存token,最後將token返回給前臺,之後請求帶上token。
SysLoginController.java
/** * 登陸接口 */ @PostMapping(value = "/sys/login") public HttpResult login(@RequestBody LoginBean loginBean) throws IOException { String username = loginBean.getUsername(); String password = loginBean.getPassword(); // 用戶信息 SysUser user = sysUserService.findByUserName(username); // 帳號不存在、密碼錯誤 if (user == null) { return HttpResult.error("帳號不存在"); } if (!match(user, password)) { return HttpResult.error("密碼不正確"); } // 帳號鎖定 if (user.getStatus() == 0) { return HttpResult.error("帳號已被鎖定,請聯繫管理員"); } // 生成token,並保存到數據庫 SysUserToken data = sysUserTokenService.createToken(user.getUserId()); return HttpResult.ok(data); } /** * 驗證用戶密碼 * @param user * @param password * @return */ public boolean match(SysUser user, String password) { return user.getPassword().equals(PasswordUtils.encrypte(password, user.getSalt())); }
SysUserTokenServiceImpl.java,生成並保存token,這裏把token保存在數據庫,也能夠選擇保存在redis或session。
@Override public SysUserToken createToken(long userId) { // 生成一個token String token = TokenGenerator.generateToken(); // 當前時間 Date now = new Date(); // 過時時間 Date expireTime = new Date(now.getTime() + EXPIRE * 1000); // 判斷是否生成過token SysUserToken sysUserToken = findByUserId(userId); if(sysUserToken == null){ sysUserToken = new SysUserToken(); sysUserToken.setUserId(userId); sysUserToken.setToken(token); sysUserToken.setLastUpdateTime(now); sysUserToken.setExpireTime(expireTime); // 保存token,這裏選擇保存到數據庫,也能夠放到Redis或Session之類可存儲的地方 save(sysUserToken); } else{ sysUserToken.setToken(token); sysUserToken.setLastUpdateTime(now); sysUserToken.setExpireTime(expireTime); // 若是token已經生成,則更新token的過時時間 update(sysUserToken); } return sysUserToken; }
登陸測試
登陸 Swagger: localhost:8088/swagger-ui.html
用戶名:admin 密碼: admin
登陸成功以後,會返回token,以下圖所示。
登陸成功以後,通常的邏輯是調到主頁,這裏咱們能夠繼續訪問一個接口看成登陸成功以後的跳轉(如 /dept/findTree,不用傳參方便)。
而後咱們就會發現調用失敗,甚至打斷點到目標接口代碼,鏈接口代碼都沒有進來,根本沒有調用到findTree接口。
這是必然的,由於引入樂Shiro以後便有了權限認證,若是訪問請求沒有攜帶token是不能經過驗證的,具體解決方案參加下面的登陸流程。
登陸流程
爲了幫助你們理解 shiro 的工做流程,這裏對使用了 shiro 之後,咱們項目的登陸流程作一下簡單的說明。
咱們開啓Debug模式,給登陸接口及過濾器和認證器都打上斷點,調用登陸接口,跟着代碼移動的腳步來了解整個登陸的流程。
首先代碼來到了咱們調用的接口: login
成功驗證用戶密碼,即將生成和保存token
根據條件生成或更新token,成功後登陸接口會將token返回給前臺,前臺會帶上token進入登陸驗證
登陸接口返回以後就已經登陸成功了,按照通常邏輯,這時就會跳轉到主頁了,咱們這邊沒有頁面,就經過訪問接口來模擬吧。
咱們訪問Swagger裏 dept/findTree 接口,獲取機構數據,這個接口不用傳參,比較方便。
結果發現訪問沒有訪問正常結果,甚至debug發現連對應的後臺接口代碼都沒有進去。那是由於加了shiro之後,訪問除配置放過外的接口都是須要驗證的。
咱們直接在瀏覽器訪問:http://localhost:8088/dept/findTree,發現代碼來到了咱們在過濾器設置的斷點裏邊。
由於咱們訪問接口的時候,沒有把剛纔登陸成功以後返回的token信息攜帶過來,因此在過濾器裏驗證token失敗,返回"invalid token" 提示
果真,在代碼執行完畢以後,頁面獲得 「invalid token」 的提示,那咱們要繼續訪問還得帶上token才行。
那怎樣才能讓 swagger 發送請求的時候把 token 也帶過去呢,咱們這樣處理。
修改 Swagger 配置,添加請求頭參數,用來傳遞 token。
SwaggerConfig.java
package com.louis.kitty.boot.config; import java.util.ArrayList; import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import springfox.documentation.builders.ApiInfoBuilder; import springfox.documentation.builders.ParameterBuilder; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.schema.ModelRef; import springfox.documentation.service.ApiInfo; import springfox.documentation.service.Parameter; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2; @Configuration @EnableSwagger2 public class SwaggerConfig { @Bean public Docket createRestApi(){ // 添加請求參數,咱們這裏把token做爲請求頭部參數傳入後端 ParameterBuilder parameterBuilder = new ParameterBuilder(); List<Parameter> parameters = new ArrayList<Parameter>(); parameterBuilder.name("token").description("令牌") .modelRef(new ModelRef("string")).parameterType("header").required(false).build(); parameters.add(parameterBuilder.build()); return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select() .apis(RequestHandlerSelectors.any()).paths(PathSelectors.any()) .build().globalOperationParameters(parameters); // return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()) // .select() // .apis(RequestHandlerSelectors.any()) // .paths(PathSelectors.any()).build(); } private ApiInfo apiInfo(){ return new ApiInfoBuilder() .title("Kitty API Doc") .description("This is a restful api document of Kitty.") .version("1.0") .build(); } }
重啓代碼,發現接口頁面已經多了token請求參數了。
咱們先調用登陸接口,拿到返回的token以後,把token複製過來一塊兒發送過去。
繼續用 amdin 用戶登陸,得到返回 token
攜帶 token 再次訪問 findTree 接口。
代碼進入過濾器,發現 token 已經成功傳過來了,往下執行 executeLogin 繼續登陸流程。
上面方法調用下面的接口,嘗試從請求頭或請求參數中獲取token。
父類的 executeLogin 方法調用 createToken 建立 token,而後使用 Subject 進行登陸。
過濾器的 createToken 方法返回咱們自定義的 token 對象。
Subject 調用 SecurityManager 繼續進行登陸流程。
看下面的調用棧截圖,通過系列操做以後,終於來到了咱們的 OAuth2Realm,這裏有咱們的登陸和受權邏輯。
來到 OAuth2Realm 的 doGetAuthenticationInfo 方法,將前臺傳遞的token跟後臺存儲的作比對,比對成功繼續往下走。
驗證成功以後,代碼終於來到了咱們的目標接口,成功的完成了調用。
繼續往前,放行代碼,代碼執行完畢,調用界面成功的返回告終果。
咱們不傳 token 或者傳一個不存在的 token 試試。
發現代碼在過濾器驗證的時候沒有經過,返回 「Token 失效」 提示。
接口響應結果,提示 「token失效,請從新登陸」。
最後注意:加了Shiro以後每次調試接口都須要傳遞token,對咱們開發來講也是麻煩,若有須要能夠經過如下方法取消驗證。
在 ShiroConfig 配置類中,把接口路徑映射到 anon 過濾器,調試時就不須要 token 驗證了。