包含前后端技术栈、功能介绍、角色权限设置及数据库设计等内容,附源码下载链接,是学习权限管理系统的优质资源。
前言
最近一直在学习权限框架,光学不敲,那肯定不行,所以有了这个项目。项目实现了jwt无状态登录、redis缓存、token续期和可控。算是个比较通用且有亮点的权限管理项目吧。🤭
一、项目介绍
1.运行
项目下载
gitee: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.yssmx.com/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对认证、授权数据进行缓存,不然每次请求都会去查询数据库。文章来源:https://www.toymoban.com/news/detail-457691.html
小结
本文的所有源码包含前端我都放在我的gitee上了,大家可以下载下来作为自己项目的后台管理系统。下一篇我会用这个系统实现各种文件的上传下载预览,包括分片上传和断点续传,具体会整合minio和kkViewFile。文章来源地址https://www.toymoban.com/news/detail-457691.html
到了这里,关于从零开发一个自己的Shiro+Vue通用后台管理系统(附源码)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!