使用springboot3.X+spring security6+ JWT+MyBatisPlus搭建一个后端基础脚手架(1)

为什么会有这篇文章

由于各种不可控因素,最近这几年参与的项目基本都是java8/java11+springboot2.X的项目,springboot3.x已经出来很久了,但是一直没有在正式项目中用过,最近有机会可能会用到springboot3来做个正式项目,所以想搭建一个最基础的脚手架作为新项目的起点。

  1. 这篇文章记录一下过程,作为备忘可以以后查询
  2. 踩到的坑也记录下来,可以供其他用到类似技术的同学参考
  3. 这几年面试了好多java后端小伙伴,发现好多同学都是用别人搭好的框架,对于框架为何这么选型一头雾水,这篇文章希望给这些小伙伴一个思路

涉及知识点

会涵盖以下技术点:

  1. springboot3
  2. spring security6
  3. JWT
  4. MyBatis Plus
  5. liquibase
  6. OpenAPI/springdoc/knife4j
  7. 全局异常处理
  8. lombok
  9. mapstruct
  10. Redis
  11. MyBatisPlus代码生成器

我个人的观点来说,任何一个springboot项目,除了最后一点大部分项目不使用,剩余的技术点是必选项。当然,ORM可能不选用MyBatisPlus,而是采用JPA+SpringData的方式,数据库一致性工具使用flyway。 上述涵盖的东西放在一篇文章中算是比较多了,而且每一个技术点都能写一篇很长的文章,所以我不讨论技术点优劣,不深入解释,甚至有些不能算是最佳实践,只是为一个新项目的起点做出最小的配置,让项目能够跑起来,后续用到的同学再根据自己需要慢慢优化。

新建一个springboot3的项目

可以上网站start.spring.io 中建一个项目。 如上图所示,我选择了Maven作为项目构建工具,选择了最新的稳定版3.2.5,选择了java21,同时勾选了以下的项目依赖: Spring Web Spring Security Lombok MySQL Driver(项目中用postgresql的也很多,但是国内更喜欢用mysql) 当然也可以不使用网站,利用集成开发工具也行。

加入数据库访问

目前应用干不了什么实质工作,我们加入数据库ORM,顺便把properties文件改为yaml格式。 任何项目不可能只有生产环境,一般来说会有各个环境,服务于不同的人员: local:自己开发用,不少项目这个配置文件是被git忽略掉,只有开发者个人使用的 dev:开发人员前后端联调,跟local最大的区别就是这里面配置的网络环境一般是内网IP test:测试人员测试使用 uat:客户验收使用 prod:生产 现在就拆分一下: application.yaml:

yaml 复制代码
spring:
  application:
    name: Springboot3-Springsecurity6-Mybatisplus
  mvc:
    pathmatch:
      matching-strategy: ANT_PATH_MATCHER
  main:
    banner-mode: off

  liquibase:
    change-log: classpath:db/changelog/master.yaml
    enabled: true

# MyBatis配置
#mybatis:
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    banner: off
  #  # 搜索指定包别名
  #  typeAliasesPackage: com.focus.**.domain
  #  # 配置mapper的扫描,找到所有的mapper.xml映射文件
  #  mapperLocations: classpath*:mapper/**/*Mapper.xml
  mapper-locations: classpath*:mapper/**/*Mapper.xml
  type-aliases-package: com.sptan.**.domain

server:
  servlet:
    context-path: /ssmp
  shutdown: graceful

application-local.yaml

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/ssmp?useUnicode=true&characterEncoding=UTF-8
    username: root
    password: "1234qweR"

server:
  port: 6666

logging:
  file:
    path: /Users/liupeng/logs/ssmp
  level:
    com.sptan: debug

演示用,只有local的配置文件就够用了,连的本机的测试数据库,希望后续演示一切顺利,端口号比666还要多一个6,主打一个足够6!

建数据库

建一个空的数据库,大家在编码时,建议选utf8mb4,比utf8有更好的兼容性,存储表情符时少很多烦恼。

引入MyBatis Plus

引入非常简单:

xml 复制代码
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>${mybatisplus.version}</version>
        </dependency>

我们使用最新版,3.5.5 maven构建文件这时长这样: MyBatis Plus的开发人员做了很多工作,基本上拿来即用,唯一需要配置的就是Mapper文件的位置。 配置文件可以加载springboot启动类上,也可以放在一个专门的配置类上,我们使用后者。 这时发现spring给我们生成的包名太长了,新建工程时没有注意,现在改一下,改成:ssmp,应用名也顺便一块改了。 现在主类这样:

java 复制代码
package com.sptan.ssmp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SsmpApplication {

    public static void main(String[] args) {
        SpringApplication.run(SsmpApplication.class, args);
    }

}

MyBatis Plus的配置类:

java 复制代码
package com.sptan.ssmp.mybatis;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Description: MybatisPlusConfig .
 *
 * @author lp
 */
@Configuration
@MapperScan("com.sptan.ssmp.**.mapper")
public class MybatisPlusConfig {

