Apache Shiro
優勢特點
它是一個功能強大、靈活的,優秀開源的安全框架。
它可以處理身份驗證、授權、企業會話管理和加密。
它易於使用和理解,相比Spring Security入門門檻低。
主要功能
- 驗證用戶身份
- 用戶訪問權限控制
- 支持單點登錄(SSO)功能
- 可以響應認證、訪問控制,或Session事件
- 支持提供“Remember Me”服務
- 。。。
框架體系
Shiro 的整體框架如下圖所示:
Authentication(認證), Authorization(授權), Session Management(會話管理), Cryptography(加密)被 Shiro 框架的開發團隊稱之為應用安全的四大基石。
它們分別是:
- Authentication(認證):用戶身份識別,通常被稱為用戶“登錄”。
- Authorization(授權):訪問控制。比如某個用戶是否具有某個操作的使用權限。
- Session Management(會話管理):特定於用戶的會話管理,甚至在非web 應用程序。
- Cryptography(加密):在對數據源使用加密算法加密的同時,保證易於使用。
除此之外,還有其他的功能來支持和加強這些不同應用環境下安全領域的關注點。特別是對以下的功能支持:
- Web支持:Shiro 提供的 web 支持 api ,可以很輕松的保護 web 應用程序的安全。
- 緩存:緩存是 Apache Shiro 保證安全操作快速、高效的重要手段。
- 並發:Apache Shiro 支持多線程應用程序的並發特性。
- 測試:支持單元測試和集成測試,確保代碼和預想的一樣安全。
- “Run As”:這個功能允許用戶假設另一個用戶的身份(在許可的前提下)。
- “Remember Me”:跨 session 記錄用戶的身份,只有在強制需要時才需要登錄。
主要流程
在概念層,Shiro 架構包含三個主要的理念:Subject,SecurityManager 和 Realm。下面的圖展示了這些組件如何相互作用,我們將在下面依次對其進行描述。
- Subject:當前用戶,Subject 可以是一個人,但也可以是第三方服務、守護進程帳戶、時鍾守護任務或者其它–當前和軟件交互的任何事件。
- SecurityManager:管理所有Subject,SecurityManager 是 Shiro 架構的核心,配合內部安全組件共同組成安全傘。
- Realms:用於進行權限信息的驗證,我們自己實現。Realm 本質上是一個特定的安全 DAO:它封裝與數據源連接的細節,得到Shiro 所需的相關的數據。在配置 Shiro 的時候,你必須指定至少一個Realm 來實現認證(authentication)和/或授權(authorization)。
我們需要實現Realms的Authentication 和 Authorization。其中 Authentication 是用來驗證用戶身份,Authorization 是授權訪問控制,用於對用戶進行的操作授權,證明該用戶是否允許進行當前操作,如訪問某個鏈接,某個資源文件等。
以上描述摘抄自純潔的微笑博客文章,更多詳情可以參考:
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 驗證了。