关于springsecurity:开发SpringBootJwtVue的前后端分离后台管理系统VueAdmin-后端笔记

为了让更多同学学习到前后端拆散管理系统的搭建过程,这里我写了具体的开发过程的文档,应用的是springsecurity + jwt + vue的技术栈组合,如果有帮忙,别忘了点个赞和关注我的公众号哈!

线上预览:https://markerhub.com/vueadmin

效果图:


首发公众号:MarkerHub

作者:吕一明

我的项目源码:关注公众号 MarkerHub 回复【 234 】获取

线上预览:https://markerhub.com/vueadmin

我的项目视频:https://www.bilibili.com/video/BV1af4y1s7Wh/

转载请保留此申明,感激!

另外我还有另外一个前后端博客我的项目博客VueBlog,如果有须要能够关注公众号MarkerHub,回复【VueBlog】获取哈!!

1. 前言

从零开始搭建一个我的项目骨架,最好抉择适合相熟的技术,并且在将来易拓展,适宜微服务化体系等。所以个别以Springboot作为咱们的框架根底,这是离不开的了。

而后数据层,咱们罕用的是Mybatis,易上手,不便保护。然而单表操作比拟艰难,特地是增加字段或缩小字段的时候,比拟繁琐,所以这里我举荐应用Mybatis Plus(https://mp.baomidou.com/),… CRUD 操作,从而节俭大量工夫。

作为一个我的项目骨架,权限也是咱们不能疏忽的,上一个我的项目vueblog咱们应用了shiro,然而有些同学想学学SpringSecurity,所以这一期咱们应用security作为咱们的权限管制和会话管制的框架。

思考到我的项目可能须要部署多台,一些须要共享的信息就保留在中间件中,Redis是当初支流的缓存中间件,也适宜咱们的我的项目。

而后因为前后端拆散,所以咱们应用jwt作为咱们用户身份凭证,并且session咱们会禁用,这样以前传统我的项目应用的形式咱们可能就不再适宜应用,这点须要留神了。

ok,咱们当初就开始搭建咱们的我的项目脚手架!

技术栈:

  • SpringBoot
  • mybatis plus
  • spring security
  • lombok
  • redis
  • hibernate validatior
  • jwt

2. 新建springboot我的项目,留神版本

这里,咱们应用IDEA来开发咱们我的项目,新建步骤比较简单,咱们就不截图了。

开发工具与环境:

  • idea
  • mysql
  • jdk 8
  • maven3.3.9

新建好的我的项目构造如下,SpringBoot版本应用的目前最新的2.4.0版本

pom的jar包导入如下:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.4.0</version>
    <relativePath/>
</parent>
<groupId>com.markerhub</groupId>
<artifactId>vueadmin-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>vueadmin-java</name>
<description>公众号:MarkerHub</description>
<properties>
    <java.version>1.8</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>
  • devtools:我的项目的热加载重启插件
  • lombok:简化代码的工具

3. 整合mybatis plus,生成代码

接下来,咱们来整合mybatis plus,让我的项目能实现根本的增删改查操作。步骤很简略:能够去官网看看:https://mp.baomidou.com/guide/

第一步:导入jar包

pom中导入mybatis plus的jar包,因为前面会波及到代码生成,所以咱们还须要导入页面模板引擎,这里咱们用的是freemarker。

<!--整合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>

第二步:而后去写配置文件

server:
  port: 8081
# DataSource Config
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/vueadmin?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: admin
mybatis-plus:
  mapper-locations: classpath*:/mapper/**Mapper.xml

下面除了配置数据库的信息,还配置了myabtis plus的mapper的xml文件的扫描门路,这一步不要遗记了。而后因为前段默认是8080端口了,所以后端咱们设置为8081端口,避免端口抵触。

第三步:开启mapper接口扫描,增加分页、防全表更新插件

新建一个包:通过@mapperScan注解指定要变成实现类的接口所在的包,而后包上面的所有接口在编译之后都会生成相应的实现类。

  • com.markerhub.config.MybatisPlusConfig

    @Configuration
    @MapperScan("com.markerhub.mapper")
    public class MybatisPlusConfig {
     /**
      * 新的分页插件,一弛缓二缓遵循mybatis的规定,
      * 须要设置 MybatisConfiguration#useDeprecatedExecutor = false
      * 防止缓存呈现问题(该属性会在旧插件移除后一起移除)
      */
     @Bean
     public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 避免全表更新和删除
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
        return interceptor;
     }
     @Bean
     public ConfigurationCustomizer configurationCustomizer() {
        return configuration -> configuration.setUseDeprecatedExecutor(false);
     }
    }

    下面代码中,咱们给Mybatis plus增加了2个拦截器,这是依据mp官网配置的:

  • PaginationInnerInterceptor:新的分页插件
  • BlockAttackInnerInterceptor:避免全表更新和删除

    第四步:创立数据库和表

因为是后盾管理系统的权限模块,所以咱们须要思考的表次要就几个:用户表、角色表、菜单权限表、以及关联的用户角色两头表、菜单角色两头表。就5个表,至于什么字段其实都听随便的,用户表外面除了用户名、明码字段必要,其余其实都听随便,而后角色和菜单咱们能够参考一下其余的零碎、或者本人在做我的项目的过程中须要的时候在增加也行,反正从新生成代码也是十分简便的事件,综合思考,数据库名称为vueadmin,咱们建表语句如下:

  • vueadmin.sql

    /*
    Navicat MySQL Data Transfer
    Source Server         : localhost
    Source Server Version : 50717
    Source Host           : localhost:3306
    Source Database       : vueadmin
    Target Server Type    : MYSQL
    Target Server Version : 50717
    File Encoding         : 65001
    Date: 2021-01-23 09:41:50
    */
    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=21 DEFAULT CHARSET=utf8;
    -- ----------------------------
    -- 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=8 DEFAULT CHARSET=utf8;
    -- ----------------------------
    -- 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;
    -- ----------------------------
    -- 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=7 DEFAULT CHARSET=utf8;
    -- ----------------------------
    -- 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=15 DEFAULT CHARSET=utf8mb4;