    /**
     * Mybatis plus interceptor mybatis plus interceptor.
     *
     * @return the mybatis plus interceptor
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 分页
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 防止全表更新与删除
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
        return interceptor;
    }
}

引入liquibase

启动任何一个访问DB的项目,只要客户允许,我是一定要引入liquibase或者flyway这类数据库一致性工具的。 引入liquibase后,建表语句直接放在liquibase脚本中。 初始的SQL脚本如下:

sql 复制代码
create table sys_admin_user
(
  id             bigint auto_increment comment '主键ID'
  primary key,
  email          varchar(64)  default '' not null comment '邮箱',
  mobile         varchar(64)  default '' not null comment '手机',
  user_name      varchar(64)  default '' not null comment '用户姓名, 不能重复',
  nick_name      varchar(64)  default '' not null comment '用户昵称',
  password       varchar(256) default '' not null comment '密码,密文',
  gender       int(11) default 0 not null comment '1男2女0未知',
  avatar         varchar(256) default '' not null comment '头像地址',
  status         int          default 1  not null comment '启用状态:0->禁用;1->启用',
  sort           int          default 0  not null comment '排序',
  delete_flag    tinyint(1)   default 0  not null comment '1:已删除, 0:正常未删除',
  version        int          default 1  not null comment '版本信息',
  ctime          datetime                not null comment '创建时间',
  utime          datetime                not null comment '最后更新时间',
  cuid           bigint       default 0  not null comment '创建人ID',
  opuid          bigint       default 0  not null comment '更新人ID',
  constraint uni_email
  unique (email, delete_flag)
)
    comment '管理端用户表' row_format = DYNAMIC;

create table sys_dept
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  parent_id   bigint                   null comment '上级部门ID',
  name        varchar(64)   default '' not null comment '角色名称',
  full_path   varchar(8000) default '' not null comment '完整路径',
  status      int           default 1  not null comment '启用状态:0->禁用;1->启用',
  sort        int           default 0  not null comment '排序',
  delete_flag tinyint(1)    default 0  not null comment '1:已删除, 0:正常未删除',
  version     int           default 1  not null comment '版本信息',
  ctime       datetime                 not null comment '创建时间',
  utime       datetime                 not null comment '最后更新时间',
  cuid        bigint        default 0  not null comment '创建人ID',
  opuid       bigint        default 0  not null comment '更新人ID'
)
    comment '部门表' row_format = DYNAMIC;

create table sys_menu
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  parent_id   bigint       default 0  not null comment '父级ID',
  name        varchar(64)  default '' not null comment '菜单或者按钮名称',
  node_type   int          default 2  not null comment '节点类型,1文件夹,2页面,3按钮, 4:子页面或者页面元素',
  icon_url    varchar(256) default '' not null comment '图标地址',
  link_url    varchar(256) default '' not null comment '页面对应的前端地址',
  level       int          default 1  not null comment '层级',
  full_path   varchar(512) default '' not null comment '树id的路径 整个层次上的路径id,逗号分隔',
  status      int          default 1  not null comment '启用状态:0->禁用;1->启用',
  sort        int          default 0  not null comment '排序',
  delete_flag tinyint(1)   default 0  not null comment '1:已删除, 0:正常未删除',
  version     int          default 1  not null comment '版本信息',
  ctime       datetime                not null comment '创建时间',
  utime       datetime                not null comment '最后更新时间',
  cuid        bigint       default 0  not null comment '创建人ID',
  opuid       bigint       default 0  not null comment '更新人ID'
)
    comment '菜单表' row_format = DYNAMIC;

create table sys_role_info
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  code        varchar(64) default '' not null comment '编码,用于处理特殊业务',
  name        varchar(64) default '' not null comment '角色名称',
  role_type   int         default 2  not null comment '0:超级管理员, 1: 管理员 2: 城市合伙人 3:普通用户',
  status      int         default 1  not null comment '启用状态:0->禁用;1->启用',
  sort        int         default 0  not null comment '排序',
  delete_flag tinyint(1)  default 0  not null comment '1:已删除, 0:正常未删除',
  version     int         default 1  not null comment '版本信息',
  ctime       datetime               not null comment '创建时间',
  utime       datetime               not null comment '最后更新时间',
  cuid        bigint      default 0  not null comment '创建人ID',
  opuid       bigint      default 0  not null comment '更新人ID',
  constraint uni_code
  unique (code, delete_flag)
)
    comment '角色表' row_format = DYNAMIC;

create table sys_role_menu_link
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  role_id     bigint     default 0 not null comment '角色ID',
  menu_id     bigint     default 0 not null comment '菜单ID',
  delete_flag tinyint(1) default 0 not null comment '1:已删除, 0:正常未删除',
  version     int        default 1 not null comment '版本信息',
  ctime       datetime             not null comment '创建时间',
  utime       datetime             not null comment '最后更新时间',
  cuid        bigint     default 0 not null comment '创建人ID',
  opuid       bigint     default 0 not null comment '更新人ID'
)
    comment '角色菜单关联表' row_format = DYNAMIC;

create index idx_role
    on sys_role_menu_link (role_id, delete_flag);

create table sys_user_dept_link
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  user_id     bigint     default 0 not null comment '用户ID',
  dept_id     bigint     default 0 not null comment '部门ID',
  delete_flag tinyint(1) default 0 not null comment '1:已删除, 0:正常未删除',
  version     int        default 1 not null comment '版本信息',
  ctime       datetime             not null comment '创建时间',
  utime       datetime             not null comment '最后更新时间',
  cuid        bigint     default 0 not null comment '创建人ID',
  opuid       bigint     default 0 not null comment '更新人ID'
)
    comment '用户部门关联表' row_format = DYNAMIC;

create index idx_dept
    on sys_user_dept_link (dept_id, delete_flag);

create index idx_user
    on sys_user_dept_link (user_id, delete_flag);

create table sys_user_role_link
(
  id          bigint auto_increment comment '主键ID'
  primary key,
  user_id     bigint     default 0 not null comment '用户ID',
  role_id     bigint     default 0 not null comment '角色ID',
  delete_flag tinyint(1) default 0 not null comment '1:已删除, 0:正常未删除',
  version     int        default 1 not null comment '版本信息',
  ctime       datetime             not null comment '创建时间',
  utime       datetime             not null comment '最后更新时间',
  cuid        bigint     default 0 not null comment '创建人ID',
  opuid       bigint     default 0 not null comment '更新人ID'
)
    comment '用户角色关联表' row_format = DYNAMIC;

create index idx_role
    on sys_user_role_link (role_id, delete_flag);

create index idx_user
    on sys_user_role_link (user_id, delete_flag);

这个脚本中包含了用户、部门、角色、菜单等基本信息,可以说是一个管理后台的必须的表。 上面的配置文件application.yaml中已经包含了liquibase的配置信息,它的入口是classpath:db/changelog/master.yaml. 我们看看classpath:db/changelog/master.yaml的内容

yaml 复制代码
databaseChangeLog:
- include:
    file: change-log.yaml
    relativeToChangelogFile: true

很简单,指向了change-log.yaml这个配置文件,看看change-log.yaml:

yaml 复制代码
databaseChangeLog:
  - property:
      name: now
      value: current_timestamp
      dbms: postgresql
  - property:
      name: now
      value: now()
      dbms: h2,mysql
  - property:
      name: now
      value: sysdate
      dbms: oracle
  - property:
      name: autoIncrement
      value: ture
      dbms: mysql,h2,postgresql,oracle
  - changeSet:
      id: init
      author: lp
      runOnChange: true
      changes:
        - sqlFile:
            path: db/changelog/sql/init.sql

也很简单,指定了一些数据库方言,最重要的是changeSet部分,包含了各次数据库变更的SQL历史,在这个SQL历史中,可以包含初始的建表信息、数据库内容的初始化数据,也可以包含后续表结构的变更信息。总之,后续的DDL操作只通过liquibase脚本来完成,而不是提供单独的SQL,一个环境一个环境的去执行。且不说环境多了容易出错,还有多人合作的问题,有的开发改了数据库结构,没有通知大家,可能别不清楚这个改动,对他的修改做了覆盖,环境多,人员多,会出现脚本问题的概率越来越大,使用liquibase,能解决这类不一致问题。 把我们上面建表SQL放在db/changelog/sql/init.sql中即可。 现在程序结构长这样: 数据库是空的 记得项目还需要因为liquibase的依赖,目前最新的版本是4.27.0 构建一下,发现有test错误,先不考虑单体测试,把SsmpApplicationTests这个测试类删掉。 备注:这里删除测试类,不是因为单体测试不重要,单体测试非常非常重要,我们删除是因为:

  1. 这里说明脚手架构造,暂时不需要引入单体测试
  2. springboot级别的测试比较慢,我更倾向于service层的单体测试,粒度更小,运行更快

给项目指定profile为local,运行一下。 没有意外的话,运行成功。 运行后启动log中看到生成了随机的password,这是我们引入spring security的副作用,实际项目中,没有人会用到这种随机生产密码的方式,这个后面提到安全问题时再解决。 从命令行中看到liquibase脚本成功执行了。 我们看看效果: 看到我们的脚本成功创建了表。还有两个表名为databasechange开头的表,这是liquibase运行产生的表,一个记录执行历史,一个起到全局锁控制并发的作用。databasechangelog记录执行历史,可以看看这个表的内容,还是很清晰的,既然liquibase靠着这个表来维护数据一致性,假如我把databasechangelog这个表清空了怎么样呢?感兴趣的可以试试。 注意:不要在生产环境中试!假如你就想在生产环境中试的话,请不要说是跟我学的! 程序员天生喜欢用代码控制一切,利用liquibase,可以用代码来控制数据库的变更,之前无序和不可追踪的数据库变更,变得直观、可追踪、可管理,能够消除大部分数据库结构不一致带来的问题,只要客户允许,推荐所有项目使用类似机制。 表建立好了,不过还没有实质功能。我们下面实现注册一个用户,并用新注册的用户进行登录。

引入安全机制

Spring Security是一套相当复杂的安全框架,不仅复杂,变化还快,快到5.6版引入的新特性,6.1版就被标注了要废弃,说是7.0版本就彻底删除😒。理清楚这个框架比较花时间,用简介的文字介绍更难,详细的说明以后再说,今天只说我们脚手架用到的。

JWT

对于一个普通的前后端分离的web项目,JWT算是事实上的安全标准了。简单、无状态、适用面广。 首先引入jwt相关的库,这个不是唯一的,还有很多其他可用的,大家按需使用即可。

xml 复制代码
    <properties>
        ......
        <jjwt.version>0.12.5</jjwt.version>
    </properties>
    <dependencies>
        ......

        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
    </dependencies>

补齐用户表相关的entity、mapper及service

注册用户需要往用户表中添加数据,我们补齐这些基础类。

entity

java 复制代码
package com.sptan.ssmp.domain;

import com.baomidou.mybatisplus.annotation.TableName;
import com.sptan.ssmp.mybatis.entity.AbstractFocusBaseEntity;
import lombok.Data;

/**
 * <p>
 * 管理端用户表
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@TableName("sys_admin_user")
public class AdminUser extends AbstractFocusBaseEntity {

    private static final long serialVersionUID = 1L;

    private String email;

    private String mobile;

    private String userName;

    private String nickName;

    private String password;

    private Integer gender;

    private String avatar;

    private Integer status;

    private Integer sort;

}

建表时应该发现了,我们的表有些共通字段,这些共通字段放在基类中:

java 复制代码
package com.sptan.ssmp.mybatis.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * Description: Entity基类 .
 *
 * @author lp
 */
@JsonIgnoreProperties(value = {
    "hibernateLazyInitializer",
    "handler",
    "fieldHandler"
})
@Data
public class AbstractFocusBaseEntity implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.AUTO)
    private Long id ;

    @TableField(fill = FieldFill.INSERT)
    private Long cuid;

    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime ctime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long opuid;

    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime utime;

    private Boolean deleteFlag = false;

}

