SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 后端篇(五): 数据表设计、使用 jwt、redis、sms 工具类完善注册登录逻辑
(1) 相关博文地址:
SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 前端篇(一):搭建基本环境:https://www.cnblogs.com/l-y-h/p/12930895.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 前端篇(二):引入 element-ui 定义基本页面显示:https://www.cnblogs.com/l-y-h/p/12935300.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 前端篇(三):引入 js-cookie、axios、mock 封装请求处理以及返回结果:https://www.cnblogs.com/l-y-h/p/12955001.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 前端篇(四):引入 vuex 进行状态管理、引入 vue-i18n 进行国际化管理:https://www.cnblogs.com/l-y-h/p/12963576.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 前端篇(五):引入 vue-router 进行路由管理、模块化封装 axios 请求、使用 iframe 标签嵌套页面:https://www.cnblogs.com/l-y-h/p/12973364.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 前端篇(六):使用 vue-router 进行动态加载菜单:https://www.cnblogs.com/l-y-h/p/13052196.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 后端篇(一): 搭建基本环境、整合 Swagger、MyBatisPlus、JSR303 以及国际化操作:https://www.cnblogs.com/l-y-h/p/13083375.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 后端篇(二): 整合 Redis(常用工具类、缓存)、整合邮件发送功能:https://www.cnblogs.com/l-y-h/p/13163653.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 后端篇(三): 整合阿里云 OSS 服务 -- 上传、下载文件、图片:https://www.cnblogs.com/l-y-h/p/13202746.html SpringBoot + Vue + ElementUI 实现后台管理系统模板 -- 后端篇(四): 整合阿里云 短信服务、整合 JWT 单点登录:https://www.cnblogs.com/l-y-h/p/13214493.html
(2)代码地址:
https://github.com/lyh-man/admin-vue-template.git
一、数据表设计
1、需求分析
(1)目的:
由于此项目作为一个后台管理系统模板,不同用户登录后应该有不同的操作权限,所以此处实现一个简单的菜单权限控制。即不同用户登录系统后,会展示不同的菜单,并对菜单具有操作(增删改查)的权限。
(2)数据表设计(自己瞎捣鼓的,有不对的地方还望 DBA 大神不吝赐教(=_=)):
需求:
一个用户登录系统后,根据其所代表的的角色,去查询其对应的菜单权限,并返回相应的菜单数据。
整个设计核心可以分为:用户、用户角色(下面简称角色)、菜单权限(下面简称菜单)。
思考一:
一个用户只拥有一个角色,一个角色可以被多个用户拥有。
一个角色可以有多个菜单,一个菜单可以被多个角色拥有。
即 角色 与 用户间为 1 对 多关系,角色 与 菜单 间为 多对多关系。
所以可以在用户表中定义一个字段作为外键 关联到 角色表。
而角色表 与 菜单表 采用 中间表去维护。
思考二:
一个用户可以有多个角色,一个角色可以被多个用户拥有。
一个角色可以有多个菜单,一个菜单可以被多个角色拥有。
即 菜单 与 角色 间属于 多对多关系,用户 与 角色间 也属于 多对多关系。
所以 用户表 与 角色表间、角色表 与 菜单表间均可以采用 中间表维护。
为了避免使用外键,此处我均采用中间表对三张表进行数据关联。
最终设计(三个主表,两个中间表):
用户表 sys_user
用户角色表 sys_user_role
角色表 sys_role
角色菜单表 sys_role_menu
菜单表 sys_menu
2、用户表(sys_user)设计
(1)必须字段:
用户 ID、用户名、用户手机号、用户密码。
其中:
用户手机号 作为用户注册、登录的依据(用户名也可以登录)。
用户名为 用户登录后显示的 昵称。
用户密码 需要密文存储(此项目中 前端、后端均对密码进行 MD5 加密处理)。
(2)数据表结构如下:
-- DROP DATABASE IF EXISTS admin_template; -- -- CREATE DATABASE admin_template; -- --------------------------sys_user 用户表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_user; -- 用户表 CREATE TABLE sys_user ( id bigint NOT NULL COMMENT '用户 ID', name varchar(20) NOT NULL COMMENT '用户名', mobile varchar(20) NOT NULL COMMENT '用户手机号', password varchar(64) NOT NULL COMMENT '用户密码', sex tinyint DEFAULT NULL COMMENT '性别, 0 表示女, 1 表示男', age tinyint DEFAULT NULL COMMENT '年龄', avatar varchar(255) DEFAULT NULL COMMENT '头像', email varchar(100) DEFAULT NULL COMMENT '邮箱', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', disabled_flag tinyint DEFAULT NULL COMMENT '禁用标志, 0 表示未禁用, 1 表示禁用', wx_id varchar(128) DEFAULT NULL COMMENT '微信 openid(拓展字段、用于第三方微信登录)', qq_id varchar(128) DEFAULT NULL COMMENT 'QQ openid(拓展字段、用于第三方 QQ 登录)', PRIMARY KEY(id), UNIQUE INDEX(name), UNIQUE INDEX(mobile) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户表'; -- 插入数据 INSERT INTO `sys_user`(`id`, `name`, `mobile`, `password`, `sex`, `age`, `avatar`, `email`, `create_time`, `update_time`, `delete_flag`, `disabled_flag`, `wx_id`, `qq_id`) VALUES (1278601251755454466, 'superAdmin', '17730125031', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL), (1278601251755451232, 'admin', '17730125032', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL), (1278601251755456778, 'jack', '17730125033', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL); -- --------------------------sys_user 用户表---------------------------------------
3、角色表(sys_role)设计
(1)必须字段:
角色 ID,角色名称。
其中:
角色名称用于定位用户角色。
(2)数据表结构如下:
-- DROP DATABASE IF EXISTS admin_template; -- -- CREATE DATABASE admin_template; -- --------------------------sys_role 角色表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_role; -- 系统用户角色表 CREATE TABLE sys_role ( id bigint NOT NULL COMMENT '角色 ID', role_name varchar(20) NOT NULL COMMENT '角色名称', role_code varchar(20) DEFAULT NULL COMMENT '角色码', remark varchar(255) DEFAULT NULL COMMENT '角色备注', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表'; -- 插入数据 INSERT INTO `sys_role`(`id`, `role_name`, `role_code`, `remark`, `create_time`, `update_time`, `delete_flag`) VALUES (1278601251755451245, 'superAdmin', '1001', '超级管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755452551, 'admin', '2001', '普通管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755458779, 'user', '3001', '普通用户','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_role 角色表---------------------------------------
4、菜单权限表(sys_menu)设计
(1)必须字段:
当前菜单 ID,父菜单 ID,菜单名,菜单类型,菜单路径
其中:
当前菜单 ID 与 父菜单 ID 用于确定菜单的层级顺序。
菜单类型 用于确定是否显示在菜单目录中(按钮不显示在菜单目录中)。
菜单路径 用于确定最终指向的 组件路径(使用 vue-route 进行路由跳转)。
注:
最外层 父菜单 ID 此处设置为 0,但不创建 ID 为 0 的数据。
(2)数据表结构如下:
-- DROP DATABASE IF EXISTS admin_template; -- -- CREATE DATABASE admin_template; -- --------------------------sys_menu 菜单权限表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_menu; -- 系统菜单权限表 CREATE TABLE sys_menu ( menu_id bigint NOT NULL COMMENT '当前菜单 ID', parent_id bigint NOT NULL COMMENT '当前菜单父菜单 ID', name_zh varchar(20) NOT NULL COMMENT '中文菜单名称', name_en varchar(40) NOT NULL COMMENT '英文菜单名称', type tinyint NOT NULL COMMENT '菜单类型,0 表示目录,1 表示菜单项,2 表示按钮', url varchar(100) NOT NULL COMMENT '访问路径', icon varchar(100) DEFAULT NULL COMMENT '菜单图标', order_num int DEFAULT NULL COMMENT '菜单项顺序', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(menu_id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统菜单权限表'; -- 插入数据 INSERT INTO `sys_menu`(`menu_id`, `parent_id`, `name_zh`, `name_en`, `type`, `url`, `icon`, `order_num`, `create_time`, `update_time`, `delete_flag`) VALUES (127860125171111, 0, '系统管理', 'System Control', 0, '', 'el-icon-setting', 0,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172211, 127860125171111, '用户管理', 'User Control', 1, 'sys/UserList', 'el-icon-user', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173311, 127860125171111, '角色管理', 'Role Control', 1, 'sys/RoleControl', 'el-icon-price-tag', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174411, 127860125171111, '菜单管理', 'Menu Control', 1, 'sys/MenuControl', 'el-icon-menu', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172221, 127860125172211, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172231, 127860125172211, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172241, 127860125172211, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172251, 127860125172211, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173321, 127860125173311, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173331, 127860125173311, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173341, 127860125173311, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173351, 127860125173311, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174421, 127860125174411, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174431, 127860125174411, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174441, 127860125174411, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174451, 127860125174411, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125175511, 0, '帮助', 'help', 0, '', 'el-icon-info', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125175521, 127860125175511, '百度', 'Baidu', 1, 'https://www.baidu.com/', 'el-icon-menu', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125175531, 127860125175511, '博客', 'Blog', 1, 'https://www.cnblogs.com/l-y-h/', 'el-icon-menu', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_menu 菜单权限表---------------------------------------
5、中间表设计(sys_user_role、sys_role_menu)
(1)设计原则:
中间表存储的是相关联两表的主键。
(2)用户角色表如下:
-- DROP DATABASE IF EXISTS admin_template; -- -- CREATE DATABASE admin_template; -- --------------------------sys_user_role 用户角色表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_user_role; -- 系统用户角色表 CREATE TABLE sys_user_role ( id bigint NOT NULL COMMENT '用户角色表 ID', role_id bigint NOT NULL COMMENT '角色 ID', user_id bigint NOT NULL COMMENT '用户 ID', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表'; -- 插入数据 INSERT INTO `sys_user_role`(`id`, `role_id`, `user_id`, `create_time`, `update_time`, `delete_flag`) VALUES (1278601251755452234, '1278601251755451245', '1278601251755454466', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755453544, '1278601251755452551', '1278601251755451232', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755454664, '1278601251755458779', '1278601251755456778', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_user_role 用户角色表---------------------------------------
(3)角色菜单表如下:
-- DROP DATABASE IF EXISTS admin_template; -- -- CREATE DATABASE admin_template; -- --------------------------sys_role_menu 系统角色菜单表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_role_menu; -- 系统角色菜单表 CREATE TABLE sys_role_menu ( id bigint NOT NULL COMMENT '角色菜单表 ID', role_id bigint NOT NULL COMMENT '角色 ID', menu_id varchar(20) NOT NULL COMMENT '菜单 ID', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统角色菜单表'; -- 插入数据 INSERT INTO `sys_role_menu`(`id`, `role_id`, `menu_id`, `create_time`, `update_time`, `delete_flag`) VALUES (1278601251755461111, '1278601251755451245', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461112, '1278601251755451245', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461113, '1278601251755451245', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461114, '1278601251755451245', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461115, '1278601251755451245', '1278601251755452221', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461116, '1278601251755451245', '1278601251755452231', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461117, '1278601251755451245', '1278601251755452241', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461118, '1278601251755451245', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461119, '1278601251755451245', '1278601251755453321', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461120, '1278601251755451245', '1278601251755453331', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461121, '1278601251755451245', '1278601251755453341', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461122, '1278601251755451245', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461123, '1278601251755451245', '1278601251755454421', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461124, '1278601251755451245', '1278601251755454431', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461125, '1278601251755451245', '1278601251755454441', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461126, '1278601251755451245', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461127, '1278601251755451245', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461128, '1278601251755451245', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461129, '1278601251755451245', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462111, '1278601251755452551', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462112, '1278601251755452551', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462113, '1278601251755452551', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462114, '1278601251755452551', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462115, '1278601251755452551', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462116, '1278601251755452551', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462117, '1278601251755452551', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462118, '1278601251755452551', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462119, '1278601251755452551', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462120, '1278601251755452551', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755463111, '1278601251755458779', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755463112, '1278601251755458779', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755463113, '1278601251755458779', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_role_menu 系统角色菜单表---------------------------------------
6、完整表结构以及相关数据插入
-- DROP DATABASE IF EXISTS admin_template; -- -- CREATE DATABASE admin_template; -- --------------------------sys_user 用户表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_user; -- 用户表 CREATE TABLE sys_user ( id bigint NOT NULL COMMENT '用户 ID', name varchar(20) NOT NULL COMMENT '用户名', mobile varchar(20) NOT NULL COMMENT '用户手机号', password varchar(64) NOT NULL COMMENT '用户密码', sex tinyint DEFAULT NULL COMMENT '性别, 0 表示女, 1 表示男', age tinyint DEFAULT NULL COMMENT '年龄', avatar varchar(255) DEFAULT NULL COMMENT '头像', email varchar(100) DEFAULT NULL COMMENT '邮箱', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', disabled_flag tinyint DEFAULT NULL COMMENT '禁用标志, 0 表示未禁用, 1 表示禁用', wx_id varchar(128) DEFAULT NULL COMMENT '微信 openid(拓展字段、用于第三方微信登录)', qq_id varchar(128) DEFAULT NULL COMMENT 'QQ openid(拓展字段、用于第三方 QQ 登录)', PRIMARY KEY(id), UNIQUE INDEX(name, mobile) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户表'; -- 插入数据 INSERT INTO `sys_user`(`id`, `name`, `mobile`, `password`, `sex`, `age`, `avatar`, `email`, `create_time`, `update_time`, `delete_flag`, `disabled_flag`, `wx_id`, `qq_id`) VALUES (1278601251755454466, 'superAdmin', '17730125031', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL), (1278601251755451232, 'admin', '17730125032', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL), (1278601251755456778, 'jack', '17730125033', 'e10adc3949ba59abbe56e057f20f883e', 1, 23, NULL, "m_17730125031@163.com", '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0, 0, NULL, NULL); -- --------------------------sys_user 用户表--------------------------------------- -- --------------------------sys_role 角色表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_role; -- 系统用户角色表 CREATE TABLE sys_role ( id bigint NOT NULL COMMENT '角色 ID', role_name varchar(20) NOT NULL COMMENT '角色名称', role_code varchar(20) DEFAULT NULL COMMENT '角色码', remark varchar(255) DEFAULT NULL COMMENT '角色备注', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表'; -- 插入数据 INSERT INTO `sys_role`(`id`, `role_name`, `role_code`, `remark`, `create_time`, `update_time`, `delete_flag`) VALUES (1278601251755451245, 'superAdmin', '1001', '超级管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755452551, 'admin', '2001', '普通管理员','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755458779, 'user', '3001', '普通用户','2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_role 角色表--------------------------------------- -- --------------------------sys_user_role 用户角色表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_user_role; -- 系统用户角色表 CREATE TABLE sys_user_role ( id bigint NOT NULL COMMENT '用户角色表 ID', role_id bigint NOT NULL COMMENT '角色 ID', user_id bigint NOT NULL COMMENT '用户 ID', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统用户角色表'; -- 插入数据 INSERT INTO `sys_user_role`(`id`, `role_id`, `user_id`, `create_time`, `update_time`, `delete_flag`) VALUES (1278601251755452234, '1278601251755451245', '1278601251755454466', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755453544, '1278601251755452551', '1278601251755451232', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755454664, '1278601251755458779', '1278601251755456778', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_user_role 用户角色表--------------------------------------- -- --------------------------sys_menu 菜单权限表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_menu; -- 系统菜单权限表 CREATE TABLE sys_menu ( menu_id bigint NOT NULL COMMENT '当前菜单 ID', parent_id bigint NOT NULL COMMENT '当前菜单父菜单 ID', name_zh varchar(20) NOT NULL COMMENT '中文菜单名称', name_en varchar(40) NOT NULL COMMENT '英文菜单名称', type tinyint NOT NULL COMMENT '菜单类型,0 表示目录,1 表示菜单项,2 表示按钮', url varchar(100) NOT NULL COMMENT '访问路径', icon varchar(100) DEFAULT NULL COMMENT '菜单图标', order_num int DEFAULT NULL COMMENT '菜单项顺序', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(menu_id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统菜单权限表'; -- 插入数据 INSERT INTO `sys_menu`(`menu_id`, `parent_id`, `name_zh`, `name_en`, `type`, `url`, `icon`, `order_num`, `create_time`, `update_time`, `delete_flag`) VALUES (127860125171111, 0, '系统管理', 'System Control', 0, '', 'el-icon-setting', 0,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172211, 127860125171111, '用户管理', 'User Control', 1, 'sys/UserList', 'el-icon-user', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173311, 127860125171111, '角色管理', 'Role Control', 1, 'sys/RoleControl', 'el-icon-price-tag', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174411, 127860125171111, '菜单管理', 'Menu Control', 1, 'sys/MenuControl', 'el-icon-menu', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172221, 127860125172211, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172231, 127860125172211, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172241, 127860125172211, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125172251, 127860125172211, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173321, 127860125173311, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173331, 127860125173311, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173341, 127860125173311, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125173351, 127860125173311, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174421, 127860125174411, '添加', 'Add', 2, '', '', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174431, 127860125174411, '删除', 'Delete', 2, '', '', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174441, 127860125174411, '修改', 'Update', 2, '', '', 3,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125174451, 127860125174411, '查看', 'List', 2, '', '', 4,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125175511, 0, '帮助', 'help', 0, '', 'el-icon-info', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125175521, 127860125175511, '百度', 'Baidu', 1, 'https://www.baidu.com/', 'el-icon-menu', 1,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (127860125175531, 127860125175511, '博客', 'Blog', 1, 'https://www.cnblogs.com/l-y-h/', 'el-icon-menu', 2,'2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_menu 菜单权限表--------------------------------------- -- --------------------------sys_role_menu 系统角色菜单表--------------------------------------- USE admin_template; DROP TABLE IF EXISTS sys_role_menu; -- 系统角色菜单表 CREATE TABLE sys_role_menu ( id bigint NOT NULL COMMENT '角色菜单表 ID', role_id bigint NOT NULL COMMENT '角色 ID', menu_id varchar(20) NOT NULL COMMENT '菜单 ID', create_time datetime DEFAULT NULL COMMENT '创建时间', update_time datetime DEFAULT NULL COMMENT '修改时间', delete_flag tinyint DEFAULT NULL COMMENT '逻辑删除标志,0 表示未删除, 1 表示删除', PRIMARY KEY(id) ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COMMENT='系统角色菜单表'; -- 插入数据 INSERT INTO `sys_role_menu`(`id`, `role_id`, `menu_id`, `create_time`, `update_time`, `delete_flag`) VALUES (1278601251755461111, '1278601251755451245', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461112, '1278601251755451245', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461113, '1278601251755451245', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461114, '1278601251755451245', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461115, '1278601251755451245', '1278601251755452221', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461116, '1278601251755451245', '1278601251755452231', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461117, '1278601251755451245', '1278601251755452241', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461118, '1278601251755451245', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461119, '1278601251755451245', '1278601251755453321', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461120, '1278601251755451245', '1278601251755453331', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461121, '1278601251755451245', '1278601251755453341', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461122, '1278601251755451245', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461123, '1278601251755451245', '1278601251755454421', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461124, '1278601251755451245', '1278601251755454431', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461125, '1278601251755451245', '1278601251755454441', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461126, '1278601251755451245', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461127, '1278601251755451245', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461128, '1278601251755451245', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755461129, '1278601251755451245', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462111, '1278601251755452551', '1278601251755451111', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462112, '1278601251755452551', '1278601251755452211', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462113, '1278601251755452551', '1278601251755453311', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462114, '1278601251755452551', '1278601251755454411', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462115, '1278601251755452551', '1278601251755452251', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462116, '1278601251755452551', '1278601251755453351', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462117, '1278601251755452551', '1278601251755454451', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462118, '1278601251755452551', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462119, '1278601251755452551', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755462120, '1278601251755452551', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755463111, '1278601251755458779', '1278601251755455511', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755463112, '1278601251755458779', '1278601251755455521', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0), (1278601251755463113, '1278601251755458779', '1278601251755455531', '2020-07-02 16:07:48', '2020-07-02 16:07:48', 0); -- --------------------------sys_role_menu 系统角色菜单表---------------------------------------
二、完善注册登录逻辑
1、注册、登录需求分析:
(1)用户种类:
超级管理员、普通管理员、普通用户。
其中:
通过注册方式创建的用户均为 普通用户。
普通管理员由超级管理员创建。
超级管理员使用 系统默认的数据(不可创建、修改)。
默认:
普通用户 -- 账号:jack 密码:123456
普通管理员 -- 账号:admin 密码:123456
超级管理员 -- 账号:superAdmin 密码:123456
(2)注册需求:
输入用户名、密码,并根据 手机号 发送验证码进行注册。
其中:
用户名 不能为 纯数字 组成 或者 包含 @ 符号(为了与手机号、邮箱进行区分)。
密码前后端均采用 MD5 加密,两次加密。
验证码时效性为 5 分钟(此项目中借用 redis 进行过期时间控制)。
(3)登录需求:
登录方式:密码登录、短信登录。
其中:
短信登录 是根据 手机号以及验证码 进行登录(跳过密码输入操作)。
密码登录 是根据 手机号 或者 用户名 加密码 的方式进行登录。
登录时提供忘记密码功能,根据手机号重置密码。
登录时限制同一账号登陆人数。
注:
此项目中限制同一账号登陆人数为 1 人,即同时只允许一个 账号登陆系统。
实现限制同一账号登陆人数思路:
并发执行时,存在同一个用户在多处同时登陆,此处为了限制只能允许一个人登陆系统,使用 redis 进行辅助。其中 key 为 用户名(或者 ID 值)、 value 为 token 值(JWT 值)。
用户第一次访问系统时,首先判定是否为第一次登录系统(检查 redis 中是否存在 token),不存在则为第一次登录,需要将 token 存入 redis 中,并将该 token 返回给用户。存在则继续判定是否为重复登录系统(检查 token 是否一致)。token 一致,则为同一用户再次访问系统。token 不一致,则用户为重复登录系统,此时需要剔除前一个登录用户(比较当前 token 与 redis 中 token 的时间戳),如果当前 token 时间戳 大于等于 redis 中 token 时间戳,则当前时间戳为最新登录者,此时剔除 redis 中的 token 数据(即将 当前 token 数据存入 redis),如果 小于 redis 中 token 时间戳,则 redis 中 token 为最新登录者,需剔除当前 token(不返回 token 给用户,即登录失败,引导用户重新登录)。
注意:
此处为了实现效果,还需要修改 单点登录 逻辑,之前单点登录逻辑中,根据 token 可以直接解析出 用户信息。
但是在此处 token 并不一定有效,因为存在同一用户在多处登录,每一次登录均会产生一个 token(定义拦截器,拦截除了登录请求外的所有请求,这样使每次登录请求均能产生 token,非登录请求验证是否存在 token),此时为了限制只允许一人登录,即只有一个 token 生效。
需要与 redis 中存储的 token 比较后才可确认。若 两者 token 不同,需引导用户重新进行登录操作,并将最新的 token 存入 redis(感觉代码好像变得有点冗余了(=_=),毕竟每次还得与 redis 进行交互,有更方便的方法还望不吝赐教)。
2、生成基本代码
(1)使用 mybatis-plus 代码生成器根据 sys_user 表生成基本代码。
此处不再重复截图,详细使用过程参考:
https://www.cnblogs.com/l-y-h/p/13083375.html#_label2_1
此处只截细节部分:
Step1:
修改实体类,添加 @TableField(用于自动填充)、@TableLogic(用于逻辑删除) 注解。
Step2:
由于新增了填充字段 disabledFlag,所以需给其添加填充规则。
Step3:
修改 mapper 扫描路径,此处可以使用通配符 **(只用一个 * 不生效时使用两个 **)。
3、编写一个工具类( Md5Util.java) 用于加密密码
(1)目的
此项目中使用 MD5 进行密码加密,使用其他方式亦可。
此加密方式网上随便搜搜就可以搜的到,代码实现也不尽相同,此处代码来源于网络。
(2)代码实现如下:
package com.lyh.admin_template.back.common.utils; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public class MD5Util { public static String encrypt(String strSrc) { try { char hexChars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; byte[] bytes = strSrc.getBytes(); MessageDigest md = MessageDigest.getInstance("MD5"); md.update(bytes); bytes = md.digest(); int j = bytes.length; char[] chars = new char[j * 2]; int k = 0; for (int i = 0; i < bytes.length; i++) { byte b = bytes[i]; chars[k++] = hexChars[b >>> 4 & 0xf]; chars[k++] = hexChars[b & 0xf]; } return new String(chars); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5加密出错!!+" + e); } } }
4、调整 JWT 工具类、SMS 工具类
(1)目的:
之前考虑的有点欠缺,这两个工具类使用起来有点问题,稍作修改。
(2)修改 JWT 工具类 JwtUtil.java
主要修改 自定义数据 的方式,以及自定义 过期时间。
package com.lyh.admin_template.back.common.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.apache.commons.lang3.StringUtils; import javax.servlet.http.HttpServletRequest; import java.util.Date; /** * JWT 操作工具类 */ public class JwtUtil { // 设置默认过期时间(15 分钟) private static final long DEFAULT_EXPIRE = 1000L * 60 * 15; // 设置 jwt 生成 secret(随意指定) private static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO"; /** * 生成 jwt token,并指定默认过期时间 15 分钟 */ public static String getJwtToken(Object data) { return getJwtToken(data, DEFAULT_EXPIRE); } /** * 生成 jwt token,根据指定的 过期时间 */ public static String getJwtToken(Object data, Long expire) { String JwtToken = Jwts.builder() // 设置 jwt 类型 .setHeaderParam("typ", "JWT") // 设置 jwt 加密方法 .setHeaderParam("alg", "HS256") // 设置 jwt 主题 .setSubject("admin-user") // 设置 jwt 发布时间 .setIssuedAt(new Date()) // 设置 jwt 过期时间 .setExpiration(new Date(System.currentTimeMillis() + expire)) // 设置自定义数据 .claim("data", data) // 设置密钥与算法 .signWith(SignatureAlgorithm.HS256, APP_SECRET) // 生成 token .compact(); return JwtToken; } /** * 判断token是否存在与有效,true 表示未过期,false 表示过期或不存在 */ public static boolean checkToken(String jwtToken) { if (StringUtils.isEmpty(jwtToken)) { return false; } try { // 获取 token 数据 Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken); // 判断是否过期 return claimsJws.getBody().getExpiration().after(new Date()); } catch (Exception e) { throw new RuntimeException(e); } } /** * 判断token是否存在与有效 */ public static boolean checkToken(HttpServletRequest request) { return checkToken(request.getHeader("token")); } /** * 根据 token 获取数据 */ public static Claims getTokenBody(HttpServletRequest request) { return getTokenBody(request.getHeader("token")); } /** * 根据 token 获取数据 */ public static Claims getTokenBody(String jwtToken) { if (StringUtils.isEmpty(jwtToken)) { return null; } Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken); return claimsJws.getBody(); } }
(3)修改 短信发送工具类 SmsUtil.java
主要修改 其返回数据的方式,返回 code,而非 boolean 数据。
package com.lyh.admin_template.back.common.utils; import com.aliyuncs.CommonRequest; import com.aliyuncs.CommonResponse; import com.aliyuncs.DefaultAcsClient; import com.aliyuncs.IAcsClient; import com.aliyuncs.http.MethodType; import com.aliyuncs.profile.DefaultProfile; import com.lyh.admin_template.back.modules.sms.entity.SmsResponse; import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * sms 短信发送工具类 */ @Data @Component public class SmsUtil { @Value("${aliyun.accessKeyId}") private String accessKeyId; @Value("${aliyun.accessKeySecret}") private String accessKeySecret; @Value("${aliyun.signName}") private String signName; @Value("${aliyun.templateCode}") private String templateCode; @Value("${aliyun.regionId}") private String regionId; private final static String OK = "OK"; /** * 发送短信 */ public String sendSms(String phoneNumbers) { if (StringUtils.isEmpty(phoneNumbers)) { return null; } DefaultProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret); IAcsClient client = new DefaultAcsClient(profile); CommonRequest request = new CommonRequest(); // 固定参数,无需修改 request.setSysMethod(MethodType.POST); request.setSysDomain("dysmsapi.aliyuncs.com"); request.setSysVersion("2017-05-25"); request.setSysAction("SendSms"); request.putQueryParameter("RegionId", regionId); // 设置手机号 request.putQueryParameter("PhoneNumbers", phoneNumbers); // 设置签名模板 request.putQueryParameter("SignName", signName); // 设置短信模板 request.putQueryParameter("TemplateCode", templateCode); // 设置短信验证码 String code = getCode(); request.putQueryParameter("TemplateParam", "{\"code\":" + code +"}"); try { CommonResponse response = client.getCommonResponse(request); System.out.println(response.getData()); // 转换返回的数据(需引入 Gson 依赖) SmsResponse smsResponse = GsonUtil.fromJson(response.getData(), SmsResponse.class); // 当 message 与 code 均为 ok 时,短信发送成功、否则失败 if (SmsUtil.OK.equals(smsResponse.getMessage()) && SmsUtil.OK.equals(smsResponse.getCode())) { return code; } return null; } catch (Exception e) { throw new RuntimeException(e); } } /** * 获取 6 位验证码 */ public String getCode() { return String.valueOf((int)((Math.random()*9+1)*100000)); } }
5、完善三种登录方式
(1)三种登录方式:
密码登录:
用户名 + 密码。
手机号 + 密码。
验证码登录:
手机号 + 验证码。
(2)定义相关 vo 类 以及 进行 国际化、JSR303 处理
定义 vo(viewObject)实体类去接收数据,并对其进行 JSR303 校验,当然国际化也得一起处理。
国际化数据如下:
详细使用请参考:https://www.cnblogs.com/l-y-h/p/13083375.html#_label2_4
【en】 sys.user.name.notEmpty=Sys user name cannot be null sys.user.phone.notEmpty=Sys user mobile cannot be null sys.user.password.notEmpty=Sys user password cannot be null sys.user.code.notEmpty=Sys user code cannot be null sys.user.phone.format.error=Sys user mobile format error sys.user.name.format.error=Sys user name format error 【zh】 sys.user.name.notEmpty=用户名不能为空 sys.user.phone.notEmpty=用户手机号不能为空 sys.user.password.notEmpty=用户密码不能为空 sys.user.code.notEmpty=验证码不能为空 sys.user.phone.format.error=用户手机号格式错误 sys.user.name.format.error=用户名格式错误
vo 以及 JSR303 数据校验如下:
定义分组,用于不同场景的数据校验(不定义也行)。
详细使用可参考:https://www.cnblogs.com/l-y-h/p/13083375.html#_label2_2
【LoginGroup】 package com.lyh.admin_template.back.common.validator.group.sys; /** * 新增登录的 Group 校验规则 */ public interface LoginGroup { } 【RegisterGroup】 package com.lyh.admin_template.back.common.validator.group.sys; /** * 新增注册的 Group 校验规则 */ public interface RegisterGroup { }
为了逻辑看起来简单,此处使用了三种 vo 分别接受不同场景下的登录数据。
三种 vo 如下:
【用户名 + 密码】 package com.lyh.admin_template.back.modules.sys.vo; import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup; import lombok.Data; import javax.validation.constraints.NotEmpty; /** * 登录时的视图数据类(view object), * 用于接收使用 用户名 + 密码 登陆的数据与操作。 */ @Data public class NamePwdLoginVo { @NotEmpty(message = "{sys.user.name.notEmpty}", groups = {LoginGroup.class}) private String userName; @NotEmpty(message = "{sys.user.password.notEmpty}", groups = {LoginGroup.class}) private String password; } 【手机号 + 密码】 package com.lyh.admin_template.back.modules.sys.vo; import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup; import lombok.Data; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; /** * 登录时的视图数据类(view object), * 用于接收使用 手机号 + 密码 登陆的数据与操作。 */ @Data public class PhonePwdLoginVo { @NotEmpty(message = "{sys.user.phone.notEmpty}", groups = {LoginGroup.class}) @Pattern(message = "{sys.user.phone.format.error}", regexp = "0?(13|14|15|18|17)[0-9]{9}", groups = {LoginGroup.class}) private String phone; @NotEmpty(message = "{sys.user.password.notEmpty}", groups = {LoginGroup.class}) private String password; } 【手机号 + 验证码】 package com.lyh.admin_template.back.modules.sys.vo; import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup; import lombok.Data; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; /** * 登录时的视图数据类(view object), * 用于接收使用 手机号 + 验证码 登陆的数据与操作。 */ @Data public class PhoneCodeLoginVo { @NotEmpty(message = "{sys.user.phone.notEmpty}", groups = {LoginGroup.class}) @Pattern(message = "{sys.user.phone.format.error}", regexp = "0?(13|14|15|18|17)[0-9]{9}", groups = {LoginGroup.class}) private String phone; @NotEmpty(message = "{sys.user.code.notEmpty}", groups = {LoginGroup.class}) private String code; }
定义一个 vo,用于存储 jwt 自定义数据。
package com.lyh.admin_template.back.modules.sys.vo; import lombok.Data; /** * 保存 JWT 对应存储的数据 */ @Data public class JwtVo { // 保存用户 ID private Long id; // 保存用户名 private String name; // 保存用户手机号 private String phone; // 保存 JWT 创建时间戳 private Long time; }
(3)密码登录
主要流程:
接收数据,并对数据校验,对通过校验的数据进行操作。
根据数据去数据库查找数据,若查找失败,则返回相关异常数据。若存在数据,进行下面操作。
使用 JWT 工具类将相关数据封装,并存放在 redis 中,其中以数据 ID 为 key,jwt 为 value。
最后将 jwt 数据返回,命名为 token(前台接收数据并保存,一般存放于 cookie 的 header )。
jwt 与 redis 逻辑需要注意一下:
由于此项目中只允许某用户同时登陆系统的人数为 1,即某用户多次登录时,后一次登录的 jwt 需要替换掉 redis 中的 jwt,并发操作执行可能导致 后一次 jwt 的生成时机 在 redis 中 jwt 之前,直接替换会使最新的登录者被剔除,所以每次登录操作不能直接替换掉 redis 中的 jwt。
每次登录前,生成 jwt 后,应该去查询 redis 中是否存在对应的 jwt,如果不存在,则直接将当前 jwt 存入 redis 中,如果存在,则比较两个 jwt 的时间戳,若 redis 中 jwt 大于当前 jwt,则当前登录失败,否则将当前 jwt 存入 redis 中。
后台代码实现如下:(前台代码后续再整合)
package com.lyh.admin_template.back.modules.sys.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.lyh.admin_template.back.common.utils.*; import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup; import com.lyh.admin_template.back.modules.sys.entity.SysUser; import com.lyh.admin_template.back.modules.sys.service.SysUserService; import com.lyh.admin_template.back.modules.sys.vo.JwtVo; import com.lyh.admin_template.back.modules.sys.vo.NamePwdLoginVo; import com.lyh.admin_template.back.modules.sys.vo.PhonePwdLoginVo; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Date; /** * <p> * 系统用户表 前端控制器 * </p> * * @author lyh * @since 2020-07-02 */ @RestController @RequestMapping("/sys/sys-user") @Api(tags = "用户登录、注册操作") public class SysUserController { /** * 用于操作 sys_user 表 */ @Autowired private SysUserService sysUserService; /** * 用于操作 redis */ @Autowired private RedisUtil redisUtil; /** * 常量,表示用户密码登录操作 */ private static final String USER_NAME_STATUS = "0"; /** * 常量,表示手机号密码登录操作 */ private static final String PHONE_STATUS = "1"; /** * 获取 jwt * @return jwt */ private String getJwt(SysUser sysUser) { // 获取需要保存在 jwt 中的数据 JwtVo jwtVo = new JwtVo(); jwtVo.setId(sysUser.getId()); jwtVo.setName(sysUser.getName()); jwtVo.setPhone(sysUser.getMobile()); jwtVo.setTime(new Date().getTime()); // 获取 jwt 数据,设置过期时间为 30 分钟 String jwt = JwtUtil.getJwtToken(jwtVo, 1000L * 60 * 30); // 判断用户是否重复登录(code 有值则重复登录,需要保留最新的登录者,剔除前一个登录者) String code = redisUtil.get(String.valueOf(sysUser.getId())); // 获取当前时间戳 Long currentTime = new Date().getTime(); // 如果 redis 中存在 jwt 数据,则根据时间戳比较谁为最新的登陆者 if (StringUtils.isNotEmpty(code)) { // 获取 redis 中存储的 jwt 数据 JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(code).get("data")), JwtVo.class); // redis jwt 大于 当前时间戳,则 redis 中 jwt 为最新登录者,当前登录失败 if (redisJwt.getTime() > currentTime) { return null; } } // 把数据存放在 redis 中,设置过期时间为 30 分钟 redisUtil.set(String.valueOf(sysUser.getId()), jwt, 60 * 30); return jwt; } /** * 使用密码进行真实登录操作 * @param account 账号(用户名或手机号) * @param pwd 密码 * @param status 是否使用用户名登录(0 表示用户名登录,1 表示手机号登录) * @return jwt */ private String pwdLogin(String account, String pwd, String status) { // 新增查询条件 QueryWrapper queryWrapper = new QueryWrapper(); // 如果是用户名 + 密码登录,则根据 姓名 + 密码 查找数据 if (USER_NAME_STATUS.equals(status)) { queryWrapper.eq("name", account); } // 如果是手机号 + 密码登录,则根据 手机号 + 密码 查找数据 if (PHONE_STATUS.equals(status)) { queryWrapper.eq("mobile", account); } // 添加密码条件,密码进行 MD5 加密后再与数据库数据比较 queryWrapper.eq("password", MD5Util.encrypt(pwd)); // 获取用户数据 SysUser sysUser = sysUserService.getOne(queryWrapper); // 如果存在用户数据 if (sysUser != null) { return getJwt(sysUser); } return null; } @ApiOperation(value = "使用用户名、密码登录") @PostMapping("/login/namePwdLogin") public Result namePwdLogin(@Validated({LoginGroup.class}) @RequestBody NamePwdLoginVo namePwdLoginVo) { String jwt = pwdLogin(namePwdLoginVo.getUserName(), namePwdLoginVo.getPassword(), USER_NAME_STATUS); if (StringUtils.isNotEmpty(jwt)) { return Result.ok().message("登录成功").data("token", jwt); } return Result.error().message("登录失败"); } @ApiOperation(value = "使用手机号、密码登录") @PostMapping("/login/phonePwdLogin") public Result phonePwdLogin(@Validated({LoginGroup.class}) @RequestBody PhonePwdLoginVo phonePwdLoginVo) { String jwt = pwdLogin(phonePwdLoginVo.getPhone(), phonePwdLoginVo.getPassword(), PHONE_STATUS); if (StringUtils.isNotEmpty(jwt)) { return Result.ok().message("登录成功").data("token", jwt); } return Result.error().message("登录失败"); } }
使用 swagger 简单测试一下:
点击用户名 + 密码登录,生成 token,存入 redis 中并设置过期时间 30 分钟(1800 秒)。
点击手机号 + 密码登录,会重新生成 token,并存入 redis 中。
并发操作,可以使用 Jmeter 进行测试(此处省略)。
(4)验证码登录
获取验证码流程:
首先获取验证码(此处不考虑并发情况,毕竟手机号只有一个用户能用,应该避免重复获取验证码的情况),并将其存放与 redis 中,设置过期时间为 5 分钟。
为了避免重复获取验证码,可以根据其已过期时间是否小于 1 分钟判断,即 1 分钟内不可以重复获取验证码。
验证码登录流程:
接收数据,并校验数据,通过检验的数据进行下面处理。
先检查 redis 中是否存在验证码,若不存在验证码(验证码不存在或失效),则登录失败。否则,根据手机号去查询用户数据,生成 jwt,存放与 redis 中并返回。
后台代码实现如下:(前台代码后续再整合)
package com.lyh.admin_template.back.modules.sys.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.lyh.admin_template.back.common.utils.*; import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup; import com.lyh.admin_template.back.modules.sys.entity.SysUser; import com.lyh.admin_template.back.modules.sys.service.SysUserService; import com.lyh.admin_template.back.modules.sys.vo.JwtVo; import com.lyh.admin_template.back.modules.sys.vo.PhoneCodeLoginVo; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.Date; /** * <p> * 系统用户表 前端控制器 * </p> * * @author lyh * @since 2020-07-02 */ @RestController @RequestMapping("/sys/sys-user") @Api(tags = "用户登录、注册操作") public class SysUserController { /** * 用于操作 sys_user 表 */ @Autowired private SysUserService sysUserService; /** * 用于操作 redis */ @Autowired private RedisUtil redisUtil; /** * 用于操作 短信验证码发送 */ @Autowired private SmsUtil smsUtil; /** * 获取 jwt * @return jwt */ private String getJwt(SysUser sysUser) { // 获取需要保存在 jwt 中的数据 JwtVo jwtVo = new JwtVo(); jwtVo.setId(sysUser.getId()); jwtVo.setName(sysUser.getName()); jwtVo.setPhone(sysUser.getMobile()); jwtVo.setTime(new Date().getTime()); // 获取 jwt 数据,设置过期时间为 30 分钟 String jwt = JwtUtil.getJwtToken(jwtVo, 1000L * 60 * 30); // 判断用户是否重复登录(code 有值则重复登录,需要保留最新的登录者,剔除前一个登录者) String code = redisUtil.get(String.valueOf(sysUser.getId())); // 获取当前时间戳 Long currentTime = new Date().getTime(); // 如果 redis 中存在 jwt 数据,则根据时间戳比较谁为最新的登陆者 if (StringUtils.isNotEmpty(code)) { // 获取 redis 中存储的 jwt 数据 JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(code).get("data")), JwtVo.class); // redis jwt 大于 当前时间戳,则 redis 中 jwt 为最新登录者,当前登录失败 if (redisJwt.getTime() > currentTime) { return null; } } // 把数据存放在 redis 中,设置过期时间为 30 分钟 redisUtil.set(String.valueOf(sysUser.getId()), jwt, 60 * 30); return jwt; } /** * 使用 验证码进行真实登录操作 * @param phone 手机号 * @param code 验证码 * @return jwt */ private String codeLogin(String phone, String code) { // 获取 redis 中存放的验证码 String redisCode = redisUtil.get(phone); // 存在验证码,且输入的验证码与 redis 存放的验证码相同,则根据手机号去数据库查询数据 if (StringUtils.isNotEmpty(redisCode) && code.equals(redisCode)) { // 新增查询条件 QueryWrapper queryWrapper = new QueryWrapper(); // 根据手机号去查询数据 queryWrapper.eq("mobile", phone); SysUser sysUser = sysUserService.getOne(queryWrapper); // 如果存在用户数据 if (sysUser != null) { return getJwt(sysUser); } } return null; } @ApiOperation(value = "使用手机号、验证码登录") @PostMapping("/login/phoneCodeLogin") public Result phoneCodeLogin(@Validated({LoginGroup.class}) @RequestBody PhoneCodeLoginVo phoneCodeLoginVo) { String jwt = codeLogin(phoneCodeLoginVo.getPhone(), phoneCodeLoginVo.getCode()); if (StringUtils.isNotEmpty(jwt)) { return Result.ok().message("登录成功").data("token", jwt); } return Result.error().message("登录失败"); } @ApiOperation(value = "获取短信验证码") @GetMapping("/login/getCode") public Result getCode(String phone) { // 设置默认过期时间 Long defaultTime = 60L * 5; // 先判断 redis 中是否存储过验证码(设置期限为 1 分钟),防止重复获取验证码 Long expire = redisUtil.getExpire(phone); if (expire != null && (defaultTime - expire < 60)) { return Result.error().message("验证码已发送,1 分钟后可再次获取验证码"); } else { // 获取 短信验证码 String code = smsUtil.sendSms(phone); if (StringUtils.isNotEmpty(code)) { // 把验证码存放在 redis 中,并设置 过期时间 为 5 分钟 redisUtil.set(phone, code, defaultTime); return Result.ok().message("验证码获取成功").data("code", code); } } return Result.error().message("验证码获取失败"); } }
使用 swagger 简单测试一下:
首先获取验证码,其会存放于 redis 中,过期时间为 5 分钟(300 秒)。若 1 分钟内重复点击验证码,会提示相关信息(验证码已发送,1 分钟后再次获取)。
然后根据 手机号和验证码进行登录操作。
6、完善注册逻辑
(1)主要流程:
先获取验证码,验证码处理与验证码登录相同(此处不再重复)。
输入用户名、密码、手机号、以及得到的验证码,后端对数据进行校验,校验通过的数据进行下面操作。
先检查 redis 中是否存在验证码,若不存在验证码(验证码不存在或失效)或者验证码与当前验证码不同,则注册失败,如存在且相同,则进行下面操作。
根据用户名与手机号,对数据库数据进行查找,若存在数据则注册失败,若不存在,则向数据库添加数据。由于给用户名和手机号添加了唯一性约束,所以可以直接进行插入操作,存在数据会返回异常,不存在数据会直接插入。
(2)代码实现如下:
首先定义一个 vo 类,用于接收数据。
package com.lyh.admin_template.back.modules.sys.vo; import com.lyh.admin_template.back.common.validator.group.sys.RegisterGroup; import lombok.Data; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; /** * 注册时对应的视图数据类(view object), * 用于接收并处理 注册时的数据。 */ @Data public class RegisterVo { @NotEmpty(message = "{sys.user.name.notEmpty}", groups = {RegisterGroup.class}) @Pattern(message = "{sys.user.name.format.error}", regexp = "^.*[^\\d].*$", groups = {RegisterGroup.class}) private String userName; @NotEmpty(message = "{sys.user.password.notEmpty}", groups = {RegisterGroup.class}) private String password; @NotEmpty(message = "{sys.user.phone.notEmpty}", groups = {RegisterGroup.class}) @Pattern(message = "{sys.user.phone.format.error}", regexp = "0?(13|14|15|18|17)[0-9]{9}", groups = {RegisterGroup.class}) private String phone; @NotEmpty(message = "{sys.user.code.notEmpty}", groups = {RegisterGroup.class}) private String code; }
接口如下:
由于 注册 用户均属于 普通用户,所以注册的同时需要给其绑定角色,即向 sys_user 插入数据后,还需要向 sys_user_role 插入数据(需要使用代码生成器生成相关代码,此处省略)。
由于出现多表插入操作,此处使用 @Transactional 对事务进行控制。
注:
@Transactional 需要写在 Service 层,写在 Controller 层不生效。
在 service 层定义一个 saveUser 方法。
package com.lyh.admin_template.back.modules.sys.service; import com.baomidou.mybatisplus.extension.service.IService; import com.lyh.admin_template.back.modules.sys.entity.SysUser; /** * <p> * 系统用户表 服务类 * </p> * * @author lyh * @since 2020-07-02 */ public interface SysUserService extends IService<SysUser> { public boolean saveUser(SysUser sysUser); }
在 service 实现类中,重写方法并完善注册逻辑。
package com.lyh.admin_template.back.modules.sys.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.lyh.admin_template.back.modules.sys.entity.SysRole; import com.lyh.admin_template.back.modules.sys.entity.SysUser; import com.lyh.admin_template.back.modules.sys.entity.SysUserRole; import com.lyh.admin_template.back.modules.sys.mapper.SysUserMapper; import com.lyh.admin_template.back.modules.sys.service.SysRoleService; import com.lyh.admin_template.back.modules.sys.service.SysUserRoleService; import com.lyh.admin_template.back.modules.sys.service.SysUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; /** * <p> * 系统用户表 服务实现类 * </p> * * @author lyh * @since 2020-07-02 */ @Service public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService { @Autowired private SysRoleService sysRoleService; @Autowired private SysUserRoleService sysUserRoleService; /** * 先插入数据到 用户表 sys_user 中。 * 再获取数据 ID 与 角色 ID 并插入到 用户角色表 sys_user_role 中。 * @param sysUser 用户数据 * @return true 表示插入成功, false 表示失败 */ @Override @Transactional(propagation = Propagation.REQUIRED,isolation = Isolation.DEFAULT,timeout=36000,rollbackFor=Exception.class) public boolean saveUser(SysUser sysUser) { // 向 sys_user 表中插入数据 if (this.save(sysUser)) { // 获取当前用户的 ID QueryWrapper queryWrapper = new QueryWrapper(); queryWrapper.eq("name", sysUser.getName()); SysUser sysUser2 = this.getOne(queryWrapper); // 获取普通用户角色 ID QueryWrapper queryWrapper2 = new QueryWrapper(); queryWrapper2.eq("role_name", "user"); SysRole sysRole = sysRoleService.getOne(queryWrapper2); // 插入到 用户-角色 表中(sys_user_role) SysUserRole sysUserRole = new SysUserRole(); sysUserRole.setUserId(sysUser2.getId()).setRoleId(sysRole.getId()); return sysUserRoleService.save(sysUserRole); } return false; } }
controller 层接口如下:
package com.lyh.admin_template.back.modules.sys.controller; import com.lyh.admin_template.back.common.utils.MD5Util; import com.lyh.admin_template.back.common.utils.RedisUtil; import com.lyh.admin_template.back.common.utils.Result; import com.lyh.admin_template.back.common.utils.SmsUtil; import com.lyh.admin_template.back.common.validator.group.sys.RegisterGroup; import com.lyh.admin_template.back.modules.sys.entity.SysUser; import com.lyh.admin_template.back.modules.sys.service.SysUserService; import com.lyh.admin_template.back.modules.sys.vo.RegisterVo; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; /** * <p> * 系统用户表 前端控制器 * </p> * * @author lyh * @since 2020-07-02 */ @RestController @RequestMapping("/sys/sys-user") @Api(tags = "用户登录、注册操作") public class SysUserController { /** * 用于操作 sys_user 表 */ @Autowired private SysUserService sysUserService; /** * 用于操作 redis */ @Autowired private RedisUtil redisUtil; /** * 用于操作 短信验证码发送 */ @Autowired private SmsUtil smsUtil; @ApiOperation(value = "获取短信验证码") @GetMapping("/login/getCode") public Result getCode(String phone) { // 设置默认过期时间 Long defaultTime = 60L * 5; // 先判断 redis 中是否存储过验证码(设置期限为 1 分钟),防止重复获取验证码 Long expire = redisUtil.getExpire(phone); if (expire != null && (defaultTime - expire < 60)) { return Result.error().message("验证码已发送,1 分钟后可再次获取验证码"); } else { // 获取 短信验证码 String code = smsUtil.sendSms(phone); if (StringUtils.isNotEmpty(code)) { // 把验证码存放在 redis 中,并设置 过期时间 为 5 分钟 redisUtil.set(phone, code, defaultTime); return Result.ok().message("验证码获取成功").data("code", code); } } return Result.error().message("验证码获取失败"); } @ApiOperation(value = "用户注册") @PostMapping("/register") public Result register(@Validated({RegisterGroup.class}) @RequestBody RegisterVo registerVo) { if (save(registerVo)) { return Result.ok().message("用户注册成功"); } return Result.error().message("用户注册失败"); } /** * 真实注册操作 * @param registerVo 注册数据 * @return true 为插入成功, false 为失败 */ public boolean save(RegisterVo registerVo) { // 判断 redis 中是否存在 验证码 String code = redisUtil.get(registerVo.getPhone()); // redis 中存在验证码且与当前验证码相同 if (StringUtils.isNotEmpty(code) && code.equals(registerVo.getCode())) { SysUser sysUser = new SysUser(); sysUser.setName(registerVo.getUserName()).setPassword(MD5Util.encrypt(registerVo.getPassword())); sysUser.setMobile(registerVo.getPhone()); return sysUserService.saveUser(sysUser); } return false; } }
使用 swagger 简单测试一下,添加数据。
7、完善登出逻辑
(1)目的:
让客户端 保存的 token 失效,则用户再次访问系统后由于 token 失效而无法继续访问,需重新登录后才可访问。
后台操作(非必须操作):
返回一个 过期时间为 1 秒的 token(或返回一个无效 token),并删除 redis 中的 token。
前台操作:
前台保存无效的 token。
清除 token(简单粗暴)。
(2)代码如下:(仅后台代码,前台代码此处省略、后续整合)
package com.lyh.admin_template.back.modules.sys.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.lyh.admin_template.back.common.utils.JwtUtil; import com.lyh.admin_template.back.common.utils.RedisUtil; import com.lyh.admin_template.back.common.utils.Result; import com.lyh.admin_template.back.modules.sys.entity.SysUser; import com.lyh.admin_template.back.modules.sys.service.SysUserService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** * <p> * 系统用户表 前端控制器 * </p> * * @author lyh * @since 2020-07-02 */ @RestController @RequestMapping("/sys/sys-user") @Api(tags = "用户登录、注册操作") public class SysUserController { /** * 用于操作 sys_user 表 */ @Autowired private SysUserService sysUserService; /** * 用于操作 redis */ @Autowired private RedisUtil redisUtil; @ApiOperation(value = "用户登出") @GetMapping("/logout") public Result logout(@RequestParam String userName) { // 先获取用户数据 QueryWrapper queryWrapper = new QueryWrapper(); queryWrapper.eq("name", userName); SysUser sysUser = sysUserService.getOne(queryWrapper); // 用户存在时 if (sysUser != null) { // 生成并返回一个无效的 token String jwt = JwtUtil.getJwtToken(null, 1000L); // 删除 redis 中的 token redisUtil.del(String.valueOf(sysUser.getId())); return Result.ok().message("登出成功").data("token", jwt); } return Result.error().message("登出失败"); } }
使用 swagger 简单测试一下:
某用户登录后,会返回一个有效 token,并在 redis 中保存。
用户登出后,返回一个无效 token,并删除 redis 中数据。
8、完整的登录、注册、登出接口代码
包括三种登录接口、注册接口、登出接口、获取验证码接口。
package com.lyh.admin_template.back.modules.sys.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.lyh.admin_template.back.common.utils.*; import com.lyh.admin_template.back.common.validator.group.sys.LoginGroup; import com.lyh.admin_template.back.common.validator.group.sys.RegisterGroup; import com.lyh.admin_template.back.modules.sys.entity.SysUser; import com.lyh.admin_template.back.modules.sys.service.SysUserService; import com.lyh.admin_template.back.modules.sys.vo.*; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.Date; /** * <p> * 系统用户表 前端控制器 * </p> * * @author lyh * @since 2020-07-02 */ @RestController @RequestMapping("/sys/sys-user") @Api(tags = "用户登录、注册操作") public class SysUserController { /** * 用于操作 sys_user 表 */ @Autowired private SysUserService sysUserService; /** * 用于操作 redis */ @Autowired private RedisUtil redisUtil; /** * 用于操作 短信验证码发送 */ @Autowired private SmsUtil smsUtil; /** * 常量,表示用户密码登录操作 */ private static final String USER_NAME_STATUS = "0"; /** * 常量,表示手机号密码登录操作 */ private static final String PHONE_STATUS = "1"; /** * 获取 jwt * @return jwt */ private String getJwt(SysUser sysUser) { // 获取需要保存在 jwt 中的数据 JwtVo jwtVo = new JwtVo(); jwtVo.setId(sysUser.getId()); jwtVo.setName(sysUser.getName()); jwtVo.setPhone(sysUser.getMobile()); jwtVo.setTime(new Date().getTime()); // 获取 jwt 数据,设置过期时间为 30 分钟 String jwt = JwtUtil.getJwtToken(jwtVo, 1000L * 60 * 30); // 判断用户是否重复登录(code 有值则重复登录,需要保留最新的登录者,剔除前一个登录者) String code = redisUtil.get(String.valueOf(sysUser.getId())); // 获取当前时间戳 Long currentTime = new Date().getTime(); // 如果 redis 中存在 jwt 数据,则根据时间戳比较谁为最新的登陆者 if (StringUtils.isNotEmpty(code)) { // 获取 redis 中存储的 jwt 数据 JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(code).get("data")), JwtVo.class); // redis jwt 大于 当前时间戳,则 redis 中 jwt 为最新登录者,当前登录失败 if (redisJwt.getTime() > currentTime) { return null; } } // 把数据存放在 redis 中,设置过期时间为 30 分钟 redisUtil.set(String.valueOf(sysUser.getId()), jwt, 60 * 30); return jwt; } /** * 使用密码进行真实登录操作 * @param account 账号(用户名或手机号) * @param pwd 密码 * @param status 是否使用用户名登录(0 表示用户名登录,1 表示手机号登录) * @return jwt */ private String pwdLogin(String account, String pwd, String status) { // 新增查询条件 QueryWrapper queryWrapper = new QueryWrapper(); // 如果是用户名 + 密码登录,则根据 姓名 + 密码 查找数据 if (USER_NAME_STATUS.equals(status)) { queryWrapper.eq("name", account); } // 如果是手机号 + 密码登录,则根据 手机号 + 密码 查找数据 if (PHONE_STATUS.equals(status)) { queryWrapper.eq("mobile", account); } // 添加密码条件,密码进行 MD5 加密后再与数据库数据比较 queryWrapper.eq("password", MD5Util.encrypt(pwd)); // 获取用户数据 SysUser sysUser = sysUserService.getOne(queryWrapper); // 如果存在用户数据 if (sysUser != null) { return getJwt(sysUser); } return null; } /** * 使用 验证码进行真实登录操作 * @param phone 手机号 * @param code 验证码 * @return jwt */ private String codeLogin(String phone, String code) { // 获取 redis 中存放的验证码 String redisCode = redisUtil.get(phone); // 存在验证码,且输入的验证码与 redis 存放的验证码相同,则根据手机号去数据库查询数据 if (StringUtils.isNotEmpty(redisCode) && code.equals(redisCode)) { // 新增查询条件 QueryWrapper queryWrapper = new QueryWrapper(); // 根据手机号去查询数据 queryWrapper.eq("mobile", phone); SysUser sysUser = sysUserService.getOne(queryWrapper); // 如果存在用户数据 if (sysUser != null) { return getJwt(sysUser); } } return null; } @ApiOperation(value = "使用用户名、密码登录") @PostMapping("/login/namePwdLogin") public Result namePwdLogin(@Validated({LoginGroup.class}) @RequestBody NamePwdLoginVo namePwdLoginVo) { String jwt = pwdLogin(namePwdLoginVo.getUserName(), namePwdLoginVo.getPassword(), USER_NAME_STATUS); if (StringUtils.isNotEmpty(jwt)) { return Result.ok().message("登录成功").data("token", jwt); } return Result.error().message("登录失败").code(HttpStatus.SC_UNAUTHORIZED); } @ApiOperation(value = "使用手机号、密码登录") @PostMapping("/login/phonePwdLogin") public Result phonePwdLogin(@Validated({LoginGroup.class}) @RequestBody PhonePwdLoginVo phonePwdLoginVo) { String jwt = pwdLogin(phonePwdLoginVo.getPhone(), phonePwdLoginVo.getPassword(), PHONE_STATUS); if (StringUtils.isNotEmpty(jwt)) { return Result.ok().message("登录成功").data("token", jwt); } return Result.error().message("登录失败").code(HttpStatus.SC_UNAUTHORIZED); } @ApiOperation(value = "使用手机号、验证码登录") @PostMapping("/login/phoneCodeLogin") public Result phoneCodeLogin(@Validated({LoginGroup.class}) @RequestBody PhoneCodeLoginVo phoneCodeLoginVo) { String jwt = codeLogin(phoneCodeLoginVo.getPhone(), phoneCodeLoginVo.getCode()); if (StringUtils.isNotEmpty(jwt)) { return Result.ok().message("登录成功").data("token", jwt); } return Result.error().message("登录失败").code(HttpStatus.SC_UNAUTHORIZED); } @ApiOperation(value = "获取短信验证码") @GetMapping("/login/getCode") public Result getCode(String phone) { // 设置默认过期时间 Long defaultTime = 60L * 5; // 先判断 redis 中是否存储过验证码(设置期限为 1 分钟),防止重复获取验证码 Long expire = redisUtil.getExpire(phone); if (expire != null && (defaultTime - expire < 60)) { return Result.error().message("验证码已发送,1 分钟后可再次获取验证码"); } else { // 获取 短信验证码 String code = smsUtil.sendSms(phone); if (StringUtils.isNotEmpty(code)) { // 把验证码存放在 redis 中,并设置 过期时间 为 5 分钟 redisUtil.set(phone, code, defaultTime); return Result.ok().message("验证码获取成功").data("code", code); } } return Result.error().message("验证码获取失败"); } @ApiOperation(value = "用户登出") @GetMapping("/logout") public Result logout(@RequestParam String userName) { // 先获取用户数据 QueryWrapper queryWrapper = new QueryWrapper(); queryWrapper.eq("name", userName); SysUser sysUser = sysUserService.getOne(queryWrapper); // 用户存在时 if (sysUser != null) { // 生成并返回一个无效的 token String jwt = JwtUtil.getJwtToken(null, 1000L); // 删除 redis 中的 token redisUtil.del(String.valueOf(sysUser.getId())); return Result.ok().message("登出成功").data("token", jwt); } return Result.error().message("登出失败"); } @ApiOperation(value = "用户注册") @PostMapping("/register") public Result register(@Validated({RegisterGroup.class}) @RequestBody RegisterVo registerVo) { if (save(registerVo)) { return Result.ok().message("用户注册成功"); } return Result.error().message("用户注册失败").code(HttpStatus.SC_UNAUTHORIZED); } /** * 真实注册操作 * @param registerVo 注册数据 * @return true 为插入成功, false 为失败 */ public boolean save(RegisterVo registerVo) { // 判断 redis 中是否存在 验证码 String code = redisUtil.get(registerVo.getPhone()); // redis 中存在验证码且与当前验证码相同 if (StringUtils.isNotEmpty(code) && code.equals(registerVo.getCode())) { SysUser sysUser = new SysUser(); sysUser.setName(registerVo.getUserName()).setPassword(MD5Util.encrypt(registerVo.getPassword())); sysUser.setMobile(registerVo.getPhone()); return sysUserService.saveUser(sysUser); } return false; } }
9、定义一个拦截器,用于拦截除登录注册请求外的所有请求
(1)目的:
由于采用 JWT 进行单点登录,每次请求前都需要对 token 进行校验,为了避免在接口中重复进行校验操作,此处可以使用拦截器,拦截每个请求,校验通过后放行请求并返回数据,校验未通过直接返回错误数据。
拦截器需要直接放行登录、注册等请求,未登录、注册时没有 token 数据,只有登录后才有 token 数据,拦截了 登录、注册请求后,不会产生 token,成为一个死循环。
(2)代码实现如下:
Step1:定义一个拦截器
对于拦截的请求,首先检查 token 是否过期,过期返回 401 状态码。未过期进行下面操作。
获取 token 信息,并根据 token 的 id 值从 redis 中获取 redis 中存储的 token。若 redis 中不存在 token,即用户未登录,返回 401 状态码。存在 token 则进行下面操作。
若两 token 相同,即 同一用户再次访问系统,放行该请求。token 不同,则意味着 同一用户 在不同地方进行登录,需保留最新的登录者信息。根据时间戳比较,谁大谁为最新登录者,并将其值保存在 redis 中。
/** * 定义一个拦截器,用于拦截请求,并对 JWT 进行验证 */ class JWTInterceptor extends HandlerInterceptorAdapter { /** * 访问 controller 前被调用 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取 token(从 header 或者 参数中获取) String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { token = request.getParameter("token"); } // 验证 token 是否过期(根据时间戳比较) if (JwtUtil.checkToken(token)) { // 获取 token 中的数据 Claims claims = JwtUtil.getTokenBody(token); System.out.println(claims.getExpiration()); JwtVo jwt = GsonUtil.fromJson(String.valueOf(claims.get("data")), JwtVo.class); // 获取 redis 中存储的 token String redisToken = redisUtil.get(String.valueOf(jwt.getId())); // 当前 token 与 redis 中存储的 token 进行比较 if (StringUtils.isNotEmpty(redisToken)) { // 获取 redis 中 token 的数据 JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(redisToken).get("data")), JwtVo.class); // 若两者 token 相同,则为同一用户再次访问系统,放行 if (redisToken.equals(token)) { return true; } else if (redisJwt.getTime() <= jwt.getTime()){ // redis 中 token 生成时间戳 小于等于 当前 token 生成时间戳,即当前用户为最新登录者 // redis 保存当前最新的 token,并放行 redisUtil.set(String.valueOf(redisJwt.getId()), token, 60 * 30); return true; } } } // 认证失败,返回数据,并返回 401 状态码 returnJsonData(response); return false; } }
Step2:定义拦截请求后的数据返回结果。
返回 json 数据,并定义 code 为 401(授权失败)。
/** * 返回 json 格式的数据 */ public void returnJsonData(HttpServletResponse response) { PrintWriter pw = null; response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); try { pw = response.getWriter(); // 返回 code 为 401,表示 token 失效。 pw.print(GsonUtil.toJson(Result.error().message("token 失效或过期").code(HttpStatus.SC_UNAUTHORIZED))); } catch (IOException e) { log.error(e.getMessage()); throw new RuntimeException(e); } }
Step3:定义拦截请求规则:
/** * 定义拦截器,拦截请求。 * 其中: * addPathPatterns 用于添加需要拦截的请求。 * excludePathPatterns 用于添加不需要拦截的请求。 * 此处: * 拦截所有请求,但是排除 登录、注册 请求 以及 swagger 请求。 */ @Bean(name = "JWTInterceptor") public WebMvcConfigurer JWTInterceptor() { return new WebMvcConfigurer() { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JWTInterceptor()) // 拦截所有请求 .addPathPatterns("/**") // 不拦截 登录、注册、忘记密码请求 .excludePathPatterns("/sys/sys-user/login/*", "/sys/sys-user/register") // 不拦截 swagger 请求 .excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**"); } }; }
完整拦截逻辑:
package com.lyh.admin_template.back.common.config; import com.lyh.admin_template.back.common.utils.GsonUtil; import com.lyh.admin_template.back.common.utils.JwtUtil; import com.lyh.admin_template.back.common.utils.RedisUtil; import com.lyh.admin_template.back.common.utils.Result; import com.lyh.admin_template.back.modules.sys.vo.JwtVo; import io.jsonwebtoken.Claims; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; @Slf4j @Configuration public class JWTConfig { @Autowired private RedisUtil redisUtil; /** * 定义拦截器,拦截请求。 * 其中: * addPathPatterns 用于添加需要拦截的请求。 * excludePathPatterns 用于添加不需要拦截的请求。 * 此处: * 拦截所有请求,但是排除 登录、注册 请求 以及 swagger 请求。 */ @Bean(name = "JWTInterceptor") public WebMvcConfigurer JWTInterceptor() { return new WebMvcConfigurer() { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JWTInterceptor()) // 拦截所有请求 .addPathPatterns("/**") // 不拦截 登录、注册、忘记密码请求 .excludePathPatterns("/sys/sys-user/login/*", "/sys/sys-user/register") // 不拦截 swagger 请求 .excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**"); } }; } /** * 定义一个拦截器,用于拦截请求,并对 JWT 进行验证 */ class JWTInterceptor extends HandlerInterceptorAdapter { /** * 访问 controller 前被调用 */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取 token(从 header 或者 参数中获取) String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { token = request.getParameter("token"); } // 验证 token 是否过期(根据时间戳比较) if (JwtUtil.checkToken(token)) { // 获取 token 中的数据 Claims claims = JwtUtil.getTokenBody(token); JwtVo jwt = GsonUtil.fromJson(String.valueOf(claims.get("data")), JwtVo.class); // 获取 redis 中存储的 token String redisToken = redisUtil.get(String.valueOf(jwt.getId())); // 当前 token 与 redis 中存储的 token 进行比较 if (StringUtils.isNotEmpty(redisToken)) { // 获取 redis 中 token 的数据 JwtVo redisJwt = GsonUtil.fromJson(String.valueOf(JwtUtil.getTokenBody(redisToken).get("data")), JwtVo.class); // 若两者 token 相同,则为同一用户再次访问系统,放行 if (redisToken.equals(token)) { return true; } else if (redisJwt.getTime() <= jwt.getTime()){ // redis 中 token 生成时间戳 小于等于 当前 token 生成时间戳,即当前用户为最新登录者 // redis 保存当前最新的 token,并放行 redisUtil.set(String.valueOf(redisJwt.getId()), token, 60 * 30); return true; } } } // 认证失败,返回数据,并返回 401 状态码 returnJsonData(response); return false; } } /** * 返回 json 格式的数据 */ public void returnJsonData(HttpServletResponse response) { PrintWriter pw = null; response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); try { pw = response.getWriter(); // 返回 code 为 401,表示 token 失效。 pw.print(GsonUtil.toJson(Result.error().message("token 失效或过期").code(HttpStatus.SC_UNAUTHORIZED))); } catch (IOException e) { log.error(e.getMessage()); throw new RuntimeException(e); } } }
10、给 Swagger 添加统一验证参数(设置 token)
(1)目的:
由于后台使用过滤器拦截了请求,使用 swagger 测试时,由于未携带 token 而被拦截,导致 返回 401 状态码。
可以给 Swagger 添加统一验证参数,在请求发送前统一给 header 加上 token 参数。
(2)代码实现:
来源于网络,没有深究为什么这么写,套用即可。
在原本 swagger 基础上,添加如下代码:
securitySchemes(security())
securityContexts(securityContexts());
package com.lyh.admin_template.back.common.config; import com.google.common.collect.Lists; import io.swagger.annotations.ApiOperation; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import springfox.documentation.builders.ApiInfoBuilder; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.service.ApiInfo; import springfox.documentation.service.ApiKey; import springfox.documentation.service.AuthorizationScope; import springfox.documentation.service.SecurityReference; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spi.service.contexts.SecurityContext; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2; import java.util.ArrayList; import java.util.List; @Configuration @EnableSwagger2 @Profile({"dev","test"}) public class SwaggerConfig { @Bean public Docket createRestApi() { return new Docket(DocumentationType.SWAGGER_2) .apiInfo(apiInfo()) .select() // 加了ApiOperation注解的类,才会生成接口文档 .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class)) // 指定包下的类,才生成接口文档 .apis(RequestHandlerSelectors.basePackage("com.lyh.admin_template.back")) .paths(PathSelectors.any()) .build() .securitySchemes(security()) .securityContexts(securityContexts()); } private ApiInfo apiInfo() { return new ApiInfoBuilder() .title("Swagger 测试") .description("Swagger 测试文档") .termsOfServiceUrl("https://www.cnblogs.com/l-y-h/") .version("1.0.0") .build(); } private List<ApiKey> security() { return Lists.newArrayList( new ApiKey("token", "token", "header") ); } private List<SecurityContext> securityContexts() { return Lists.newArrayList( SecurityContext.builder().securityReferences(defaultAuth()) //过滤要验证的路径 .forPaths(PathSelectors.regex("^(?!auth).*$")) .build() ); } //增加全局认证 List<SecurityReference> defaultAuth() { AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything"); AuthorizationScope[] authorizationScopes = new AuthorizationScope[1]; authorizationScopes[0] = authorizationScope; List<SecurityReference> securityReferences = new ArrayList<>(); // 由于 securitySchemes() 方法中 header 写入值为 token,所以此处为 token securityReferences.add(new SecurityReference("token", authorizationScopes)); return securityReferences; } }
(3)简单测试一下:
首先登录,获取到 token。没有设置 token 时,访问 登出接口 会被拦截。
设置 token 后,登出接口不会被拦截。