为什么会有这篇文章
由于各种不可控因素,最近这几年参与的项目基本都是java8/java11+springboot2.X的项目,springboot3.x已经出来很久了,但是一直没有在正式项目中用过,最近有机会可能会用到springboot3来做个正式项目,所以想搭建一个最基础的脚手架作为新项目的起点。
- 这篇文章记录一下过程,作为备忘可以以后查询
- 踩到的坑也记录下来,可以供其他用到类似技术的同学参考
- 这几年面试了好多java后端小伙伴,发现好多同学都是用别人搭好的框架,对于框架为何这么选型一头雾水,这篇文章希望给这些小伙伴一个思路
涉及知识点
会涵盖以下技术点:
- springboot3
- spring security6
- JWT
- MyBatis Plus
- liquibase
- OpenAPI/springdoc/knife4j
- 全局异常处理
- lombok
- mapstruct
- Redis
- 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这个测试类删掉。 备注:这里删除测试类,不是因为单体测试不重要,单体测试非常非常重要,我们删除是因为:
- 这里说明脚手架构造,暂时不需要引入单体测试
- 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();
}
}
这个类得说明一下:
- 过滤器链securityFilterChain定义了基本的安全配置;
- http.csrf(AbstractHttpConfigurer::disable)禁用了跨站请求伪造;
- 注册和登录的过程中,还没有token,所以把相关接口(我们计划请求前缀为/api/v1/auth/)允许无token访问;
- 后面是一堆接口文档相关的,也允许无token访问,这个后面再说明;
- 其他的接口需要认证,在实际项目中,需要放过哪些接口,要根据业务需要来判断,比如你提供了一个第三方调用的接口,你们直接使用特殊的请求头或者oauth2之类的其他方式认证,这里也得允许访问,否则对方调用你的接口是,会被spring security拦截了;
- 配置session为无状态;
- 把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里,怎么在保证安全的基础上区分出登录用户呢?没有做过的同学先想一想,做过类似工作的同学先坐下,反思一下你为什么要浪费时间看我这篇入门的文章呢?