第五步:代码生成

  1. 获取我的项目数据库所对应表和字段的信息
  2. 新建一个freemarker的页面模板 – SysUser.java.ftl – ${baseEntity}
  3. 提供相干须要进行渲染的动态数据 – BaseEntity、表字段、正文、baseEntity=SuperEntity
  4. 应用freemarker模板引擎进行渲染! – SysUser.java

    # 获取表
    SELECT
     *
    FROM
     information_schema. TABLES
    WHERE
     TABLE_SCHEMA = (SELECT DATABASE());
    # 获取字段
    SELECT
     *
    FROM
     information_schema. COLUMNS
    WHERE
     TABLE_SCHEMA = (SELECT DATABASE())
    AND TABLE_NAME = "sys_user";

    有了数据库之后,那么当初就曾经能够应用mybatis plus了,官网给咱们提供了一个代码生成器,而后我写上本人的参数之后,就能够间接依据数据库表信息生成entity、service、mapper等接口和实现类。
    因为代码比拟长,就不贴出来了,阐明一下重点:

  • com.markerhub.CodeGenerator

下面代码生成的过程中,我默认所有的实体类都继承BaseEntity,控制器都继承BaseController,所以在代码生成之前,最好先编写这两个基类:

  • com.markerhub.entity.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;
    }
  • com.markerhub.controller.BaseController

    public class BaseController {
     @Autowired
     HttpServletRequest req;
    }

    而后咱们独自运行CodeGenerator的main办法,留神调整CodeGenerator的数据库连贯、账号密码啥的,而后咱们输出表名称,通过逗号隔开:sys_menu,sys_role,sys_role_menu,sys_user,sys_user_role
    执行后果胜利:

而后咱们生成了一些代码如下:

这里有点须要留神,因为关联的用户角色两头表、菜单角色两头表咱们是没有created等几个公共字段的,所以咱们把这两个实体继承BaseEntity去掉:

最初这样子的:

@Data
public class SysRoleMenu {
   ...
}

简洁!不便!通过下面的步骤,基本上咱们曾经把mybatis plus框架集成到我的项目中了,并且也生成了根本的代码,省了好多功夫。而后咱们做个简略测试:

  • com.markerhub.controller.TestController

    @RestController
    public class TestController {
     @Autowired
     SysUserService userService;
     @GetMapping("/test")
     public Object test() {
        return userService.list();
     }
    }

    而后sys_user随便增加几条数据,后果如下:

ok,毛什么问题,大家不必在意明码是怎么生成的,前面咱们会说到,你当初随便填写就好了。对了,好多人问我的浏览器的json数据怎么显示这么难看,这是因为我用了JSONView这个插件:

4. 后果封装

因为是前后端拆散的我的项目,所以咱们有必要对立一个后果返回封装类,这样前后端交互的时候有个对立的规范,约定后果返回的数据是失常的或者遇到异样了。

这里咱们用到了一个Result的类,这个用于咱们的异步对立返回的后果封装。一般来说,后果外面有几个因素必要的

  • 是否胜利,可用code示意(如200示意胜利,400示意异样)
  • 后果音讯
  • 后果数据

