目录
前言
最近一直在学习权限框架,光学不敲,那肯定不行,所以有了这个项目。项目实现了jwt无状态登录、redis缓存、token续期和可控。算是个比较通用且有亮点的权限管理项目吧。🤭
一、项目介绍
1.运行
项目下载
gitee:vue.git" title="https://gitee.com/wusupweilgy/springboot-vue.git">https://gitee.com/wusupweilgy/springboot-vue.git
蓝奏云:https://wwp.lanzoup.com/iR1AY0ttqcob
2.技术栈
前端:vue2,element-ui、axios、echars组件
后端:springboot、mybatis-plus、shiro、jwt、redis
3.功能
-
用户、角色和菜单的权限管理
-
统计在线人数、注册人数等
-
个人密码、用户信息的修改
-
根据角色不同,前端动态渲染菜单导航
4.角色权限介绍
-
admin角色拥有删除功能,其他角色没有
-
admin和vip角色能查看用户信息,普通用户不行
-
admin和vip能进行添加操作,普通用户不行
-
个人信息和修改密码,登录过就可以访问
二、流程讲解
1.用户点击注册,系统将密码加密后存入数据库中。
2.用户登录,主要是校验账号密码并生成 token(jwt),然后存储到Redis,这里存的是签发时间,比token(jwt)中设置的过期时间长,为了实现token的自动续期。文章末尾我有细说。
3.用户访问需要认证的资源时,需要进行token校验和续期判断
三、数据库
E-R图设计
没有加外键,因为增加会造成数据库压力。实体表都加入了逻辑删除字段。
数据库脚本
/*
Navicat Premium Data Transfer
Source Server : local
Source Server Type : MySQL
Source Server Version : 80028
Source Host : localhost:3306
Source Schema : shiro_jwt_vue2
Target Server Type : MySQL
Target Server Version : 80028
File Encoding : 65001
Date: 22/04/2023 14:18:39
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for files
-- ----------------------------
DROP TABLE IF EXISTS `files`;
CREATE TABLE `files` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件名称',
`type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件类型',
`size` bigint(0) NULL DEFAULT NULL COMMENT '文件大小(kb)',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '下载链接',
`md5` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件md5',
`is_delete` tinyint(1) NULL DEFAULT 0 COMMENT '是否删除',
`enable` tinyint(1) NULL DEFAULT 1 COMMENT '是否禁用链接',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 73 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of files
-- ----------------------------
INSERT INTO `files` VALUES (73, 'lgy.jpg', 'jpg', 35, 'http://localhost:9090/files/2023/04/22/20230422123301000000166.jpg', 'eb81db8974a4924ba39ccc049c078516', 0, 1, '2023-04-22 00:33:01', NULL);
INSERT INTO `files` VALUES (74, 'lgy.png', 'png', 197, 'http://localhost:9090/files/2023/04/22/20230422123303000000698.png', '466ebb0a2ea027b04ab2f60f2dcbf1f6', 0, 1, '2023-04-22 00:33:04', NULL);
-- ----------------------------
-- Table structure for sys_dict
-- ----------------------------
DROP TABLE IF EXISTS `sys_dict`;
CREATE TABLE `sys_dict` (
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '名称',
`value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '内容',
`type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '类型'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_dict
-- ----------------------------
INSERT INTO `sys_dict` VALUES ('user', 'el-icon-user', 'icon');
INSERT INTO `sys_dict` VALUES ('house', 'el-icon-house', 'icon');
INSERT INTO `sys_dict` VALUES ('menu', 'el-icon-menu', 'icon');
INSERT INTO `sys_dict` VALUES ('s-custom', 'el-icon-s-custom', 'icon');
INSERT INTO `sys_dict` VALUES ('s-grid', 'el-icon-s-grid', 'icon');
INSERT INTO `sys_dict` VALUES ('document', 'el-icon-document', 'icon');
INSERT INTO `sys_dict` VALUES ('coffee', 'el-icon-coffee\r\n', 'icon');
INSERT INTO `sys_dict` VALUES ('s-marketing', 'el-icon-s-marketing', 'icon');
INSERT INTO `sys_dict` VALUES ('files', 'el-icon-files', 'icon');
-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '名称',
`path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '路径',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '图标',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '描述',
`permission` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '权限标识',
`pid` int(0) NULL DEFAULT NULL COMMENT '父级id',
`page_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '页面路径',
`sort_num` int(0) NULL DEFAULT NULL COMMENT '排序',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`is_delete` tinyint(0) NULL DEFAULT 0 COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 48 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES (4, '系统管理', NULL, 'el-icon-s-grid', NULL, NULL, NULL, NULL, 300, NULL, NULL, 0);
INSERT INTO `sys_menu` VALUES (5, '用户管理', '/user', 'el-icon-user', NULL, 'user', 4, 'User', 301, NULL, '2023-04-20 10:17:23', 0);
INSERT INTO `sys_menu` VALUES (6, '角色管理', '/role', 'el-icon-s-custom', NULL, 'role', 4, 'Role', 302, NULL, '2023-04-20 10:53:11', 0);
INSERT INTO `sys_menu` VALUES (7, '菜单管理', '/menu', 'el-icon-menu', NULL, NULL, 4, 'Menu', 303, NULL, NULL, 0);
INSERT INTO `sys_menu` VALUES (10, '主页', '/home', 'el-icon-house', '主页', NULL, NULL, 'Home', 0, NULL, '2023-04-20 09:45:07', 0);
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
`role_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '唯一标识',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '名称',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '描述',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`is_delete` tinyint(0) NULL DEFAULT 0 COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, 'admin', '超级管理员', '烦烦烦', NULL, '2023-04-20 09:32:12', 0);
INSERT INTO `sys_role` VALUES (2, 'user', '普通用户', NULL, NULL, NULL, 0);
INSERT INTO `sys_role` VALUES (3, 'vip', 'Vip用户', NULL, NULL, '2023-04-22 00:33:12', 0);
-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
`role_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色id',
`menu_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '菜单id',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`role_id`, `menu_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色-菜单-关联表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO `sys_role_menu` VALUES ('1', '10', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '4', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '47', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '5', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '6', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('1', '7', '2023-04-21 17:02:29', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '10', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '4', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '5', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('2', '6', '2023-04-22 12:32:24', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '10', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '4', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '5', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '6', '2023-04-22 14:13:53', NULL);
INSERT INTO `sys_role_menu` VALUES ('3', '7', '2023-04-22 14:13:53', NULL);
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
`username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户名',
`password` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '密码',
`nickname` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '昵称',
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '邮箱',
`phonenumber` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '电话',
`address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '地址',
`avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'http://localhost:9090/files/20230409082108000000936.jpg' COMMENT '头像',
`is_delete` tinyint(0) NULL DEFAULT 0 COMMENT '是否删除',
`create_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 38 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'admin', '202cb962ac59075b964b07152d234b70', '无所谓^_^', '2673152463@qq.com', '2673152463', '浙江省', 'http://localhost:9090/files/2023/04/22/20230422123301000000166.jpg', 0, '2022-01-22 21:10:27', '2023-04-22 00:33:05');
INSERT INTO `sys_user` VALUES (16, 'vip', '202cb962ac59075b964b07152d234b70', '小黑子', '2', '2', '2', 'http://localhost:9090/files/2023/04/22/20230422123303000000698.png', 0, '2022-02-26 22:10:14', '2023-04-22 12:20:29');
INSERT INTO `sys_user` VALUES (17, 'user', '202cb962ac59075b964b07152d234b70', '我是三三哦豁', '3', '2673152463', '3', 'https://profile.csdnimg.cn/B/7/0/1_weixin_51603038', 0, '2022-02-26 22:10:18', '2023-04-22 12:16:05');
INSERT INTO `sys_user` VALUES (18, 'nzz', '202cb962ac59075b964b07152d234b70', '哪吒', '2', '2', '2', '', 0, '2022-03-29 16:59:44', '2023-04-21 23:16:50');
INSERT INTO `sys_user` VALUES (25, 'sir', '202cb962ac59075b964b07152d234b70', '安琪拉', NULL, NULL, NULL, NULL, 0, '2022-06-08 17:00:47', '2023-04-21 23:16:50');
INSERT INTO `sys_user` VALUES (26, 'err', '202cb962ac59075b964b07152d234b70', '妲己', '11', '1', '1', NULL, 0, '2022-07-08 17:20:01', '2023-04-21 23:10:29');
INSERT INTO `sys_user` VALUES (28, 'ddd', '202cb962ac59075b964b07152d234b70', 'ddd', '', '', '', 'http://localhost:9090/file/7de0e50f915547539db12023cf997276.jpg', 0, '2022-11-09 10:41:07', '2023-04-21 23:10:29');
INSERT INTO `sys_user` VALUES (29, 'ffff', '202cb962ac59075b964b07152d234b70', 'ffff', NULL, NULL, NULL, NULL, 0, '2022-12-10 11:53:31', '2023-04-21 23:10:29');
INSERT INTO `sys_user` VALUES (36, 'aaa', '47bce5c74f589f4867dbd57e9ca9f808', NULL, NULL, NULL, NULL, 'http://localhost:9090/files/20230409082108000000936.jpg', 0, '2023-04-21 22:45:25', '2023-04-21 23:10:16');
INSERT INTO `sys_user` VALUES (37, 'fff', '343d9040a671c45832ee5381860e2996', NULL, NULL, NULL, NULL, 'http://localhost:9090/files/20230409082108000000936.jpg', 0, '2023-04-21 23:02:56', '2023-04-21 23:17:24');
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
`user_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户id',
`role_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色id',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`user_id`, `role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户-角色关联表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES ('1', '1', '2023-04-20 11:03:24', NULL);
INSERT INTO `sys_user_role` VALUES ('16', '3', '2023-04-22 12:21:54', NULL);
INSERT INTO `sys_user_role` VALUES ('17', '2', '2023-04-22 12:16:05', NULL);
INSERT INTO `sys_user_role` VALUES ('18', '2', '2023-04-22 12:30:26', NULL);
INSERT INTO `sys_user_role` VALUES ('25', '2', '2023-04-22 12:30:29', NULL);
INSERT INTO `sys_user_role` VALUES ('26', '2', '2023-04-22 12:30:34', NULL);
INSERT INTO `sys_user_role` VALUES ('28', '2', '2023-04-22 12:30:36', NULL);
INSERT INTO `sys_user_role` VALUES ('29', '2', '2023-04-22 12:30:39', NULL);
INSERT INTO `sys_user_role` VALUES ('35', '1', '2023-04-20 09:07:19', NULL);
INSERT INTO `sys_user_role` VALUES ('36', '2', '2023-04-22 12:30:41', NULL);
INSERT INTO `sys_user_role` VALUES ('37', '2', '2023-04-22 12:30:45', NULL);
SET FOREIGN_KEY_CHECKS = 1;
四、系统搭建
项目结构
建议小伙伴们去我的gitee上下载源码,然后运行。因为代码有点(优点)多,不好全部写在博客里😂
项目依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.wusuowei</groupId>
<artifactId>Shiro_Jwt</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Shiro_Jwt</name>
<description>Shiro_Jwt</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.6.13</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.20</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.13.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
<!-- md5加密 -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.6</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.50</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.wusuowei.shiro_jwt_vue.ShiroJwtApplication</mainClass>
<skip>true</skip>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
核心代码
1.JWT 工具类
主要用来生成 token、校验 token
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.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.wusuowei.shiro_jwt.model.po.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JWTUtil {
//token有效时长30分钟
private static Long EXPIRE;
//token的密钥
private static String SECRET;
//refresh-expire续期过期时间
private static Long REFRESHEXPIRE;
@Value("${jwt.expire}")
public void setExpire(Long expire){
JWTUtil.EXPIRE = expire*1000;
}
@Value("${jwt.secret}")
public void setSecret(String secret){
JWTUtil.SECRET = secret;
}
@Value("${jwt.refresh-expire}")
public void setRefreshExpire(Long refreshExpire){
JWTUtil.REFRESHEXPIRE = refreshExpire;
}
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private RedisUtil redisUtil2;
private static RedisUtil redisUtil;
@PostConstruct
public void init(){
JWTUtil.redisUtil = redisUtil2;
}
public static String createToken(User user) throws UnsupportedEncodingException {
//token过期时间
Date date=new Date(System.currentTimeMillis()+EXPIRE);
//Date now = new Date();
long now = System.currentTimeMillis();
//jwt的header部分
Map<String ,Object> map=new HashMap<>();
map.put("alg","HS256");
map.put("typ","JWT");
//使用jwt的api生成token
String token= JWT.create()
.withHeader(map)
.withClaim("uid", user.getId().toString())//私有声明
.withExpiresAt(date)//过期时间
.withIssuedAt(new Date(now))//签发时间
.sign(Algorithm.HMAC256(SECRET));//签名
redisUtil.hset("refresh",String.valueOf(user.getId()),Long.valueOf((long) Math.floor(now/1000)), REFRESHEXPIRE);
return token;
}
//校验token的有效性,1、token的header和payload是否没改过;2、没有过期
public static boolean verify(String token){
try {
//解密
JWTVerifier verifier=JWT.require(Algorithm.HMAC256(SECRET)).build();
verifier.verify(token);
return true;
}catch (TokenExpiredException e){
return true;
}catch (Exception e) {
return false;
}
}
public static boolean isJwtExpired(String token){
/**
* @desc 判断token是否过期
* @author lj
*/
try {
DecodedJWT decodeToken = JWT.decode(token);
return decodeToken.getExpiresAt().before(new Date());
} catch(Exception e){
return true;
}
}
//无需解密也可以获取token的信息
public static String getUserId(String token){
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("uid").asString();
} catch (JWTDecodeException e) {
return null;
}
}
//无需解密也可以获取token的信息
public static String getAccessToken(String token){
try {
DecodedJWT jwt = JWT.decode(token);
return String.valueOf(jwt.getIssuedAt().getTime()/1000);
} catch (JWTDecodeException e) {
return "";
}
}
}
2.JWTFilter
主要作用就是拦截请求,判断请求头中书否携带 token。如果携带,就交给 Realm 处理。
import com.wusuowei.shiro_jwt.model.po.User;
import com.wusuowei.shiro_jwt.shiro.JWTToken;
import com.wusuowei.shiro_jwt.utils.JWTUtil;
import com.wusuowei.shiro_jwt.utils.RedisUtil;
import com.wusuowei.shiro_jwt.utils.SpringContextUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
@Slf4j
public class JWTFilter extends BasicHttpAuthenticationFilter {
//是否允许访问,如果带有 token,则对 token 进行检查,否则直接通过
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//判断请求的请求头是否带上 "Token"
if (isLoginAttempt(request, response)) {
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
log.info("认证出错");
responseError(response, e.getMessage()); //这里就不进行跳转了,直接全局异常捕获
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
return true;
}
/**
* 判断用户的请求是否为认证。
* 检测 header 里面是否包含 Token 字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
System.out.println("是认证请求isLoginAttempt");
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader("token");
return token != null;
}
/*
* executeLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token
* 然后调用getSubject方法来获取当前用户再调用login方法来实现登录
* 这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。
* */
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("executeLogin");
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String token = req.getHeader("token");
JWTToken jwt = null;
String newJwtToken = getNewJwtToken(req, res, token);
if (newJwtToken != null) {
token = newJwtToken;
}
jwt = new JWTToken(token);
//交给自定义的realm对象去登录,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwt);
return true;
}
/**
* @description token续期
* @param request 要求
* @param response 回答
* @param token 令牌
* @return {@link String }
* @author LGY
* @date 2023/04/18 19:44
*/
private String getNewJwtToken(HttpServletRequest request, HttpServletResponse response, String token) throws Exception {
RedisUtil redisUtil = SpringContextUtils.getBean(RedisUtil.class);
String uid = null;
try {
uid = JWTUtil.getUserId(token);
} catch (Exception e) {
throw new AuthenticationException("token非法,不是规范的token,可能被篡改了");
}
if (!JWTUtil.verify(token) || uid == null) {
throw new AuthenticationException("token认证失效,token错误或者过期,请重新登陆");
}
String refreshToken = String.valueOf(redisUtil.hget("refresh",uid));
String accessToken = JWTUtil.getAccessToken(token);
if (StringUtils.isBlank(refreshToken) || !accessToken.equals(refreshToken)) {
throw new AuthenticationException("token过期,请重新登陆");
}
//token续期
if (JWTUtil.isJwtExpired(token) && accessToken.equals(refreshToken)) {
//生成新token
User user = new User();
user.setId(Integer.valueOf(uid));
token = JWTUtil.createToken(user);
log.info("token续期成功:" + token);
response.addHeader("refreshtoken", token);
response.setHeader("Access-Control-Expose-Headers", "refreshtoken");
return token;
}
return null;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("preHandle");
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
res.setHeader("Access-control-Allow-Origin", req.getHeader("Origin"));
res.setHeader("Access-control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
res.setHeader("Access-control-Allow-Headers", req.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
res.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 将非法请求跳转到 /unauthorized/**
*/
private void responseError(ServletResponse response, String message) {
System.out.println("responseError");
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
//设置编码,否则中文字符在重定向时会变为空字符串
message = URLEncoder.encode(message, "UTF-8");
httpServletResponse.sendRedirect("/unauthorized/" + message);
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
}
3.JwtToken
shiro 在没有和 jwt 整合之前,用户的账号密码被封装成了 UsernamePasswordToken 对象,UsernamePasswordToken 其实是 AuthenticationToken 的实现类。这里既然要和 jwt 整合,JWTFilter 传递给 Realm 的 token 必须是 AuthenticationToken 的实现类。
import org.apache.shiro.authc.AuthenticationToken;
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;
}
}
4.自定义 Realm
继承 AuthorizingRealm从数据库中读取用户数据,实现认证和授权两个方法
import com.wusuowei.shiro_jwt.model.po.Menu;
import com.wusuowei.shiro_jwt.model.po.Role;
import com.wusuowei.shiro_jwt.model.po.User;
import com.wusuowei.shiro_jwt.service.MenuService;
import com.wusuowei.shiro_jwt.service.RoleService;
import com.wusuowei.shiro_jwt.service.UserService;
import com.wusuowei.shiro_jwt.utils.JWTUtil;
import com.wusuowei.shiro_jwt.utils.RedisUtil;
import org.apache.commons.lang3.StringUtils;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class MyRealm extends AuthorizingRealm {
@Value("${jwt.refresh-expire}")
//refresh-expire续期过期时间
private Long REFRESHEXPIRE;
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private MenuService menuService;
@Autowired
private RedisUtil redisUtil;
//根据token判断此Authenticator是否使用该realm
//必须重写不然shiro会报错
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如@RequiresRoles,@RequiresPermissions之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
System.out.println("授权~~~~~");
User user = (User) principals.getPrimaryPrincipal();
String uid = String.valueOf(user.getId());
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
List<Role> redisRoles = (List<Role>) redisUtil.hget("userPower", "roles:"+uid);
List<Menu> redisPermissions = (List<Menu>) redisUtil.hget("userPower", "permission:"+uid);
if (redisRoles != null && redisPermissions != null) {
info.addRoles(redisRoles.stream().map(Role::getRoleKey).collect(Collectors.toSet()));
info.addStringPermissions(redisPermissions.stream().filter(item -> StringUtils.isNotBlank(item.getPermission())).map(Menu::getPermission).collect(Collectors.toSet()));
return info;
}
//查询数据库来获取用户的角色
List<Role> roles = roleService.getRoles(uid);
info.addRoles(roles.stream().map(Role::getRoleKey).collect(Collectors.toSet()));
//查询数据库来获取用户的权限
List<Menu> permissions = menuService.getPermissionByUid(uid);
Set<String> collect = permissions.stream().filter(item -> StringUtils.isNotBlank(item.getPermission())).map(Menu::getPermission).collect(Collectors.toSet());
info.addStringPermissions(collect);
redisUtil.hset("userPower", "roles:" + uid, roles, REFRESHEXPIRE);
redisUtil.hset("userPower", "permissions:" + uid, collect, REFRESHEXPIRE);
return info;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可,在需要用户认证和鉴权的时候才会调用
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
System.out.println("认证~~~~~~~");
String jwt = (String) token.getCredentials();
String uid = JWTUtil.getUserId(jwt);
User redisUser = (User) redisUtil.get("userInfo:" + uid);
if (redisUser != null) {
return new SimpleAuthenticationInfo(redisUser, jwt, "MyRealm");
}
User user = userService.getById(uid);
if (user == null) {
throw new AuthenticationException("该用户不存在");
}
redisUtil.hset("userInfo",uid, user, REFRESHEXPIRE);
return new SimpleAuthenticationInfo(user, jwt, "MyRealm");
}
}
5.配置Shiro
ShiroConfig主要配置了:过滤器、安全管理器和不进行拦截的路径,比如登录
import com.wusuowei.shiro_jwt.filter.JWTFilter;
import com.wusuowei.shiro_jwt.shiro.MyRealm;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
@Configuration
public class ShiroConfig {
@Bean(name = "securityManager")
public DefaultWebSecurityManager securityManager(MyRealm myRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置自定义 realm.
securityManager.setRealm(myRealm);
//关闭session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/**
* 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
*/
@Bean
public ShiroFilterFactoryBean factory(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
// 添加自己的过滤器并且取名为jwt
LinkedHashMap<String, Filter> filterMap = new LinkedHashMap<>();
//设置我们自定义的JWT过滤器
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
// 设置无权限时跳转的 url;
factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
LinkedHashMap<String, String> filterRuleMap = new LinkedHashMap<>();
// 访问 /unauthorized/** 不通过JWTFilter
filterRuleMap.put("/unauthorized/**", "anon");
filterRuleMap.put("/login", "anon");
filterRuleMap.put("/register", "anon");
filterRuleMap.put("/check", "anon");
filterRuleMap.put("/files/**", "anon");
filterRuleMap.put("/test2", "anon");
// filterRuleMap.put("/logout", "anon");
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 添加注解支持,如果不加的话很有可能注解失效
*/
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
}
五、项目核心逻辑介绍
1.jwt无状态登录
在微服务中我们一般采用的是无状态登录,而传统的session方式,在前后端分离的微服务架构下,如继续使用则必须要解决跨域sessionId问题、集群session共享问题等等。这显然是费力不讨好的事,而整合shiro,它的默认实现就是通过session的方式。
原因:
(1)shiro默认的拦截跳转都是跳转url页面,而前后端分离后,后端并无权干涉页面跳转。
(2)shiro默认使用的登录拦截校验机制恰恰就是使用的session。
解决:在这个系统中,我通过在ShiroConfig中配置filterRuleMap.put("/login", "anon");放行登录请求,然后登录成功后生成token并返回给前端,之后前端的每次请求都携带这个token,后端的JWTFilter进行过滤判断,然后通过调用getSubject(request, response).login(jwt);交给MyRealm进行认证、授权。
2.token可控
为什么要token可控。因为如果用户登录好几次,拿到很多token,用户就可以通过这些token(没有过期且正确)中的任意一个进行访问。但是如果我们想控制用户的登录,实现一些功能,比如让能统计在线人数,就需要实现token的可控。
解决:登录认证通过后返回AccessToken信息(在AccessToken中保存当前的时间和用户id),同时在Redis中设置一条Key为用户id,Value为当前时间戳(登录时间和token中的一样)的RefreshToken
核心代码在JWTUtil的createToken方法中
//使用jwt的api生成token
String token= JWT.create()
.withHeader(map)
.withClaim("uid", user.getId().toString())//私有声明
.withExpiresAt(date)//过期时间
.withIssuedAt(new Date(now))//签发时间
.sign(Algorithm.HMAC256(SECRET));//签名
redisUtil.hset("refreshToken",String.valueOf(user.getId()),Long.valueOf((long) Math.floor(now/1000)), REFRESHTOKENEXPIRE);
现在认证时必须AccessToken没被篡改过以及Redis存在所对应的RefreshToken,且RefreshToken时间戳和AccessToken信息中时间戳一致才算认证通过,这样可以做到JWT的可控性,如果重新登录获取了新的AccessToken,旧的AccessToken就认证不了,因为Redis中所存放的的RefreshToken时间戳信息只会和最新的AccessToken信息中携带的时间戳一致,这样每个用户就只能使用最新的AccessToken认证。
核心代码在JWTFilter中的getNewJwtToken方法中
if (!JWTUtil.verify(token) || uid == null) {
throw new AuthenticationException("token认证失效,请重新登陆");
}
String refreshToken = String.valueOf(redisUtil.hget("refreshToken",uid));
String accessToken = JWTUtil.getAccessToken(token);
if (StringUtils.isBlank(refreshToken) || !accessToken.equals(refreshToken)) {
throw new AuthenticationException("token过期,请重新登陆");
}
3.token续期
如果用户正在访问我们的网站,突然token过期了,这时用户只能重新登录获取新的token进行访问,这样的用户体验肯定不好。
解决:1. 本身AccessToken的过期时间为5分钟,RefreshToken过期时间为30分钟,当登录后时间过了5分钟之后,当前AccessToken便会过期失效,再次带上AccessToken访问判断是否过期,如果过期,开始判断是否要进行AccessToken刷新,首先redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致就进行AccessToken刷新。
2. 刷新后新的AccessToken过期时间依旧为5分钟,时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期,最终将刷新的AccessToken设置到在Response的Header中的refreshToken字段返回。
3. 同时前端进行获取替换,下次用新的AccessToken进行访问即可。
核心代码还是在JWTFilter中的getNewJwtToken方法中
//token续期
if (JWTUtil.isJwtExpired(token) && accessToken.equals(refreshToken)) {
//生成新token
User user = new User();
user.setId(Integer.valueOf(uid));
token = JWTUtil.createToken(user);
log.info("token续期成功:" + token);
response.addHeader("refreshToken", token);
response.setHeader("Access-Control-Expose-Headers", "refreshToken");
return token;
}
4.redis缓存数据
在MyRealm中用Redis对认证、授权数据进行缓存,不然每次请求都会去查询数据库。
小结
本文的所有源码包含前端我都放在我的gitee上了,大家可以下载下来作为自己项目的后台管理系统。下一篇我会用这个系统实现各种文件的上传下载预览,包括分片上传和断点续传,具体会整合minio和kkViewFile。
创作不易,可以的话,给作者施舍一个三连吧😶🌫️😶🌫️😶🌫️
更多推荐