Spring Boot中集成 Shiro
Shiro 是一個強大、簡單易用的 Java 安全框架,主要用來更便捷的認證,授權,加密,會話管等等,可
爲任何應用提供安全保障。本課程主要來介紹 Shiro 的認證和授權功能。
1. Shiro 三大核心組件
Shiro 有三大核心的組件: Subject 、 SecurityManager 和 Realm 。先來看一下它們之間的關係。
- Subject:認證主體。它包含兩個信息:Principals 和 Credentials。
Principals:身份。可以是用戶名,郵件,手機號碼等等,用來標識一個登錄主體身份;
Credentials:憑證。常見有密碼,數字證書等等。 - SecurityManager:安全管理員。這是 Shiro 架構的核心,它就像 Shiro 內部所有原件的保護傘一樣。我們在項目中一般都會配置 SecurityManager,開發人員大部分精力主要是在 Subject 認證主體上面。我們在與 Subject 進行交互的時候,實際上是 SecurityManager 在背後做一些安全操作。
- Realms:Realms 是一個域,它是連接 Shiro 和具體應用的橋樑,當需要與安全數據交互的時候,比如用戶賬戶、訪問控制等,Shiro 就會從一個或多個 Realms 中去查找。我們一般會自己定製Realm,這在下文會詳細說明。
2. Shiro 身份和權限認證
2.1 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 過程
3.1 依賴導入
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
3.2 數據庫表數據初始化
這裏主要涉及到三張表:用戶表、角色表和權限表,其實在 demo 中,我們完全可以自己模擬一下,不用建表,但是爲了更加接近實際情況,我們還是加入 mybatis,來操作數據庫。下面是數據庫表的腳本。
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `t_permission`;
CREATE TABLE `t_permission` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`permissionname` varchar(50) NOT NULL COMMENT '權限名',
`role_id` int(0) NULL DEFAULT NULL COMMENT '外鍵關聯role',
PRIMARY KEY (`id`) USING BTREE,
INDEX `role_id`(`role_id`) USING BTREE,
CONSTRAINT `t_permission_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 ;
INSERT INTO `t_permission` VALUES (1, 'user:*', 1);
INSERT INTO `t_permission` VALUES (2, 'student:*', 2);
DROP TABLE IF EXISTS `t_role`;
CREATE TABLE `t_role` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`rolename` varchar(20) NULL DEFAULT NULL COMMENT '角色名稱',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 ;
INSERT INTO `t_role` VALUES (1, 'admin');
INSERT INTO `t_role` VALUES (2, 'teacher');
INSERT INTO `t_role` VALUES (3, 'student');
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT '用戶主鍵',
`username` varchar(20) NOT NULL COMMENT '用戶名',
`password` varchar(20) NOT NULL COMMENT '密碼',
`role_id` int(0) NULL DEFAULT NULL COMMENT '外鍵關聯role表',
PRIMARY KEY (`id`) USING BTREE,
INDEX `role_id`(`role_id`) USING BTREE,
CONSTRAINT `t_user_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 ;
INSERT INTO `t_user` VALUES (1, 'c1', '123456', 1);
INSERT INTO `t_user` VALUES (2, 'c2', '123456', 2);
INSERT INTO `t_user` VALUES (3, 'c3', '123456', 3);
SET FOREIGN_KEY_CHECKS = 1;
解釋一下這裏的權限: user:*
表示權限可以是 user:create
或者其他, * 處表示一個佔位符,我們
可以自己定義,具體的會在下文 Shiro 配置那裏說明。
3.3 自定義 Realm
有了數據庫表和數據之後,我們開始自定義 realm,自定義 realm 需要繼承 AuthorizingRealm 類,因爲該類封裝了很多方法,它也是一步步繼承自 Realm 類的,繼承了 AuthorizingRealm 類後,需要重寫兩個方法:
doGetAuthenticationInfo()
方法:用來驗證當前登錄的用戶,獲取認證信息
doGetAuthorizationInfo()
方法:用來爲當前登陸成功的用戶授予權限和角色
具體實現如下,
/**
* 自定義realm
*
* @author Manaphy chen
* @date 2020/4/3
*/
@Slf4j
public class MyShiroRealm extends AuthorizingRealm {
@Resource
private UserService userService;
/**
* 用來驗證當前登錄的用戶,獲取認證信息
*
* @param principalCollection principalCollection
* @return {@link AuthorizationInfo}
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//todo 驗證當前登錄的用戶會執行該方法
//獲取用戶名
String username = (String) principalCollection.getPrimaryPrincipal();
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
//給該用戶設置角色,角色信息存在t_role表中取
authorizationInfo.setRoles(userService.getRoles(username));
//給該用戶設置權限,權限信息存在t_permission表中取
authorizationInfo.setStringPermissions(userService.getPermissions(username));
return authorizationInfo;
}
/**
* 用來爲當前登陸成功的用戶授予權限和角色
*
* @param authenticationToken 身份驗證令牌
* @return {@link AuthenticationInfo}* @throws AuthenticationException 身份驗證異常
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//todo 驗證用戶權限會執行該方法
//根據token獲取用戶名
String username = (String) authenticationToken.getPrincipal();
//根據用戶名從數據庫中查詢該用戶
User user = userService.getByUsername(username);
if (user != null) {
//把當前用戶存到session中
SecurityUtils.getSubject().getSession().setAttribute("user", user);
//傳入用戶名和密碼進行身份認證,並返回認證信息
return new SimpleAuthenticationInfo(username, user.getPassword(), getName());
}
return null;
}
}
從上面兩個方法中可以看出:驗證身份的時候是根據用戶輸入的用戶名先從數據庫中查出該用戶名對應的用戶,這時候並沒有涉及到密碼,也就是說到這一步的時候,即使用戶輸入的密碼不對,也是可以查出來該用戶的,然後將該用戶的正確信息封裝到 authcInfo 中返回給 Shiro,接下來就是Shiro的事了,它會根據這裏面的真實信息與用戶前臺輸入的用戶名和密碼進行校驗, 這個時候也要校驗密碼了,如果校驗通過就讓用戶登錄,否則跳轉到指定頁面。同理,權限驗證的時候也是先根據用戶名從數據庫中獲取與該用戶名有關的角色和權限,然後封裝到 authorizationInfo 中返回給 Shiro。
3.4 Shiro 配置
自定義的 realm 寫好了,接下來需要對 Shiro 進行配置了。我們主要配置三個東西:自定義 realm、安
全管理器 SecurityManager 和 Shiro 過濾器。如下:
/**
* Shiro配置類
*
* @author Manaphy chen
* @date 2020/4/7
*/
@Slf4j
@Configuration
public class ShiroConfig {
/**
* 注入自定義的realm
*
* @return {@link MyShiroRealm}
*/
@Bean
public MyShiroRealm myRealm() {
//todo 1.註冊自定義的realm,此時並不會執行裏面的方法
return new MyShiroRealm();
}
/**
* 注入安全管理器
*
* @return {@link SecurityManager}
*/
@Bean
public SecurityManager securityManager() {
//todo 2.註冊securityManager,並將自定義realm加進來
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(myRealm());
log.info("---securityManager註冊完成---");
return securityManager;
}
/**
* 注入Shiro過濾器
* 常用的權限介紹
* anon 開放權限,可以理解爲匿名用戶或遊客,可以直接訪問的
* authc 需要身份認證的
* logout 註銷,執行後會直接跳轉到 shiroFilterFactoryBean.setLoginUrl(); 設置的 url,即登錄頁面
* roles[admin] 參數可寫多個,表示是某個或某些角色才能通過,多個參數時寫roles["admin,user"],當有多個參數時必須每個參數都通過纔算通過
* perms[user] 參數可寫多個,表示需要某個或某些權限才能通過,多個參數時寫perms[“user, admin”],當有多個參數時必須每個參數都通過纔算通過
*
* @param securityManager 配置安全管理器
* @return {@link ShiroFilterFactoryBean}
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
//todo 3.注入Shiro過濾器
//定義shiroFactoryBean
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//設置自定義的securityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
//設置默認登錄的url,身份認證失敗會訪問該url
shiroFilterFactoryBean.setLoginUrl("/login");
//設置成功之後要跳轉的鏈接
shiroFilterFactoryBean.setSuccessUrl("/success");
//設置未授權界面,權限認證失敗會訪問該url
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");
//LinkedHashMap是有序的,進行順序攔截器配置
LinkedHashMap<String, String> filterChainMap = new LinkedHashMap<>();
//配置可以匿名訪問的地址,可以根據實際情況自己添加,放行一些靜態資源等
filterChainMap.put("/css/**", "anon");
filterChainMap.put("/imgs/**", "anon");
filterChainMap.put("/js/**", "anon");
//登錄 url 放行
filterChainMap.put("/login", "anon");
//`/user/admin`開頭的需要身份認證
filterChainMap.put("/user/admin*", "authc");
//`/user/student`開頭的需要角色認證,是`admin`才允許
filterChainMap.put("/user/student*/**", "roles[admin]");
//`/user/teacher` 開頭的需要權限認證,是`user:create`才允許
filterChainMap.put("/user/teacher*/**", "perms[\"user:create\"]");
//配置logout過濾器
filterChainMap.put("/logout", "logout");
//所有url必須通過認證纔可以訪問,這行代碼必須放在所有權限設置的最後
//filterChainMap.put("/**", "authc");
//設置shiroFilterFactoryBean的FilterChainDefinitionMap
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainMap);
log.info("---shiroFilterFactoryBean註冊完成---");
return shiroFilterFactoryBean;
}
}
3.5 使用 Shiro 進行認證
到這裏,我們對 Shiro 的準備工作都做完了,接下來開始使用 Shiro 進行認證工作。我們首先來設計幾個接口:
接口一: 使用 http://localhost:8080/user/admin 來驗證身份認證
接口二: 使用 http://localhost:8080/user/student 來驗證角色認證
接口三: 使用 http://localhost:8080/user/teacher 來驗證權限認證
接口四: 使用 http://localhost:8080/user/login 來實現用戶登錄
然後來一下認證的流程:
流程一: 直接訪問接口一(此時還未登錄),認證失敗,跳轉到 login.html 頁面讓用戶登錄,登錄會請求接口四,實現用戶登錄功能,此時 Shiro 已經保存了用戶信息了。
流程二: 再次訪問接口一(此時用戶已經登錄),認證成功,跳轉到 success.html 頁面,展示用戶信息。
流程三: 訪問接口二,測試角色認證是否成功。
流程四: 訪問接口三,測試權限認證是否成功。
3.5.1 身份、角色、權限認證接口
/**
* 用戶控制器
*
* @author Manaphy chen
* @date 2020/4/7
*/
@Controller
@RequestMapping("/user")
public class UserController {
/**
* 用戶登錄接口
*
* @param user 用戶
* @param request 請求
* @return {@link String}
*/
@PostMapping("/login")
public String login(User user, HttpServletRequest request) {
//根據用戶名和密碼創建token
UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
//獲取subject認證主體
Subject subject = SecurityUtils.getSubject();
try {
//開始認證,這一步會跳到我們自定義的realm中
subject.login(token);
request.getSession().setAttribute("user", user);
return "success";
} catch (Exception e) {
request.getSession().setAttribute("user", user);
request.setAttribute("error", "用戶名或密碼錯誤!");
return "login";
}
}
/**
* 註銷
*
* @return {@link String}
*/
@GetMapping("/logout")
public String logout() {
SecurityUtils.getSubject().logout();
return "login";
}
/**
* 身份認證測試接口
*
* @param request 請求
* @return {@link String}
*/
@GetMapping("/admin")
public String admin(HttpServletRequest request) {
User user = (User) request.getSession().getAttribute("user");
return "success";
}
/**
* 角色認證測試接口
*
* @param request 請求
* @return {@link String}
*/
@GetMapping("/student")
public String student(HttpServletRequest request) {
return "success";
}
/**
* 權限認證測試接口
*
* @param request 請求
* @return {@link String}
*/
@GetMapping("/teacher")
public String teacher(HttpServletRequest request) {
return "success";
}
}
分析登錄接口:
首先會根據前端傳過來的用戶名和密碼,創建一個 token,然後使用 SecurityUtils 來創建一個認證主體,接下來開始調用 subject.login(token) 開始進行身份認證了,注意這裏傳了剛剛創建的 token,就如註釋中所述,這一步會跳轉到我們自定義的 realm 中,進入 doGetAuthenticationInfo 方法,所以到這裏,您就會明白該方法中那個參數 token 了。然後就是上文分析的那樣,開始進行身份認證。
3.6 其餘代碼
用戶實體
@Data
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Integer id;
private String username;
private String password;
}
跳轉控制器
@Controller
public class IndexController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/unauthorized")
public String unauthorized() {
return "unauthorized";
}
}
用戶dao
public interface UserDao {
/**
* 得到用戶名
*
* @param username 用戶名
* @return {@link User}
*/
@Select("select * from t_user where username = #{username}")
User getByUsername(String username);
/**
* 得到的角色
*
* @param username 用戶名
* @return {@link Set<String>}
*/
@Select("select r.rolename from t_user u,t_role r " +
"where u.role_id = r.id and u.username = #{username}")
Set<String> getRoles(String username);
/**
* 獲得權限
*
* @param username 用戶名
* @return {@link Set<String>}
*/
@Select("select p.permissionname from t_user u,t_role r,t_permission p " +
"where u.role_id = r.id and p.role_id = r.id and u.username = #{username}")
Set<String> getPermissions(String username);
}
用戶service
@Service
public class UserService {
@Resource
private UserDao userDao;
public User getByUsername(String username) {
return userDao.getByUsername(username);
}
public Set<String> getRoles(String username) {
return userDao.getRoles(username);
}
public Set<String> getPermissions(String username) {
return userDao.getPermissions(username);
}
}
頁面
login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>登錄頁面</title>
</head>
<body>
<form th:action="@{/user/login}" method="post">
username:<input type="text" name="username"/><br>
password:<input type="password" name="password"/><br>
<input type="submit" value="登陸">
</form>
</body>
</html>
success.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>成功</title>
</head>
<body>
歡迎你<span th:if="${session.user}" th:text="${session.user.username}"></span>
<a th:href="@{/user/logout}">退出</a>
</body>
</html>
unauthorized.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>認證未成功頁面</title>
</head>
<body>
認證未通過,或者權限不足
<a th:href="@{/user/logout}">退出</a>
</body>
</html>
3.5.2 測試
瀏覽器請求http://localhost:8080/user/admin
會進行身份認證,因爲此時未登錄,所以會跳轉到 IndexController 中的 /login
接口,然後跳轉到 login.html 頁面讓我們登錄,使用用戶名密碼爲 csdn/123456 登錄之後,我們在瀏覽器中請求 http://localhost:8080/user/student 接口,會進行角色認證,因爲數據庫中 csdn1 的用戶角色是 admin,所以和配置中的吻合,認證通過;我們再請求 http://localhost:8080/user/teacher 接口,會進行權限認證,因爲數據庫中 csdn1 的用戶權限爲 user:* ,滿足配置中的 user:create ,所以認證通過。
接下來,我們點退出,系統會註銷重新讓我們登錄,我們使用 csdn2 這個用戶來登錄,重複上述操作,當在進行角色認證和權限認證這兩步時,就認證不通過了,因爲數據庫中 csdn2 這個用戶存的角色和權限與配置中的不同,所以認證不通過。