SpringBoot + VUE + Shiro + JWT 实现前后端分离登录认证
SpringBoot + VUE + Shiro + JWT 实现前后端分离登录认证
最近在学习Apache Shiro
这个安全框架,感觉这部分技术有点抽象不易于理解,需要一定的逻辑理解其具体的流程,但是在使用时,又好像一般流程以及大部分代码都是固定的,仿佛是一个个工具类,所以为了后期的使用和学习,在这里就从0到1的写一个基于SpringBoot + VUE + Shiro + JWT的前后端分离项目背景下实现登录认证的Demo。这里是一个整合的小案例,各部分又分别都是一个个小的技术,均可以单独使用,有关于每个技术的具体内容可以看我之前的文章。
在下面的内容中,有关代码部分,首先大部分都是直接放得最终运行的代码,虽然在编码中遇到了各种各样大大小小的坑,在处理过程中,未能完全记录其过程,但是几乎都是又注释标注的,方便大家学习。其次代码肯定不是绝对完美的,针对不同的问题,有不同的解决方案,如果有问题希望大佬指正,最后部分代码参考自网络,向大佬致敬。
废话不多说,下面直接开始!
一、环境准备
1.1前端
- Node.js
v16.14.0
官网:Node.js 中文网 (nodejs.cn) - npm
8.3.1
- VUE
@vue/cli 5.0.4
官网:Home | Vue CLI (vuejs.org) - Element UI
2.15.8
官网:Element - 网站快速成型工具 - Axios
0.27.2
官网:Axios 中文文档 | Axios 中文网
1.2后端
- Java
JDK1.8
- SpringBoot
2.6.6
Shiro 1.8.0
- JWT
3.19.1
- MySQL
5.7.35
- .....
1.3工具
- OS
Windows 10 专业版
- 前端IDE
WebStrom
- 后端IDE
IDEA_2021.1.2
大家都知道,开发环境不同或是某一技术版本不一致都很有可能是BUG的来源,所以如果在自己电脑测试,尽量确保版本一致,一旦出现问题,多留意是不是环境不同,版本不一致造成的,切记不可盲目去百度直接各种Copy,结果未必能解决问题,反而跑偏方向,带来新的其他问题,在网络上查找技术解决方案时,多留意版本与环境问题!
二、前端开发
2.1使用VUE-CLI搭建项目
注意:大家需要在自己电脑提前安装好Node.js、npm、以及vueCLI,并确保正确添加到环境变量。这部分比较简单,这里不再演示。
- 新建项目文件夹
sirioVue
- 在控制台中进入项目文件夹
shiroVue
下执行命令:$ vue create shiro
- 然后依次进行如下选择:
Manually select features
(*) Babel 和 (*) Router
2.x
N
In package.json
N
- 等待项目安装相关依赖
- 根据提示依次执行以下命令:
$ cd shiro $ npm run serve
- 等待项目启动,然后浏览器访问 http://localhost:8080/
看到以上页面,到此前端使用VUE脚手架创建的项目就完成了。
2.2安装Axios、Element UI
- Axios
- 在控制台中执行如下命令:
# 先安装axios $ npm install axios --save # 或者npm i axios --S # 然后安装vue-axios $ npm install --save vue-axios # 可以不安装 # 也可以使用一条命令: $ npm install --save axios vue-axios
- 修改项目主配置文件
main.js
// main.js 添加 import axios from 'axios' import VueAxios from 'vue-axios' Vue.use(VueAxios,axios);
- 在控制台中执行如下命令:
- Element UI
- 在控制台中执行如下命令:
npm i element-ui -S
- 修改项目主配置文件
main.js
// main.js 添加 import ElementUI from 'element-ui'; import 'element-ui/lib/theme-chalk/index.css'; Vue.use(ElementUI);
- 在控制台中执行如下命令:
2.3封装axios
在实际使用中,我们往往需要加入各种请求头,以及配置全局固定的接口地址,所以为了方便使用,我们要封装axios以便添加拦截器。
- 在项目中新建 src/axios/request.js
import axios from 'axios'; // 导入axios import {Message} from 'element-ui' // 使用element-ui Message做消息提醒 import router from '../router' // 导入路由 // 创建Axios 实例 const service = axios.create({ timeout: 7000, // 超时时间 单位是ms,这里设置了7s的超时时间 baseURL: 'http://localhost/', // 公共接口 // headers: {'X-Custom-Header': 'foobar'} // 可以设置全局请求头 }) service.defaults.withCredentials = true; //允许跨域携带cookie信息 // 添加一个请求拦截器 // 发请求前做的一些处理,数据转化,配置请求头,设置token,设置loading等,根据需求去添加 service.interceptors.request.use( config => { config.headers.languagetype = 'CN'; // 示例:加上一个公共头部 config.headers = { 'Content-Type':'application/json' // 还可以这样配置请求头 } let token = window.localStorage.getItem('token'); // 获取token //if(token){ //config.params = {'token':token} //如果要求携带在参数中 //config.headers.token= token; //如果要求携带在请求头中 //} token && (config.headers.Authorization = token) // 添加头部信息token 键名为Authorization return config; }, error => { console.log(error); return Promise.reject(error); } ) // 响应拦截器 // 接收到响应数据并成功后的一些共有的处理,关闭loading等 service.interceptors.response.use( response => { // 接收后台参数状态 const res = response.data; if(res.code === 200 || res.status === 200 || res.success) { return res; }else { let message = (res.error && res.error.message) || res.message || res.msg || '未知错误'; Message({ message, type: 'error', duration: 5 * 1000 }); console.log('拦截器打印错误:', res); // 这里可以设置后台返回状态码是500或者是其他,然后重定向跳转 if(res.code === 500) { router.push('/') } return Promise.reject( new Error(res.message || (res.error &&res.error.message) || '未知错误') ); } }, error => { /***** 接收到异常响应的处理开始 *****/ console.log('服务器错误信息:', error); if (error && error.response) { // 1.公共错误处理 // 2.根据响应码具体处理 switch (error.response.status) { case 400: error.message = '错误请求' break; case 401: error.message = '未授权,请重新登录' break; case 403: error.message = '拒绝访问' break; case 404: error.message = '请求错误,未找到该资源' window.location.href = "/NotFound" break; case 405: error.message = '请求方法未允许' break; case 408: error.message = '请求超时' break; case 500: error.message = '服务器端出错' break; case 501: error.message = '网络未实现' break; case 502: error.message = '网络错误' break; case 503: error.message = '服务不可用' break; case 504: error.message = '网络超时' break; case 505: error.message = 'http版本不支持该请求' break; default: error.message = `连接错误${error.response.status}` } } else { // 超时处理 if (JSON.stringify(error).includes('timeout')) { Message.error('服务器响应超时,请刷新当前页') } error.message = '连接服务器失败' } Message.error(error.message) /***** 处理结束 *****/ //如果不需要错误处理,以上的处理过程都可省略 return Promise.resolve(error.response) } ) export default service;
以上代码可以认为是通用代码,可以根据实际项目需要进行修改!
- main.js中引用
// main.js 最终版 import Vue from 'vue' import App from './App.vue' import router from './router' // import VueAxios from 'vue-axios' import ElementUI from 'element-ui'; import 'element-ui/lib/theme-chalk/index.css'; import service from './axios/request'; // 引入封装的request.js文件 Vue.prototype.$axios = service //全局注册,使用方法为:this.$axios Vue.use(ElementUI); // Vue.use(VueAxios,axios); Vue.config.productionTip = false new Vue({ router, render: h => h(App) }).$mount('#app')
2.4新建Login.vue组件
<template>
<div class="main">
<div style="margin: 130px auto; background-color: #fff; width: 400px; height: 380px; padding: 20px; border-radius: 10px">
<h1 style="text-align: center;">用户登录</h1><br>
<el-form :model="loginForm" status-icon :rules="rules" ref="loginForm" label-width="80px" class="demo-ruleForm">
<el-form-item label="账号" prop="username">
<el-input type="name" v-model="loginForm.username" autocomplete="off"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input type="password" v-model="loginForm.password" autocomplete="off"></el-input>
</el-form-item>
<div style="margin-left: 55px">
<el-form-item class="btn">
<el-button type="primary" @click="submitForm('loginForm')">提交</el-button>
<el-button type="success" @click="resetForm('loginForm')">重置</el-button>
</el-form-item>
</div>
</el-form>
</div>
</div>
</template>
<script>
export default {
name: "Login",
data() {
return {
loginForm: {
username: 'admin',
password: '123456'
},
rules: {
username: [{
required: true,
message: '请输入账号',
trigger: 'blur'
}, {
min: 3,
max: 8,
message: '长度在 3 到 8 个字符',
trigger: 'blur'
}],
password: [{
required: true,
message: '请输入密码',
trigger: 'blur'
}, {
min: 6,
max: 16,
message: '长度在 6 到 16 个字符',
trigger: 'blur'
}]
}
};
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
this.$axios.post("/user/login",this.loginForm).then((resp) => {
if (resp.code === 200){
this.$message.success(resp.msg);
localStorage.setItem("token", resp.data);
this.$router.push('/home')
}else {
this.$message.error(resp.msg);
}
})
} else {
console.log('请正确输入每项数据!');
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
}
}
}
</script>
<style scoped>
.main {
height: 90vh;
background-image: linear-gradient(to bottom right, #FC466B, #3F5EFB);
overflow: hidden;
}
</style>
2.5新建Register.vue组件
注册页面与登录页面基本一致,这里不再写出。
2.6新建Home.vue组件
<template>
<div>
<h1>我是后台主页,只有认证成功后才可以访问!</h1>
<el-table
:data="tableData"
style="width: 100%">
<el-table-column
prop="id"
label="编号"
width="180">
</el-table-column>
<el-table-column
prop="name"
label="书名"
width="180">
</el-table-column>
<el-table-column
prop="price"
label="价格">
</el-table-column>
</el-table>
<el-button @click="logout" type="danger">退出登录</el-button>
</div>
</template>
<script>
export default {
name: "Home",
data(){
return {
tableData: [],
}
},
mounted() {
this.$axios.post("/books").then((resp) =>{
if (resp.code === 200){
this.$message.success(resp.msg);
this.tableData = resp.data;
}else{
this.$message.error(resp.msg);
this.$router.push('/')
}
})
},
methods: {
logout(){
this.$axios.get("/user/logout").then((resp) => {
if (resp.code === 200){
this.$message.success(resp.msg);
localStorage.removeItem('token');
}else {
this.$message.success("后端故障");
}
}).finally(()=>{
this.$router.push('/')
})
}
}
}
</script>
<style scoped>
</style>
2.7全局路由配置
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from '../views/Login.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'login',
component: Login
},
{
path: '/register',
name: 'register',
component: () => import('../views/Register.vue')
},
{
path: '/home',
name: 'Home',
component: () => import('../views/Home.vue')
}
]
const router = new VueRouter({
routes
})
export default router
2.8修改App.vue
<template>
<div id="app">
<nav>
<router-link to="/">用户登录</router-link> |
<router-link to="/register">用户注册</router-link>
</nav>
<router-view/>
</div>
</template>
2.9页面展示
三、后端开发
3.0数据库准备
在正式后端开发前,需要提前准备好数据库以及对应的用户信息表。
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(32) NOT NULL COMMENT '用户名',
`password` varchar(64) NOT NULL COMMENT '用户密码',
`salt` varchar(64) NOT NULL COMMENT '加密盐',
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`) COMMENT '用户名唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
另外首先需要搭建一个SpringBoot空项目,这里不做演示。
3.1导入依赖坐标
<dependencies>
<!--web环境-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--测试环境-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--Mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--数据库连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.6</version>
</dependency>
<!--Mybatis-Plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!--Shiro安全框架-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.8.0</version>
</dependency>
<!--thymeleaf视图解析-->
<!--这里前期我使用了thymeleaf,后来前后端分离就不需要了-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--JWT验证-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.19.1</version>
</dependency>
<!--简化实体类开发-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
<!--处理JSON-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.72</version>
</dependency>
</dependencies>
3.2项目配置
server:
port: 80
spring:
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/shiro?characterEncoding=utf-8&useSSL=false&serverTimezone=GMT
username: root
password: 123123
mybatis-plus:
global-config:
db-config:
table-prefix: tb_
3.3三层通用代码开发
全局统一返回实体
@Data
public class Result<T> {
private Integer code;
private T data;
private String msg;
public static <T> Result<T> success(T data, String msg){
Result<T> r = new Result<T>();
r.setCode(200);
r.setData(data);
r.setMsg(msg);
return r;
}
public static <T> Result<T> error(T data, String msg) {
Result<T> r = new Result<T>();
r.setCode(500);
r.setData(data);
r.setMsg(msg);
return r;
}
}
实体类
@Data
public class User {
private Long id;
private String username;
private String password;
private String salt;
}
@Data
public class Book {
private Integer id;
private String name;
private Double price;
}
持久层
package cn.imyjs.mapper;
import cn.imyjs.pojo.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
业务层
// 对应接口省略
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public User getUserByName(String username) {
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(StringUtils.isNotBlank(username), User::getUsername, username);
User user = userMapper.selectOne(lambdaQueryWrapper);
return user;
}
}
控制层
package cn.imyjs.controller;
import cn.imyjs.common.Result;
import cn.imyjs.config.shiro.JWTToken;
import cn.imyjs.config.shiro.JWTUtil;
import cn.imyjs.pojo.User;
import cn.imyjs.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.crypto.hash.Md5Hash;
import org.apache.shiro.subject.Subject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* @Classname UserController
* @Description TODO
* @Date 2022/4/28 14:59
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@RequestMapping("user")
@RestController
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@PostMapping("login")
public Result<String> login(@RequestBody User user) {
log.info("登录-->username = {}, password = {}", user.getUsername(), user.getPassword());
String token;
User userDB = userService.getUserByName(user.getUsername());
if (userDB != null) {
String salt = userDB.getSalt();
log.info("该用户注册时使用的加密盐:{}", salt);
// 根据用户输入的明文密码进行MD5加密,并且使用一样的salt以及进行相同的哈希散列次数
String encryptionPassword = new Md5Hash(user.getPassword(), salt, 1024).toString();
log.info("用户本次登录输入的密码加密后:{}", encryptionPassword);
// 根据用户名和用户输入密码(加密后的)生成Token
token = JWTUtil.generalToken(user.getUsername(), encryptionPassword);
log.info("生成Token:" + token);
// 保存到JWTToken 方便Shiro进行校验
// 本质就是自定义AuthenticationToken
JWTToken jwtToken = new JWTToken(token);
try {
SecurityUtils.getSubject().login(jwtToken);
log.info("登录成功!");
return Result.success(token, "登录成功");
} catch (AuthenticationException e) {
e.printStackTrace();
return Result.error("登录失败", "用户密码错误!");
}
} else {
log.error("未找到该用户!");
return Result.error("未找到该用户", "未找到该用户");
}
}
@GetMapping("logout")
public Result<String> logout(){
Subject subject = SecurityUtils.getSubject();
subject.logout();
return Result.success("退出成功!","退出成功!");
}
@PostMapping("register")
public Result<String> register(@RequestBody User user){
log.info("注册-->username = " + user.getUsername() + "password = " + user.getPassword());
String salt = UUID.randomUUID().toString();
String dbPassword = new Md5Hash(user.getPassword(), salt, 1024).toHex();
user.setPassword(dbPassword);
user.setSalt(salt);
if (userService.save(user)){
log.info("注册成功");
// return "redirect:/";
return Result.success("注册成功", "注册成功");
}else {
log.error("注册失败");
// return "redirect:/register";
return Result.error("注册失败", "注册失败");
}
}
}
@RestController
@RequestMapping("books")
public class DataController {
@PostMapping()
public Result<List<Book>> getAllBooks(){
// 为了方便,这里只为模拟数据,所以就没有对应数据库
List<Book> books = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Book book = new Book();
book.setId(i + 1);
book.setName("JAVA核心技术");
book.setPrice(88.88);
books.add(book);
}
return Result.success(books, "查询全部书籍成功!");
}
}
以上代码并不优雅,实际业务开发时,应保证大部分业务代码放在Service层,而控制层应减少代码,主要核心负责映射请求路径并分发路由。
好了,到这里,相信大家对于前边的操作都非常熟悉了,下边才真正是这篇文章要记录的哦!
3.4Shiro+JWT认证开发
流程梳理
用户注册
- 前台用户输入用户名和明文密码发送POST请求到服务端进行注册。
- 后端用户注册服务收到后进行产生随机盐(这里使用UUID),然后对于明文密码进行加密(这里使用 明文密码 + 盐 进行MD5加密并且进行HASH散列1024次生成密文保存至数据库,注册完成。
用户登录
- 前台用户输入用户名和明文密码发送POST请求到服务端进行登录。
- 后端用户登录服务收到后首先进行根据用户名进行查询数据库,找到该用户注册时所用的加密盐,并按照同样的逻辑进行加密,产生用户名和加密后的密码。
- 然后使用
JWTUtil
对于用户名和加密密码作为负荷进行生成Token。注意:这里需要将token保存到自定义JWTToken
实体类中,为了后面Shiro进行校验,JWTToken本质就是自定义AuthenticationToken
。 - 此时使用
SecurityUtils
工具类的getSubject方法获取主体并调用login()进行登录,并且传入刚才的JWTToken实例作为认证token。 - 登录成功后由Shiro的SecurityManger进行管理。否则根据异常类型进行返回错误信息。
Shiro认证
基本使用流程
- 创建
SecurityManager
安全管理器 - 给安全管理器提供
Realm
(提供用户数据信息,可以对接数据库查询,以及提供认证规则) - 给安装工具类
SecurityUtils
中设置默认安全管理器 - 从
SecurityUtils
中获取主体对象(可以认为当前登录用户) - 使用身份信息(用户名、密码)创建token令牌
- subject.login(token)进行用户登录认证
WEB应用使用
由于配置了Shiro安全框架,请求在到达控制层方法之前会被Shiro过滤器拦截,从此整个系统的安全认证、权限管理由Shiro的安全管理器进行管理。
- 创建配置类
ShiroConfig
注入ShiroFilterFactoryBean
- 给
ShiroFilterFactoryBean
提供SecurityManager
安全管理器 - 由于我们需要集成JWT,所以需要进行重写一个过滤器
JWTFilter
提供给ShiroFilterFactoryBean
- 然后进行配置认证和授权规则,即那些请求资源需要进行认证
- 自定义
UserRealm
使之继承AuthorizingRealm
,在这里进行认证和授权规则
代码开发
JWTUtils
package cn.imyjs.config.shiro;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import java.util.Date;
/**
* @Classname JWTUtil
* @Description TODO
* @Date 2022/4/28 15:51
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@Slf4j
public class JWTUtil {
/**
* 设置Token过期时间
*/
private static final long EXPIRE_TIME = 30 * 60 * 1000;
/**
* 校验token是否正确
* @param token 密钥
* @param username 用户名
* @param secret 用户的密码(MD5 + Slat + hash散列)进行加密
* @return 密钥是否正确
*/
public static boolean verify(String token, String username, String secret) {
log.info("校验token:用户名=" + username + ", 密码=" + secret);
try {
//使用HMAC250算法加密密钥
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (Exception exception) {
return false;
}
}
/**
* 获得token中的信息无需secret解密也能获得
* @param token 密钥
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成签名,30min后过期
* @param username 用户名
* @param secret 用户的密码(MD5 + Slat + hash散列)进行加密
* @return 加密的token
*/
public static String generalToken(String username, String secret) {
log.info("生成签名:用户名=" + username + ", 密码=" + secret);
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
//密钥和加密算法
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
}
/**
* 判断过期
*
* @param token
* @return
*/
public static boolean isExpire(String token) {
DecodedJWT jwt = JWT.decode(token);
return System.currentTimeMillis() > jwt.getExpiresAt().getTime();
}
}
该类作为JWT工具类,主要用于进行生成Token,校验Token、根据Token获取用户名等,一般编写比较固定。
ShiroConfig
package cn.imyjs.config.shiro;
import org.apache.shiro.mgt.SecurityManager;
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 javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;
/**
* @Classname ShiroConfig
* @Description TODO
* @Date 2022/4/28 14:08
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@Configuration
public class ShiroConfig {
// 创建ShiroFilter
@Bean()
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
//封装ShiroFilter的过滤器工厂
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//注入安全管理器
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 添加自定义过滤器并且取名为jwt
Map<String, Filter> filterMap = new HashMap<>(); // import javax.servlet.Filter;
filterMap.put("jwt", new JWTFilter());
shiroFilterFactoryBean.setFilters(filterMap);
// 设置认证和授权规则
HashMap<String, String> filterRules = new HashMap<>();
// anon : 不用登陆验证就能访问
filterRules.put("/","anon");//登录页面
filterRules.put("/user/login","anon");// 登录接口
filterRules.put("/register","anon");// 注册页面
filterRules.put("/user/register","anon");// 注册接口
// 必须登录后才能访问
filterRules.put("/**","jwt"); // 其他任何资源
// 配置认证和授权规则
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterRules);
//除了使用shiro内置的服务器,还可以自定义过滤器,然后通过下面的方法添加进去。
shiroFilterFactoryBean.setFilters(filterMap);
return shiroFilterFactoryBean;
}
// 创建SecurityManager
@Bean
public DefaultWebSecurityManager securityManager(UserRealm realm){
//需要注意这里是DefaultWebSecurityManager,该对象是专门用于整合springboot使用的SecurityManager,
// 而不是使用DefaultSecurityManager
return new DefaultWebSecurityManager(realm);
}
}
Shiro核心配置类,主要用于创建ShiroFilter,创建SecurityManager等。
JWTToken
package cn.imyjs.config.shiro;
import org.apache.shiro.authc.AuthenticationToken;
/**
* @Classname JWTToken
* @Description TODO
* @Date 2022/4/28 17:14
* @Created by YJS
* @WebSite www.imyjs.cn
*/
public class JWTToken implements AuthenticationToken {
// 密钥
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
该类实现了AuthenticationToken类,也就是自定义了Token,在Realm类中进行认证时的参数就是自定义的authenticationToken。
UserRealm
package cn.imyjs.config.shiro;
import cn.imyjs.pojo.User;
import cn.imyjs.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* @Classname UserRealm
* @Description TODO
* @Date 2022/4/28 14:17
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@Service
@Slf4j
public class UserRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
/**
* 自定义AuthenticationToken必须重写此方法,不然Shiro会报错:
* Please ensure that the appropriate Realm implementation is configured correctly
* or that the realm accepts AuthenticationTokens of this type.
*
* 请确保正确配置了相应的领域实现,或者领域接受此类型的AuthenticationToken。
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
//授权,该方法的作用就是查出用户所有角色与权限信息,并添加进simpleAuthorizationInfo对象里面。
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
//登录验证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 从authenticationToken中获取JWT生成的Token
String JWTtoken = (String) authenticationToken.getCredentials();
log.info("JWTtoken = " + JWTtoken);
// 从JWTUtil的token中获取用户名
String username = JWTUtil.getUsername(JWTtoken);
log.info("username = " + username);
if (username == null) {
throw new AuthenticationException(" token错误,请重新登入!");
}
//根据用户名查询数据库获取用户信息
User user = userService.getUserByName(username);
if (user == null){
throw new AuthenticationException("账号不存在");
}
if(JWTUtil.isExpire(JWTtoken)){
throw new AuthenticationException(" token过期,请重新登入!");
}
// 由于使用的是JWT,所以密码需要自己验证而不是交给shiro去验证。
if (!JWTUtil.verify(JWTtoken, username, user.getPassword())) {
log.info("用户密码:" + user.getPassword());
throw new CredentialsException("用户密码错误!");
}
// if(user.getStatus()==0){
// throw new LockedAccountException("账号已被锁定!");
// }
//返回带有用户名与密码的AuthenticationInfo
return new SimpleAuthenticationInfo(user, JWTtoken, this.getName());
}
}
该类实现提供了正确的用户数据以及认证、授权校验以及异常反馈等。
JWTFilter
package cn.imyjs.config.shiro;
import cn.imyjs.common.Result;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.http.HttpStatus;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* @Classname JWTFilter
* @Description 自定义JWT过滤器
* @Date 2022/4/28 15:43
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@Slf4j
public class JWTFilter extends BasicHttpAuthenticationFilter {
/**
* 预处理,进行验证之前执行的方法,可以理解为该过滤器最先执行的方法。
* 该方法执行后执行isAccessAllowed方法。
*
* @return
* @throws Exception
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
System.err.println("preHandle");
return super.preHandle(request, response);
}
/**
* 认证之前执行该方法
* 该方法用于判断是否登录,
* BasicHttpAuthenticationFilter底层是通过subject.isAuthenticated()方法判断的是否登录的。
*
* @return 该方法返回值:
* 如果未登录,返回false, 进入onAccessDenied。
* 如果登录了,返回true, 允许访问,不用继续验证,可以访问接口获取数据。
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
System.err.println("isAccessAllowed");
Subject subject = SecurityUtils.getSubject();
return null != subject && subject.isAuthenticated();
// return super.isAccessAllowed(request, response, mappedValue);
}
/**
* 认证未通过执行该方法
* 判断是否拒绝访问。
* 当用户没有认证成功时,就必须进行httpBasic验证。
* 在该方法内部,先使用isLoginAttempt方法判断是否登录模式。
* 如果是登录模式,就执行executeLogin方法。反之,执行sendChallenge
*
* @return 该方法返回值:
* 如果执行了executeLogin并登陆成功,返回true,允许访问。
* 如果执行executeLogin但没登陆成功或者执行了sendChallenge,返回false,拒绝访问。
*/
@Override
//返回true,允许访问,返回false,拒绝访问。
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
System.err.println("onAccessDenied");
//完成token登入
//1.检查请求头中是否含有token
HttpServletRequest httpServletRequest= (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Authorization");
log.info("Authorization: " + token);
//2. 如果客户端没有携带token,拦下请求
if(null==token||"".equals(token)){
responseTokenError(response,"Token无效,您无权访问该接口");
return false;
}
//3. 如果有,对进行进行token验证
JWTToken jwtToken = new JWTToken(token);
try {
SecurityUtils.getSubject().login(jwtToken);
} catch (AuthenticationException e) {
System.out.println(e.getMessage());
responseTokenError(response,e.getMessage());
return false;
}
return true;
}
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Authorization");
return token != null;
}
@Override
protected boolean sendChallenge(ServletRequest request, ServletResponse response) {
System.err.println("sendChallenge");
return super.sendChallenge(request, response);
}
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorization = httpServletRequest.getHeader("Authorization");
UsernamePasswordToken token = new UsernamePasswordToken(authorization, authorization);
getSubject(request,response).login(token);
return true;
}
/**
* 无需转发,直接返回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()) {
// 这里可以使用全局返回信息类型!
out.write(JSON.toJSONString(Result.error("error", msg)));
} catch (IOException e) {
e.printStackTrace();
}
}
}
自定义JWT过滤器类。
跨域处理
在开发过程中,由于前后端分离,总会遇到跨域问题,也就是浏览器的同源策略导致的,网上关于跨域问题的解决方案有很多,其实大多也都是差不多的,总之遇到了就是各种Copy,但是这次在这里使用之前的配置竟然没有效果,我尝试了许多方法,仍然不见效果,常规的解决方案在访问不需要进行Shiro进行过滤的资源时有效,但是一旦访问需要拦截过滤的资源时就不生效了,这个问题,网上的解决方案好像也有许多,经过分析其产生的原因就是多个Filter的优先级问题。
CorsFilter
package cn.imyjs.config.filter;
import org.springframework.http.HttpStatus;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebFilter(urlPatterns = "/**")
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setHeader("Access-control-Allow-Origin", request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", request.getHeader("Access-Control-Request-Headers"));
response.addHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT");
if ("OPTIONS".equals(request.getMethod())) {
response.setStatus(HttpStatus.NO_CONTENT.value());
return;
} else {
filterChain.doFilter(request, response);
}
}
}
自定义解决跨域问题的过滤器。
FilterConfig
package cn.imyjs.config;
import cn.imyjs.config.filter.CorsFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.DispatcherType;
/**
* @Classname FilterConfig
* @Description TODO
* @Date 2022/4/29 15:17
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean replaceTokenFilter(){
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setDispatcherTypes(DispatcherType.REQUEST);
registration.setFilter( new CorsFilter());
registration.addUrlPatterns("/*");
registration.setName("CorsFilter ");
//设置优先级最高
registration.setOrder(1);
return registration;
}
}
配置自定义的过滤器,使之优先级高于Shiro过滤器。
全局异常处理
GlobalExceptionHandler
package cn.imyjs.config;
import cn.imyjs.common.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* @Classname GlobalExceptionHandler
* @Description TODO
* @Date 2022/4/28 22:17
* @Created by YJS
* @WebSite www.imyjs.cn
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public Result<String> doOtherException(Exception e){
return Result.error(e.getMessage(), "后端故障!");
}
}
这里并没有进行分类异常类型,只是进行了统一处理。
四、补充知识
到这里,整体开发就结束了。下面对于Shiro、JWT相关核心组成进行简单记录。
Shiro
主要对象
Subject
Subject即主体
,外部应用与subject进行交互,subject记录了当前操作用户,将用户的概念理解为当前操作的主体,可能是一个通过浏览器请求的用户,也可能是一个运行的程序。 Subject在shiro中是一个接口,接口中定义了很多认证授相关的方法,外部程序通过subject进行认证授,而subject是通过SecurityManager
安全管理器进行认证授权。
将当前登录用户封装为一个Subject,包含用户名、密码等各种信息,交由SecurityManager来进行认证、授权等管理。
SecurityManager
SecurityManager即安全管理器
,对全部的subject进行安全管理,它是shiro的核心,负责对所有的subject进行安全管理。通过SecurityManager可以完成subject的认证、授权等,实质上SecurityManager是通过Authenticator进行认证,通过Authorizer进行授权,通过SessionManager进行会话管理等。
SecurityManager是一个接口,继承了Authenticator, Authorizer, SessionManager这三个接口。
Shiro的核心组成,对全部的Subject进行安全管理。
Authenticator
Authenticator即认证器
,对用户身份进行认证,Authenticator是一个接口,shiro提供ModularRealmAuthenticator实现类,通过ModularRealmAuthenticator基本上可以满足大多数需求,也可以自定义认证器。
Authorizer
Authorizer即授权器
,用户通过认证器认证通过,在访问功能时需要通过授权器判断用户是否有此功能的操作权限。
Realm
Realm即领域
,相当于datasource数据源,securityManager进行安全认证需要通过Realm获取用户权限数据,比如:如果用户身份数据在数据库那么realm就需要从数据库获取用户身份信息。
注意:不要把realm理解成只是从数据源取数据,在realm中还有认证授权校验的相关的代码。需要把Realm提供SecurityManager
认证过程
自定义Realm
中必须重写其两个方法:
- 用于认证处理:
doGetAuthenticationInfo(AuthenticationToken authenticationToken)
返回AuthenticationInfo
- 用于授权处理:
doGetAuthorizationInfo(PrincipalCollection principalCollection)
返回AuthorizationInfo
用于认证处理的方法参数authenticationToken
就是subject.login(token)
里面的Token,我们通过该Token获取传入的用户名,用该用户名查出用户密码信息。如果该用户不存在,返回null,进而进行异常处理;如果存在,将该用户的密码封装在AuthenticationInfo
中返回,用于shiro判断密码是否正确。需要注意的是,AuthenticationInfo
第一个参数是用于传递给授权方法使用的,与登录验证基本没有关系,不用和登录的token参数保持一致。简单来说,用于认证处理的方法原理就是通过用户名获取到用户过后,代表用户名验证成功,将用户密码返回用于密码验证(这一步是shiro内部完成),所以就不必保持传入AuthenticationToken
的第一个参数与AuthenticationInfo
的第一个参数保持一致了,因为通过用户名没有获取到用户密码这些信息就已经表示用户名验证失败了。需要注意的是AuthenticationInfo
的第一个参数虽然可以传任意对象,但是该对象必须对获取该用户的角色与权限有帮助,这是第一个参数最主要的作用。
JWT
有关JWT的相关内容看这里:一篇文章学会JSON Web Token (JWT) - 编程那点事儿 (imyjs.cn)
在这里JWT提供的作用就是Shiro中subject.login(token)的Token不是真正的用户信息而是通过自定义AuthenticationToken设置的由JWT工具类生成的签名也就是返回给前端的Token,在UserRealm中进行认证时,认证方法中的参数authenticationToken正是JWTtoken,然后再拿JWTtoken去JWTUtils工具类获取真正的用户名。由于使用的是JWT,所以密码需要自己验证而不是交给Shiro去验证。
BasicHttpAuthenticationFilter
自定义Filter需要重写内置BasicHttpAuthenticationFilter
过滤器,其提供的主要方法:
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception
预处理,进行验证之前执行的方法,可以理解为该过滤器最先执行的方法。该方法执行后执行isAccessAllowed方法。 通常在这里进行跨域处理,但是我尝试无效!protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)
认证之前执行该方法该方法用于判断是否登录,BasicHttpAuthenticationFilter底层是通过subject.isAuthenticated()方法判断的是否登录的。
该方法返回值:
如果未登录,返回false, 进入onAccessDenied。
如果登录了,返回true, 允许访问,不用继续验证,可以访问接口获取数据。protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception
认证未通过执行该方法判断是否拒绝访问。当用户没有登录访问该过滤器的过滤的接口时,就必须进行httpBasic验证。
在该方法内部,先使用isLoginAttempt方法判断是否登录模式。
如果是登录模式,就执行executeLogin方法。反之,执行sendChallenge
该方法返回值:
如果执行了executeLogin并登陆成功,返回true,允许访问。
如果执行executeLogin但没登陆成功或者执行了sendChallenge,返回false,拒绝访问。
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response)
是否是登录模式
判断依据,是否携带header(Authorization)。
该方法返回值:
该方法返回true,携带 是登录模式 。
返回false,该方法不是登录模式。
- protected boolean sendChallenge(ServletRequest request, ServletResponse response)该方法主要作用是,在请求没有携带header(Authorization)时,添加响应头WWW-Authenticate进行httpBasc验证。
该方法返回值:
只有fasle。
浏览器接收到含有WWW-Authenticate的响应头会弹出输入用户名与密码的输入框。
- protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception在请求携带header(Authorization),通过获取请求头Authorization的获取username与password创建token。
然后调用subject.login(token)使用该token进行登录验证。
格式:Authorization: Basic 6ICB5p2/OjEyMzQ1Ng==
该方法返回值:
true 登陆成功。
false 登陆失败。
ShiroFilterFactoryBean
Shiro过滤器配置
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter() {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
// Shiro的核心安全接口,这个属性是必须的
shiroFilter.setSecurityManager(securityManager());
// shiroFilter.setLoginUrl("");//身份认证失败,则跳转到登录页面的配置 没有登录的用户请求需要登录的页面时自动跳转到登录页面,不是必须的属性,不输入地址的话会自动寻找项目web项目的根目录下的”/login.jsp”页面。
// shiroFilter.setSuccessUrl("");//登录成功默认跳转页面,不配置则跳转至”/”。如果登陆前点击的一个需要登录的页面,则在登录自动跳转到那个需要登录的页面。不跳转到此。
// shiroFilter.setUnauthorizedUrl("");//没有权限默认跳转的页面
// shiroFilter.setFilterChainDefinitions("");//filterChainDefinitions的配置顺序为自上而下,以最上面的为准
//自定义过滤
//oauth2
Map<String, Filter> filters = new HashMap<>(16);
filters.put("oauth2", new Oauth2Filter());
shiroFilter.setFilters(filters);
//Shiro验证URL时,URL匹配成功便不再继续匹配查找(所以要注意配置文件中的URL顺序,尤其在使用通配符时)
// 配置不会被拦截的链接 顺序判断
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/favicon.ico", "anon");
filterMap.put("/webjars/**", "anon");
filterMap.put("/web/**", "anon");
filterMap.put("/login", "anon");
//所有请求需要oauth2认证
filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
当运行一个Web应用程序时,Shiro将会创建一些有用的默认Filter实例,并自动地在[main]项中将它们置为可用自动地可用的默认的Filter实例是被DefaultFilter枚举类定义的,枚举的名称字段就是可供配置的名称。我们可以用这些过滤器来配置控制指定url的权限:
配置缩写 | 对应的过滤器 | 功能 |
---|---|---|
anon | AnonymousFilter | 指定url可以匿名访问 |
authc | FormAuthenticationFilter | 指定url需要form表单登录,默认会从请求中获取username 、password ,rememberMe 等参数并尝试登录,如果登录不了就会跳转到loginUrl配置的路径。我们也可以用这个过滤器做默认的登录逻辑,但是一般都是我们自己在控制器写登录逻辑的,自己写的话出错返回的信息都可以定制嘛。 |
authcBasic | BasicHttpAuthenticationFilter | 指定url需要basic登录 |
logout | LogoutFilter | 登出过滤器,配置指定url就可以实现退出功能,非常方便 |
noSessionCreation | NoSessionCreationFilter | 禁止创建会话 |
perms | PermissionsAuthorizationFilter | 需要指定权限才能访问 |
port | PortFilter | 需要指定端口才能访问 |
rest | HttpMethodPermissionFilter | 将http请求方法转化成相应的动词来构造一个权限字符串,这个感觉意义不大,有兴趣自己看源码的注释 |
roles | RolesAuthorizationFilter | 需要指定角色才能访问 |
ssl | SslFilter | 需要https请求才能访问 |
user | UserFilter | 需要已登录或“记住我”的用户才能访问 |
通常可将这些过滤器分为两组:
- anon,authc,authcBasic,user是认证过滤器
- perms,port,rest,roles,ssl是授权过滤器
注意
user和authc不同:当应用开启了rememberMe时,用户下次访问时可以是一个user,但绝不会是authc,因为authc是需要重新认证的。
user表示用户不一定已通过认证,只要曾被Shiro记住过登录状态的用户就可以正常发起请求,比如rememberMe 说白了,以前的一个用户登录时开启了rememberMe,然后他关闭浏览器,下次再访问时他就是一个user,而不会authc。
举几个例子
- /admin=authc,roles[admin] 表示用户必需已通过认证,并拥有admin角色才可以正常发起'/admin'请求
- /edit=authc,perms[admin:edit] 表示用户必需已通过认证,并拥有admin:edit权限才可以正常发起'/edit'请求
- /home=user 表示用户不一定需要已经通过认证,只需要曾经被Shiro记住过登录状态就可以正常发起'/home'请求
各默认过滤器常用如下(注意URL Pattern里用到的是两颗星,这样才能实现任意层次的全匹配)
- /admins/**=anon 无参,表示可匿名使用,可以理解为匿名用户或游客
- /admins/user/**=authc 无参,表示需认证才能使用
- /admins/user/**=authcBasic 无参,表示httpBasic认证
- /admins/user/**=ssl 无参,表示安全的URL请求,协议为https
- /admins/user/**=perms[user:add:*] 参数可写多个,多参时必须加上引号,且参数之间用逗号分割,如/admins/user/**=perms["user:add:*,user:modify:*"]。当有多个参数时必须每个参数都通过才算通过,相当于isPermitedAll()方法
- /admins/user/**=port[8081] 当请求的URL端口不是8081时,跳转到schemal://serverName:8081?queryString。其中schmal是协议http或https等,serverName是你访问的Host,8081是Port端口,queryString是你访问的URL里的?后面的参数
- /admins/user/**=rest[user] 根据请求的方法,相当于/admins/user/**=perms[user:method],其中method为post,get,delete等
- /admins/user/**=roles[admin] 参数可写多个,多个时必须加上引号,且参数之间用逗号分割,如:/admins/user/**=roles["admin,guest"]。当有多个参数时必须每个参数都通过才算通过,相当于hasAllRoles()方法
五、总结
对于Shiro安全框架的学习感觉并不是像学个SSM框架那样学一点会用一点,而Shiro好像是一连串的,要想学明白就必须需要搞清楚它的具体工作流程,以及各部分是进行如何关联的,但是他在使用起来还是比较容易,但是这是与实际的业务复杂程度有关系的,总之就是这部分必须实践上手去撸代码,才能体会更佳。
微信关注