所以可失去封装如下:

  • com.markerhub.common.lang.Result

    @Data
    public class Result implements Serializable {
      private int code; // 200是失常,非200示意异样
      private String msg;
      private Object data;
      
      public static Result succ(Object data) {
          return succ(200, "操作胜利", data);
      }
      public static Result succ(int code, String msg, Object data) {
          Result r = new Result();
          r.setCode(code);
          r.setMsg(msg);
          r.setData(data);
          return r;
      }
      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示意五权限回绝拜访等,留神灵便应用。

    5. 全局异样解决

有时候不可避免服务器报错的状况,如果不配置异样解决机制,就会默认返回tomcat或者nginx的5XX页面,对普通用户来说,不太敌对,用户也不懂什么状况。这时候须要咱们程序员设计返回一个敌对简略的格局给前端。

解决方法如下:通过应用@ControllerAdvice来进行对立异样解决,@ExceptionHandler(value = RuntimeException.class)来指定捕捉的Exception各个类型异样 ,这个异样的解决,是全局的,所有相似的异样,都会跑到这个中央解决。

步骤二、定义全局异样解决,@ControllerAdvice示意定义全局控制器异样解决,@ExceptionHandler示意针对性异样解决,可对每种异样针对性解决。

  • com.markerhub.common.exception.GlobalExceptionHandler

    /**
     * 全局异样解决
     */
    @Slf4j
    @RestControllerAdvice
    public class GlobalExceptionHandler {
      @ResponseStatus(HttpStatus.FORBIDDEN)
      @ExceptionHandler(value = AccessDeniedException.class)
      public Result handler(AccessDeniedException e) {
          log.info("security权限有余:----------------{}", e.getMessage());
          return Result.fail("权限有余");
      }
      
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(value = MethodArgumentNotValidException.class)
      public Result handler(MethodArgumentNotValidException e) {
          log.info("实体校验异样:----------------{}", e.getMessage());
          BindingResult bindingResult = e.getBindingResult();
          ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
          return Result.fail(objectError.getDefaultMessage());
      }
      
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(value = IllegalArgumentException.class)
      public Result handler(IllegalArgumentException e) {
          log.error("Assert异样:----------------{}", e.getMessage());
          return Result.fail(e.getMessage());
      }
      
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(value = RuntimeException.class)
      public Result handler(RuntimeException e) {
          log.error("运行时异样:----------------{}", e);
          return Result.fail(e.getMessage());
      }
    }

    下面咱们捕获了几个异样:

  • ShiroException:shiro抛出的异样,比方没有权限,用户登录异样
  • IllegalArgumentException:解决Assert的异样
  • MethodArgumentNotValidException:解决实体校验的异样
  • RuntimeException:捕获其余异样

6. 整合Spring Security

很多人不懂spring security,感觉这个框架比shiro要难,确实,security更加简单一点,同时性能也更加弱小,咱们首先来看一下security的原理,这里咱们援用一张来自江南一点雨大佬画的一张原理图(https://blog.csdn.net/u012702547/article/details/89629415):

(引自江南一点雨的博客)

下面这张图肯定要好好看,特地清晰,毕竟security是责任链的设计模式,是一堆过滤器链的组合,如果对于这个流程都不分明,那么你就谈不上了解security。那么针对咱们当初的这个零碎,咱们能够本人设计一个security的认证计划,联合江南一点雨大佬的博客,咱们失去这样一套流程:

https://www.processon.com/view/link/606b0b5307912932d09adcb3

流程阐明:

  1. 客户端发动一个申请,进入 Security 过滤器链。
  2. 当到 LogoutFilter 的时候判断是否是登出门路,如果是登出门路则到 logoutHandler ,如果登出胜利则到 logoutSuccessHandler 登出胜利解决。如果不是登出门路则间接进入下一个过滤器。
  3. 当到 UsernamePasswordAuthenticationFilter 的时候判断是否为登录门路,如果是,则进入该过滤器进行登录操作,如果登录失败则到 AuthenticationFailureHandler ,登录失败处理器解决,如果登录胜利则到 AuthenticationSuccessHandler 登录胜利处理器解决,如果不是登录申请则不进入该过滤器。
  4. 进入认证BasicAuthenticationFilter进行用户认证,胜利的话会把认证了的后果写入到SecurityContextHolder中SecurityContext的属性authentication下面。如果认证失败就会交给AuthenticationEntryPoint认证失败解决类,或者抛出异样被后续ExceptionTranslationFilter过滤器解决异样,如果是AuthenticationException就交给AuthenticationEntryPoint解决,如果是AccessDeniedException异样则交给AccessDeniedHandler解决。
  5. 当到 FilterSecurityInterceptor 的时候会拿到 uri ,依据 uri 去找对应的鉴权管理器,鉴权管理器做鉴权工作,鉴权胜利则到 Controller 层,否则到 AccessDeniedHandler 鉴权失败处理器解决。

Spring Security 实战干货:必须把握的一些内置 Filter:https://blog.csdn.net/qq_35067322/article/details/102690579

ok,下面咱们说的流程中波及到几个组件,有些是咱们须要依据理论状况来重写的。因为咱们是应用json数据进行前后端数据交互,并且咱们返回后果也是特定封装的。咱们先再总结一下咱们须要理解的几个组件:

  • LogoutFilter – 登出过滤器
  • logoutSuccessHandler – 登出胜利之后的操作类
  • UsernamePasswordAuthenticationFilter – from提交用户名明码登录认证过滤器
  • AuthenticationFailureHandler – 登录失败操作类
  • AuthenticationSuccessHandler – 登录胜利操作类
  • BasicAuthenticationFilter – Basic身份认证过滤器
  • SecurityContextHolder – 平安上下文动态工具类
  • AuthenticationEntryPoint – 认证失败入口
  • ExceptionTranslationFilter – 异样解决过滤器
  • AccessDeniedHandler – 权限有余操作类
  • FilterSecurityInterceptor – 权限判断拦截器、进口

有了下面的组件,那么认证与受权两个问题咱们就曾经靠近啦,咱们当初须要做的就是去重写咱们的一些要害类。

引入Security与jwt

首先咱们导入security包,因为咱们前后端交互用户凭证用的是JWT,所以咱们也导入jwt的相干包,而后因为验证码的存储须要用到redis,所以引入redis。最初为了一些工具类,咱们引入hutool。

  • pom.xml

    <!-- 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>

    启动redis,而后咱们再启动我的项目,这时候咱们再去拜访http://localhost:8081/test,会发现零碎会先判断到你未登录跳转到http://localhost:8081/login,因为security内置了登录页,用户名为user,明码在启动我的项目的时候打印在了控制台。登录实现之后咱们才能够失常拜访接口。
    因为每次启动明码都会扭转,所以咱们通过配置文件来配置一下默认的用户名和明码:

  • application.yml

    spring:
    security:
      user:
        name: user
        password: 111111

    用户认证

首先咱们来解决用户认证问题,分为首次登陆,和二次认证。

  • 首次登录认证:用户名、明码和验证码实现登录
  • 二次token认证:申请头携带Jwt进行身份认证

应用用户名明码来登录的,而后咱们还想增加图片验证码,那么security给咱们提供的UsernamePasswordAuthenticationFilter能应用吗?

首先security的所有过滤器都是没有图片验证码这回事的,看起来不实用了。其实这里咱们能够灵便点,如果你仍然想沿用自带的UsernamePasswordAuthenticationFilter,那么咱们就在这过滤器之前增加一个图片验证码过滤器。当然了咱们也能够通过自定义过滤器继承UsernamePasswordAuthenticationFilter,而后本人把验证码验证逻辑和认证逻辑写在一起,这也是一种解决形式。

咱们这次解决形式是在UsernamePasswordAuthenticationFilter之前自定义一个图片过滤器CaptchaFilter,提前校验验证码是否正确,这样咱们就能够应用UsernamePasswordAuthenticationFilter了,而后登录失常或失败咱们都能够通过对应的Handler来返回咱们特定格局的封装后果数据。

生成验证码

首先咱们学生成验证码,之前咱们曾经援用了google的验证码生成器,咱们先来配置一下图片验证码的生成规定:

  • com.markerhub.config.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;
     }
    }

    下面我定义了图片验证码的长宽字体色彩等,本人能够调整哈。
    而后咱们通过控制器提供生成验证码的办法:

  • com.markerhub.controller.AuthController

    @Slf4j
    @RestController
    public class AuthController extends BaseController{
     @Autowired
     private Producer producer;
     /**
      * 图片验证码
      */
     @GetMapping("/captcha")
     public Result captcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String code = producer.createText();
        String key = UUID.randomUUID().toString();
        BufferedImage image = producer.createImage(code);
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", outputStream);
        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);
        log.info("验证码 -- {} - {}", key, code);
        return Result.succ(
              MapUtil.builder()
              .put("token", key)
              .put("base64Img", base64Img)
              .build()
        );
     }
    }

    因为前后端拆散,咱们禁用了session,所以咱们把验证码放在了redis中,应用一个随机字符串作为key,并传送到前端,前端再把随机字符串和用户输出的验证码提交上来,这样咱们就能够通过随机字符串获取到保留的验证码和用户的验证码进行比拟了是否正确了。
    而后因为图片验证码的形式,所以咱们进行了encode,把图片进行了base64编码,这样前端就能够显示图片了。

而前端的解决,咱们之前是应用了mockjs进行随机生成数据的,当初后端有接口之后,咱们只须要在main.js中去掉mockjs的引入即可,这样前端就能够拜访后端的接口而不被mock拦挡了。

验证码认证过滤器

图片验证码进行认证验证码是否正确。

  • CaptchaFilter

    /**
     * 图片验证码校验过滤器,在登录过滤器前
     */
    @Slf4j
    @Component
    public class CaptchaFilter extends OncePerRequestFilter {
     private final String loginUrl = "/login";
     @Autowired
     RedisUtil redisUtil;
     @Autowired
     LoginFailureHandler loginFailureHandler;
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
           throws ServletException, IOException {
        String url = request.getRequestURI();
        if (loginUrl.equals(url) && request.getMethod().equals("POST")) {
           log.info("获取到login链接,正在校验验证码 -- " + url);
           try {
              validate(request);
           } catch (CaptchaException e) {
              log.info(e.getMessage());
              // 交给登录失败处理器解决
              loginFailureHandler.onAuthenticationFailure(request, response, e);
           }
        }
        filterChain.doFilter(request, response);
     }
     private void validate(HttpServletRequest request) {
        String code = request.getParameter("code");
        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);
     }
    }

    下面代码中,因为验证码须要存储,所以增加了RedisUtil工具类,这个工具类代码咱们就不贴出来了。

  • com.markerhub.util.RedisUtil

