这是我参与8月更文挑战的第26天,活动详情查看:8月更文挑战
后端的学习笔记记录
前言
技术栈
SpringBoot + mybatis plus + spring security + lombok +redis + hibernate validatior + jwt
新建Springboot 项目,注意版本
开发工具与环境: idea mysql maven 3.3.9
项目的结构
pom.xml
<dependencies>
<!-- Spring boot 启动的 jar -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 热部署的jar -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- lombok get ,set 生成 jar-->
<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>
<!--整合mybatis plus https://baomidou.com/-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<!--mp代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.30</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 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>
<!-- hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MySQL 依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.11</version>
</dependency>
</dependencies>
配置文件
server:
port: 8081
# DataSource Config
spring:
# Spring security 的账号密码修改
security:
user:
name: user
password: 111111
# 热部署的
freemarker:
cache: false
devtools:
restart:
enabled: true
additional-exclude: WEB-INF/**
#数据库的
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=UTF-8
username: root
password: 123456
# mybatis-plus 的
mybatis-plus:
mapper-locations: classpath*:/mapper/**Mapper.xml
# 设置 jwt 的配置
fjj:
jwt:
header: Authorization
expire: 604800 #7天,秒单位
secret: ji8n3439n439n43ld9ne9343fdfer49h
开启mapper接口扫描,添加分页,防全表更新插件
/**
* 新的分页插件,一缓和二缓遵循mybatis的规则,
* * 需要设置 MybatisConfiguration#useDeprecatedExecutor = false
* * 避免缓存出现问题(该属性会在旧插件移除后一同移除)
*/
// 加入注解
@Configuration
// 扫描 mapper 包
@MapperScan("com.example.springbootvue.mapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页的插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
// 防止全表更新的插件
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
@Bean
public ConfigurationCustomizer configurationCustomizer () {
return configuration -> configuration.setUseDeprecatedExecutor(false);
}
}
给Mybatis plus 添加了2个 拦截器,根据 mp 官网配置的 PaginationInnerInterceptor:新的分页插件 BlockAttackInnerInterceptor:防止全表更新和删除
创建数据库和表
主要 5个 表 用户表,角色表,菜单权限表,以及关联的用户角色中间表,菜单角色中间表。5个表。 数据库脚本
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');
代码生成以及测试
package com.example.springbootvue;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class 生成代码 {
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotBlank(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
gc.setAuthor("fjj");
gc.setOpen(false);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("123456");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
// pc.setModuleName(scanner("模块名"));
pc.setParent("com.example.demo");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
// 公共父类
// 写于父类中的公共字段
strategy.setInclude(scanner("user").split(","));
strategy.setControllerMappingHyphenStyle(true);
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}
运行
生成所有的代码
测试生成代码
写测试的controller
测试
对结果集进行封装
因为是前后端分离的项目,所以有必要统一一个结果返回封装类,这样前后端交互的时候有个统一的标准,约定结果返回的数据是正常的或者遇到异常了 创建Result 类,用于异步统一放回的结果封装 必要要素
- 是否成功,用code 表示 (200为成功,400 为异常)
- 结果消息
- 结果数据
package com.example.Common;
import lombok.Data;
import java.io.Serializable;
@Data
// 实现序列化的接口
public class Result implements Serializable {
// 状态码
private int code;
// 消息
private String msg;
// 结果 data
private Object data;
public static Result succ(Object data) {
return succ(200, "操作成功", data);
}
// 成功的 结果
public static Result succ(int code, String msg, Object data) {
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
public static Result fail(String msg) {
return fail(400, msg, null);
}
public static Result fail(String msg, Object data) {
return fail(400, msg, data);
}
public static Result fail(int code, String msg, Object data) {
Result r = new Result();
r.setCode(code);
r.setMsg(msg);
r.setData(data);
return r;
}
}
另外出了在结果封装类上的code可以提现数据是否正常,我们还可以通过http的状态码来提现访问是否遇到了异常,比如401表示无权限拒绝访问等,注意灵活使用。
全局异常处理
有时候不可避免服务器报错的情况,如果不配置异常处理的机制,就会默认返回tomcat 或者 nginx 的5xx 页面,对普通用户来说,不太友好,我们应该返回一个友好简单的格式 给前端
思路如下 通过使用 @ControllerAdvice 来进行统一异常处理 @ExceptionHandler(value = RuntimeException.class) 来指定捕获的exception各个类型异常,这个异常的处理是全局的,所有类似的异常,都会跑到这个地方处理 定义全局异常处理 @ControllerAdvice 表示定义全局控制器异常处理 @ExceptionHandler 表示针对性异常处理,可对每种异常针对性处理
package com.example.Excepts;
import com.example.Common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
// 加入注解
@Slf4j
@RestControllerAdvice
public class GlobalExcepts {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = IllegalArgumentException.class)
// Accert 异常 ----------------------{}
public Result handelr (IllegalArgumentException e) {
log.error("Accert 异常 ----------------------{}",e.getMessage());
return Result.fail(e.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = RuntimeException.class)
public Result handelr (RuntimeException e) {
log.error("运行时异常------------------{}",e.getMessage());
return Result.fail(e.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handelr (MethodArgumentNotValidException e) {
BindingResult result = e.getBindingResult();
ObjectError objectError = result.getAllErrors().stream().findFirst().get();
log.error("实体校验异常------------------{}",e.getMessage());
return Result.fail(objectError.getDefaultMessage());
}
}
IllegalArgumentException:处理Assert的异常 MethodArgumentNotValidException:处理实体校验的异常 RuntimeException:捕捉其他异常
整合 Spring Security
Security工作原理分析
说明
- 客户端发起一个请求,进入Security 过滤器链
- 当到LogoutFilter 的时候判断是否是登出的路径,如果是登出路径则到logoutHandler ,如果登出成功则到logoutSuccessHandler 登出成功处理,如果不是登出路径则直接进入下一个过滤器
- 当到UsernamePasswordSuthenticationFilter 的时候判断是否为登录路径,如果是,则进入该过滤器登录操作,如果失败则到SuthenticationFailureHandler,登录失败处理器处理,如果登录成功则到AuthenticationSuccessHandler 登录成功处理器处理,如果不是登录请求则不进入该过滤器
- 进入认证BasicAuthenticationFilter 进行用户认证,成功的话会把认证了的结果写入到SecurityContextHolder 中的 securityContext 的属性authention 上面。如果认证失败就会 认证失败处理类,或者抛出异常被后续ExceptionTranslationFilter过滤器处理异常,如果是AuthenticationException就交给AuthenticationEntryPoint处理,如果是AccessDeniedException异常则交给AccessDeniedHandler处理。
- 当到 FilterSecurityInterceptor 的时候会拿到 uri ,根据 uri 去找对应的鉴权管理器,鉴权管理器做鉴权工作,鉴权成功则到 Controller 层,否则到 AccessDeniedHandler 鉴权失败处理器处理。 总共需要出现的组件 LogoutFilter - 登出过滤器 logoutSuccessHandler - 登出成功之后的操作类 UsernamePasswordAuthenticationFilter - from提交用户名密码登录认证过滤器 AuthenticationFailureHandler - 登录失败操作类 AuthenticationSuccessHandler - 登录成功操作类 BasicAuthenticationFilter - Basic身份认证过滤器 SecurityContextHolder - 安全上下文静态工具类 AuthenticationEntryPoint - 认证失败入口 ExceptionTranslationFilter - 异常处理过滤器 AccessDeniedHandler - 权限不足操作类 FilterSecurityInterceptor - 权限判断拦截器、出口
引入Security 与 Jwt
首先我们导入security 包,因为我们前后端交互用户认证凭证用的是Jwt 所以我们也导入jwt 的相关包。导入redis 用作验证码
<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>
启动redis,然后我们再启动项目,这时候我们再去访问http://localhost:8081/test,会发现系统会先判断到你未登录跳转到http://localhost:8081/login,因为security内置了登录页,用户名为user,密码在启动项目的时候打印在了控制台。登录完成之后我们才可以正常访问接口。因为每次启动密码都会改变,所以我们通过配置文件来配置一下默认的用户名和密码:
spring:
# Spring security 的账号密码修改
security:
user:
name: user
password: 111111
用户认证,验证码生成
分为首次登陆和二次认证
- 首次登陆认证:用户名,密码和验证码完成登陆
- 二次token认证:请求头携带jwt 进行身份认证 使用用户名密码来登录的,然后我们还想添加图片验证码,那么security给我们提供的UsernamePasswordAuthenticationFilter就不能使用了。 解决方式是在UsernamePasswordAuthenticationFilter之前自定义一个图片过滤器CaptchaFilter,提前校验验证码是否正确,这样我们就可以使用UsernamePasswordAuthenticationFilter了,然后登录正常或失败我们都可以通过对应的Handler来返回我们特定格式的封装结果数据。 生成验证码 首先我们先生成验证码,之前我们已经引用了google的验证码生成器,我们先来配置一下图片验证码的生成规则:
KaptchaConfig
// 加上注解
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha producer() {
Properties properties = new Properties();
properties.put("kaptcha.border", "no");
properties.put("kaptcha.textproducer.font.color", "black");
properties.put("kaptcha.textproducer.char.space", "4");
properties.put("kaptcha.image.height", "40");
properties.put("kaptcha.image.width", "120");
properties.put("kaptcha.textproducer.font.size", "30");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
上面验证码的长宽字体颜色等,是可以调整的 通过控制器提供生成验证码的方法 **这里需要用到一个redis 的工具类 **
@Component
public class RedisUtil {
@Autowired
private 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);
}
}
一个常量类
生成验证码Controller
@RestController
public class AuthController extends BaseController {
// 生成验证码的 控制器
// 注入 我们的图片验证码
@Autowired
private Producer producer;
// 注入redis 的工具类
@Autowired
RedisUtil redisUtil;
@GetMapping("/captcha")
public Result captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 获取状态码
String code = producer.createText();
// 获取 key 的值 设置为 uuid
String key = UUID.randomUUID().toString();
// 生成图片
BufferedImage image = producer.createImage(code);
// 获取字节数组 输出流
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", outputStream);
// 64 位转码
BASE64Encoder encoder = new BASE64Encoder();
String str = "data:image/jpeg;base64,";
String base64Img = str + encoder.encode(outputStream.toByteArray());
// 存到redis 中 并且设置过期时间
redisUtil.hset(Const.CAPTCHA_KEY,key,code,120);
return Result.succ(
MapUtil.builder()
.put("token",key)
.put("base64Img",base64Img)
.build()
);
}
}
因为前后端分离,禁用了session,所以我们把验证码放在了redis 中,使用一个随机字符串作为key 传到前端,前端再把随机字符串和用户输入的验证码提交上来,这样就可以通过随机字符串获取保存的验证码和用户的验证码比较是否正确
因为是图片验证码方式,所以进行了encode ,把图片进行了base64 编码这样前端就可以显示图片了 前端只需要把mock.js 去掉就可以了
验证码认证过滤器
图片验证码静进行认证验证码是否正确 登录失败的 LoginFailureHandler
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
// 设置头信息 的编码规范
httpServletRequest.setCharacterEncoding("application/json;charset=UTF-8");
// 设置输出流
ServletOutputStream outputStream = httpServletResponse.getOutputStream();
// 设置错误的异常输出结果
Result fail = Result.fail("用户名或者密码错误");
// 写入的 格式
outputStream.write(JSONUtil.toJsonStr(fail).getBytes("UTF-8"));
// 刷新关闭流
outputStream.flush();
outputStream.close();
}
}
自定义Captcha 异常类
CaptchaFilter
/**
* 图片验证码校验过滤器,在登录过滤器前
*/
@Slf4j
@Component
public class CaptchaFilter extends OncePerRequestFilter {
// 登录的url
private final String loginUrl = "/login";
// 注入 redis
@Autowired
RedisUtil redisUtil;
// 失败的登录的
@Autowired
LoginFailureHandler loginFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
// 获取 url
String url = httpServletRequest.getRequestURI();
if (loginUrl.equals(url) && httpServletRequest.getMethod().equals("post")) {
log.info("获取到login链接,正在校验验证码 -- " + url);
try {
validate(httpServletRequest);
} catch (CaptchaException e) {
log.info(e.getMessage());
// 交给登录失败处理器处理
loginFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
private void validate(HttpServletRequest request) {
// 获取 code 值
String code = request.getParameter("code");
// 获取 token
String token = request.getParameter("token");
// 判断是不是空
if (StringUtils.isBlank(code) || StringUtils.isBlank(token)) {
throw new CaptchaException("验证码不能为空");
}
if (!code.equals(redisUtil.hget(Const.CAPTCHA_KEY, token))) {
throw new CaptchaException("验证码不正确");
}
// 一次性使用
redisUtil.hdel(Const.CAPTCHA_KEY, token);
}
}
配置SecurityConfig
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
LoginFailureHandler loginFailureHandler;
@Autowired
CaptchaFilter captchaFilter;
public static final String[] URL_WHITELIST = {"/webjars/**", "/favicon.ico", "/captcha", "/login", "/logout",};
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.formLogin()
.failureHandler(loginFailureHandler)
.and()
.authorizeRequests()
.antMatchers(URL_WHITELIST).permitAll()
//白名单
.anyRequest().authenticated()
// 不会创建session
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 登录验证码校验过滤器 ;
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) ;
}}
首先formLogin我们定义了表单登录提交的方式以及定义了登录失败的处理器,后面我们还要定义登录成功的处理器的。然后authorizeRequests我们除了白名单的链接之外其他请求都会被拦截。再然后就是禁用session,最后是设定验证码过滤器在登录过滤器之前。然后我们打开前端的/login,发现出现了问题,后面我处理,我们先用postman调试接口 查看 验证码能不能出来。
就算我们登录成功,security默认跳转到/链接,但是又会因为没有权限访问/,所有又会教你去登录,所以我们必须取消原先默认的登录成功之后的操作,根据我们之前分析的流程,登录成功之后会走AuthenticationSuccessHandler,因此在登录之前,我们先去自定义这个登录成功操作类:
Jwt 的工具类
package com.example.Util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
// 提供 get ,set
@Data
// 加入Spring 注解
@Component
// 前缀
@ConfigurationProperties(prefix = "fjj.jwt")
public class JwtUtils {
// 设置时长
private long expire;
private String secret;
private String header;
// 生产 jwt
public String generateToken(String username) {
Date date = new Date();
// 过期时间
Date expiredate = new Date(date.getTime() + 1000 * expire);
return Jwts.builder()
.setHeaderParam("typ","JWT")
.setSubject(username)
.setIssuedAt(date)
.setExpiration(expiredate) // 设置7天过期时间
.signWith(SignatureAlgorithm.HS512,secret)
.compact();
}
// 解析 JWT
public Claims getClaimsBytoken(String jwt) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(jwt)
.getBody();
} catch (Exception e) {
return null;
}
}
// jwt 是否过期
public boolean isToken(Claims claims) {
return claims.getExpiration().before(new Date());
}
}
LoginSuccessHandler 登录成功之后我们利用用户名生成jwt
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
// 注入 Jwt 工具类
@Autowired
JwtUtils jwtUtils;
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = response.getOutputStream();
// 生成jwt返回
String jwt = jwtUtils.generateToken(authentication.getName());
response.setHeader(jwtUtils.getHeader(), jwt);
Result result = Result.succ("");
outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
outputStream.flush();
outputStream.close();
}
}
然后我们再security配置中添加上登录成功之后的操作类: SecurityConfig
Postman 测试
请求头中有Authorization
搞定,登录成功啦,验证码也正常验证了。
身份认证-1
登录成功之后前端就可以获取到了Jwt 的信息,前端中我们保存在了store 中,同时也保存在了localStorage 中,然后每次axios 请求之前,我们都会添加上我们的头信息。回顾
所以后端进行用户识别的时候,我们需要通过请求头中获取jwt ,然后解析出我们的用户名,这样我们就可以知道是谁在访问接口。判断是否有权限。 自定义过滤器用来识别Jwt JWTAuthenticationFilter
@Slf4j
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
// 注入 jwt 工具类
@Autowired
JwtUtils jwtUtils;
// 注入redis 的工具类
@Autowired
RedisUtil redisUtil;
// 注入 用户的服务类
@Autowired
ISysUserService sysUserService;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("jwt 检验 filter");
// 获取到 jwt
String jwt = request.getHeader(jwtUtils.getHeader());
// 判断是不是空
if (StrUtil.isBlankOrUndefined(jwt)) {
chain.doFilter(request, response);
return;
}
// 判断 token 有没有异常
Claims claim = jwtUtils.getClaimsBytoken(jwt);
if (claim == null) {
throw new JwtException("token异常!");
}
// 判断是否过期
if (jwtUtils.isToken(claim)) {
throw new JwtException("token 过期");
}
// 获取当前登录的用户
String username = claim.getSubject();
log.info("用户-{},正在登陆!", username);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, null, new TreeSet<>());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
// 继续向下执行
chain.doFilter(request, response);
}
}
主要逻辑,获取到用户名之后直接封装成UsernamePasswordAuthenticationToken,之后交给 SecurityContextHolder 参数传递 authentication 对象,这样后续security 就能获取到当前登录的用户信息了,也就完成了用户认证 。当认证失败就会进入 AuthrntionEntryPoint 于是我们自定义认证失败返回的数据 JwtAuthenticationEntryPoint
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
log.info("认证失败,没有登陆");
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ServletOutputStream outputStream = response.getOutputStream();
Result result = Result.fail("请先登录!");
outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
outputStream.flush();
outputStream.close();
}
}
不管是什么原因,认证失败,就要重新登录,所以返回的信息直接明了 添加到SecurityConfig
// 加入注解
@Configuration
// 加入 Security 的注解
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 注入 LoginFailureHandler
@Autowired
private LoginFailureHandler loginFailureHandler;
@Autowired
private LoginSuccessHandler loginSuccessHandler;
@Autowired
private CaptchaFilter captchaFilter;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(authenticationManager());
return filter;
}
// 注入 密码
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// 设置白名单
private static final String[] URL_WHITELIST = {
"/login",
"/logout",
"/captcha",
"/favicon.ico"
};
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
// 登录配置
.formLogin()
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
// 退出的
// 禁用 session
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 配置拦截规则
.and()
.authorizeRequests()
.antMatchers(URL_WHITELIST).permitAll()
.anyRequest().authenticated()
// 异常处理器
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
// 配置自定义的过滤器
.and()
.addFilter(jwtAuthenticationFilter())
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
;
}
}
身份认证 - 2
之前我们的用户名密码配置在配置文件中的,而且密码也用的是明文,这明显不符合我们的要求,我们的用户必须是存储在数据库中,密码也是得经过加密的。所以我们先来解决这个问题,然后再去弄授权
插入一条用户数据,但这里有个问题,就是我们的密码怎么生成?密码怎么来的?这里我们使用Security内置了的BCryptPasswordEncoder,里面就有生成和匹配密码是否正确的方法,也就是加密和验证策略。因此我们再SecurityConfig中进行配置:
这样系统就会使用我们找个新的密码策略进行匹配密码是否正常了。之前我们配置文件配置的用户名密码去掉:
ok,我们先使用BCryptPasswordEncoder给我们生成一个密码,给数据库添加一条数据先,我们再TestController中注入BCryptPasswordEncoder,然后使用encode进行密码加密,对了,记得在SecurityConfig中吧/test/**添加白名单哈,不然访问会提示你登录!!
TestController
@RestController
public class TestController {
// 注入
@Autowired
ISysUserService sysUserService;
@Autowired
BCryptPasswordEncoder bCryptPasswordEncoder;
@GetMapping("/test")
public Object test() {
return sysUserService.list();
}
// 生成测试密码
@GetMapping("/test/pass")
public Result passEncode() {
// 密码加密
String encode = bCryptPasswordEncoder.encode("123456");
// 密码验证
boolean matches = bCryptPasswordEncoder.matches("123456", encode);
return Result.succ(MapUtil.builder().put("pass", encode).put("marches", matches).build());
}
}
postman 测试
但是先我们登录过程系统不是从我们数据库中获取数据的,因此,我们需要重新定义这个查用户数据的过程,我们需要重写UserDetailsService接口 UserDetailsServiceImpl
@Service
public class UserDetailsServiceimpl implements UserDetailsService {
// 注入 服务层
@Autowired
SysUserServiceImpl SysUserServiceImpl;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 调用查询名字的方法
SysUser sysUser = SysUserServiceImpl.getByUsername(username);
// 判断名字是不是空的
if (sysUser == null) {
throw new UsernameNotFoundException("用户名或者密码错误");
}
return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), getUserAuthority(sysUser.getId()));
}
public List<GrantedAuthority> getUserAuthority(Long userId) {
// 角色菜单操作的权限l
// 角色 (ROLE_admin) 菜单操作权限 sys:user:list
String authority = SysUserServiceImpl.getUserAuthorityInfo(userId);
return AuthorityUtils.commaSeparatedStringToAuthorityList(authority);
}
}
因为security在认证用户身份的时候会调用UserDetailsService.loadUserByUsername()方法,因此我们重写了之后security就可以根据我们的流程去查库获取用户了。然后我们把UserDetailsServiceImpl配置到SecurityConfig中:
// 加入注解
@Configuration
// 加入 Security 的注解
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 注入 LoginFailureHandler
@Autowired
private LoginFailureHandler loginFailureHandler;
@Autowired
private LoginSuccessHandler loginSuccessHandler;
@Autowired
private CaptchaFilter captchaFilter;
@Autowired
private JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
// 注入 UserDetailsServiceimpl 实现类
@Autowired
UserDetailsServiceimpl userDetailsServiceimpl;
// 注入 JwtLogoutSuccessHandler
@Autowired
JwtLogoutSuccessHandler jwtLogoutSuccessHandler;
// 注入 Security jwt 的 过滤器
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
return jwtAuthenticationFilter;
}
// 注入 密码
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// 设置白名单
private static final String[] URL_WHITELIST = {
"/login",
"/logout",
"/captcha",
"/favicon.ico"
};
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
// 登录配置
.formLogin()
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
// 退出的
.and()
.logout()
.logoutSuccessHandler(jwtLogoutSuccessHandler)
// 禁用 session
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 配置拦截规则
.and()
.authorizeRequests()
.antMatchers(URL_WHITELIST).permitAll()
.anyRequest().authenticated()
// 异常处理器
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// 配置自定义的过滤器
.and()
.addFilter(jwtAuthenticationFilter())
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsServiceimpl);
}
}
然后上面UserDetailsService.loadUserByUsername()默认返回的UserDetails,我们自定义了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;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
数据基本没变,我就添加多了一个用户的id而已
4、登录成功,并在请求头中获取到了Authorization,也就是JWT。完美!!
解决授权
然后关于权限部分,也是security的重要功能,当用户认证成功之后,我们就知道谁在访问系统接口,这是又有一个问题,就是这个用户有没有权限来访问我们这个接口呢,要解决这个问题,我们需要知道用户有哪些权限,哪些角色,这样security才能我们做权限判断。
之前我们已经定义及几张表,用户、角色、菜单、以及一些关联表,一般当权限粒度比较细的时候,我们都通过判断用户有没有此菜单或操作的权限,而不是通过角色判断,而用户和菜单是不直接做关联的,是通过用户拥有哪些角色,然后角色拥有哪些菜单权限这样来获得的。 问题1:我们是在哪里赋予用户权限的?有两个地方: 1、用户登录,调用调用UserDetailsService.loadUserByUsername()方法时候可以返回用户的权限信息。 2、接口调用进行身份认证过滤器时候JWTAuthenticationFilter,需要返回用户权限信息 问题2:在哪里决定什么接口需要什么权限? Security内置的权限注解: @PreAuthorize:方法执行前进行权限检查 @PostAuthorize:方法执行后进行权限检查 @Secured:类似于 @PreAuthorize 可以在Controller的方法前添加这些注解表示接口需要什么权限。 比如需要Admin角色权限:
@PreAuthorize("hasRole('admin')")
比如需要添加管理员的操作权限
@PreAuthorize("hasAuthority('sys:user:save')")
授权、验证权限的流程: 1,用户登录或者调用接口时候识别到用户,并获取到用户的权限信息 2,注解标识Controller中的方法需要的权限或者角色 3,Security通过FilterSecurityInterceptor匹配URI和权限是否匹配 4,有权限则可以访问接口,当无权限的时候返回异常交给AccessDeniedHandler操作类处理。
UserDetailsServiceImpl
.JWTAuthenticationFilter
SysUserServiceImpl
/**
* <p>
* 服务实现类
* </p>
*
* @author fjj
* @since 2021-07-05
*/
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements ISysUserService {
// 注入角色表的 service
@Autowired
SysRoleServiceImpl SysRoleServiceImpl;
// 注入 SysMenuServiceImpl
@Autowired
SysMenuServiceImpl sysMenuService;
@Autowired
// 注入 User 表的 Mapper
SysUserMapper sysUserMapper;
// 由于 每次查询都要 查询一次数据库,所以 加入 redis 用来做缓存
@Autowired
RedisUtil redisUtil;
@Override
public SysUser getByUsername(String username) {
return getOne(new QueryWrapper<SysUser>().eq("username", username));
}
@Override
public String getUserAuthorityInfo(Long userId) {
SysUser sysUser = sysUserMapper.selectById(userId);
// 角色
// ROLE_admin,ROLE_normal,sys:user:list
String authority = "";
if (redisUtil.hasKey("GrantedAuthority:"+sysUser.getUsername())) {
authority = (String) redisUtil.get("GrantedAuthority:"+sysUser.getUsername());
} else {
// 获取角色
List<SysRole> roles = SysRoleServiceImpl.list(new QueryWrapper<SysRole>()
.inSql("id", "select role_id from sys_user_role where user_id = " + userId));
System.out.println(roles);
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;
}
@Override
public void clearUserAuthorityInfo(String username) {
// 如果名字发生改变删除缓存
redisUtil.del("GrantedAuthority:" + username);
}
@Override
public void clearUserAuthorityInfoByRoleId(Long roleId) {
// 如果角色的Id 发生 改变
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());
});
}
@Override
public void clearUserAuthorityInfoByMenuId(Long menuId) {
List<SysUser> sysUsers = sysUserMapper.listByMenuId(menuId);
sysUsers.forEach(u -> {
this.clearUserAuthorityInfo(u.getUsername());
});
}
}
可以看到,我通过用户id分别获取到用户的角色信息和菜单信息,然后通过逗号链接起来,因为角色信息我们需要这样“ROLE_”+角色,所以才有了上面的写法:比如用户拥有Admin角色和添加用户权限,则最后的字符串是:ROLE_admin,sys:user:save。同时为了避免多次查库,做了一层缓存。 然后sysUserMapper.getNavMenuIds(userId)因为要查询数据库,具体SQL如下:
<select id="getNavMenuIds" resultType="java.lang.Long">
SELECT
DISTINCT rm.menu_id
FROM
sys_user_role ur
LEFT JOIN sys_role_menu rm ON ur.role_id = rm.role_id
WHERE ur.user_id = #{userId}
</select>
上面表示通过用户ID获取用户关联的菜单的id,因此需要用到两个中间表的关联了。ok,这样我们就赋予了用户角色和操作权限了。后面我们只需要在Controller添加上具体注解表示需要的权限,Security就会自动帮我们自动完成权限校验了。
权限缓存
因为上面我在获取用户权限那里添加了个缓存,这时候问题来了,就是权限缓存的实时更新问题,比如当后台更新某个管理员的权限角色信息的时候如果权限缓存信息没有实时更新,就会出现操作无效的问题,那么我们现在点定义几个方法,用于清除某个用户或角色或者某个菜单的权限的方法:
SysUserServiceImpl
/**
* <p>
* 服务实现类
* </p>
*
* @author fjj
* @since 2021-07-05
*/
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements ISysUserService {
// 注入角色表的 service
@Autowired
SysRoleServiceImpl SysRoleServiceImpl;
// 注入 SysMenuServiceImpl
@Autowired
SysMenuServiceImpl sysMenuService;
@Autowired
// 注入 User 表的 Mapper
SysUserMapper sysUserMapper;
// 由于 每次查询都要 查询一次数据库,所以 加入 redis 用来做缓存
@Autowired
RedisUtil redisUtil;
@Override
public SysUser getByUsername(String username) {
return getOne(new QueryWrapper<SysUser>().eq("username", username));
}
@Override
public String getUserAuthorityInfo(Long userId) {
SysUser sysUser = sysUserMapper.selectById(userId);
// 角色
// ROLE_admin,ROLE_normal,sys:user:list
String authority = "";
if (redisUtil.hasKey("GrantedAuthority:"+sysUser.getUsername())) {
authority = (String) redisUtil.get("GrantedAuthority:"+sysUser.getUsername());
} else {
// 获取角色
List<SysRole> roles = SysRoleServiceImpl.list(new QueryWrapper<SysRole>()
.inSql("id", "select role_id from sys_user_role where user_id = " + userId));
System.out.println(roles);
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;
}
@Override
public void clearUserAuthorityInfo(String username) {
// 如果名字发生改变删除缓存
redisUtil.del("GrantedAuthority:" + username);
}
@Override
public void clearUserAuthorityInfoByRoleId(Long roleId) {
// 如果角色的Id 发生 改变
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());
});
}
@Override
public void clearUserAuthorityInfoByMenuId(Long menuId) {
List<SysUser> sysUsers = sysUserMapper.listByMenuId(menuId);
sysUsers.forEach(u -> {
this.clearUserAuthorityInfo(u.getUsername());
});
}
}
上面最后一个方法查到了与菜单关联的所有用户的,具体sql如下:
<select id="listByMenuId" resultType="com.example.demo.entity.SysUser">
SELECT DISTINCT
su.*
FROM
sys_user_role ur
LEFT JOIN sys_role_menu rm ON ur.role_id = rm.role_id
LEFT JOIN sys_user su ON ur.user_id = su.id
WHERE
rm.menu_id = #{menuId}
</select>
有了这几个方法之后,在哪里调用?这就简单了,在更新、删除角色权限、更新、删除菜单的时候调用,虽然我们现在还没写到这几个方法,后续我们再写增删改查的时候记得加上就行啦。