一、创建项目及配置
①:创建新的项目及常用包
②:引入依赖和配置
- devtools:项目的热加载重启插件
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</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></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.11</version></dependency><!--支持使用 JDBC 访问数据库 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><!--整合mybatis plus https://baomidou.com/--><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.1</version></dependency><!-- mybatis-plus-generator --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-generator</artifactId><version>3.4.1</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid</artifactId><version>1.2.16</version></dependency><!--引入hutool--><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.18</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><scope>runtime</scope><optional>true</optional></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-extension</artifactId><version>3.5.3.1</version></dependency>
server:port: 19005
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://10.17.4.14:3306/security_study?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghaiusername: rootpassword: # 密码thymeleaf:cache: false # 不使用缓存check-template: true # 检查thymeleaf模板是否存在
mybatis-plus:mapper-locations: classpath*:/mapper/**Mapper.xml
③:开启mapper接口扫描,添加分页、防全表更新插件
@Configuration
@MapperScan ("com.it.mapper")
public class MybatisPlusConfig {/*** 新的分页插件,一缓和二缓遵循mybatis的规则,* 需要设置 MybatisConfiguration#useDeprecatedExecutor = false* 避免缓存出现问题(该属性会在旧插件移除后一同移除)*/@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));// 防止全表更新和删除interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());return interceptor;}@Beanpublic ConfigurationCustomizer configurationCustomizer() {return configuration -> {configuration.setUseGeneratedShortKey(false);};}
}
④:创建数据库和表
SET FOREIGN_KEY_CHECKS=0;-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`parent_id` bigint(20) DEFAULT NULL COMMENT '父菜单ID,一级菜单为0',`name` varchar(64) NOT NULL,`path` varchar(255) DEFAULT NULL COMMENT '菜单URL',`perms` varchar(255) DEFAULT NULL COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',`component` varchar(255) DEFAULT NULL,`type` int(5) NOT NULL COMMENT '类型 0:目录 1:菜单 2:按钮',`icon` varchar(32) DEFAULT NULL COMMENT '菜单图标',`orderNum` int(11) DEFAULT NULL COMMENT '排序',`created` datetime NOT NULL,`updated` datetime DEFAULT NULL,`statu` int(5) NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8;-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES ('1', '0', '系统管理', '', 'sys:manage', '', '0', 'el-icon-s-operation', '1', '2021-01-15 18:58:18', '2021-01-15 18:58:20', '1');
INSERT INTO `sys_menu` VALUES ('2', '1', '用户管理', '/sys/users', 'sys:user:list', 'sys/User', '1', 'el-icon-s-custom', '1', '2021-01-15 19:03:45', '2021-01-15 19:03:48', '1');
INSERT INTO `sys_menu` VALUES ('3', '1', '角色管理', '/sys/roles', 'sys:role:list', 'sys/Role', '1', 'el-icon-rank', '2', '2021-01-15 19:03:45', '2021-01-15 19:03:48', '1');
INSERT INTO `sys_menu` VALUES ('4', '1', '菜单管理', '/sys/menus', 'sys:menu:list', 'sys/Menu', '1', 'el-icon-menu', '3', '2021-01-15 19:03:45', '2021-01-15 19:03:48', '1');
INSERT INTO `sys_menu` VALUES ('5', '0', '系统工具', '', 'sys:tools', null, '0', 'el-icon-s-tools', '2', '2021-01-15 19:06:11', null, '1');
INSERT INTO `sys_menu` VALUES ('6', '5', '数字字典', '/sys/dicts', 'sys:dict:list', 'sys/Dict', '1', 'el-icon-s-order', '1', '2021-01-15 19:07:18', '2021-01-18 16:32:13', '1');
INSERT INTO `sys_menu` VALUES ('7', '3', '添加角色', '', 'sys:role:save', '', '2', '', '1', '2021-01-15 23:02:25', '2021-01-17 21:53:14', '0');
INSERT INTO `sys_menu` VALUES ('9', '2', '添加用户', null, 'sys:user:save', null, '2', null, '1', '2021-01-17 21:48:32', null, '1');
INSERT INTO `sys_menu` VALUES ('10', '2', '修改用户', null, 'sys:user:update', null, '2', null, '2', '2021-01-17 21:49:03', '2021-01-17 21:53:04', '1');
INSERT INTO `sys_menu` VALUES ('11', '2', '删除用户', null, 'sys:user:delete', null, '2', null, '3', '2021-01-17 21:49:21', null, '1');
INSERT INTO `sys_menu` VALUES ('12', '2', '分配角色', null, 'sys:user:role', null, '2', null, '4', '2021-01-17 21:49:58', null, '1');
INSERT INTO `sys_menu` VALUES ('13', '2', '重置密码', null, 'sys:user:repass', null, '2', null, '5', '2021-01-17 21:50:36', null, '1');
INSERT INTO `sys_menu` VALUES ('14', '3', '修改角色', null, 'sys:role:update', null, '2', null, '2', '2021-01-17 21:51:14', null, '1');
INSERT INTO `sys_menu` VALUES ('15', '3', '删除角色', null, 'sys:role:delete', null, '2', null, '3', '2021-01-17 21:51:39', null, '1');
INSERT INTO `sys_menu` VALUES ('16', '3', '分配权限', null, 'sys:role:perm', null, '2', null, '5', '2021-01-17 21:52:02', null, '1');
INSERT INTO `sys_menu` VALUES ('17', '4', '添加菜单', null, 'sys:menu:save', null, '2', null, '1', '2021-01-17 21:53:53', '2021-01-17 21:55:28', '1');
INSERT INTO `sys_menu` VALUES ('18', '4', '修改菜单', null, 'sys:menu:update', null, '2', null, '2', '2021-01-17 21:56:12', null, '1');
INSERT INTO `sys_menu` VALUES ('19', '4', '删除菜单', null, 'sys:menu:delete', null, '2', null, '3', '2021-01-17 21:56:36', null, '1');-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`name` varchar(64) NOT NULL,`code` varchar(64) NOT NULL,`remark` varchar(64) DEFAULT NULL COMMENT '备注',`created` datetime DEFAULT NULL,`updated` datetime DEFAULT NULL,`statu` int(5) NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `name` (`name`) USING BTREE,UNIQUE KEY `code` (`code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES ('3', '普通用户', 'normal', '只有基本查看功能', '2021-01-04 10:09:14', '2021-01-30 08:19:52', '1');
INSERT INTO `sys_role` VALUES ('6', '超级管理员', 'admin', '系统默认最高权限,不可以编辑和任意修改', '2021-01-16 13:29:03', '2021-01-17 15:50:45', '1');-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`role_id` bigint(20) NOT NULL,`menu_id` bigint(20) NOT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=102 DEFAULT CHARSET=utf8mb4;-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO `sys_role_menu` VALUES ('60', '6', '1');
INSERT INTO `sys_role_menu` VALUES ('61', '6', '2');
INSERT INTO `sys_role_menu` VALUES ('62', '6', '9');
INSERT INTO `sys_role_menu` VALUES ('63', '6', '10');
INSERT INTO `sys_role_menu` VALUES ('64', '6', '11');
INSERT INTO `sys_role_menu` VALUES ('65', '6', '12');
INSERT INTO `sys_role_menu` VALUES ('66', '6', '13');
INSERT INTO `sys_role_menu` VALUES ('67', '6', '3');
INSERT INTO `sys_role_menu` VALUES ('68', '6', '7');
INSERT INTO `sys_role_menu` VALUES ('69', '6', '14');
INSERT INTO `sys_role_menu` VALUES ('70', '6', '15');
INSERT INTO `sys_role_menu` VALUES ('71', '6', '16');
INSERT INTO `sys_role_menu` VALUES ('72', '6', '4');
INSERT INTO `sys_role_menu` VALUES ('73', '6', '17');
INSERT INTO `sys_role_menu` VALUES ('74', '6', '18');
INSERT INTO `sys_role_menu` VALUES ('75', '6', '19');
INSERT INTO `sys_role_menu` VALUES ('76', '6', '5');
INSERT INTO `sys_role_menu` VALUES ('77', '6', '6');
INSERT INTO `sys_role_menu` VALUES ('96', '3', '1');
INSERT INTO `sys_role_menu` VALUES ('97', '3', '2');
INSERT INTO `sys_role_menu` VALUES ('98', '3', '3');
INSERT INTO `sys_role_menu` VALUES ('99', '3', '4');
INSERT INTO `sys_role_menu` VALUES ('100', '3', '5');
INSERT INTO `sys_role_menu` VALUES ('101', '3', '6');-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`username` varchar(64) DEFAULT NULL,`password` varchar(64) DEFAULT NULL,`avatar` varchar(255) DEFAULT NULL,`email` varchar(64) DEFAULT NULL,`city` varchar(64) DEFAULT NULL,`created` datetime DEFAULT NULL,`updated` datetime DEFAULT NULL,`last_login` datetime DEFAULT NULL,`statu` int(5) NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `UK_USERNAME` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES ('1', 'admin', '$2a$10$R7zegeWzOXPw871CmNuJ6upC0v8D373GuLuTw8jn6NET4BkPRZfgK', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', '123@qq.com', '广州', '2021-01-12 22:13:53', '2021-01-16 16:57:32', '2020-12-30 08:38:37', '1');
INSERT INTO `sys_user` VALUES ('2', 'test', '$2a$10$0ilP4ZD1kLugYwLCs4pmb.ZT9cFqzOZTNaMiHxrBnVIQUGUwEvBIO', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', 'test@qq.com', null, '2021-01-30 08:20:22', '2021-01-30 08:55:57', null, '1');-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`user_id` bigint(20) NOT NULL,`role_id` bigint(20) NOT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4;-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES ('4', '1', '6');
INSERT INTO `sys_user_role` VALUES ('7', '1', '3');
INSERT INTO `sys_user_role` VALUES ('13', '2', '3');
⑤:结果数据封装
@Data
public class Response<T> {/*** 结果** @mock true*/private boolean success;/*** 状态码** @mock 200*/private int code;/*** 消息提示** @mock 操作成功*/private String msg;/*** 结果体** @mock null*/private T data;public Response () {}public Response (int code, Object status) {super();this.code = code;this.msg = status.toString();if (code == 1) {this.success = true;} else {this.success = false;}}public Response (int code, String status, T result) {super();this.code = code;this.msg = status;this.data = result;if (code == 1) {this.success = true;} else {this.success = false;}}public static Response<?> ok() {return new Response<>(1, "success");}public static <T> Response<T> ok(T t) {return new Response<T>(1, "success", t);}public static Response<?> error(String status) {return new Response<>(500, status);}public static Response<?> error(int code, String status) {return new Response<>(code, status);}
}
⑥:全局异常处理
有时候不可避免服务器报错的情况,如果不配置异常处理机制,就会默认返回tomcat或者nginx的5XX页面,对普通用户来说,不太友好,用户也不懂什么情况。这时候需要我们程序员设计返回一个友好简单的格式给前端。
处理办法如下:通过使用 @ControllerAdvice 来进行统一异常处理 @ExceptionHandler(value = RuntimeException.class) 来指定捕获的Exception各个类型异常 ,这个异常的处理,是全局的,所有类似的异常,都会跑到这个地方处理。
步骤二、定义全局异常处理,@ControllerAdvice表示定义全局控制器异常处理,@ExceptionHandler表示针对性异常处理,可对每种异常针对性处理。
/*** 全局异常处理*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {@ResponseStatus (HttpStatus.FORBIDDEN)@ExceptionHandler (value = AccessDeniedException.class)public Response<?> handler(AccessDeniedException e) {log.info("security权限不足:----------------{}", e.getMessage());return Response.error("权限不足");}@ResponseStatus(HttpStatus.BAD_REQUEST)@ExceptionHandler(value = MethodArgumentNotValidException.class)public Response<?> handler(MethodArgumentNotValidException e) {log.info("实体校验异常:----------------{}", e.getMessage());BindingResult bindingResponse = e.getBindingResult();ObjectError objectError = bindingResponse.getAllErrors().stream().findFirst().get();return Response.error(objectError.getDefaultMessage());}@ResponseStatus(HttpStatus.BAD_REQUEST)@ExceptionHandler(value = IllegalArgumentException.class)public Response<?> handler(IllegalArgumentException e) {log.error("Assert异常:----------------{}", e.getMessage());return Response.error(e.getMessage());}@ResponseStatus(HttpStatus.BAD_REQUEST)@ExceptionHandler(value = RuntimeException.class)public Response<?> handler(RuntimeException e) {log.error("运行时异常:----------------{}", e.getMessage());return Response.error(e.getMessage());}
}
上面我们捕捉了几个异常:
- ShiroException:shiro抛出的异常,比如没有权限,用户登录异常
- IllegalArgumentException:处理Assert的异常
- MethodArgumentNotValidException:处理实体校验的异常
- RuntimeException:捕捉其他异常
二、整合Spring Security
①:security的原理
很多人不懂spring security,觉得这个框架比shiro要难,的确,security更加复杂一点,同时功能也更加强大,我们首先来看一下security的原理,这里我们引用一张来自江南一点雨大佬画的一张原理图(https://blog.csdn.net/u012702547/article/details/89629415)
上面这张图一定要好好看,特别清晰,毕竟security是责任链的设计模式,是一堆过滤器链的组合,如果对于这个流程都不清楚,那么你就谈不上理解security。那么针对我们现在的这个系统,我们可以自己设计一个security的认证方案,结合江南一点雨大佬的博客,我们得到这样一套流程:
https://www.processon.com/view/link/606b0b5307912932d09adcb3
流程说明:
- 客户端发起一个请求,进入 Security 过滤器链。
- 当到 LogoutFilter 的时候判断是否是登出路径,如果是登出路径则到 logoutHandler ,如果登出成功则到 logoutSuccessHandler 登出成功处理。如果不是登出路径则直接进入下一个过滤器。
- 当到 UsernamePasswordAuthenticationFilter 的时候判断是否为登录路径,如果是,则进入该过滤器进行登录操作,如果登录失败则到 AuthenticationFailureHandler ,登录失败处理器处理,如果登录成功则到 AuthenticationSuccessHandler 登录成功处理器处理,如果不是登录请求则不进入该过滤器。
- 进入认证BasicAuthenticationFilter进行用户认证,成功的话会把认证了的结果写入到SecurityContextHolder中SecurityContext的属性authentication上面。如果认证失败就会交给AuthenticationEntryPoint认证失败处理类,或者抛出异常被后续ExceptionTranslationFilter过滤器处理异常,如果是AuthenticationException就交给AuthenticationEntryPoint处理,如果是AccessDeniedException异常则交给AccessDeniedHandler处理。
- 当到 FilterSecurityInterceptor 的时候会拿到 uri ,根据 uri 去找对应的鉴权管理器,鉴权管理器做鉴权工作,鉴权成功则到 Controller 层,否则到 AccessDeniedHandler 鉴权失败处理器处理。
Spring Security 实战干货:
必须掌握的一些内置 Filter:https://blog.csdn.net/qq_35067322/article/details/102690579
因为我们是使用json数据进行前后端数据交互,并且我们返回结果也是特定封装的。我们先再总结一下我们需要了解的几个组件:
- LogoutFilter - 登出过滤器
- logoutSuccessHandler - 登出成功之后的操作类
- UsernamePasswordAuthenticationFilter - from提交用户名密码登录认证过滤器
- AuthenticationFailureHandler - 登录失败操作类
- AuthenticationSuccessHandler - 登录成功操作类
- BasicAuthenticationFilter - Basic身份认证过滤器
- SecurityContextHolder - 安全上下文静态工具类
- AuthenticationEntryPoint - 认证失败入口
- ExceptionTranslationFilter - 异常处理过滤器
- AccessDeniedHandler - 权限不足操作类
- FilterSecurityInterceptor - 权限判断拦截器、出口有了上面的组件,那么认证与授权两个问题我们就已经接近啦,我们现在需要做的就是去重写我们的一些关键类。
②:引入Security与jwt
首先我们导入security包,因为我们前后端交互用户凭证用的是JWT,所以我们也导入jwt的相关包,然后因为验证码的存储需要用到redis,所以引入redis。
<!-- springboot security --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- jwt --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><!-- 图片验证码生成器--><dependency><groupId>com.github.axet</groupId><artifactId>kaptcha</artifactId><version>0.0.9</version></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.11</version></dependency>
③:启动项目
启动redis,然后我们再启动项目,这时候我们再去访问http://localhost:19005/,会发现系统会先判断到你未登录跳转到http://localhost:19005/login,因为security内置了登录页,用户名为user,密码在启动项目的时候打印在了控制台。登录完成之后我们才可以正常访问接口。
因为每次启动密码都会改变,所以我们通过配置文件来配置一下默认的用户名和密码:
spring:security:user:name: userpassword: 111111redis:host: 1.117.94.134password: # 密码port: 6379
④:创建Redis工具类和Redis序列化方式
1.Redis工具类
@Component
public class RedisUtil {@Autowiredprivate RedisTemplate redisTemplate;/*** 指定缓存失效时间** @param key 键* @param time 时间(秒)* @return*/public boolean expire(String key, long time) {try {if (time > 0) {redisTemplate.expire(key, time, TimeUnit.SECONDS);}return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 根据key 获取过期时间** @param key 键 不能为null* @return 时间(秒) 返回0代表为永久有效*/public long getExpire(String key) {return redisTemplate.getExpire(key, TimeUnit.SECONDS);}/*** 判断key是否存在** @param key 键* @return true 存在 false不存在*/public boolean hasKey(String key) {try {return redisTemplate.hasKey(key);} catch (Exception e) {e.printStackTrace();return false;}}/*** 删除缓存** @param key 可以传一个值 或多个*/@SuppressWarnings("unchecked")public void del(String... key) {if (key != null && key.length > 0) {if (key.length == 1) {redisTemplate.delete(key[0]);} else {redisTemplate.delete(CollectionUtils.arrayToList(key));}}}//============================String============================= /*** 普通缓存获取** @param key 键* @return 值*/public Object get(String key) {return key == null ? null : redisTemplate.opsForValue().get(key);}/*** 普通缓存放入** @param key 键* @param value 值* @return true成功 false失败*/public boolean set(String key, Object value) {try {redisTemplate.opsForValue().set(key, value);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 普通缓存放入并设置时间** @param key 键* @param value 值* @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期* @return true成功 false 失败*/public boolean set(String key, Object value, long time) {try {if (time > 0) {redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);} else {set(key, value);}return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 递增** @param key 键* @param delta 要增加几(大于0)* @return*/public long incr(String key, long delta) {if (delta < 0) {throw new RuntimeException("递增因子必须大于0");}return redisTemplate.opsForValue().increment(key, delta);}/*** 递减** @param key 键* @param delta 要减少几(小于0)* @return*/public long decr(String key, long delta) {if (delta < 0) {throw new RuntimeException("递减因子必须大于0");}return redisTemplate.opsForValue().increment(key, -delta);}//================================Map================================= /*** HashGet** @param key 键 不能为null* @param item 项 不能为null* @return 值*/public Object hget(String key, String item) {return redisTemplate.opsForHash().get(key, item);}/*** 获取hashKey对应的所有键值** @param key 键* @return 对应的多个键值*/public Map<Object, Object> hmget(String key) {return redisTemplate.opsForHash().entries(key);}/*** HashSet** @param key 键* @param map 对应多个键值* @return true 成功 false 失败*/public boolean hmset(String key, Map<String, Object> map) {try {redisTemplate.opsForHash().putAll(key, map);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** HashSet 并设置时间** @param key 键* @param map 对应多个键值* @param time 时间(秒)* @return true成功 false失败*/public boolean hmset(String key, Map<String, Object> map, long time) {try {redisTemplate.opsForHash().putAll(key, map);if (time > 0) {expire(key, time);}return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 向一张hash表中放入数据,如果不存在将创建** @param key 键* @param item 项* @param value 值* @return true 成功 false失败*/public boolean hset(String key, String item, Object value) {try {redisTemplate.opsForHash().put(key, item, value);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 向一张hash表中放入数据,如果不存在将创建** @param key 键* @param item 项* @param value 值* @param time 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间* @return true 成功 false失败*/public boolean hset(String key, String item, Object value, long time) {try {redisTemplate.opsForHash().put(key, item, value);if (time > 0) {expire(key, time);}return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 删除hash表中的值** @param key 键 不能为null* @param item 项 可以使多个 不能为null*/public void hdel(String key, Object... item) {redisTemplate.opsForHash().delete(key, item);}/*** 判断hash表中是否有该项的值** @param key 键 不能为null* @param item 项 不能为null* @return true 存在 false不存在*/public boolean hHasKey(String key, String item) {return redisTemplate.opsForHash().hasKey(key, item);}/*** hash递增 如果不存在,就会创建一个 并把新增后的值返回** @param key 键* @param item 项* @param by 要增加几(大于0)* @return*/public double hincr(String key, String item, double by) {return redisTemplate.opsForHash().increment(key, item, by);}/*** hash递减** @param key 键* @param item 项* @param by 要减少记(小于0)* @return*/public double hdecr(String key, String item, double by) {return redisTemplate.opsForHash().increment(key, item, -by);}//============================set============================= /*** 根据key获取Set中的所有值** @param key 键* @return*/public Set<Object> sGet(String key) {try {return redisTemplate.opsForSet().members(key);} catch (Exception e) {e.printStackTrace();return null;}}/*** 根据value从一个set中查询,是否存在** @param key 键* @param value 值* @return true 存在 false不存在*/public boolean sHasKey(String key, Object value) {try {return redisTemplate.opsForSet().isMember(key, value);} catch (Exception e) {e.printStackTrace();return false;}}/*** 将数据放入set缓存** @param key 键* @param values 值 可以是多个* @return 成功个数*/public long sSet(String key, Object... values) {try {return redisTemplate.opsForSet().add(key, values);} catch (Exception e) {e.printStackTrace();return 0;}}/*** 将set数据放入缓存** @param key 键* @param time 时间(秒)* @param values 值 可以是多个* @return 成功个数*/public long sSetAndTime(String key, long time, Object... values) {try {Long count = redisTemplate.opsForSet().add(key, values);if (time > 0) expire(key, time);return count;} catch (Exception e) {e.printStackTrace();return 0;}}/*** 获取set缓存的长度** @param key 键* @return*/public long sGetSetSize(String key) {try {return redisTemplate.opsForSet().size(key);} catch (Exception e) {e.printStackTrace();return 0;}}/*** 移除值为value的** @param key 键* @param values 值 可以是多个* @return 移除的个数*/public long setRemove(String key, Object... values) {try {Long count = redisTemplate.opsForSet().remove(key, values);return count;} catch (Exception e) {e.printStackTrace();return 0;}}//===============================list================================= /*** 获取list缓存的内容** @param key 键* @param start 开始* @param end 结束 0 到 -1代表所有值* @return*/public List<Object> lGet(String key, long start, long end) {try {return redisTemplate.opsForList().range(key, start, end);} catch (Exception e) {e.printStackTrace();return null;}}/*** 获取list缓存的长度** @param key 键* @return*/public long lGetListSize(String key) {try {return redisTemplate.opsForList().size(key);} catch (Exception e) {e.printStackTrace();return 0;}}/*** 通过索引 获取list中的值** @param key 键* @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推* @return*/public Object lGetIndex(String key, long index) {try {return redisTemplate.opsForList().index(key, index);} catch (Exception e) {e.printStackTrace();return null;}}/*** 将list放入缓存** @param key 键* @param value 值* @return*/public boolean lSet(String key, Object value) {try {redisTemplate.opsForList().rightPush(key, value);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 将list放入缓存** @param key 键* @param value 值* @param time 时间(秒)* @return*/public boolean lSet(String key, Object value, long time) {try {redisTemplate.opsForList().rightPush(key, value);if (time > 0) expire(key, time);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 将list放入缓存** @param key 键* @param value 值* @return*/public boolean lSet(String key, List<Object> value) {try {redisTemplate.opsForList().rightPushAll(key, value);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 将list放入缓存** @param key 键* @param value 值* @param time 时间(秒)* @return*/public boolean lSet(String key, List<Object> value, long time) {try {redisTemplate.opsForList().rightPushAll(key, value);if (time > 0) expire(key, time);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 根据索引修改list中的某条数据** @param key 键* @param index 索引* @param value 值* @return*/public boolean lUpdateIndex(String key, long index, Object value) {try {redisTemplate.opsForList().set(key, index, value);return true;} catch (Exception e) {e.printStackTrace();return false;}}/*** 移除N个值为value** @param key 键* @param count 移除多少个* @param value 值* @return 移除的个数*/public long lRemove(String key, long count, Object value) {try {Long remove = redisTemplate.opsForList().remove(key, count, value);return remove;} catch (Exception e) {e.printStackTrace();return 0;}}//================有序集合 sort set===================/*** 有序set添加元素** @param key* @param value* @param score* @return*/public boolean zSet(String key, Object value, double score) {return redisTemplate.opsForZSet().add(key, value, score);}public long batchZSet(String key, Set<ZSetOperations.TypedTuple> typles) {return redisTemplate.opsForZSet().add(key, typles);}public void zIncrementScore(String key, Object value, long delta) {redisTemplate.opsForZSet().incrementScore(key, value, delta);}public void zUnionAndStore(String key, Collection otherKeys, String destKey) {redisTemplate.opsForZSet().unionAndStore(key, otherKeys, destKey);}/*** 获取zset数量* @param key* @param value* @return*/public long getZsetScore(String key, Object value) {Double score = redisTemplate.opsForZSet().score(key, value);if(score==null){return 0;}else{return score.longValue();}}/*** 获取有序集 key 中成员 member 的排名 。* 其中有序集成员按 score 值递减 (从大到小) 排序。* @param key* @param start* @param end* @return*/public Set<ZSetOperations.TypedTuple> getZSetRank(String key, long start, long end) {return redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);}}
2.设置Redis序列化方式
@Configuration
public class RedisConfig {@Bean// 定义 RedisTemplate BeanRedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {// 创建 RedisTemplate 实例RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();// 设置连接工厂redisTemplate.setConnectionFactory(redisConnectionFactory);// 配置 JSON 序列化器Jackson2JsonRedisSerializer<Object> redisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);redisSerializer.setObjectMapper(new ObjectMapper());// 设置键的序列化器为 StringRedisSerializerredisTemplate.setKeySerializer(new StringRedisSerializer());// 设置值的序列化器为 StringRedisSerializerredisTemplate.setValueSerializer(new StringRedisSerializer());// 设置哈希键的序列化器为 StringRedisSerializerredisTemplate.setHashKeySerializer(new StringRedisSerializer());// 设置哈希值的序列化器为 StringRedisSerializerredisTemplate.setHashValueSerializer(new StringRedisSerializer());return redisTemplate;}
}
⑤:用户认证
首先我们来解决用户认证问题,分为首次登陆,和二次认证
- 首次登录认证:用户名、密码和验证码完成登录
- 二次token认证:请求头携带Jwt进行身份认证
使用用户名密码来登录的,然后我们还想添加图片验证码,那么security给我们提供的UsernamePasswordAuthenticationFilter能使用吗?
首先security的所有过滤器都是没有图片验证码这回事的,看起来不适用了。其实这里我们可以灵活点,如果你依然想沿用自带的UsernamePasswordAuthenticationFilter,那么我们就在这过滤器之前添加一个图片验证码过滤器。当然了我们也可以通过自定义过滤器继承UsernamePasswordAuthenticationFilter,然后自己把验证码验证逻辑和认证逻辑写在一起,这也是一种解决方式。
我们这次解决方式是在UsernamePasswordAuthenticationFilter之前自定义一个图片过滤器CaptchaFilter,提前校验验证码是否正确,这样我们就可以使用UsernamePasswordAuthenticationFilter了,然后登录正常或失败我们都可以通过对应的Handler来返回我们特定格式的封装结果数据。
⑥:生成验证码
首先我们先生成验证码,之前我们已经引用了google的验证码生成器,我们先来配置一下图片验证码的生成规则:
1.创建KaptchaConfig 定义图片验证码的长宽字体颜色等
@Configuration
public class KaptchaConfig {@Bean// 创建 DefaultKaptcha Beanpublic DefaultKaptcha defaultKaptcha() {// 配置 Kaptcha 的属性Properties properties = new Properties();// 设置验证码边框为无properties.put("kaptcha.border", "no");// 设置验证码文本颜色为黑色properties.put("kaptcha.textproducer.font.color", "black");// 设置字符间隔为4个像素properties.put("kaptcha.textproducer.char.space", "4");// 设置验证码图片高度为40像素properties.put("kaptcha.image.height", "40");// 设置验证码图片宽度为120像素properties.put("kaptcha.image.width", "120");// 设置验证码文本字体大小为30properties.put("kaptcha.textproducer.font.size", "30");// 使用配置创建 Config 实例Config config = new Config(properties);// 创建 DefaultKaptcha 实例DefaultKaptcha defaultKaptcha = new DefaultKaptcha();// 设置配置defaultKaptcha.setConfig(config);return defaultKaptcha;}
}
2. 创建AuthController 生成验证码方法
因为前后端分离,我们禁用了session,所以我们把验证码放在了redis中,使用一个随机字符串作为key,并传送到前端,前端再把随机字符串和用户输入的验证码提交上来,这样我们就可以通过随机字符串获取到保存的验证码和用户的验证码进行比较了是否正确了。
然后因为图片验证码的方式,所以我们进行了encode,把图片进行了base64编码,这样前端就可以显示图片了。
- 定义一个常量类 Const
public class Const {public final static String CAPTCHA_KEY = "captcha";
}
- AuthController
@RestController
@Slf4j
public class AuthController {@Autowiredprivate AuthService authService;/*** 生成验证码* @DateTime: 2023/11/19 14:28** @param request:* @param response:* @return Response<?>* @author: Coke*/@GetMapping("/captcha")public Response<?> captcha(HttpServletRequest request, HttpServletResponse response){return authService.captcha(request, response);}
}
- AuthService
public interface AuthService {Response<?> captcha (HttpServletRequest request, HttpServletResponse response);
}
- AuthServiceImpl
@Slf4j
@Service
public class AuthServiceImpl implements AuthService {@Autowiredprivate Producer producer;@Autowiredprivate RedisUtil redisUtil;/*** 生成验证码* @DateTime: 2023/11/19 14:28** @param request:* @param response:* @return Response<?>* @author: Coke*/@Overridepublic Response<?> captcha (HttpServletRequest request, HttpServletResponse response) {// 生成验证码文本String code = producer.createText();// 生成一个随机的keyString key = UUID.randomUUID().toString();// 生成验证码图片BufferedImage image = producer.createImage(code);ByteArrayOutputStream outputStream = new ByteArrayOutputStream();try {// 将验证码图片写入输出流ImageIO.write(image, "jpg", outputStream);// 将输出流中的图片转换为Base64编码的字符串BASE64Encoder base64Encoder = new BASE64Encoder();String str = "data:image/jpeg;base64,";String base64Img = str + base64Encoder.encode(outputStream.toByteArray());// 将验证码和key存入Redis,并设置过期时间为120秒redisUtil.hset(Const.CAPTCHA_KEY, key, code, 120);// 打印验证码和对应的key到日志中log.info("验证码 -- {} - {}", key, code);// 构建返回的Map对象Map<Object, Object> map = MapUtil.builder().put("key", key).put("base64Img", base64Img).build();// 返回成功响应,携带验证码的key和Base64编码的图片return Response.ok(map);} catch (IOException e) {// 捕获IO异常,返回错误响应e.printStackTrace();return Response.error("验证码生成异常," + e.getMessage());}}
}
⑦:登录认证
登录失败的时候交给AuthenticationFailureHandler,所以我们自定义了LoginFailureHandler
其实主要就是获取异常的消息,然后封装,最后转成json返回给前端而已
1.LoginFailureHandler
/*** 处理认证失败的逻辑** @author: Coke* @DateTime: 2023/11/19/15:05**/
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {@Override// 处理认证失败的逻辑public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {// 设置响应的内容类型为JSON,使用UTF-8字符集response.setContentType("application/json;charset=UTF-8");// 获取响应输出流ServletOutputStream outputStream = response.getOutputStream();// 构建错误响应,如果异常消息是"Bad credentials",则返回"用户名或密码不正确",否则返回异常消息Response<?> error = Response.error("Bad credentials".equals(exception.getMessage()) ? "用户名或密码不正确" : exception.getMessage());// 将错误响应转换为JSON字符串,并写入输出流中outputStream.write(JSONUtil.toJsonStr(error).getBytes(StandardCharsets.UTF_8));outputStream.flush();outputStream.close();}
}
2.com.it.config.SecurityConfig
首先formLogin我们定义了表单登录提交的方式以及定义了登录失败的处理器,后面我们还要定义登录成功的处理器的。然后authorizeRequests我们除了白名单的链接之外其他请求都会被拦截。再然后就是禁用session,最后是设定验证码过滤器在登录过滤器之前。
/*** 定义登录失败时的处理、白名单、请求授权规则、不创建session安全策略** @author: Coke* @DateTime: 2023/11/19/15:15**/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity (prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate LoginFailureHandler loginFailureHandler;@Autowiredprivate CaptchaFilter captchaFilter;public static final String[] URL_WHITELIST = {"/webjars/**","/favicon.ico","/captcha","/login","/logout",};@Override// 配置HttpSecurity,定义安全策略protected void configure(HttpSecurity http) throws Exception {http.cors().and().csrf().disable() // 启用跨域支持,禁用CSRF保护.formLogin().failureHandler(loginFailureHandler) // 登录失败处理器.and().authorizeRequests().antMatchers(URL_WHITELIST).permitAll() // 设置白名单,允许访问的URL.anyRequest().authenticated() // 其他所有请求需要身份验证.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 不会创建session.and().addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class); // 登录验证码校验过滤器}}
⑧:验证码认证过滤器
验证码出错的时候我们返回异常信息,这是一个认证异常,所以我们自定了一个CaptchaException
1.com.it.exception.CaptchaException
/*** 验证码出错的时候我们返回异常信息** @author: Coke* @DateTime: 2023/11/19/15:27**/
public class CaptchaException extends AuthenticationException {public CaptchaException(String msg) {super(msg);}
}
2.com.it.filter.CaptchaFilter
/*** 用户登录时校验验证码,如果验证码不正确,则通过登录失败处理器返回相应的错误信息** @author: Coke* @DateTime: 2023/11/19/15:01**/
@Slf4j
@Component
public class CaptchaFilter extends OncePerRequestFilter {private final String loginUrl = "/login";@Autowiredprivate RedisUtil redisUtil;@Autowiredprivate LoginFailureHandler loginFailureHandler;@Override// 进行过滤操作protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {// 获取请求的URLString url = request.getRequestURI();// 判断是否为登录请求且为POST请求if (loginUrl.equals(url) && "POST".equals(request.getMethod())) {log.info("获取到login链接,正在校验验证码 -- " + url);try {// 校验验证码validate(request);} catch (CaptchaException e) {log.info(e.getMessage());// 将 CaptchaException 转换为 AuthenticationExceptionAuthenticationException authenticationException = new AuthenticationServiceException(e.getMessage(), e);// 交给登录失败处理器处理loginFailureHandler.onAuthenticationFailure(request, response, authenticationException);}}// 继续处理请求filterChain.doFilter(request, response);}// 验证验证码private void validate(HttpServletRequest request) throws CaptchaException {// 从请求参数中获取验证码和keyString code = request.getParameter("code");String key = request.getParameter("key");// 判断验证码和key是否为空if (StringUtils.isBlank(code) || StringUtils.isBlank(key)) {throw new CaptchaException("验证码不能为空");}// 从Redis中获取存储的验证码String storedCode = (String) redisUtil.hget(Const.CAPTCHA_KEY, key);// 判断输入的验证码是否正确if (!StrUtil.equals(code, storedCode)) {throw new CaptchaException("验证码不正确");}// 验证通过,删除Redis中的验证码redisUtil.hdel(Const.CAPTCHA_KEY, key);}
}
⑨:接口测试(Postman)
1.http://127.0.0.1:19005/captcha
可以看到,我们的随机码key和base64Img编码都是正常的
- redis中也有数据
三、身份认证
这时候我们就可以去提交表单了吗,其实还不可以,为啥?因为就算我们登录成功,security默认跳转到/链接,但是又会因为没有权限访问/,所有又会教你去登录,所以我们必须取消原先默认的登录成功之后的操作,根据我们之前分析的流程,登录成功之后会走AuthenticationSuccessHandler,因此在登录之前,我们先去自定义这个登录成功操作类:
①:生成JWT
1.引入依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId><optional>true</optional></dependency>
2.添加配置
coke:jwt:header: Authorizationexpire: 604800 #7天,秒单位secret: ji8n3439n439n43ld9ne9343fdfer49h
3.创建jwtUtil工具类
@Data
@Component
@ConfigurationProperties(prefix = "coke.jwt")
public class JwtUtils {// JWT 过期时间(单位:秒)private long expire;// JWT 密钥,用于签名和验证private String secret;// JWT 头部字段,可自定义private String header;/*** 生成 JWT** @param username 用户名* @return JWT 字符串*/public String generateToken(String username) {// 获取当前时间Date nowDate = new Date();// 计算过期时间,当前时间 + 过期时长Date expireDate = new Date(nowDate.getTime() + expire);// 使用 JWT Builder 构建 JWTreturn Jwts.builder().setHeaderParam("typ", "JWT") // 设置头部信息,通常为JWT.setSubject(username) // 设置主题,通常为用户名.setIssuedAt(nowDate) // 设置签发时间,即当前时间.setExpiration(expireDate) // 设置过期时间.signWith(SignatureAlgorithm.HS512, secret) // 使用HS512签名算法和密钥进行签名.compact();}/*** 解析 JWT 获取声明** @param jwt JWT 字符串* @return JWT 中的声明部分*/public Claims getClaimByToken(String jwt) {try {// 使用 JWT 解析器解析 JWT,并获取声明部分return Jwts.parser().setSigningKey(secret) // 设置解析时的密钥,必须与生成时的密钥一致.parseClaimsJws(jwt).getBody();} catch (Exception e) {// 解析失败,返回nullreturn null;}}/*** 检查 JWT 是否过期** @param claims JWT 中的声明部分* @return 是否过期*/public boolean isTokenExpired(Claims claims) {// 检查过期时间是否在当前时间之前return claims.getExpiration().before(new Date());}}
4.com.it.config.LoginSuccessHandler
/*** 在认证成功时利用用户名生成JWT,并将其设置到响应头中,然后返回一个成功的JSON响应** @author: Coke* @DateTime: 2023/11/19/19:40**/
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {@Autowiredprivate JwtUtils jwtUtils;@Override// 处理认证成功的逻辑public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {// 设置响应的内容类型为JSON,使用UTF-8字符集response.setContentType("application/json;charset=UTF-8");// 获取响应输出流ServletOutputStream outputStream = response.getOutputStream();// 生成JWT并设置到响应头String jwt = jwtUtils.generateToken(authentication.getName());response.setHeader(jwtUtils.getHeader(), jwt);// 构建成功响应Response<?> ok = Response.ok();// 将成功响应转换为JSON字符串,并写入输出流中outputStream.write(JSONUtil.toJsonStr(ok).getBytes(StandardCharsets.UTF_8));outputStream.flush();outputStream.close();}
}
5.然后我们再security配置中添加上登录成功之后的操作类
- com.it.config.SecurityConfig
@Autowired
LoginSuccessHandler loginSuccessHandler;...
# configure代码:http.cors().and().csrf().disable().formLogin().failureHandler(loginFailureHandler) // 登录失败处理器.successHandler(loginSuccessHandler) // 登录成功处理器
②:身份认证 1
登录成功之后前端就可以获取到了jwt的信息,前端中我们是保存在了store中,同时也保存在了localStorage中,然后每次axios请求之前,我们都会添加上我们的请求头信息
所以后端进行用户身份识别的时候,我们需要通过请求头中获取jwt,然后解析出我们的用户名,这样我们就可以知道是谁在访问我们的接口啦,然后判断用户是否有权限等操作
1.那么我们自定义一个过滤器用来进行识别jwt
- com.it.filter.JWTAuthenticationFilter
@Slf4j
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {@Autowiredprivate JwtUtils jwtUtils;@Autowiredprivate RedisUtil redisUtil;// 构造方法,接收 AuthenticationManagerpublic JWTAuthenticationFilter(AuthenticationManager authenticationManager) {super(authenticationManager);}@Override// 进行JWT校验的过滤操作protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {// 日志记录JWT校验过滤器的执行log.info("JWT校验过滤器执行");// 从请求头中获取JWTString jwt = request.getHeader(jwtUtils.getHeader());// 如果JWT为空,则直接放行,继续处理下一个过滤器if (StrUtil.isBlankOrUndefined(jwt)) {chain.doFilter(request, response);return;}// 使用JWT工具类解析JWT获取声明Claims claim = jwtUtils.getClaimByToken(jwt);// 如果JWT异常,则抛出JwtExceptionif (claim == null) {throw new JwtException("Token异常");}// 如果JWT已过期,则抛出JwtExceptionif (jwtUtils.isTokenExpired(claim)) {throw new JwtException("Token已过期");}// 从JWT中获取用户名String username = claim.getSubject();// 日志记录正在登录的用户信息log.info("用户-{},正在登录!", username);// 构建认证令牌,此时认证信息中没有密码(为null),并设置用户拥有的权限集合为空集合UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, new TreeSet<>());// 将认证信息设置到安全上下文中SecurityContextHolder.getContext().setAuthentication(authenticationToken);// 继续处理请求chain.doFilter(request, response);}
}
上面的逻辑也很简单,正如我前面说到的,获取到用户名之后我们直接把封装成UsernamePasswordAuthenticationToken,之后交给SecurityContextHolder参数传递authentication对象,这样后续security就能获取到当前登录的用户信息了,也就完成了用户认证。
当认证失败的时候会进入AuthenticationEntryPoint,于是我们自定义认证失败返回的数据
- com.it.config.JwtAuthenticationEntryPoint
/*** 定义认证失败处理类** @author: Coke* @DateTime: 2023/11/19/20:17**/
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {@Overridepublic void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {log.info("认证失败!未登录!");response.setContentType("application/json;charset=UTF-8");response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);ServletOutputStream outputStream = response.getOutputStream();Response<?> error = Response.error("请先登录!");outputStream.write(JSONUtil.toJsonStr(error).getBytes(StandardCharsets.UTF_8));outputStream.flush();outputStream.close();}
}
2.然后我们把认证过滤器和认证失败入口配置到SecurityConfig中
- com.it.config.SecurityConfig
@Autowired
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;@Bean
JWTAuthenticationFilter jwtAuthenticationFilter() throws Exception {JWTAuthenticationFilter filter = new JWTAuthenticationFilter(authenticationManager());return filter;
}.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
.addFilter(jwtAuthenticationFilter())
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) // 登录验证码校验过滤器
这样携带jwt请求头我们就可以正常访问我们的接口了。
③:身份认证 2
之前我们的用户名密码配置在配置文件中的,而且密码也用的是明文,这明显不符合我们的要求,我们的用户必须是存储在数据库中,密码也是得经过加密的。所以我们先来解决这个问题,然后再去弄授权
首先来插入一条用户数据,但这里有个问题,就是我们的密码怎么生成?密码怎么来的?这里我们使用Security内置了的BCryptPasswordEncoder,里面就有生成和匹配密码是否正确的方法,也就是加密和验证策略。因此我们再SecurityConfig中进行配置:
- com.it.config.SecurityConfig
@BeanBCryptPasswordEncoder bCryptPasswordEncoder() {return new BCryptPasswordEncoder();}
这样系统就会使用我们找个新的密码策略进行匹配密码是否正常了。之前我们配置文件配置的用户名密码去掉:
# security:
# user:
# name: user
# password: 111111
- 添加依赖
<dependency><groupId>javax.validation</groupId><artifactId>validation-api</artifactId></dependency>
1.创建实体类
- BaseEntity
@Data
public class BaseEntity implements Serializable {@TableId(value = "id", type = IdType.AUTO)private Long id;private LocalDateTime created;private LocalDateTime updated;private Integer statu;
}
- SysRole
@Data
@EqualsAndHashCode(callSuper = true)
public class SysRole extends BaseEntity {private static final long serialVersionUID = 1L;@NotBlank(message = "角色名称不能为空")private String name;@NotBlank(message = "角色编码不能为空")private String code;/*** 备注*/private String remark;@TableField(exist = false)private List<Long> menuIds = new ArrayList<>();
}
- SysUser
@Data
@EqualsAndHashCode(callSuper = true)
public class SysUser extends BaseEntity {private static final long serialVersionUID = 1L;@NotBlank(message = "用户名不能为空")private String username;private String password;private String avatar;@NotBlank(message = "邮箱不能为空")@Email(message = "邮箱格式不正确")private String email;private String city;private LocalDateTime lastLogin;@TableField(exist = false)private List<SysRole> sysRoles = new ArrayList<>();}
2.SysUserMapper
@Repository
public interface SysUserMapper extends BaseMapper<SysUser> {
}
3.SysUserService
public interface SysUserService extends IService<SysUser> {SysUser getByUsername(String username);
}
4.SysUserServiceImpl
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {@AutowiredSysUserMapper sysUserMapper;@AutowiredRedisUtil redisUtil;@Overridepublic SysUser getByUsername(String username) {return getOne(new QueryWrapper<SysUser>().eq("username", username));}
}
5.UserDetailsServiceImpl
我们登录过程系统不是从我们数据库中获取数据的,因此,我们需要重新定义这个查用户数据的过程,我们需要重写UserDetailsService接口。
- 首先自定义了AccountUser去重写了UserDetails,这也是为了后面我们可能会调整用户的一些数据等
public class AccountUser implements UserDetails {private Long userId;private String password;private final String username;private final Collection<? extends GrantedAuthority> authorities;private final boolean accountNonExpired;private final boolean accountNonLocked;private final boolean credentialsNonExpired;private final boolean enabled;public AccountUser (Long userId, String username, String password, Collection<? extends GrantedAuthority> authorities) {this(userId, username, password, true, true, true, true, authorities);}public AccountUser (Long userId, String username, String password, boolean enabled, boolean accountNonExpired,boolean credentialsNonExpired, boolean accountNonLocked,Collection<? extends GrantedAuthority> authorities) {Assert.isTrue(username != null && !"".equals(username) && password != null,"Cannot pass null or empty values to constructor");this.userId = userId;this.username = username;this.password = password;this.enabled = enabled;this.accountNonExpired = accountNonExpired;this.credentialsNonExpired = credentialsNonExpired;this.accountNonLocked = accountNonLocked;this.authorities = authorities;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return this.authorities;}@Overridepublic String getPassword() {return this.password;}@Overridepublic String getUsername() {return this.username;}@Overridepublic boolean isAccountNonExpired() {return this.accountNonExpired;}@Overridepublic boolean isAccountNonLocked() {return this.accountNonLocked;}@Overridepublic boolean isCredentialsNonExpired() {return this.credentialsNonExpired;}@Overridepublic boolean isEnabled() {return this.enabled;}
}
- UserDetailsServiceImpl
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate SysUserService sysUserService;@Overridepublic UserDetails loadUserByUsername (String username) throws UsernameNotFoundException {SysUser sysUser = sysUserService.getByUsername(username);if (sysUser == null) {throw new UsernameNotFoundException("用户名或密码不正确!");}return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), new TreeSet<>());}
}
因为security在认证用户身份的时候会调用UserDetailsService.loadUserByUsername()方法,因此我们重写了之后security就可以根据我们的流程去查库获取用户了。然后我们把UserDetailsServiceImpl配置到SecurityConfig中
- com.it.config.SecurityConfig
@Autowired
UserDetailsServiceImpl userDetailsService;@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userDetailsService);
}
④:Postman测试登录
1.获取验证码 http://127.0.0.1:19005/captcha
2.从控制台获取到对应的验证码
3.提交登录表单
登录成功,并在请求头中获取到了Authorization,也就是JWT。完美!
⑤:解决授权
01. 流程分析
然后关于权限部分,也是security的重要功能,当用户认证成功之后,我们就知道谁在访问系统接口,这是又有一个问题,就是这个用户有没有权限来访问我们这个接口呢,要解决这个问题,我们需要知道用户有哪些权限,哪些角色,这样security才能我们做权限判断。
之前我们已经定义及几张表,用户、角色、菜单、以及一些关联表,一般当权限粒度比较细的时候,我们都通过判断用户有没有此菜单或操作的权限,而不是通过角色判断,而用户和菜单是不直接做关联的,是通过用户拥有哪些角色,然后角色拥有哪些菜单权限这样来获得的。
问题1:我们是在哪里赋予用户权限的?有两个地方:
-
1、用户登录,调用调用UserDetailsService.loadUserByUsername()方法时候可以返回用户的权限信息。
-
2、接口调用进行身份认证过滤器时候JWTAuthenticationFilter,需要返回用户权限信息
问题2:在哪里决定什么接口需要什么权限?
Security内置的权限注解
-
@PreAuthorize:方法执行前进行权限检查
-
@PostAuthorize:方法执行后进行权限检查
-
@Secured:类似于 @PreAuthorize可以在Controller的方法前添加这些注解表示接口需要什么权限。
比如需要Admin角色权限:
@PreAuthorize("hasRole('admin')")
比如需要添加管理员的操作权限
@PreAuthorize("hasAuthority('sys:user:save')")
ok,我们再来整体梳理一下授权、验证权限的流程:
- 用户登录或者调用接口时候识别到用户,并获取到用户的权限信息
- 注解标识Controller中的方法需要的权限或角色
- Security通过FilterSecurityInterceptor匹配URI和权限是否匹配
- 有权限则可以访问接口,当无权限的时候返回异常交给AccessDeniedHandler操作类处理
ok,流程清晰之后我们就开始我们的编码:
02. 编码
.1.创建菜单实体类
@Data
@EqualsAndHashCode(callSuper = true)
public class SysMenu extends BaseEntity {private static final long serialVersionUID = 1L;/*** 父菜单ID,一级菜单为0*/@NotNull(message = "上级菜单不能为空")private Long parentId;@NotBlank(message = "菜单名称不能为空")private String name;/*** 菜单URL*/private String path;/*** 授权(多个用逗号分隔,如:user:list,user:create)*/@NotBlank(message = "菜单授权码不能为空")private String perms;private String component;/*** 类型 0:目录 1:菜单 2:按钮*/@NotNull(message = "菜单类型不能为空")private Integer type;/*** 菜单图标*/private String icon;/*** 排序*/@TableField("orderNum")private Integer orderNum;@TableField(exist = false)private List<SysMenu> children = new ArrayList<>();
}
2.菜单Dto
/*** {* name: 'SysUser',* title: '用户管理',* icon: 'el-icon-s-custom',* path: '/sys/users',* component: 'sys/User',* children: []* },*/
@Data
public class SysMenuDto implements Serializable {private Long id;private String name;private String title;private String icon;private String path;private String component;private List<SysMenuDto> children = new ArrayList<>();}
3.菜单mapper层 SysMenuMapper
public interface SysMenuMapper extends BaseMapper<SysMenu> {}
4.菜单接口SysMenuService
public interface SysMenuService extends IService<SysMenu> {List<SysMenuDto> getCurrentUserNav();List<SysMenu> tree();}
6.菜单接口实现类 SysMenuServiceImpl
@Service
public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> implements SysMenuService {@AutowiredSysUserService sysUserService;@AutowiredSysUserMapper sysUserMapper;@Overridepublic List<SysMenuDto> getCurrentUserNav() {String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();SysUser sysUser = sysUserService.getByUsername(username);List<Long> menuIds = sysUserMapper.getNavMenuIds(sysUser.getId());List<SysMenu> menus = this.listByIds(menuIds);// 转树状结构List<SysMenu> menuTree = buildTreeMenu(menus);// 实体转DTOreturn convert(menuTree);}@Overridepublic List<SysMenu> tree() {// 获取所有菜单信息List<SysMenu> sysMenus = this.list(new QueryWrapper<SysMenu>().orderByAsc("orderNum"));// 转成树状结构return buildTreeMenu(sysMenus);}private List<SysMenuDto> convert(List<SysMenu> menuTree) {List<SysMenuDto> menuDtos = new ArrayList<>();menuTree.forEach(m -> {SysMenuDto dto = new SysMenuDto();dto.setId(m.getId());dto.setName(m.getPerms());dto.setTitle(m.getName());dto.setComponent(m.getComponent());dto.setPath(m.getPath());if (m.getChildren().size() > 0) {// 子节点调用当前方法进行再次转换dto.setChildren(convert(m.getChildren()));}menuDtos.add(dto);});return menuDtos;}private List<SysMenu> buildTreeMenu(List<SysMenu> menus) {List<SysMenu> finalMenus = new ArrayList<>();// 先各自寻找到各自的孩子for (SysMenu menu : menus) {for (SysMenu e : menus) {if (menu.getId() == e.getParentId()) {menu.getChildren().add(e);}}// 提取出父节点if (menu.getParentId() == 0L) {finalMenus.add(menu);}}System.out.println(JSONUtil.toJsonStr(finalMenus));return finalMenus;}
}
7.SysUserMapper
@Repository
public interface SysUserMapper extends BaseMapper<SysUser> {@Select("SELECT DISTINCT rm.menu_id FROM sys_user_role ur LEFT JOIN `sys_role_menu` rm ON rm.role_id = ur.role_id WHERE ur.user_id = #{userId};")List<Long> getNavMenuIds(Long userId);List<SysUser> listByMenuId(Long menuId);
}
8.角色mapper层 SysRoleMapper
public interface SysRoleMapper extends BaseMapper<SysRole> {}
9.角色接口SysRoleService
public interface SysRoleService extends IService<SysRole> {List<SysRole> listRolesByUserId(Long userId);}
10.角色接口实现 SysRoleServiceImpl
@Service
public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole> implements SysRoleService {@Overridepublic List<SysRole> listRolesByUserId(Long userId) {List<SysRole> sysRoles = this.list(new QueryWrapper<SysRole>().inSql("id", "select role_id from sys_user_role where user_id = " + userId));return sysRoles;}
}
11.SysUserServiceImpl
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {@AutowiredSysUserMapper sysUserMapper;@Autowiredprivate SysMenuService sysMenuService;@Autowiredprivate SysRoleService sysRoleService;@AutowiredRedisUtil redisUtil;@Overridepublic SysUser getByUsername(String username) {return getOne(new QueryWrapper<SysUser>().eq("username", username));}@Overridepublic String getUserAuthorityInfo (Long userId) {SysUser sysUser = sysUserMapper.selectById(userId);String authority = "";if (redisUtil.hasKey("GrantedAuthority:" + sysUser.getUsername())) {authority = (String) redisUtil.get("GrantedAuthority:" + sysUser.getUsername());} else {// 获取角色编码List<SysRole> roles = sysRoleService.list(new QueryWrapper<SysRole>().inSql("id", "select role_id from sys_user_role where user_id = " + userId));if (roles.size() > 0) {String roleCodes = roles.stream().map(r -> "ROLE_" + r.getCode()).collect(Collectors.joining(","));authority = roleCodes.concat(",");}// 获取菜单操作编码List<Long> menuIds = sysUserMapper.getNavMenuIds(userId);if (menuIds.size() > 0) {List<SysMenu> menus = sysMenuService.listByIds(menuIds);String menuPerms = menus.stream().map(m -> m.getPerms()).collect(Collectors.joining(","));authority = authority.concat(menuPerms);}redisUtil.set("GrantedAuthority:" + sysUser.getUsername(), authority, 60 * 60);}return authority;}
}
12.UserDetailsServiceImpl
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {... return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), getUserAuthority(sysUser.getId()));
}public List<GrantedAuthority> getUserAuthority(Long userId) {// 通过内置的工具类,把权限字符串封装成GrantedAuthority列表return AuthorityUtils.commaSeparatedStringToAuthorityList(sysUserService.getUserAuthorityInfo(userId));
}
13.修改JWTAuthenticationFilter
@Autowiredprivate UserDetailsServiceImpl userDetailsService;@AutowiredSysUserService sysUserService;// 从JWT中获取用户名String username = claim.getSubject();// 日志记录正在登录的用户信息log.info("用户-{},正在登录!", username);SysUser sysUser = sysUserService.getByUsername(username);UsernamePasswordAuthenticationToken token= new UsernamePasswordAuthenticationToken(username, null, userDetailsService.getUserAuthority(sysUser.getId()));// 将认证信息设置到安全上下文中SecurityContextHolder.getContext().setAuthentication(token);
⑥:权限缓存
因为上面我在获取用户权限那里添加了个缓存,这时候问题来了,就是权限缓存的实时更新问题,比如当后台更新某个管理员的权限角色信息的时候如果权限缓存信息没有实时更新,就会出现操作无效的问题,那么我们现在点定义几个方法,用于清除某个用户或角色或者某个菜单的权限的方法
1.SysUserService中添加一接口
void clearUserAuthorityInfo(String username);void clearUserAuthorityInfoByRoleId(Long roleId);void clearUserAuthorityInfoByMenuId(Long menuId);
2.SysUserServiceImpl
// 删除某个用户的权限信息@Overridepublic void clearUserAuthorityInfo(String username) {redisUtil.del("GrantedAuthority:" + username);}// 删除所有与该角色关联的用户的权限信息@Overridepublic void clearUserAuthorityInfoByRoleId(Long roleId) {List<SysUser> sysUsers = this.list(new QueryWrapper<SysUser>().inSql("id", "select user_id from sys_user_role where role_id = " + roleId));sysUsers.forEach(u -> {this.clearUserAuthorityInfo(u.getUsername());});}// 删除所有与该菜单关联的所有用户的权限信息@Overridepublic void clearUserAuthorityInfoByMenuId(Long menuId) {List<SysUser> sysUsers = sysUserMapper.listByMenuId(menuId);sysUsers.forEach(u -> {this.clearUserAuthorityInfo(u.getUsername());});}
上面最后一个方法查到了与菜单关联的所有用户的,具体sql如下
- SysUserMapper
@Select("SELECT DISTINCT su.* FROM sys_user_role ur LEFT JOIN `sys_role_menu` rm ON rm.role_id = ur.role_id LEFT JOIN `sys_user` su ON su.id = ur.user_id WHERE rm.menu_id = #{menuId};")List<SysUser> listByMenuId(Long menuId);
有了这几个方法之后,在哪里调用?这就简单了,在更新、删除角色权限、更新、删除菜单的时候调用
⑦:退出数据返回
1.JwtLogoutSuccessHandler
@Component
public class JwtLogoutSuccessHandler implements LogoutSuccessHandler {@Autowiredprivate JwtUtils jwtUtils;@Override// 处理注销成功的逻辑public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)throws IOException, ServletException {// 如果认证信息不为空,则使用 SecurityContextLogoutHandler 进行注销if (authentication != null) {new SecurityContextLogoutHandler().logout(request, response, authentication);}// 设置响应的内容类型为JSON,使用UTF-8字符集response.setContentType("application/json;charset=UTF-8");// 将JWT的头部设置为空字符串,即清除JWTresponse.setHeader(jwtUtils.getHeader(), "");// 获取响应输出流ServletOutputStream out = response.getOutputStream();// 构建成功响应Response<?> ok = Response.ok();// 将成功响应转换为JSON字符串,并写入输出流中out.write(JSONUtil.toJsonStr(ok).getBytes(StandardCharsets.UTF_8));// 刷新输出流out.flush();// 关闭输出流out.close();}
}
⑧:无权限数据返回
JwtAccessDeniedHandler
@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {@Override// 处理访问被拒绝的逻辑public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)throws IOException, ServletException {// 记录权限不足的日志信息log.info("权限不足!!");// 设置响应的内容类型为JSON,使用UTF-8字符集response.setContentType("application/json;charset=UTF-8");// 设置响应状态为403 Forbiddenresponse.setStatus(HttpServletResponse.SC_FORBIDDEN);// 获取响应输出流ServletOutputStream outputStream = response.getOutputStream();// 构建错误响应,包含访问被拒绝的消息Response<?> error = Response.error(accessDeniedException.getMessage());// 将错误响应转换为JSON字符串,并写入输出流中outputStream.write(JSONUtil.toJsonStr(error).getBytes(StandardCharsets.UTF_8));// 刷新输出流outputStream.flush();// 关闭输出流outputStream.close();}
}
⑨:修改登录请求接口
致此,SpringSecurity就已经完美整合到了我们的项目中来了
四、解决跨域问题
上面的调试我们都是使用的postman,如果我们和前端进行对接的时候,会出现跨域的问题,如何解决?
CorsConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration
public class CorsConfig implements WebMvcConfigurer {private CorsConfiguration buildConfig() {CorsConfiguration corsConfiguration = new CorsConfiguration();corsConfiguration.addAllowedOrigin("*");corsConfiguration.addAllowedHeader("*");corsConfiguration.addAllowedMethod("*");corsConfiguration.addExposedHeader("Authorization");return corsConfiguration;}@Beanpublic CorsFilter corsFilter() {UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**", buildConfig());return new CorsFilter(source);}@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOrigins("*")
// .allowCredentials(true).allowedMethods("GET", "POST", "DELETE", "PUT").maxAge(3600);}}