DTO

一般来说,不会直接把entity对象传递给前端,而是转成DTO,隐藏掉敏感或者前端不需要的字段,加上entity中没有但是前端又需要的字段。

通用返回类型

一般来说,项目给前端返回的格式要一致,方便前端的共通处理,我们这里增加一个通用返回类:

java 复制代码
package com.sptan.ssmp.dto.core;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Objects;

/**
 * The type Result entity.
 *
 * @param <T> the type parameter
 * @author liupeng
 * @version 1.0
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResultEntity<T> {

    /**
     * The constant SUCCESS.
     */
    public static final int SUCCESS = 200;
    /**
     * The constant BUSINESS_ERROR_CODE.
     */
    public static final int BUSINESS_ERROR_CODE = 400;

    /**
     * The constant BUSINESS_ERROR_CODE_410.
     */
    public static final int BUSINESS_ERROR_CODE_410 = 410;

    /**
     * The constant MSG_SUCCESS.
     */
    public static final String MSG_SUCCESS = "SUCCESS";

    private int code;

    private String message;

    private T data;

    /**
     * Is success boolean.
     *
     * @return the boolean
     */
    public boolean isSuccess() {
        return Objects.equals(SUCCESS, this.getCode());
    }

    /**
     * Ok result entity.
     *
     * @param <T>  the type parameter
     * @param data the data
     * @return the result entity
     */
    public static <T> ResultEntity<T> ok(T data) {
        ResultEntity<T> result = new ResultEntity<>(SUCCESS, MSG_SUCCESS, data);
        return result;
    }

    /**
     * Success result entity.
     *
     * @param <T>  the type parameter
     * @param data the data
     * @return the result entity
     */
    public static <T> ResultEntity<T> success(T data) {
        ResultEntity<T> result = new ResultEntity<>(SUCCESS, MSG_SUCCESS, data);
        return result;
    }

    /**
     * Err result entity.
     *
     * @param <T>     the type parameter
     * @param message the message
     * @return the result entity
     */
    public static <T> ResultEntity<T> error(String message) {
        ResultEntity<T> result = new ResultEntity<>(BUSINESS_ERROR_CODE, message, null);
        return result;
    }

    /**
     * Err result entity.
     *
     * @param <T>       the type parameter
     * @param errorCode the error code
     * @param message   the message
     * @return the result entity
     */
    public static <T> ResultEntity<T> error(int errorCode, String message) {
        ResultEntity<T> result = new ResultEntity<>(errorCode, message, null);
        return result;
    }

    /**
     * 业务要求某些情况下允许数据部分保存并向前端返回错误信息.
     *
     * @param <T>       the type parameter
     * @param errorCode the error code
     * @param message   the message
     * @param data      the data
     * @return the result entity
     */
    public static <T> ResultEntity<T> error(int errorCode, String message, T data) {
        ResultEntity<T> result = new ResultEntity<>(errorCode, message, data);
        return result;
    }
}