而后验证码出错的时候咱们返回异样信息,这是一个认证异样,所以咱们自定了一个CaptchaException:

  • com.javacat.common.exception.CaptchaException

    public class CaptchaException extends AuthenticationException {
     public CaptchaException(String msg) {
        super(msg);
     }
    }
  • com.markerhub.common.lang.Const

    public class Const {
     public static final String captcha_KEY = "captcha";
    }

    而后认证失败的话,咱们之前说过,登录失败的时候交给AuthenticationFailureHandler,所以咱们自定义了LoginFailureHandler

  • com.markerhub.security.LoginFailureHandler

    @Component
    public class LoginFailureHandler implements AuthenticationFailureHandler {
     @Override
     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = response.getOutputStream();
        Result result = Result.fail(
              "Bad credentials".equals(exception.getMessage()) ? "用户名或明码不正确" : exception.getMessage()
        );
        outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
     }
    }

    其实次要就是获取异样的音讯,而后封装到Result,最初转成json返回给前端而已哈。
    而后咱们配置SecurityConfig

  • com.markerhub.config.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调试接口。

能够看到,咱们的随机码token和base64Img编码都是失常的。管制台上看到咱们的验证是2yyxm:

而后咱们尝试登录,因为之前咱们曾经设置了用户名明码为user/111111,所以咱们提交表单的时候再带上咱们的token和验证码。

这时候咱们就能够去提交表单了吗,其实还不能够,为啥?因为就算咱们登录胜利,security默认跳转到/链接,然而又会因为没有权限拜访/,所有又会教你去登录,所以咱们必须勾销原先默认的登录胜利之后的操作,依据咱们之前剖析的流程,登录胜利之后会走AuthenticationSuccessHandler,因而在登录之前,咱们先去自定义这个登录胜利操作类:

  • com.markerhub.security.LoginSuccessHandler

    @Component
    public class LoginSuccessHandler implements AuthenticationSuccessHandler {
     @Autowired
     JwtUtils jwtUtils;
     
     @Override
     public void onAuthenticationSuccess(HttpServletRequest request, 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();
     }
    }

    登录胜利之后咱们利用用户名生成jwt,jwtUtils这个工具类我就不贴代码了哈,去看咱们我的项目源码,而后把jwt作为申请头返回回去,名称就叫Authorization哈。咱们须要在配置文件中配置一些jwt的一些密钥信息:

  • application.yml

    markerhub:
    jwt:
      # 加密秘钥
      secret: f4e2e52034348f86b67cde581c0f9eb5
      # token无效时长,7天,单位秒
      expire: 604800
      header: Authorization

    而后咱们再security配置中增加上登录胜利之后的操作类:

  • com.markerhub.config.SecurityConfig

    @Autowired
    LoginSuccessHandler loginSuccessHandler;
    ...
    # configure代码:
    http.cors().and().csrf().disable()
        .formLogin()
        .failureHandler(loginFailureHandler)
        .successHandler(loginSuccessHandler)

    而后咱们去postman的进行咱们的登录测试:

下面咱们能够看到,咱们曾经能够登录胜利了。而后去后果的申请头中查看jwt:

搞定,登录胜利啦,验证码也失常验证了。

身份认证 – 1

登录胜利之后前端就能够获取到了jwt的信息,前端中咱们是保留在了store中,同时也保留在了localStorage中,而后每次axios申请之前,咱们都会增加上咱们的申请头信息,能够回顾一下:

  • 前端我的项目的axios.js

所以后端进行用户身份辨认的时候,咱们须要通过申请头中获取jwt,而后解析出咱们的用户名,这样咱们就能够晓得是谁在拜访咱们的接口啦,而后判断用户是否有权限等操作。

