SpringBoot整合Shiro
一、Shiro 簡介
Apache Shiro 是一個強大且易用的Java安全框架,能夠用於身份驗證、授權、加密和會話管理。
Shiro 功能:
核心功能:
Authentication(認證):使用者登入,身份識別。
Authorization(授權):授權和鑑權,處理使用者和訪問的目標資源之間的許可權。
Session Management(會話管理):即 Session 的管理。
Cryptography(加密):使用者密碼加密。
其他功能:
Web支援:可以非常容易整合到web應用程式中。
快取:快取是Apache Shiro API中的第一級,以確保安全操作保持快速和高效。
併發性:多執行緒環境完成認證和授權。
測試:存在測試支援,可幫助您編寫單元測試和整合測試,並確保程式碼按預期得到保障。
“執行方式”:允許使用者承擔另一個使用者的身份(如果允許)的功能,有時在管理方案中很有用。
“記住我”:記住使用者在會話中的身份,所以使用者只需要強制登入即可。
Shiro 核心物件:
Subject:當前使用者,Subject 可以是一個人,但也可以是第三方服務、守護程序帳戶等和軟體互動的任何物件。
SecurityManager:管理所有Subject,SecurityManager 是 Shiro 框架的核心。
Realms:用於認證和授權,提供擴充套件點,使用者自行實現認證邏輯和授權邏輯。
二、SpringBoot整合Shiro
1:引入pom
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.7.1</version> </dependency>
在GroupId為 org.apache.shiro下的ArtifaceId有好幾個shiro-spring;shiro-springboot;shiro-springboot-starter。暫不清楚這三個之間的區別和聯絡,本文中引用的是 shiro-spring,使用的是當前(2021-07)最新版本 1.7.1。
2:增加配置類
配置類有兩個:
-
新建一個類 ShiroRealm ,此類繼承 AuthorizingRealm ,是框架提供的擴充套件口,使用者通過重寫doGetAuthenticationInfo 方法檢驗使用者登入資訊是否正確,並將使用者資訊存放到 session 中,此方法會丟擲 AuthenticationException 異常,需要在統一異常處理中捕獲此異常。通過重寫 doGetAuthorizationInfo 方法為當前請求的使用者賦予角色和許可權資訊,配合Shiro 提供的 @RequiresRoles 和 @RequiresPermissions 註解完成鑑權。
-
新建一個類 ShiroConfig ,此類需要新增 @Configuration 註解,此類的作用是嚮應用程式上下文(俗稱的容器)注入使用者新增的shiro配置和自定義功能。
-
注入 SecurityManager,此物件是Shiro的核心物件之一,Shiro 框架提供了多種 DefaultSecurityManger可供使用,暫不清楚他們之間的區別,本文使用的是 DefaultWebSecurityManager 。通過 setRealm 將上一步新建的認證和授權配置類注入 SecurityManager。
-
注入 ShiroFilterFactoryBean,此物件是一個過濾器,作用是配置一些預設頁面和過濾規則。setLoginUrl是配置認證失敗,預設要重定向的頁面,可以是一個jsp頁面,也可以是一個RESTFul介面。setFilterChainDefinitionMap是配置認證過濾規則,比如哪些URL不需要認證,接收一個 LinkedHashMap ,key為 url ,value 為認證策略,這裡必須要吐槽認證策略沒有設計成一個列舉。
- logout:配置退出登入過濾器,其中的具體的退出程式碼Shiro已經替我們實現了,呼叫此介面後,頁面會重定向到setLoginUrl配置的URL。
- authc:配置需要認證的URL
- anon:配置不需要認證的URL
-
注入 authorizationAttributeSourceAdvisor 和 defaultAdvisorAutoProxyCreator ,如果不注入這兩個物件,RequiresRoles 和RequiresPermissions 註解將無法使用。
-
ShiroRealm:
package com.naylor.shiro.config;
import com.alibaba.fastjson.JSON;
import com.naylor.shiro.dto.UserInfo;
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.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import java.util.ArrayList;
import java.util.List;
/**
* @ClassName ShiroRealm
* @類描述 realm(領域、範圍)不太清楚這裡用這個單詞是什麼寓意。此類繼承 AuthorizingRealm ,是框架給使用者留下的兩個擴充套件點,doGetAuthenticationInfo 擴充套件登入認證的邏輯;doGetAuthorizationInfo 擴充套件授權鑑權的邏輯
*
* @Author MingliangChen
* @Email [email protected]
* @Date 2021-07-08 9:28
* @Version 1.0.0
**/
public class ShiroRealm extends AuthorizingRealm {
/**
* 授權
* 在訪問介面前,為當前登入使用者賦予角色和許可權
* 實際應用中從資料庫中查詢使用者擁有的角色和許可權資訊
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String principal = JSON.toJSONString(principalCollection);
System.out.println(principal);
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
UserInfo userInfo = (UserInfo)principalCollection.getPrimaryPrincipal();
//根據使用者名稱查詢出使用者角色和許可權,並交給shiro管理。實際應用中使用者角色和許可權從資料庫獲取
if (userInfo.getUserName().equals("cml")) {
simpleAuthorizationInfo = buildUserCmlRolePermission();
} else if (userInfo.getUserName().equals("admin")) {
simpleAuthorizationInfo = buildUserAdminRolePermission();
} else if (userInfo.getUserName().equals("hn")) {
simpleAuthorizationInfo = buildUserHnRolePermission();
}
return simpleAuthorizationInfo;
}
/**
* 登入認證
* 儲存使用者資訊到session中
* 在呼叫登入介面後會進入到此方法(/common/singin)
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String authToken = JSON.toJSONString(authenticationToken);
System.out.println("authToken:" + authToken);
String userName = authenticationToken.getPrincipal().toString();
UserInfo userInfo = this.getUserInfoByUserName(userName);
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(userInfo, userInfo.getPassword(), getName());
return simpleAuthenticationInfo;
}
/**
* 構造使用者名稱為admin的使用者的角色和許可權
* 實際應用中使用者角色許可權資訊從資料庫中獲取
* @return
*/
private SimpleAuthorizationInfo buildUserAdminRolePermission() {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<String> roles = new ArrayList<>();
roles.add("roleA");
roles.add("roleB");
roles.add("roleC");
simpleAuthorizationInfo.addRoles(roles);
List<String> permissions = new ArrayList<>();
permissions.add("permissionsA");
permissions.add("permissionsB");
permissions.add("permissionsC");
simpleAuthorizationInfo.addStringPermissions(permissions);
return simpleAuthorizationInfo;
}
/**
* 構造使用者名稱為cml的使用者的角色和許可權
* 實際應用中使用者角色許可權資訊從資料庫中獲取
* @return
*/
private SimpleAuthorizationInfo buildUserCmlRolePermission() {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<String> roles = new ArrayList<>();
roles.add("roleA");
roles.add("roleB");
simpleAuthorizationInfo.addRoles(roles);
List<String> permissions = new ArrayList<>();
permissions.add("permissionsA");
permissions.add("permissionsB");
simpleAuthorizationInfo.addStringPermissions(permissions);
return simpleAuthorizationInfo;
}
/**
* 構造使用者名稱為hn的使用者的角色和許可權
* 實際應用中使用者角色許可權資訊從資料庫中獲取
* @return
*/
private SimpleAuthorizationInfo buildUserHnRolePermission() {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<String> roles = new ArrayList<>();
roles.add("roleA");
List<String> permissions = new ArrayList<>();
permissions.add("permissionsAA");
simpleAuthorizationInfo.addStringPermissions(permissions);
simpleAuthorizationInfo.addRoles(roles);
return simpleAuthorizationInfo;
}
/**
* 獲取使用者資訊根據使用者名稱
* 實際應用場景中是從資料庫查詢使用者資訊並根據需求組裝 userInfo 物件
*
* @param userName
* @return
*/
private UserInfo getUserInfoByUserName(String userName) {
UserInfo userInfo = new UserInfo().setId("112233445566778899").setUserName(userName).setRealName("陳明亮").setUserType(5).setNation("中國").setPassword("123456");
return userInfo;
}
}
ShiroConfig:
package com.naylor.shiro.config;
import org.apache.shiro.authc.Authenticator;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
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.mgt.WebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
/**
* @ClassName ShiroConfgi
* @類描述 Shiro 配置
* @Author MingliangChen
* @Email [email protected]
* @Date 2021-07-08 9:24
* @Version 1.0.0
**/
@Configuration
public class ShiroConfig {
/**
* 注入安全管理
* 為shiro框架核心物件,可注入不同的SecurityNamager物件,另外可根據實際需求通過securityManager的set方法自定義安全管理物件
* @return
*/
@Bean(name = "securityManager")
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(buildShiroRealm());
return securityManager;
}
/**
* 注入認證、授權
* @return
*/
@Bean(name = "shiroRealm")
public ShiroRealm buildShiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
return shiroRealm;
}
/**
* 注入過濾器
* 通過setLoginUrl配置認證失敗,重定向的uri地址,可以是一個頁面,也可以是一個RESTFul介面
*
* @param securityManager
* @return
*/
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
Map<String, String> map = new HashMap<>();
//退出登入
map.put("/logout", "logout");
//對所有URI認證
map.put("/**", "authc");
// 設定不用認證的URI
map.put("/common/login", "anon");
map.put("/common/singin", "anon");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
shiroFilterFactoryBean.setSecurityManager(securityManager);
//認證失敗重定向URI
shiroFilterFactoryBean.setLoginUrl("/common/login");
return shiroFilterFactoryBean;
}
/**
* 加入註解的使用,不加入這個註解不生效
*
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* 加入註解的使用,不加入這個註解不生效
*
* @return
*/
@Bean
@ConditionalOnMissingBean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAAP = new DefaultAdvisorAutoProxyCreator();
defaultAAP.setProxyTargetClass(true);
return defaultAAP;
}
}
3:增加全域性異常處理
捕獲異常,防止將tomcat的錯誤頁面直接拋給使用者。
配置檔案中增加以下配置:
出現錯誤時, 直接丟擲異常。這兩個配置是為了讓404異常正常丟擲
spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false
GlobalException
package com.naylor.shiro.handler;
import com.naylor.shiro.dto.GlobalResponseEntity;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authz.AuthorizationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
/**
* @ClassName GlobalException
* @類描述
* @Author MingliangChen
* @Email [email protected]
* @Date 2021-07-08 14:23
* @Version 1.0.0
**/
//@RestControllerAdvice("com.naylor")
@RestControllerAdvice()
@ResponseBody
@Slf4j
public class GlobalException {
/**
* 處理511異常
* @param e
* @return
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Object> handleAuthenticationException(AuthenticationException e) {
return new ResponseEntity<>(
new GlobalResponseEntity<>(false, "511",
e.getMessage() == null ? "認證失敗" : e.getMessage()),
HttpStatus.NETWORK_AUTHENTICATION_REQUIRED);
}
/**
* 處理401異常
* @param e
* @return
*/
@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<Object> handleAuthorizationException(AuthorizationException e) {
return new ResponseEntity<>(
new GlobalResponseEntity<>(false, "401",
e.getMessage() == null ? "未授權" : e.getMessage()),
HttpStatus.UNAUTHORIZED);
}
/**
* 處理404異常
*
* @return
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<Object> handleNoHandlerFoundException(NoHandlerFoundException e) {
return new ResponseEntity<>(
new GlobalResponseEntity(false, "404",
e.getMessage() == null ? "請求的資源不存在" : e.getMessage()),
HttpStatus.NOT_FOUND);
}
/**
* 捕獲執行時異常
*
* @param e
* @return
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Object> handleRuntimeException(RuntimeException e) {
log.error("handleRuntimeException:", e);
return new ResponseEntity<>(
new GlobalResponseEntity(false, "500",
e.getMessage() == null ? "執行時異常" : e.getMessage().replace("java.lang.RuntimeException: ", "")),
HttpStatus.INTERNAL_SERVER_ERROR);
}
/**
* 捕獲一般異常
* 捕獲未知異常
*
* @param e
* @return
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleException(Exception e) {
return new ResponseEntity<>(
new GlobalResponseEntity<>(false, "555",
e.getMessage() == null ? "未知異常" : e.getMessage()),
HttpStatus.INTERNAL_SERVER_ERROR);
}
}
4:增加統一的RESTFul響應結構體
GlobalResponse:
package com.naylor.shiro.handler;
import com.alibaba.fastjson.JSON;
import com.naylor.shiro.dto.GlobalResponseEntity;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import javax.annotation.Resource;
/**
* @ClassName GlobalResponse
* @類描述
* @Author MingliangChen
* @Email [email protected]
* @Date 2021-07-08 14:22
* @Version 1.0.0
**/
@RestControllerAdvice("com.naylor")
public class GlobalResponse implements ResponseBodyAdvice<Object> {
/**
* 攔截之前業務處理,請求先到supports再到beforeBodyWrite
* <p>
* 用法1:自定義是否攔截。若方法名稱(或者其他維度的資訊)在指定的常量範圍之內,則不攔截。
*
* @param methodParameter
* @param aClass
* @return 返回true會執行攔截;返回false不執行攔截
*/
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
//TODO 過濾
return true;
}
/**
* 向客戶端返回響應資訊之前的業務邏輯處理
* <p>
* 用法1:無論controller返回什麼型別的資料,在寫入客戶端響應之前統一包裝,客戶端永遠接收到的是約定的格式
* <p>
* 用法2:在寫入客戶端響應之前統一加密
*
* @param responseObject 響應內容
* @param methodParameter
* @param mediaType
* @param aClass
* @param serverHttpRequest
* @param serverHttpResponse
* @return
*/
@Override
public Object beforeBodyWrite(Object responseObject, MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
//responseObject是否為null
if (null == responseObject) {
return new GlobalResponseEntity<>("55555", "response is empty.");
}
//responseObject是否是檔案
if (responseObject instanceof Resource) {
return responseObject;
}
//該方法返回值型別是否是void
//if ("void".equals(methodParameter.getParameterType().getName())) {
// return new GlobalResponseEntity<>("55555", "response is empty.");
//}
if (methodParameter.getMethod().getReturnType().isAssignableFrom(Void.TYPE)) {
return new GlobalResponseEntity<>("55555", "response is empty.");
}
//該方法返回值型別是否是GlobalResponseEntity。若是直接返回,無需再包裝一層
if (responseObject instanceof GlobalResponseEntity) {
return responseObject;
}
//處理string型別的返回值
//當返回型別是String時,用的是StringHttpMessageConverter轉換器,無法轉換為Json格式
//必須在方法體上標註RequestMapping(produces = "application/json; charset=UTF-8")
if (responseObject instanceof String) {
String responseString = JSON.toJSONString(new GlobalResponseEntity<>(responseObject));
return responseString;
}
//該方法返回的媒體型別是否是application/json。若不是,直接返回響應內容
if (!mediaType.includes(MediaType.APPLICATION_JSON)) {
return responseObject;
}
return new GlobalResponseEntity<>(responseObject);
}
}
5:使用者登入認證
使用者資訊都存放在 Subject 物件中,使用者登入認證的過程只需呼叫其 login 方法即可,login方法內部會呼叫 doGetAuthenticationInfo 擴充套件點完成登入的認證。
package com.naylor.shiro.controller;
import com.naylor.shiro.dto.User;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.springframework.web.bind.annotation.*;
/**
* @ClassName LoginController
* @類描述
* @Author MingliangChen
* @Email [email protected]
* @Date 2021-07-08 9:51
* @Version 1.0.0
**/
@RestController
@RequestMapping("/common")
public class CommonController {
/**
* 提示需要登入
* @return
*/
@GetMapping(value = "/login")
public String login() {
return "請登入";
}
/**
* 登入
* @param user
* @return
*/
@PostMapping("/singin")
public String singIn(@RequestBody User user) {
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(user.getUserName(), user.getPassword());
SecurityUtils.getSubject().login(usernamePasswordToken);
return "登入成功";
}
@GetMapping("/error")
public String error() {
return "500";
}
}
6:使用者請求鑑權
通過 @RequiresRoles 和 @RequiresPermissions 註解的配合使用,完成對後端介面的鑑權。鑑權的邏輯其實就是從Shiro 中取出當前使用者擁有的角色和許可權,然後和RESTFul介面上面註解的角色和許可權進行對比,如果包含那麼就鑑權通過,允許訪問,否則就丟擲401異常。
鑑權原理:
debug 到AuthorizingRealm類的 isPermitted 方法,該方法接收兩個引數,Permission為RESTFul介面上面新增的許可權相關注解,AuthorizationInfo是當前請求使用者擁有的角色和許可權。鑑權的原理就是判斷AuthorizationInfo 中是否包含Permission。
獲取使用者資訊和session資訊:
通過 SecurityUtils 工具類中的 getSubject方法獲取使用者的登入資訊和sessionId
package com.naylor.shiro.controller;
import com.naylor.shiro.dto.UserInfo;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @ClassName AnimalController
* @類描述
* @Author MingliangChen
* @Email [email protected]
* @Date 2021-07-08 13:49
* @Version 1.0.0
**/
@RestController
@RequestMapping("/animal")
public class AnimalController {
@GetMapping("/cat")
public String cat() {
Subject subject = SecurityUtils.getSubject();
UserInfo userInfo = (UserInfo) SecurityUtils.getSubject().getPrincipal();
String sessionId = String.valueOf(SecurityUtils.getSubject().getSession().getId());
return "cat";
}
@RequiresRoles({"roleA"})
@RequiresPermissions("permissionsAA")
@GetMapping("/fish")
public String fish() {
return "fish";
}
@RequiresRoles({"roleA", "roleB"})
@GetMapping("/dog")
public String dog() {
return "dog";
}
@RequiresPermissions("permissionsC")
@GetMapping("/tiger")
public String tiger() {
Boolean a = SecurityUtils.getSubject().hasRole("roleC");
Boolean b = SecurityUtils.getSubject().isPermitted("permissionsC");
return "tiger";
}
}
7:測試
使用postman模擬使用者請求進行測試。
a. 在沒有登入的情況下,呼叫任何介面都會重定向到 /common/login 介面,該介面返回“請登入”。即使是訪問一個不存在的頁面也會重定向,因為我們在ShiroConfig 中配置的是全域性認證。
b.呼叫登入介面登入,注意使用者名稱需要和程式碼中寫死的使用者名稱一致
c.呼叫受限介面
admin 使用者有 tiger 介面的許可權,呼叫之後介面正常返回 tiger ; 沒有 fish 介面的許可權,呼叫之後返回 “Subject does not have permission [permissionsAA]”
8:總結
本文演示了SpringBoot整合 Shiro ,基於 Session 來管理使用者會話,實現使用者和web服務的認證和鑑權。Shiro 作為一個古老的框架,歷史悠久,功能和拓展性也特別的強,如使用者可以自定義 SessionMode=HTTP 從而可以達到web服務橫向擴容的目的;也可以結合 JWT 搭建無狀態的web服務;還可以搭建 oauth2 。但是後兩者並不推薦,在分散式系統和微服務應用中,推薦使用SpringBootSecutiryOauth2來搭建自己的授權服務。