用户DTO

java 复制代码
package com.sptan.ssmp.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * <p>
 * 管理端用户表.
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminUserDTO {

    private Long id;

    private String email;

    private String mobile;

    private String userName;

    private String nickName;

    private String password;

    private Integer gender;

    private String avatar;

    private Integer status;

    private Integer sort;
}

这个无脑把entity复制过来了,实际项目不要这么搞,password这种字段返给前端就搞笑了。

分页查询条件

java 复制代码
package com.sptan.ssmp.dto;

import com.sptan.ssmp.mybatis.page.PageCriteria;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * <p>
 * 管理端用户表查询条件.
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminUserCriteria extends PageCriteria {

    private Long id;

    private String email;

    private String mobile;

    private String userName;

}

分页查询条件基类

java 复制代码
package com.sptan.ssmp.mybatis.page;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;

import java.io.Serializable;
import java.util.Optional;

/**
 * The type Page req.
 *
 * @author lp
 */
@Data
public class PageCriteria implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * The constant DESC.
     */
    public static final String DESC = "desc";
    /**
     * The constant ASC.
     */
    public static final String ASC = "asc";
    private final static String[] KEYWORDS = {
        "master",
        "truncate",
        "insert",
        "select",
        "delete",
        "update",
        "declare",
        "alter",
        "drop",
        "sleep"
    };

    private Integer pageNumber = 1;

    private Integer pageSize = 10;

    private String sort;

    private String order;

    /**
     * To page page.
     *
     * @param <T> the type parameter
     * @return the page
     */
    public <T> IPage<T> toPage() {
        return PageCriteria.toPage(this);
    }

    /**
     * To page page.
     *
     * @param <T>  the type parameter
     * @param page the page
     * @return the page
     */
    public static <T> IPage<T> toPage(PageCriteria page) {
        Page<T> mybatisPage = null;
        int pageNumber = Optional.ofNullable(page.getPageNumber()).orElse(1);
        int pageSize = Optional.ofNullable(page.getPageSize()).orElse(10);
        String sort = page.getSort();
        String order = page.getOrder();
        sqlinject(sort);
        if (pageNumber < 1) {
            pageNumber = 1;
        }
        if (pageSize < 1) {
            pageSize = 10;
        }
        if (StringUtils.isNotBlank(sort)) {
            boolean isAsc = false;
            if (StringUtils.isBlank(order)) {
                isAsc = false;
            } else {
                if (DESC.equals(order.toLowerCase())) {
                    isAsc = false;
                } else if (ASC.equals(order.toLowerCase())) {
                    isAsc = true;
                }
            }
            mybatisPage = new Page<>(pageNumber, pageSize);
            if (isAsc) {
                mybatisPage.addOrder(OrderItem.asc(camel2Underline(sort)));
            } else {
                mybatisPage.addOrder(OrderItem.desc(camel2Underline(sort)));
            }
        } else {
            mybatisPage = new Page<>(pageNumber, pageSize);
        }
        return mybatisPage;
    }


    /**
     * 驼峰法转下划线.
     *
     * @param str 字符串.
     * @return 返回. string
     */
    public static String camel2Underline(String str) {
        if (StringUtils.isBlank(str)) {
            return "";
        }
        if (str.length() == 1) {
            return str.toLowerCase();
        }
        StringBuilder sb = new StringBuilder();
        for (int i = 1; i < str.length(); i++) {
            if (Character.isUpperCase(str.charAt(i))) {
                sb.append("_" + Character.toLowerCase(str.charAt(i)));
            } else {
                sb.append(str.charAt(i));
            }
        }
        return (str.charAt(0) + sb.toString()).toLowerCase();
    }

    /**
     * 防Mybatis-Plus order by注入.
     *
     * @param param 参数.
     */
    public static void sqlinject(String param) {
        if (StringUtils.isBlank(param)) {
            return;
        }
        // 转换成小写
        param = param.toLowerCase();
        // 判断是否包含非法字符
        for (String keyword : KEYWORDS) {
            if (param.contains(keyword)) {
                throw new RuntimeException(param + "包含非法字符");
            }
        }
    }
}