那么咱们自定义一个过滤器用来进行辨认jwt。

  • JWTAuthenticationFilter

    @Slf4j
    public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
     @Autowired
     JwtUtils jwtUtils;
     @Autowired
     RedisUtil redisUtil;
     @Autowired
     SysUserService sysUserService;
     public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
     }
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("jwt 校验 filter");
        String jwt = request.getHeader(jwtUtils.getHeader());
        if (StrUtil.isBlankOrUndefined(jwt)) {
           chain.doFilter(request, response);
           return;
        }
        Claims claim = jwtUtils.getClaimByToken(jwt);
        if (claim == null) {
           throw new JwtException("token异样!");
        }
        if (jwtUtils.isTokenExpired(claim.getExpiration())) {
           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就能获取到以后登录的用户信息了,也就实现了用户认证。
    当认证失败的时候会进入AuthenticationEntryPoint,于是咱们自定义认证失败返回的数据:

  • com.markerhub.security.JwtAuthenticationEntryPoint

    /**
     * 定义认证失败解决类
     */
    @Slf4j
    @Component
    public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
     @Override
     public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
           throws IOException {
        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中:

  • com.markerhub.config.SecurityConfig

    @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.markerhub.config.SecurityConfig

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
     return new BCryptPasswordEncoder();
    }

    这样零碎就会应用咱们找个新的明码策略进行匹配明码是否失常了。之前咱们配置文件配置的用户名明码去掉:

  • application.yml

    #  security:
    #    user:
    #      name: user
    #      password: 111111

    ok,咱们先应用BCryptPasswordEncoder给咱们生成一个明码,给数据库增加一条数据先,咱们再TestController中注入BCryptPasswordEncoder,而后应用encode进行明码加密,对了,记得在SecurityConfig中吧/test/**增加白名单哈,不然拜访会提醒你登录!!

  • com.markerhub.controller.TestController

    @Autowired
    BCryptPasswordEncoder bCryptPasswordEncoder;
    @GetMapping("/test/pass")
    public Result passEncode() {
     // 明码加密
     String pass = bCryptPasswordEncoder.encode("111111");
     
     // 明码验证
     boolean matches = bCryptPasswordEncoder.matches("111111", pass);
     
     return Result.succ(MapUtil.builder()
           .put("pass", pass)
           .put("marches", matches)
           .build()
     );
    }

    能够看到我明码是111111,加密以及验证的后果如下:$2a$10$R7zegeWzOXPw871CmNuJ6upC0v8D373GuLuTw8jn6NET4BkPRZfgK

data中的那一串字符串就是咱们的明码了,能够看到marches也是true,阐明明码验证也是正确的,咱们增加到咱们数据库sys_user表中:

INSERT INTO `vueadmin`.`sys_user` (`id`, `username`, `password`, `avatar`, `email`, `city`, `created`, `updated`, `last_login`, `statu`) 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');

前面咱们就能够应用admin/111111登录咱们的零碎哈。
然而先咱们登录过程零碎不是从咱们数据库中获取数据的,因而,咱们须要从新定义这个查用户数据的过程,咱们须要重写UserDetailsService接口。

  • com.markerhub.security.UserDetailsServiceImpl

    @Slf4j
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
     @Autowired
     SysUserService sysUserService;
     
     @Override
     public 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.markerhub.config.SecurityConfig

    @Autowired
    UserDetailsServiceImpl userDetailsService;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
     auth.userDetailsService(userDetailsService);
    }

    而后下面UserDetailsService.loadUserByUsername()默认返回的UserDetails,咱们自定义了AccountUser去重写了UserDetails,这也是为了前面咱们可能会调整用户的一些数据等。

  • com.markerhub.security.AccountUser

    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;
     }
     public Long getUserId() {
        return userId;
     }
     ...  
    }

    其实数据根本没变,我就增加多了一个用户的id而已。
    ok,万事俱备,咱们再次尝试去登录,看能不能登录胜利。

1、获取验证码:

2、从控制台获取到对应的验证码

3、提交登录表单

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')")

ok,咱们再来整体梳理一下受权、验证权限的流程:

  1. 用户登录或者调用接口时候辨认到用户,并获取到用户的权限信息
  2. 注解标识Controller中的办法须要的权限或角色
  3. Security通过FilterSecurityInterceptor匹配URI和权限是否匹配
  4. 有权限则能够拜访接口,当无权限的时候返回异样交给AccessDeniedHandler操作类解决

ok,流程清晰之后咱们就开始咱们的编码:

  • 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)
     );
    }
  • com.markerhub.security.JWTAuthenticationFilter

    SysUser sysUser = sysUserService.getByUsername(username);
    List<GrantedAuthority> grantedAuthorities = userDetailsService.getUserAuthority(sysUser.getId());
    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
        = new UsernamePasswordAuthenticationToken(username, null, grantedAuthorities);

    代码中的com.markerhub.service.impl.SysUserServiceImpl#getUserAuthorityInfo是重点:

    @Slf4j
    @Service
    public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
     ... 
     @Override
     public String getUserAuthorityInfo(Long userId) {
        SysUser sysUser = this.getById(userId);
        String authority = null;
        
        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));
           List<Long> menuIds = sysUserMapper.getNavMenuIds(userId);
           List<SysMenu> menus = sysMenuService.listByIds(menuIds);
           
           String roleNames = roles.stream().map(r -> "ROLE_" + r.getCode()).collect(Collectors.joining(","));
           String permNames = menus.stream().map(m -> m.getPerms()).collect(Collectors.joining(","));
           
           authority = roleNames.concat(",").concat(permNames);
           log.info("用户ID - {} ---领有的权限:{}", userId, authority);
           
           redisUtil.set("GrantedAuthority:" + sysUser.getUsername(), authority, 60*60);
           
        }
        return authority;
     }
    }

    能够看到,我通过用户id别离获取到用户的角色信息和菜单信息,而后通过逗号链接起来,因为角色信息咱们须要这样“ROLE_”+角色,所以才有了下面的写法:
    比方用户领有Admin角色和增加用户权限,则最初的字符串是:ROLE_admin,sys:user:save

同时为了防止屡次查库,我做了一层缓存,这里了解应该不难。

而后sysUserMapper.getNavMenuIds(userId)因为要查询数据库,具体SQL如下:

  • com.markerhub.mapper.SysUserMapper#getNavMenuIds

    <select id="getNavMenuIds" resultType="java.lang.Long">
      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};
    </select>

    下面示意通过用户ID获取用户关联的菜单的id,因而须要用到两个两头表的关联了。
    ok,这样咱们就赋予了用户角色和操作权限了。前面咱们只须要在Controller增加上具体注解示意须要的权限,Security就会主动帮咱们主动实现权限校验了。

权限缓存

因为下面我在获取用户权限那里增加了个缓存,这时候问题来了,就是权限缓存的实时更新问题,比方当后盾更新某个管理员的权限角色信息的时候如果权限缓存信息没有实时更新,就会呈现操作有效的问题,那么咱们当初点定义几个办法,用于革除某个用户或角色或者某个菜单的权限的办法:

  • com.markerhub.service.impl.SysUserServiceImpl

    // 删除某个用户的权限信息
    @Override
    public void clearUserAuthorityInfo(String username) {
     redisUtil.del("GrantedAuthority:" + username);
    }
    // 删除所有与该角色关联的用户的权限信息
    @Override
    public 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());
     });
    }
    // 删除所有与该菜单关联的所有用户的权限信息
    @Override
    public void clearUserAuthorityInfoByMenuId(Long menuId) {
     List<SysUser> sysUsers = sysUserMapper.listByMenuId(menuId);
     sysUsers.forEach(u -> {
        this.clearUserAuthorityInfo(u.getUsername());
     });
    }

    下面最初一个办法查到了与菜单关联的所有用户的,具体sql如下:

  • com.markerhub.mapper.SysUserMapper#listByMenuId

    <select id="listByMenuId" resultType="com.javacat.entity.SysUser">
      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};
    </select>

    有了这几个办法之后,在哪里调用?这就简略了,在更新、删除角色权限、更新、删除菜单的时候调用,尽管咱们当初还没写到这几个办法,后续咱们再写增删改查的时候记得加上就行啦。

    退出数据返回

jwt -username

token – 随机码 – redis

  • com.markerhub.security.JwtLogoutSuccessHandler

    @Component
    public class JwtLogoutSuccessHandler implements LogoutSuccessHandler {
     @Autowired
     JwtUtils jwtUtils;
     @Override
     public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
           throws IOException, ServletException {
        if (authentication != null) {
           new SecurityContextLogoutHandler().logout(request, response, authentication);
        }
        response.setContentType("application/json;charset=UTF-8");
        response.setHeader(jwtUtils.getHeader(), "");
        ServletOutputStream out = response.getOutputStream();
        Result result = Result.succ("");
        out.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        out.flush();
        out.close();
     }

无权限数据返回

  • com.markerhub.security.JwtAccessDeniedHandler

    @Slf4j
    @Component
    public class JwtAccessDeniedHandler implements AccessDeniedHandler {
     @Override
     public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
           throws IOException, ServletException {
    //    response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
        log.info("权限不够!!");
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        ServletOutputStream outputStream = response.getOutputStream();
        Result result = Result.fail(accessDeniedException.getMessage());
        outputStream.write(JSONUtil.toJsonStr(result).getBytes("UTF-8"));
        outputStream.flush();
        outputStream.close();
     }
    }

    致此,SpringSecurity就曾经完满整合到了咱们的我的项目中来了。

7. 解决跨域问题

下面的调试咱们都是应用的postman,如果咱们和前端进行对接的时候,会呈现跨域的问题,如何解决?

  • com.markerhub.config.CorsConfig

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
     private CorsConfiguration buildConfig() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.addExposedHeader("Authorization");
        return corsConfiguration;
     }
     
     @Bean
     public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", buildConfig());
        return new CorsFilter(source);
     }
     
     @Override
     public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
              .allowedOrigins("*")
    //          .allowCredentials(true)
              .allowedMethods("GET", "POST", "DELETE", "PUT")
              .maxAge(3600);
     }
    }

    8. 前后端对接的问题

因为咱们之前开发前端的时候,咱们都是应用mockjs返回随机数据的,一般来说问题不会很大,我就怕有些同学再去掉mock之后,和后端对接却显示不出数据,这就难堪了。这时候我倡议你去看我的开发视频哈。

前面因为都是接口的增删改查,难度其实不是特地大,所以大部分时候我都会间接贴代码,如果想看手把手教程,还是去看我的教学视频哈,B站搜寻MarkerHub就能够啦,公众号也是叫MarkerHub。

9. 菜单接口开发

咱们先来开发菜单的接口,因为这3个表:用户表、角色表、菜单表,才有菜单表是不须要通过其余表来获取信息的。比方用户须要关联角色,角色须要关联菜单,而菜单不须要被动关联其余表。因而菜单表的增删改查是最简略的。

再回到咱们的前端我的项目,登录实现之后咱们通过JWT获取我的项目的导航菜单和权限,那么接下来咱们就先编写这个接口。

获取菜单导航和权限的链接是/sys/menu/nav,而后咱们的菜单导航的json数据应该是这样的:

{
   title: '角色治理',
   icon: 'el-icon-rank',
   path: '/sys/roles',
   name: 'SysRoles',
   component: 'sys/Role',
   children: []
}

而后返回的权限数据应该是个数组:

["sys:menu:list","sys:menu:save","sys:user:list"...]

留神导航菜单那里有个children,也就是子菜单,是个树形构造,因为咱们的菜单可能这样:

系统管理 - 菜单治理 - 增加菜单

能够看到这就曾经有3级了菜单了。
所以在打代码时候要留神这个关系的关联。咱们的SysMenu实体类中有个parentId,然而没有children,因而咱们能够在SysMenu中增加一个children,当然了其实不增加也能够,因为咱们也须要一个dto,这样咱们能力依照下面json数据格式返回。

咱们还是来增加一个children吧:

  • com.markerhub.entity.SysMenu

    @Data
    @EqualsAndHashCode(callSuper = true)
    public class SysMenu extends BaseEntity {
     ...
     @TableField(exist = false)
     private List<SysMenu> children = new ArrayList<>();
    }

    而后咱们也先来定义一个SysMenuDto吧,晓得要返回什么样的数据,咱们就只须要去填充数据就好了

  • com.markerhub.common.dto.SysMenuDto

    @Data
    public class SysMenuDto implements Serializable {
     private Long id;
     private String title;
     private String icon;
     private String path;
     private String name;
     private String component;
     List<SysMenuDto> children = new ArrayList<>();
    }

    ok,咱们来开始咱们的编码

  • com.markerhub.controller.SysMenuController#nav

    /**
     * 获取以后用户的菜单栏以及权限
     */
    @GetMapping("/nav")
    public Result nav(Principal principal) {
     String username = principal.getName();
     SysUser sysUser = sysUserService.getByUsername(username);
     // ROLE_Admin,sys:user:save
     String[] authoritys = StringUtils.tokenizeToStringArray(
           sysUserService.getUserAuthorityInfo(sysUser.getId())
           , ",");
     return Result.succ(
           MapUtil.builder()
                 .put("nav", sysMenuService.getcurrentUserNav())
                 .put("authoritys", authoritys)
                 .map()
     );
    }

    办法中Principal principal示意注入以后用户的信息,getName就能够获取当以后用户的用户名了。sysUserService.getUserAuthorityInfo办法咱们之前曾经说过了,就在咱们登录实现或者身份认证时候须要返回用户权限时候编写的。而后通过StringUtils.tokenizeToStringArray把字符串通过逗号离开组成数组模式。
    重点在与sysMenuService.getcurrentUserNav,获取以后用户的菜单导航,

@Service
public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> implements SysMenuService {
   ...
   
   /**
    * 获取以后用户菜单导航
    */
   @Override
   public 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 = buildTreeMenu(this.listByIds(menuIds));
      return convert(menus);
   }
   
   /**
    * 把list转成树形构造的数据
    */
   private List<SysMenu> buildTreeMenu(List<SysMenu> menus){
      List<SysMenu> finalMenus = new ArrayList<>();
      for (SysMenu menu : menus) {
      
         // 先寻找各自的孩子
         for (SysMenu e : menus) {
            if (e.getParentId() == menu.getId()) {
               menu.getChildren().add(e);
            }
         }
         // 提取出父节点
         if (menu.getParentId() == 0L) {
            finalMenus.add(menu);
         }
      }
      return finalMenus;
   }
   
   /**
    * menu转menuDto
    */
   private List<SysMenuDto> convert(List<SysMenu> menus) {
      List<SysMenuDto> menuDtos = new ArrayList<>();
      menus.forEach(m -> {
         SysMenuDto dto = new SysMenuDto();
         dto.setId(m.getId());
         dto.setName(m.getPerms());
         dto.setTitle(m.getName());
         dto.setComponent(m.getComponent());
         dto.setIcon(m.getIcon());
         dto.setPath(m.getPath());
         if (m.getChildren().size() > 0) {
            dto.setChildren(convert(m.getChildren()));
         }
         menuDtos.add(dto);
      });
      return menuDtos;
   }
}