注册请求对象和注册响应对象

java 复制代码
package com.sptan.ssmp.dto.auth;

import lombok.Data;

/**
 * 用户认证请求对象.
 *
 * @author liupeng
 * @date 2024/5/3
 */
@Data
public class AuthRequest {

    private String username;

    private String password;

}
java 复制代码
package com.sptan.ssmp.dto.auth;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 用户认证响应对象.
 *
 * @author liupeng
 * @date 2024/5/3
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthResponse {

    private String token;
}

LoginUser(UserDetails的实现类)

java 复制代码
package com.sptan.ssmp.dto.auth;

import com.sptan.ssmp.domain.AdminUser;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

/**
 * 登录用户.
 *
 * @author lp
 * @since 2024-05-04
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginUser implements UserDetails {

    private AdminUser user;

    /**
     * Returns the authorities granted to the user. Cannot return <code>null</code>.
     *
     * @return the authorities, sorted by natural key (never <code>null</code>)
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

    /**
     * Returns the password used to authenticate the user.
     *
     * @return the password
     */
    @Override
    public String getPassword() {
        return "";
    }

    /**
     * Returns the username used to authenticate the user. Cannot return
     * <code>null</code>.
     *
     * @return the username (never <code>null</code>)
     */
    @Override
    public String getUsername() {
        return this.user.getUserName();
    }

    /**
     * Indicates whether the user's account has expired. An expired account cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user's account is valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * Indicates whether the user is locked or unlocked. A locked user cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * Indicates whether the user's credentials (password) has expired. Expired
     * credentials prevent authentication.
     *
     * @return <code>true</code> if the user's credentials are valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * Indicates whether the user is enabled or disabled. A disabled user cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

MapStruct

一般来说,DTO和entity长得很像,大多数字段一致,这时采用MapStruct做类型转换相当合适。 maven中引入

java 复制代码
   <properties>
        ......
        <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
    </properties>
    <dependencies>
        ......

        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${org.mapstruct.version}</version>
        </dependency>
    </dependencies>

增加相关的转换接口: MapStruct接口最合理的包名是mapper,接口名中最合理的后缀是Mapper,不过这个被mybatis占用了,只好用其他的名字代替,这里我使用Converter。

java 复制代码
package com.sptan.ssmp.converter;

import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.AdminUserDTO;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

import java.util.List;

/**
 * 管理端用户表 Mapstruct转换接口.
 *
 * @author lp
 * @since 2024-05-04
 */
@Mapper(componentModel = "spring")
public interface AdminUserConverter {

    /**
     * The constant INSTANCE.
     */
    AdminUserConverter INSTANCE = Mappers.getMapper(AdminUserConverter.class);


    /**
     * To dto base station dto.
     *
     * @param entity the entity
     * @return the base station dto
     */
    AdminUserDTO toDto(AdminUser entity);

    /**
     * dto to entity.
     *
     * @param entity the entity
     * @return the base station brief dto
     */
    AdminUser toEntity(AdminUserDTO dto);

    /**
     * To dto list.
     *
     * @param entities the entities
     * @return the list
     */
    List<AdminUserDTO> toDtoList(List<AdminUser> entities);

    /**
     * To entity list.
     *
     * @param dtos the dtos
     * @return the list
     */
     List<AdminUser> toEntities(List<AdminUserDTO> dtos);
}

上面这个只是一个例子,大家可以根据自己的需要增删方法。 mapstuct提供了很多配置项,我们采用最常用的spring模式,这个可以配置在各个文件中,考虑到很多接口都是采用相同配置,我们放在配置文件中,其他相同配置的不用单独配置了。

mapper

java 复制代码
package com.sptan.ssmp.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sptan.ssmp.domain.AdminUser;

/**
 * <p>
 * 管理端用户表 Mapper 接口
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
public interface AdminUserMapper extends BaseMapper<AdminUser> {

}

没有特殊的SQL,暂时不需要xml文件,这也是MyBatis Plus的一项小福利。

service层

接口

java 复制代码
package com.sptan.ssmp.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.AdminUserCriteria;
import com.sptan.ssmp.dto.AdminUserDTO;
import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.core.ResultEntity;


import java.util.Optional;

/**
 * <p>
 * 管理端用户表 服务类.
 * </p>
 *
 * @author lp
 * @since 2024 -05-04
 */
public interface AdminUserService extends IService<AdminUser> {

    /**
     * 保存.
     *
     * @param dto the dto
     * @return the result entity
     */
    ResultEntity<AdminUserDTO> save(AdminUserDTO dto);

    /**
     * 删除.
     *
     * @param id the id
     * @return the result entity
     */
    ResultEntity<Boolean> delete(Long id);

    /**
     * 查看详情.
     *
     * @param id the id
     * @return the result entity
     */
    ResultEntity<AdminUserDTO> detail(Long id);

    /**
     * 根据条件查询.
     *
     * @param criteria the criteria
     * @return the result entity
     */
    ResultEntity<IPage<AdminUserDTO>> search(AdminUserCriteria criteria);

    /**
     * Gets by user name.
     *
     * @param username the username
     * @return the by user name
     */
    Optional<AdminUser> findByUserName(String username);

    /**
     * Register admin user.
     *
     * @param username the username
     * @param password the password
     * @return the admin user
     */
    ResultEntity<AuthResponse> register(String username, String password);

    /**
     * Login result entity.
     *
     * @param request the request
     * @return the result entity
     */
    ResultEntity<AuthResponse> login(AuthRequest request);
}

实现类

java 复制代码
package com.sptan.ssmp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.sptan.ssmp.config.JwtProvider;
import com.sptan.ssmp.converter.AdminUserConverter;
import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.AdminUserCriteria;
import com.sptan.ssmp.dto.AdminUserDTO;
import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.auth.LoginUser;
import com.sptan.ssmp.dto.core.ResultEntity;
import com.sptan.ssmp.mapper.AdminUserMapper;
import com.sptan.ssmp.mybatis.page.PageCriteria;
import com.sptan.ssmp.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.Objects;
import java.util.Optional;

/**
 * <p>
 * 管理端用户表 服务实现类.
 * </p>
 *
 * @author lp
 * @since 2024-05-04
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class AdminUserServiceImpl extends ServiceImpl<AdminUserMapper, AdminUser> implements AdminUserService {

    /**
     * 密码加密工具类.
     */
    private final PasswordEncoder passwordEncoder;

    /**
     * JWT工具类.
     */
    private final JwtProvider jwtProvider;

    /**
     * Spring Security提供的认证管理器.
     */
    private final AuthenticationManager authenticationManager;

    /**
     * 保存.
     * 传入的参数提供id的话,是编辑用户, 如果id为空,是新增用户.
     *
     * @param dto the dto
     * @return the result entity
     */
    @Override
    public ResultEntity<AdminUserDTO> save(AdminUserDTO dto) {
        AdminUser entity = AdminUserConverter.INSTANCE.toEntity(dto);
        this.saveOrUpdate(entity);
        return ResultEntity.ok(AdminUserConverter.INSTANCE.toDto(entity));
    }

    /**
     * 删除.
     * 这里采用逻辑删除.
     *
     * @param id the id
     * @return the result entity
     */
    @Override
    public ResultEntity<Boolean> delete(Long id) {
        AdminUser entity = getById(id);
        if (entity == null || Objects.equals(true, entity.getDeleteFlag())) {
            return ResultEntity.error("数据不存在");
        }
        entity.setDeleteFlag(true);
        this.saveOrUpdate(entity);
        return ResultEntity.ok(true);
    }

    /**
     * 查看详情.
     *
     * @param id the id
     * @return the result entity
     */
    @Override
    public ResultEntity<AdminUserDTO> detail(Long id) {
        AdminUser entity = baseMapper.selectById(id);
        if (entity == null || Objects.equals(true, entity.getDeleteFlag())) {
            return ResultEntity.error("数据不存在");
        }
        AdminUserDTO dto = AdminUserConverter.INSTANCE.toDto(entity);
        return ResultEntity.ok(dto);
    }

    /**
     * 根据条件分页查询.
     *
     * @param criteria the criteria
     * @return the result entity
     */
    @Override
    public ResultEntity<IPage<AdminUserDTO>> search(AdminUserCriteria criteria) {
        LambdaQueryWrapper<AdminUser> wrapper = getSearchWrapper(criteria);
        IPage<AdminUser> entityPage = this.baseMapper.selectPage(PageCriteria.toPage(criteria), wrapper);
        return ResultEntity.ok(entityPage.convert(AdminUserConverter.INSTANCE::toDto));
    }

    /**
     * Gets by user name.
     *
     * @param username the username
     * @return the by user name
     */
    @Override
    public Optional<AdminUser> findByUserName(String username) {
        AdminUser adminUser = baseMapper.selectOne(new LambdaQueryWrapper<AdminUser>()
            .eq(AdminUser::getDeleteFlag, false)
            .eq(AdminUser::getUserName, username));
        if (adminUser == null) {
            return Optional.empty();
        } else {
            return Optional.of(adminUser);
        }
    }

    /**
     * Register admin user.
     *
     * @param username the username
     * @param password the password
     * @return the admin user
     */
    @Override
    public ResultEntity<AuthResponse> register(String username, String password) {
        LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(AdminUser::getUserName, username);
        AdminUser adminUser = baseMapper.selectOne(wrapper);
        if (adminUser != null) {
            return ResultEntity.error("已经被注册");
        }
        AdminUser record = buildAdminUser(username, password);
        this.save(record);
        LoginUser loginUser = LoginUser.builder().user(record).build();
        String token = jwtProvider.generateToken(loginUser);
        AuthResponse registerResponse = AuthResponse.builder()
            .token(token)
            .build();
        return ResultEntity.ok(registerResponse);
    }

    /**
     * Login result entity.
     *
     * @param request the request
     * @return the result entity
     */
    @Override
    public ResultEntity<AuthResponse> login(AuthRequest request) {
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        var user = findByUserName(request.getUsername())
            .orElseThrow(() -> new IllegalArgumentException("用户名或者密码不对."));
        LoginUser loginUser = LoginUser.builder().user(user).build();
        var jwt = jwtProvider.generateToken(loginUser);
        AuthResponse authResponse = AuthResponse.builder().token(jwt).build();
        return ResultEntity.ok(authResponse);
    }

    private AdminUser buildAdminUser(String username, String password) {
        AdminUser record = new AdminUser();
        record.setUserName(username);
        record.setEmail(username);
        record.setMobile(username);
        record.setPassword(passwordEncoder.encode(password));
        return record;
    }

    /**
     * 获取查询条件.
     * @param criteria
     * @return
     */
    private LambdaQueryWrapper<AdminUser> getSearchWrapper(AdminUserCriteria criteria) {
        LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<>();
        // 罗删除的用户默认不展示出来
        wrapper.eq(AdminUser::getDeleteFlag, false);
        if (criteria == null) {
            return wrapper;
        }
        if (StringUtils.hasText(criteria.getUserName())) {
            // 名字模糊查询
            wrapper.like(AdminUser::getUserName, "%" + criteria.getUserName() + "%");
        }
        return wrapper;
    }
}