接口中sysUserMapper.getNavMenuIds咱们之前就曾经写过的了,通过用户id获取菜单的id,而后前面就是转成树形构造,buildTreeMenu办法的思维很简略,咱们事实把菜单循环,让所有菜单先找到各自的子节点,而后咱们在把最顶级的菜单获取进去,这样顶级上面有二级,二级也有本人的三级。最初就是convert把menu转成menuDto。这个比较简单,就不说了。
好了,导航菜单曾经开发结束,咱们来写菜单治理的增删改查,因为菜单列表也是个树形接口,这次咱们就不是获取以后用户的菜单列表的,而是所有菜单而后组成树形构造,一样的思维,数据不一样而已。

  • com.markerhub.controller.SysMenuController

    @GetMapping("/info/{id}")
    @PreAuthorize("hasAuthority('sys:menu:list')")
    public Result info(@PathVariable("id") Long id) {
     return Result.succ(sysMenuService.getById(id));
    }
    @GetMapping("/list")
    @PreAuthorize("hasAuthority('sys:menu:list')")
    public Result list() {
     List<SysMenu> menus = sysMenuService.tree();
     return Result.succ(menus);
    }
    @PostMapping("/save")
    @PreAuthorize("hasAuthority('sys:menu:save')")
    public Result save(@Validated @RequestBody SysMenu sysMenu) {
     sysMenu.setCreated(LocalDateTime.now());
     sysMenu.setStatu(Const.STATUS_ON);
     sysMenuService.save(sysMenu);
     return Result.succ(sysMenu);
    }
    @PostMapping("/update")
    @PreAuthorize("hasAuthority('sys:menu:update')")
    public Result update(@Validated @RequestBody SysMenu sysMenu) {
     sysMenu.setUpdated(LocalDateTime.now());
     sysMenuService.updateById(sysMenu);
     // 革除所有与该菜单相干的权限缓存
     sysUserService.clearUserAuthorityInfoByMenuId(sysMenu.getId());
     return Result.succ(sysMenu);
    }
    @Transactional
    @PostMapping("/delete/{id}")
    @PreAuthorize("hasAuthority('sys:menu:delete')")
    public Result delete(@PathVariable Long id) {
     int count = sysMenuService.count(new QueryWrapper<SysMenu>().eq("parent_id", id));
     if (count > 0) {
        return Result.fail("请先删除子菜单");
     }
     
     // 先革除所有与该菜单相干的权限缓存
     sysUserService.clearUserAuthorityInfoByMenuId(id);
     sysMenuService.removeById(id);
     
     // 同步删除
     sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().eq("menu_id", id));
     return Result.succ("");
    }

    删除、更新菜单的时候记得调用依据菜单id分明用户权限缓存信息的办法哈。而后每个办法前都会带有权限注解:@PreAuthorize(“hasAuthority(‘sys:menu:delete’)”),这就要求用户有特定的操作权限能力调用这个接口,sys:menu:delete这些数据不是乱写进去的,咱们必须和数据库的数据保持一致才行,而后component字段,也是要和前端进行沟通,因为这个是链接到的前端的组件页面。
    有了增删改查,咱们就去先增加咱们的所有的菜单权限数据先。成果如下:

基本上线填好所有菜单的列表和增删改查操作权限,就ok。

10. 角色接口开发

角色的增删改查其实也简略,而且字段这么少,基本上吧菜单的增删改查复制过去,而后把menu改成role,在调整一下就差不多啦。而后有个角色关联菜单的操作,这个咱们等下讲讲,先来看代码:

@RestController
@RequestMapping("/sys/role")
public class SysRoleController extends BaseController {
   @GetMapping("/info/{id}")
   @PreAuthorize("hasAuthority('sys:role:list')")
   public Result info(@PathVariable Long id) {
      SysRole role = sysRoleService.getById(id);
      List<SysRoleMenu> roleMenus = sysRoleMenuService.list(new QueryWrapper<SysRoleMenu>().eq("role_id", id));
      List<Long> menuIds = roleMenus.stream().map(p -> p.getMenuId()).collect(Collectors.toList());
      role.setMenuIds(menuIds);
      return Result.succ(role);
   }
   
   @GetMapping("/list")
   @PreAuthorize("hasAuthority('sys:role:list')")
   public Result list(String name) {
      Page<SysRole> roles = sysRoleService.page(getPage(),
            new QueryWrapper<SysRole>()
                  .like(StrUtil.isNotBlank(name), "name", name)
      );
      return Result.succ(roles);
   }
   
   @PostMapping("/save")
   @PreAuthorize("hasAuthority('sys:role:save')")
   public Result save(@Validated @RequestBody SysRole sysRole) {
      sysRole.setCreated(LocalDateTime.now());
      sysRole.setStatu(Const.STATUS_ON);
      sysRoleService.save(sysRole);
      return Result.succ(sysRole);
   }
   
   @PostMapping("/update")
   @PreAuthorize("hasAuthority('sys:role:update')")
   public Result update(@Validated @RequestBody SysRole sysRole) {
      sysRole.setUpdated(LocalDateTime.now());
      sysRoleService.updateById(sysRole);
      return Result.succ(sysRole);
   }
   
   @Transactional
   @PostMapping("/delete")
   @PreAuthorize("hasAuthority('sys:role:delete')")
   public Result delete(@RequestBody Long[] ids){
      sysRoleService.removeByIds(Arrays.asList(ids));
      // 同步删除
      sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().in("role_id", ids));
      sysUserRoleService.remove(new QueryWrapper<SysUserRole>().in("role_id", ids));
      return Result.succ("");
   }
   
   @Transactional
   @PostMapping("/perm/{roleId}")
   @PreAuthorize("hasAuthority('sys:role:perm')")
   public Result perm(@PathVariable Long roleId, @RequestBody Long[] menuIds) {
      List<SysRoleMenu> sysRoleMenus = new ArrayList<>();
      Arrays.stream(menuIds).forEach(menuId -> {
         SysRoleMenu roleMenu = new SysRoleMenu();
         roleMenu.setMenuId(menuId);
         roleMenu.setRoleId(roleId);
         sysRoleMenus.add(roleMenu);
      });
      
      sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().eq("role_id", roleId));
      
      sysRoleMenuService.saveBatch(sysRoleMenus);
      
      // 革除所有用户的权限缓存信息
      sysUserService.clearUserAuthorityInfoByRoleId(roleId);
      return Result.succ(menuIds);
   }
}

下面办法中:

  • info办法

获取角色信息的办法,因为咱们不仅仅在编辑角色时候会用到这个办法,在回显角色关联菜单的时候也须要被调用,因而咱们须要把角色关联的所有的菜单的id也一并查问进去,也就是调配权限的操作。对应到前端就是这样的,点击调配权限,会弹出出所有的菜单列表,而后依据角色曾经关联的菜单的id回显勾选上曾经关联过的。成果如下:

而后点击保留调配权限的时候,咱们须要把角色的id和所有勾选上的菜单id的数组一起传过来,所以才有了controller中的这样的写法:

@PreAuthorize("hasAuthority('sys:role:perm')")
public Result perm(@PathVariable Long roleId, @RequestBody Long[] menuIds) {
    ...代码下面贴出来
}

能够看到,因为@RequestBody,咱们晓得menuIds是否装置body外面的,这个须要留神,对应到的前端写法就是这样:

ok,角色治理就讲到这里了,其余增删改查本人看下代码,不难哈。

11. 用户接口开发

用户治理外面有个用户关联角色的调配角色操作,和角色关联菜单的写法差不多的,其余增删改查也复制黏贴改改就好,哈哈。

  • com.markerhub.controller.SysUserController

    /**
     * 公众号:MarkerHub
     */
    @RestController
    @RequestMapping("/sys/user")
    public class SysUserController extends BaseController {
     @Autowired
     PasswordEncoder passwordEncoder;
     
     @GetMapping("/info/{id}")
     @PreAuthorize("hasAuthority('sys:user:list')")
     public Result info(@PathVariable Long id) {
        SysUser user = sysUserService.getById(id);
        Assert.notNull(user, "找不到该管理员!");
        List<SysRole> roles = sysRoleService.listRolesByUserId(user.getId());
        user.setRoles(roles);
        return Result.succ(user);
     }
     
     /**
      * 用户本人批改明码
      *
      */
     @PostMapping("/updataPass")
     public Result updataPass(@Validated @RequestBody PassDto passDto, Principal principal) {
        SysUser sysUser = sysUserService.getByUsername(principal.getName());
        boolean matches = passwordEncoder.matches(passDto.getCurrentPass(), sysUser.getPassword());
        if (!matches) {
           return Result.fail("旧明码不正确!");
        }
        sysUser.setPassword(passwordEncoder.encode(passDto.getPassword()));
        sysUser.setUpdated(LocalDateTime.now());
        sysUserService.updateById(sysUser);
        return Result.succ(null);
     }
     
     /**
      * 超级管理员重置明码
      */
     @PostMapping("/repass")
     @PreAuthorize("hasAuthority('sys:user:repass')")
     public Result repass(@RequestBody Long userId) {
        SysUser sysUser = sysUserService.getById(userId);
        sysUser.setPassword(passwordEncoder.encode(Const.DEFAULT_PASSWORD));
        sysUser.setUpdated(LocalDateTime.now());
        sysUserService.updateById(sysUser);
        return Result.succ(null);
     }
     
     @GetMapping("/list")
     @PreAuthorize("hasAuthority('sys:user:list')")
     public Result page(String username) {
        Page<SysUser> users = sysUserService.page(getPage(),
              new QueryWrapper<SysUser>()
                    .like(StrUtil.isNotBlank(username), "username", username)
        );
        users.getRecords().forEach(u -> {
           u.setRoles(sysRoleService.listRolesByUserId(u.getId()));
        });
        return Result.succ(users);
     }
     
     @PostMapping("/save")
     @PreAuthorize("hasAuthority('sys:user:save')")
     public Result save(@Validated @RequestBody SysUser sysUser) {
        sysUser.setCreated(LocalDateTime.now());
        sysUser.setStatu(Const.STATUS_ON);
        // 初始默认明码
        sysUser.setPassword(Const.DEFAULT_PASSWORD);
        if (StrUtil.isBlank(sysUser.getPassword())) {
           return Result.fail("明码不能为空");
        }
        String password = passwordEncoder.encode(sysUser.getPassword());
        sysUser.setPassword(password);
        // 默认头像
        sysUser.setAvatar(Const.DEFAULT_AVATAR);
        sysUserService.save(sysUser);
        return Result.succ(sysUser);
     }
     
     @PostMapping("/update")
     @PreAuthorize("hasAuthority('sys:user:update')")
     public Result update(@Validated @RequestBody SysUser sysUser) {
        sysUser.setUpdated(LocalDateTime.now());
        if (StrUtil.isNotBlank(sysUser.getPassword())) {
           String password = passwordEncoder.encode(sysUser.getPassword());
           sysUser.setPassword(password);
        }
        sysUserService.updateById(sysUser);
        return Result.succ(sysUser);
     }
     
     @PostMapping("/delete")
     @PreAuthorize("hasAuthority('sys:user:delete')")
     public Result delete(@RequestBody Long[] ids){
        sysUserService.removeByIds(Arrays.asList(ids));
        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().in("user_id", ids));
        return Result.succ("");
     }
     
     /**
      * 调配角色
      * @return
      */
     @Transactional
     @PostMapping("/role/{userId}")
     @PreAuthorize("hasAuthority('sys:user:role')")
     public Result perm(@PathVariable Long userId, @RequestBody Long[] roleIds) {
        System.out.println(roleIds);
        List<SysUserRole> userRoles = new ArrayList<>();
        Arrays.stream(roleIds).forEach(roleId -> {
           SysUserRole userRole = new SysUserRole();
           userRole.setRoleId(roleId);
           userRole.setUserId(userId);
           userRoles.add(userRole);
        });
        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().eq("user_id", userId));
        sysUserRoleService.saveBatch(userRoles);
        // 革除权限信息
        SysUser sysUser = sysUserService.getById(userId);
        sysUserService.clearUserAuthorityInfo(sysUser.getUsername());
        return Result.succ(roleIds);
     }
    }

    下面用到一个sysRoleService.listRolesByUserId,通过用户id获取所有关联的角色,用到了两头表,能够写sql,这里我这样写的:

  • com.markerhub.service.impl.SysRoleServiceImpl#listRolesByUserId

    @Override
    public List<SysRole> listRolesByUserId(Long userId) {
     return this.list(
           new QueryWrapper<SysRole>()
                 .inSql("id", "select role_id from sys_user_role where user_id = " + userId));
    }

    userId肯定要是本人数据库查出来的,千万别让前端传过来啥就间接调用这个办法,不然会可能会被攻打,嘿嘿嘿~最委托就是写残缺的sql,而不是这样半个sql的写法。
    最初成果如下:

12. 我的项目部署

部署我的项目其实和vueblog的部署是一样的,本人调整一下吧少年,我有写了视频和文档的:

https://www.bilibili.com/video/BV17A411E7aE/

13. 我的项目总结

好了,咱们终于又写完了一个我的项目,心愿能让你们学到点货色,这次写的文档有点乱,多多担待,太长了,写着写着就不晓得写哪了,哈哈。

另外我还有另外一个前后端博客我的项目博客,如果有须要能够关注公众号MarkerHub,回复【VueBlog】获取哈!!

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理