出来login和register方法,剩下的都是CRUD的普通方法,这里面还包含了逻辑删除和分页查询的样板代码。 MyBatis Plus提供了全局的逻辑删除字段配置,不过我这里没有使用,感兴趣的可以把AbstractFocusBaseEntity这个基类中设置为逻辑删除字段,设置后,所有使用Wrapper来查询的SQL语句的查询条件中,MyBatis Plus会自动加上delete_flag = false这个分项条件,省去了wrapper.eq(AdminUser::getDeleteFlag, false);这样单独的设置,能省一点代码;当然,根据业务不同,也偶然会有些小坑需要踩一下😊。

AdminUserServiceImpl这个实现类中,有三个实例变量还没有出现,这个是需要配置的。 我们先定义一下org.springframework.security.core.userdetails.UserDetailsService的实现类。

java 复制代码
package com.sptan.ssmp.service;

import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * The interface User service.
 */
public interface UserService {

    /**
     * User details service user details service.
     *
     * @return the user details service
     */
    UserDetailsService userDetailsService();

}

实现类:

java 复制代码
package com.sptan.ssmp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sptan.ssmp.domain.AdminUser;
import com.sptan.ssmp.dto.auth.LoginUser;
import com.sptan.ssmp.mapper.AdminUserMapper;
import com.sptan.ssmp.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {


    private final AdminUserMapper adminUserMapper;

    @Override
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                LambdaQueryWrapper<AdminUser> wrapper = new LambdaQueryWrapper<>();
                wrapper.eq(AdminUser::getUserName, username);
                AdminUser adminUser = adminUserMapper.selectOne(wrapper);
                if (adminUser == null) {
                    throw new UsernameNotFoundException("User not found");
                } else {
                    return LoginUser.builder()
                        .user(adminUser)
                        .build();
                }
            }
        };
    }

}

大多数情况下,我们更愿意实现一个org.springframework.security.core.userdetails.UserDetailsService的实现类,不过我这种方式也是可以的,定义了一个方法,返回了UserDetailsService的实例。

安全配置

定义JWT的工具类

java 复制代码
package com.sptan.ssmp.config;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

/**
 * JWT工具类,用于校验token, 生成token等.
 */
@Component
public class JwtProvider {

    @Value("${token.signing.key}")
    private String jwtSigningKey;

    /**
     * Extract user name string.
     *
     * @param token the token
     * @return the string
     */
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * Generate token string.
     *
     * @param userDetails the user details
     * @return the string
     */
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    /**
     * Is token valid boolean.
     *
     * @param token       the token
     * @param userDetails the user details
     * @return the boolean
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolvers) {
        final Claims claims = extractAllClaims(token);
        return claimsResolvers.apply(claims);
    }

    private String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder().setClaims(extraClaims).setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256).compact();
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token)
                .getBody();
    }

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

别忘了配置文件中增加签名串,一般来说,各个环境中应该采用不一样的key,特别是生产环境一定要采用单独的、保密的key。

java 复制代码
token:
  signing:
    key: 413F4428472B4B6250655368566D5970337336763979244226452948404D6351

通用的安全配置

java 复制代码
package com.sptan.ssmp.config;

import com.sptan.ssmp.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(request -> request
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/doc.html",
                    "/swagger-resources/configuration/ui",
                    "/swagger*",
                    "/swagger**/**",
                    "/webjars/**",
                    "/favicon.ico",
                    "/**/*.css",
                    "/**/*.js",
                    "/**/*.png",
                    "/**/*.gif",
                    "/v3/**",
                    "/**/*.ttf",
                    "/actuator/**",
                    "/static/**",
                    "/resources/**").permitAll()
                .anyRequest().authenticated())
            .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
            .authenticationProvider(authenticationProvider()).addFilterBefore(
                jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
        throws Exception {
        return config.getAuthenticationManager();
    }
}

这个类得说明一下:

  1. 过滤器链securityFilterChain定义了基本的安全配置;
  2. http.csrf(AbstractHttpConfigurer::disable)禁用了跨站请求伪造;
  3. 注册和登录的过程中,还没有token,所以把相关接口(我们计划请求前缀为/api/v1/auth/)允许无token访问;
  4. 后面是一堆接口文档相关的,也允许无token访问,这个后面再说明;
  5. 其他的接口需要认证,在实际项目中,需要放过哪些接口,要根据业务需要来判断,比如你提供了一个第三方调用的接口,你们直接使用特殊的请求头或者oauth2之类的其他方式认证,这里也得允许访问,否则对方调用你的接口是,会被spring security拦截了;
  6. 配置session为无状态;
  7. 把JwtAuthenticationFilter加到认证过滤器的前面;

定义方法拦截的过滤器

java 复制代码
package com.sptan.ssmp.config;

import com.sptan.ssmp.service.UserService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    private final UserService userService;
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        jwt = authHeader.substring(7);
        userEmail = jwtProvider.extractUserName(jwt);
        if (StringUtils.isNotEmpty(userEmail)
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.userDetailsService()
                    .loadUserByUsername(userEmail);
            if (jwtProvider.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }
        filterChain.doFilter(request, response);
    }
}

每次接口请求(当然除了我们放过去不校验的那几个特殊请求),都会走一遍这个过滤器,这个过滤器从请求头Authorization中拿到token,解析出用户名,到我们数据库里去查询一番,合法的用户放过去,否则认证会失败。

controller层

嗯。。。好像不差什么了。。。 万事俱备只欠东风。我们加个Controller试试。

java 复制代码
package com.sptan.ssmp.controller;


import com.sptan.ssmp.dto.auth.AuthRequest;
import com.sptan.ssmp.dto.auth.AuthResponse;
import com.sptan.ssmp.dto.core.ResultEntity;
import com.sptan.ssmp.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
    private final AdminUserService adminUserService;

    @PostMapping("/signup")
    public ResponseEntity<ResultEntity<AuthResponse>> register(@RequestBody AuthRequest request) {
        return ResponseEntity.ok(adminUserService.register(request.getUsername(), request.getPassword()));
    }

    @PostMapping("/login")
    public ResponseEntity<ResultEntity<AuthResponse>> login(@RequestBody AuthRequest request) {
        return ResponseEntity.ok(adminUserService.login(request));
    }
}

由于还没有添加swagger或者springdoc,我们先用postman跑一下。 接口失败! 看看控制台: 有些字段不允许为空,但是我们没有设置。 当然可以把字段都设置全来解决问题,但是cuid这种共通字段,每新增一条记录都需要手写代码,不是我们这等懒惰的码农喜欢干的事情,关于这些共通字段的处理,JPA和MyBatisPlus都有自己的处理方案,这种统称为数据审计,名字可能有点奇怪,不过不影响我们使用。

MyBatis Plus的数据审计

java 复制代码
package com.sptan.ssmp.mybatis;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * Description: 数据填充 .
 *
 * @author lp
 */
@Component("metaObjectHandler")
public class CustomMetaObjectHandler implements MetaObjectHandler {


    @Override
    public void insertFill(MetaObject metaObject) {
        this.setFieldValByName("delete_flag", false, metaObject);
        this.setFieldValByName("cuid", 0L, metaObject);
        this.setFieldValByName("opuid", 0L, metaObject);
        LocalDateTime now = LocalDateTime.now();
        this.setFieldValByName("ctime", now, metaObject);
        this.setFieldValByName("utime", now, metaObject);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.setFieldValByName("opuid", 0L, metaObject);
        this.setFieldValByName("utime", LocalDateTime.now(), metaObject);
    }
}

可以看到这里的数据审计有点问题:cuid和opuid都设置成0了,这肯定不是我们想要的,应该设置成什么更符合我们的业务呢?大家可以先想一想。 再跑一下:

哇,终于成功了。 查看数据库: 再用刚才的用户名密码登录一下: 失败! 没事没事,人生不如意之事十之八九,失败乃人生常态,先冷静一下。 跟踪一下: 发现我们encodedPassword是空串,因为我们定义的LoginUser对象是UserDetails的实现类,看看我们的代码: 这个肯定不对,是我们上一步的马虎导致的。马虎不可怕,知错能改,善莫大焉。 改一下:

java 复制代码
    public String getPassword() {
        return this.user.getPassword();
    }

再跑一遍,果然成功了。欧耶✌🏻

注册和登录都返回了token,我们到jwt.io/ 上看看这个token是啥玩意,实际上这个是我们自己生成的,我们肯定是知道了里面包含啥的,假如你还不知道,一定是漏了上面内容😊。 截图想说明的是,token是基本算是明文,不要把敏感信息放在token里!token中保存的信息一定不能是敏感的,如果安全性要求很高,甚至用户名也不允许放在这里。 反过来想,要是用户名都不允许放在token里,怎么在保证安全的基础上区分出登录用户呢?没有做过的同学先想一想,做过类似工作的同学先坐下,反思一下你为什么要浪费时间看我这篇入门的文章呢?

相关推荐
工业甲酰苯胺2 小时前
Spring Boot 整合 MyBatis 的详细步骤(两种方式)
spring boot·后端·mybatis
bjzhang753 小时前
SpringBoot开发——集成Tess4j实现OCR图像文字识别
spring boot·ocr·tess4j
flying jiang3 小时前
Spring Boot 入门面试五道题
spring boot
小菜yh3 小时前
关于Redis
java·数据库·spring boot·redis·spring·缓存
爱上语文5 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
荆州克莱5 小时前
springcloud整合nacos、sentinal、springcloud-gateway,springboot security、oauth2总结
spring boot·spring·spring cloud·css3·技术
serve the people5 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
罗政10 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
Java小白笔记13 小时前
关于使用Mybatis-Plus 自动填充功能失效问题
spring boot·后端·mybatis
小哇66613 小时前
Spring Boot,在应用程序启动后执行某些 SQL 语句
数据库·spring boot·sql