项目简介

Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架 Shiro,它提供了更丰富的功能,社区资源也比 Shiro 丰富。

一般来说中大型的项目都是使用 Spring Security 来做安全框架。规模较小的项目使用 Shiro 的比较多,因为 Spring Security 属于重量级安全框架,相比之下 Shiro 的配置与功能都要相对简单。在一般的 Web 应用中使用安全框架,框架主要负责认证和授权。

  • 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户

  • 授权:经过认证后判断当前用户是否有权限进行某个操作

而认证和授权也是 SpringSecurity 作为安全框架的核心功能。

本项目仅包含 Spring Security 的配置,提供两个基于 Restful 的登录,登出接口,并没有其余的功能。基本实现了无状态认证与权限管理,其余的可以自行在基础上进行拓展。

采用技术栈

本文大部分 Spring 组件版本基于 SpringBoot 的自动配置管理,故由 SpringBoot 管理的依赖版本并不列出版本号,只需要导入2.7.6版本的自动依赖管理即可。

项目最终采用的构件如下

  • SpringBoot 2.7.6

  • Mybatis-plus 3.5.2

  • MySQL 8.0.28

  • Redis 7.0.5

快速开始

采用渐进式的编码模式,可以一步步执行并且查看对应编码效果

引入依赖

采用 Gradle 对项目进行构建,在插件处引入 Lombok,SpringBoot 父级项目依赖及依赖管理器。其他依赖先初步引入spring-boot-starter-webspring-boot-starter-security两项,所需要的其余依赖将在后面的步骤中导入。最后产生的 build.gradle 文件如下

1
2
3
4
5
6
7
8
9
10
11
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.6'
id "io.spring.dependency-management" version "1.1.0"
id "io.freefair.lombok" version "6.6"
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}

引入了 Spring Security 的依赖之后,在 SpringBoot 的自动配置作用下,项目已经拥有了安全框架的所有功能,Spring Security 过滤器链上的每一个过滤器都提供了默认实现,在默认实现的基础上进行替换过滤器就是我们自定义安全框架行为的途径。

编码实现

创建主启动类与平常的 SpringBoot 项目一致,在控制器方面提供一个用于测试的简单控制器。

1
2
3
4
@RequestMapping("/hello")
public ResponseResult<String> hello() {
return new ResponseResult<>(200, "OK", "Hello Spring Security");
}

需要注意的是测试方法的返回值类型ResponseResult,ResponseResult类的定义如下。使用@JsonInclude(JsonInclude.Include.NON_NULL)来避免 Jackson 将空的字段进行转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
private Integer code;
private String msg;
private T data;

public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
}

Hello World!

找到项目的主启动类,直接启动该项目即可,在启动后会输出启动信息,请注意启动信息中会出现一条 WARN 等级的信息,形式如下

1
2
3
Using generated security password: 0a8e0eac-8c16-46a8-9c99-c0246cd515d5

This generated password is for development use only. Your security configuration must be updated before running your application in production.

此生成的密码仅供开发使用。在生产环境中运行应用程序之前,必须更新安全配置

得益于 SpringBoot 的自动配置,Spring Security 在不使用任何配置的情况下也可以进行一定的测试,默认用户名为user,默认密码则由UserDetailsServiceAutoConfiguration类产生并以 WARN 等级日志输出在控制台。

在启动后直接访问对应端口的 URL(或者任意 URL),会被 Spring Security 自动重定向至/login页面,这个页面上有一个由 Spring Security 提供的默认登陆页面。输入user与控制台中的密码即可完成登录,访问资源。

认证功能

用户校验

导入 ORM 框架

数据库 SQL 与实体类代码见文末章节,在此不统一给出

本章开始涉及数据库操作,在 Gradle 中引入 mybatis-plus 依赖

1
2
implementation 'com.baomidou:mybatis-plus-boot-starter:3.5.2'
implementation 'mysql:mysql-connector-java'

在配置文件中增加数据库相关的信息,如果所有的实体类全部使用@TableId指定表名,table-prefix属性可以不进行配置。

1
2
3
4
5
6
7
8
9
10
spring:
datasource:
username: username
password: password
url: jdbc:mysql://localhost:3306/security
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
global-config:
db-config:
table-prefix: sys_

创建三个持久化用的类,此处使用 mybatis-plus 提供的IService接口来快速实现,复杂的 SQL 可以结合业务进行书写。

1
2
3
4
5
6
7
8
9
10
//UserMapper接口
@Mapper
public interface UserMapper extends BaseMapper<User> {}

//UserService接口
public interface UserService extends IService<User> {}

//UserServiceImpl继承了mybatis-plus提供的ServiceImpl类,同时实现了UserService接口的方法
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {}

加载用户数据

对于每一个用户对象,Spring Security 提供了一个接口UserDetails,自定义对象需要在该对象中提供核心用户信息,通过继承该接口来提供对用户的封装。

UserDetailsService是 Spring Security 规定的加载用户指定数据的核心接口,该接口只有 loadUserByUsername一个接口方法, 用于通过用户名获取用户数据. 返回UserDetails对象, 表示用户的核心信息

信息封装

根据以上两个接口的特性,我们很容易想到,必须先创建一个新的LoginUser对象,让此对象继承UserDetails接口,并让用户的实体类对象成为该对象的一个属性,即可完成对用户信息的封装。由于UserDetails规定了较多的方法,继承该接口后LoginUser会需要实现很多方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {return null;}
@Override
public String getPassword() {return user.getPassword();}
@Override
public String getUsername() {return user.getUserName();}
@Override
public boolean isAccountNonLocked() {return "0".equals(user.getStatus());}
@Override
public boolean isEnabled() {return user.getDelFlag() == 0;}
@Override
public boolean isCredentialsNonExpired() {return true;}
@Override
public boolean isAccountNonExpired() {return true;}
}

从展示的代码可以看到,我们仅仅只是为其添加了user属性,并且通过 lombok 提供了无参构造器与user字段的 get/set 方法。其余的均为接口规定需要重写的方法。还需要修改的是重写接口中的两个 get 方法,默认生成的返回为空,现在让其返回用户对象内的用户名与密码

需要注意的是,下方四个返回值为 Boolean 类型的方法表示用户状态,需要四个方法均返回true才表示用户状态正常。这部分涉及到用户过期,锁定,认证过期与用户是否启用的内容,在此根据业务需求返回true来启用该对象。

编码实现

完成了用户信息的封装,可以开始实现UserDetailsService接口了。该接口用于处理接下来的认证,是用于加载用户特定数据的核心接口。接口内定义了一个根据用户名查询用户信息的方法,我们现在需要继承它并且实现其内的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class UserDetailServiceImpl implements UserDetailsService {
private final UserMapper userMapper;

@Autowired
public UserDetailServiceImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
if (Objects.isNull(user)) {
throw new RuntimeException("用户名不存在");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//数据封装成UserDetails返回
return new LoginUser(user);
}
}

需要注意的是,此处我们其实需要将用户的权限信息一同查出传递给LoginUser对象并返回。该功能将在后续的权限认证章节提到。

阶段测试要点

如果要测试,需要往用户表中写入用户数据。根据 Spring Security 的默认密码存储规则,如果你直接使用明文密码,需要在密码前加{noop}来让框架默认的密码管理器将密码识别为明文格式。例如若使用123456作为用户的密码,在这个阶段就需要数据库中的字段为{noop}123456

自定义密码处理器

在实际项目中我们不会把密码明文存储在数据库中,存储密码明文的行为是非常危险的

默认使用的 PasswordEncoder 由于提供了多种密码加密方式的处理,所以该类要求数据库中的密码格式为:{id}password。它会根据id去判断密码的加密方式。这种方式在开发中一般不使用,一般是指定一种统一的密码加密格式,也就不需要在加密密文前添加{id}的方式。要实现该功能,就需要替换 PasswordEncoder。

我们一般使用 SpringSecurity 为我们提供的BCryptPasswordEncoder来使用 BCrypt 对密码进行加密,BCrypt 是一种单向哈希加密方式。要使用 BCrypt 加密, 我们只需要使用把BCryptPasswordEncoder对象注入 Spring 容器中,SpringSecurity 就会使用该 PasswordEncoder 来进行密码校验。

要修改默认行为,就需要自定义过滤器链,可以定义一个配置类对其进行配置

版本警告:5.4 版本以下的 Spring Security 要求这个配置类要继承 WebSecurityConfigurerAdapter,该接口在 5.7 版本中已经被标记为废弃

直接在创建的配置类内将BCryptPasswordEncoder注册为一个 Bean 即可,BCryptPasswordEncoder继承了PasswordEncoder接口,Spring Security 会自动在上下文中查找实现了PasswordEncoder的 Bean 来替换默认的处理器

1
2
3
4
5
6
7
@SpringBootConfiguration
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

登录接口实现

若使用 RESTful 风格的前后端分离项目,就不能使用默认提供的登录界面,我们需要自定义登陆接口,在 Spring Security 设置放行,让用户访问这个接口的时候不用登录也能访问

在接口中我们通过AuthenticationManagerauthenticate方法来进行用户认证,所以需要在配置类中把AuthenticationManager注入容器

1
2
3
4
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

认证成功的话要生成一个 JwtToken,将其放入响应中返回。并且为了让用户下回请求时能通过 JwtToken 识别出具体的是哪个用户,我们需要把用户信息存入 Redis,可以把用户 id 作为 key。

导入 Redis 依赖

本章开始涉及 Redis 操作,在 Gradle 中引入 Redis 依赖

1
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

配置文件中新增 Redis 配置字段,如果在本机安装 Redis 且未修改端口可以直接使用默认配置(port = 6379,host = localhost)

创建登录控制器

登录方法接受一个 LoginVo 参数(一个只有username字段与password字段的 vo 类),我们期望的返回结果是一个 json 响应,在响应中携带后端生成的 JwtToken,按照 json 的格式需要给出键值对,故返回泛型为一个 Map 对象。在控制器中注入服务层接口,创建控制器如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@RestController
public class LoginController {
private final LoginService loginService;

@Autowired
public LoginController(LoginService loginService) {
this.loginService = loginService;
}

@PostMapping("/user/login")
public ResponseResult<Map<String, String>> login(@RequestBody LoginVo user) {
return loginService.login(user);
}
}

登录服务接口实现类

按照登录控制器中的返回值与参数创建登录接口

1
2
3
public interface LoginService {
ResponseResult<Map<String, String>> login(LoginVo user);
}

创建该接口的实现类LoginServiceImpl,实现类需要注入UserMapper,同时也需要注入 Spring Security 的AuthenticationManager用于管理

在配置文件中配置 JwtToken 的加密密钥与默认过期时间,使用@Value注解配置注入到实现类中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Setter
@Service
public class LoginServiceImpl implements LoginService {
private final AuthenticationManager authenticationManager;
private final RedisCache redisCache;
@Value("${anselyuki.jwt.secret}")
private String secret;
@Value("${anselyuki.jwt.expire}")
private int expire;

@Autowired
public LoginServiceImpl(AuthenticationManager authenticationManager, RedisCache redisCache) {
this.authenticationManager = authenticationManager;
this.redisCache = redisCache;
}

@Override
public ResponseResult<Map<String, String>> login(LoginVo user) {
return null;
}
}

登陆方法详细实现

接下来开始实现 login 方法,login 方法主要分为几个步骤,将对核心步骤进行详细解释

  • 使用AuthenticationManager中的authenticate方法进行用户认证

  • 若认证失败,给出相应提示

  • 若认证成功,使用UserID生成一个 JwtToken,Token 封装后返回

  • 把完整的用户信息存入 Redis

AuthenticationManager为我们提供了authenticate对用户进行认证,该方法需要一个Authentication接口类型的参数,其返回值也是该接口,通常的做法是直接使用其派生的子类,在这里选用UsernamePasswordAuthenticationToken类,该类被设计成用于描述用户名和密码的简单实现。

一旦请求被AuthenticationManager.authenticate(Authentication)方法处理了,Authentication就标示为一个认证请求的 Token. 当信息被认证了,Authentication会被线程安全的SecurityContext持有, 后者可以通过SecurityContextHolder获取

1
2
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);

传入的密码会与数据库中的密文比对,上文已经对密码处理器进行了配置,会自动使用 BCrypt 算法对其进行验证,返回的Authentication对象中会封装与认证有关的信息。若验证失败,将会返回空值,则可以抛出异常。认证成功之后,我们需要根据登录的用户为其生成 JwtToken,使其可以完成无状态的登录。在这一步选择工具类库Hutools中的 jwt 实现来提供支持,Gradle 导入依赖

1
implementation 'cn.hutool:hutool-jwt:5.8.10'

首先从Authentication中取出当前用户的信息并使用 Hutools 中的DataTime类设置两个时间戳,即该 Jwt 的签发时间和过期时间,使用 Map 初始化需要封装在 JWTPlayload 中的数据,包括签发时间,过期时间,生效时间与 UserID,加密密钥使用配置文件中注入的密钥,注意密钥长度不能过短。生成 JwtToken 字串的工具类JWTUtilhutool-jwt提供,可以方便的产生 JwtToken。

1
2
3
4
5
6
7
8
9
10
11
Map<String, Object> payload = new HashMap<>(4) {
{
//Dateime::getTime()方法返回以毫秒为单位的 UNIX 时间戳
put(JWTPayload.ISSUED_AT, now.getTime());
put(JWTPayload.EXPIRES_AT, newTime.getTime());
put(JWTPayload.NOT_BEFORE, now.getTime());
put("userId", userId);
}
};
String token = JWTUtil.createToken(payload, secret.getBytes());
redisCache.setCacheLoginUser("LOGIN:" + userId, principal, expire, TimeUnit.SECONDS);

把完整的用户信息存入 Redis,UserID 拼接前缀作为 Redis 的 Key,其中 Redis 工具类内的方法见文末附录,最终完成的login方法代码汇总如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public ResponseResult<Map<String, String>> login(LoginVo user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (Objects.isNull(authenticate)) {
throw new RuntimeException("登陆失败");
}
LoginUser principal = (LoginUser) authenticate.getPrincipal();
String userId = String.valueOf(principal.getUser().getId());
DateTime now = DateTime.now();
DateTime newTime = now.offsetNew(DateField.SECOND, expire);
Map<String, Object> payload = new HashMap<>(4) {
{
put(JWTPayload.ISSUED_AT, now.getTime());
put(JWTPayload.EXPIRES_AT, newTime.getTime());
put(JWTPayload.NOT_BEFORE, now.getTime());
put("userId", userId);
}
};
String token = JWTUtil.createToken(payload, secret.getBytes());
redisCache.setCacheLoginUser("LOGIN:" + userId, principal, expire, TimeUnit.SECONDS);
return new ResponseResult<>(200, "登录成功", Map.of("token", token));
}

至此该接口的功能已经正常,但是此时我们并没有放行该接口,需要在配置类新增一个SecurityFilterChain对象,在该对象提供的方法中放行该接口,并且需要设置其为STATELESS状态,这会使得框架永远不会创建 HttpSession,也不会使用 HttpSession 来获取 SecurityContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final String[] URL_WHITELIST = {"/user/login", "/user/logout"};

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//关闭CSRF,设置无状态连接
http.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//允许匿名访问的接口
http.authorizeRequests()
.antMatchers(URL_WHITELIST).anonymous()
//拦截其余接口
.anyRequest().authenticated();
return http.build();
}

此时可以用 Postman 访问对应的接口,在携带的用户名与密码正确时,可以看到返回了一个 JwtToken,表示配置正常。

认证过滤器

我们需要自定义一个过滤器,这个过滤器会去获取请求头中的 token,对 token 进行解析取出其中的 userid。

在上一章节中,我们设置了 STATELESS 状态,Spring Security 就不会自动在 SecurityContext 中配置 Authentication,这就需要我们手动注入与获取,即我们需要修改 Spring Security 默认的认证过滤器。

使用 userid 去 redis 中获取对应的 LoginUser 对象,封装 Authentication 对象存入 SecurityContext

在此我们选择继承OncePerRequestFilter这个类,这个类实现了 Filter 接口,规定了一个需要实现的方法doFilterInternal,同时注入 Redis 工具类与所需的注入值(@Value注解的注入使用 Setter 方法注入,故使用@Setter注解提供)。

OncePerRequestFilter 表示每次的 Request 都会进行拦截,不管是资源的请求还是数据的请求。在分离开发中,后端一般不会出现资源请求,故直接拦截全部请求进行认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Setter
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final RedisCache redisCache;
@Value("${anselyuki.jwt.header}")
private String header;
@Value("${anselyuki.jwt.secret}")
private String secret;

@Autowired
public JwtAuthenticationTokenFilter(RedisCache redisCache) {
this.redisCache = redisCache;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
}
}

过滤方法实现

新建过滤器类,注入必要的工具 Bean 与配置文件中的值,继承了OncePerRequestFilter接口,就需要实现doFilterInternal的方法,分析该方法中的代码行为如下:

  1. 获取请求头中的 token,对 token 进行解析取出其中的 userid

  2. 使用 userId 去 Redis 中获取对应的LoginUser对象

  3. 然后封装Authentication对象存入SecurityContextHolder

我们规定前端将存储我们登录返回的 JwtToken,并将这个 token 添加在 Http 请求头内,字段值由配置文件注入,在此使用Authorization作为 token 的键。从请求头中寻找该键的值。

1
2
3
4
5
String token = request.getHeader(header);
if (!StringUtils.hasText(token)) {
filterChain.doFilter(request, response);
return;
}

由于该过滤器在逻辑中只需要负责 token 认证,若请求头中没有携带 token,则我们就直接放行该请求,交由后续的过滤链处理。

关于 Token 的认证,在此处我们依旧使用 Hutools 封装的工具类,该工具类提供了一个静态方法JWTUtil::verify,传入参数为 token 与密钥,如果 jwt 格式不合法会抛出一个继承RuntimeException的错误类JWTException,格式合法但验证失败(例如加密密钥不正确,表示此 Token 可能被修改)则不会抛出异常,而是返回一个false,由于我们期望从 Token 中取出我们所需要的 UserID 用用于拼接键名,则在此拼接好 Redis 的键。同时我们也可以验证该 token 是否过期与生效时间等逻辑。基于此方法需求,我们可以很快的写出如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String redisKey;
if (!JWTUtil.verify(token, secret.getBytes())) {
JWT jwt = JWTUtil.parseToken(token);
DateTime now = DateTime.now();
DateTime notBefore = DateTime.of((Long) jwt.getPayload(RegisteredPayload.NOT_BEFORE));
DateTime expiresAt = DateTime.of((Long) jwt.getPayload(RegisteredPayload.EXPIRES_AT));
if (!now.isBefore(expiresAt)) {
throw new RuntimeException("Token已过期");
}
if (!now.isAfter(notBefore)) {
throw new RuntimeException("Token还未生效");
}
redisKey = "LOGIN:" + jwt.getPayload("userId");
} else {
throw new RuntimeException("验证失败");
}

在过滤器链中抛出的异常可以由默认的异常处理捕获,这部分也可以进行自定义

此时我们已经得到了 Redis 的键名,在 Redis 中查询相对应的键,并且将其存入SecurityContextUsernamePasswordAuthenticationToken的构造器需要三个值:principal,credentials 与 authorities。我们现在暂时不需要 credentials 与 authorities,先对这两个值置空。以便于后续过滤链使用。

1
2
3
4
5
6
LoginUser loginUser = redisCache.getCacheLoginUser(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("验证失败");
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);

逻辑方法已经完成,最后一步是放行请求。最终完成的方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader(header);
if (!StringUtils.hasText(token)) {
//未携带token,直接放行,交由后续过滤链处理
filterChain.doFilter(request, response);
return;
}
//解析token,用密钥验证token是否有效
String redisKey = null;
if (!JWTUtil.verify(token, secret.getBytes())) {
JWT jwt = JWTUtil.parseToken(token);
DateTime now = DateTime.now();
DateTime notBefore = DateTime.of((Long) jwt.getPayload(RegisteredPayload.NOT_BEFORE));
DateTime expiresAt = DateTime.of((Long) jwt.getPayload(RegisteredPayload.EXPIRES_AT));
if (!now.isBefore(expiresAt)) {
throw new RuntimeException("Token已过期");
}
if (!now.isAfter(notBefore)) {
throw new RuntimeException("Token还未生效");
}
redisKey = "LOGIN:" + jwt.getPayload("userId");
} else {
throw new RuntimeException("验证失败");
}
//从redis中获取用户信息
LoginUser loginUser = redisCache.getCacheLoginUser(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("验证失败");
}
//存入SecurityContext
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行请求
filterChain.doFilter(request, response);
}

过滤器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static final String[] URL_WHITELIST = {"/user/login", "/user/logout"};
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Autowired
public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter) {
this.authenticationConfiguration = authenticationConfiguration;
this.jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//关闭CSRF,设置无状态连接
http.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//允许匿名访问的接口
http.authorizeRequests()
.antMatchers(URL_WHITELIST).anonymous()
//拦截其余接口
.anyRequest().authenticated();
//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

跨域请求

浏览器出于安全的考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。

前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题,在此需要进行配置,让前端能进行跨域请求。

SpringBoot 配置类

WebMvcConfigurer接口的配置类中开放跨域请求,需要重写addCorsMappings方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@SpringBootConfiguration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}

Spring Security 跨域访问

由于我们的资源都会受到 Spring Security 的保护,所以想要跨域访问还要让 Spring Security 运行跨域访问。对 HttpSecurity 对象执行cors()方法即可打开跨域

1
2
3
4
5
6
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//...省略之前的配置,开启跨域请求
http.cors();
return http.build();
}

附录

工具类

RedisCache - Redis 工具类

用于提供 Redis 的一些操作封装

由于 Jackson 在反序列化复杂对象的时候会有一些问题,故我们直接使用StringRedisTemplate,在方法中手动转换为 json 字串进行存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Setter
@Component
public class RedisCache {
private final StringRedisTemplate redisTemplate;

@Autowired
public RedisCache(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

public void setCacheLoginUser(final String key, LoginUser value, long timeout, TimeUnit timeUnit) {
String str;
try {
str = new ObjectMapper().writeValueAsString(value);
} catch (JsonProcessingException e) {
return;
}
redisTemplate.opsForValue().set(key, str, timeout, timeUnit);
}

public LoginUser getCacheLoginUser(final String key) {
LoginUser loginUser;
try {
String json = redisTemplate.opsForValue().get(key);
if (json == null || json.isEmpty()) {
return null;
}
loginUser = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).readValue(json, LoginUser.class);
} catch (Exception e) {
e.printStackTrace();
return null;
}
return loginUser;
}

public void deleteLoginUser(String key) {
redisTemplate.delete(key);
}
}

WebUtils - 请求封装工具类

用于将纯 json 字符串或者响应对象封装为 Http 响应并发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class WebUtils {
public static <T> void renderString(HttpServletResponse response, ResponseResult<T> result) {
try {
renderString(response, new ObjectMapper().writeValueAsString(result), result.getCode());
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}

public static void renderString(HttpServletResponse response, String json, int code) {
try {
response.setStatus(code);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().println(json);
} catch (IOException e) {
e.printStackTrace();
}
}
}

数据库设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
DROP TABLE IF EXISTS `sys_user`;

create table sys_user
(
id varchar(32) NOT NULL comment '用户ID',
user_name varchar(64) NOT NULL DEFAULT 'NULL' comment '用户名',
nick_name varchar(64) NOT NULL DEFAULT 'NULL' comment '昵称',
password varchar(64) NOT NULL DEFAULT 'NULL' comment '密码',
status char(1) DEFAULT '0' comment '账号状态(0正常,1停用)',
email varchar(64) DEFAULT NULL comment '邮箱',
phone_number varchar(32) DEFAULT NULL comment '手机号',
sex char(1) DEFAULT NULL comment '性别(0男,1女,2未知)',
avatar varchar(128) DEFAULT NULL comment '头像',
user_type char(1) NOT NULL DEFAULT '1' comment '用户类型(0管理员,1普通用户)',
create_by varchar(32) DEFAULT NULL comment '创建人用户ID',
create_time datetime DEFAULT NULL comment '创建时间',
update_by varchar(32) DEFAULT NULL comment '更新人ID',
update_time datetime DEFAULT NULL comment '更新时间',
del_flag int(1) DEFAULT '0' comment '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (id)
) engine = innodb
default charset = utf8mb4 comment ='用户表';

DROP TABLE IF EXISTS `sys_menu`;

CREATE TABLE `sys_menu`
(
`id` varchar(32) NOT NULL,
`menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
`path` varchar(200) DEFAULT NULL COMMENT '路由地址',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
`status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` varchar(32) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(32) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='菜单表';

DROP TABLE IF EXISTS `sys_role`;

CREATE TABLE `sys_role`
(
`id` varchar(32) NOT NULL,
`name` varchar(128) DEFAULT NULL,
`role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
`status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
`del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
`create_by` varchar(32) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(32) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='角色表';

DROP TABLE IF EXISTS `sys_role_menu`;

CREATE TABLE `sys_role_menu`
(
`role_id` varchar(32) NOT NULL COMMENT '角色ID',
`menu_id` varchar(32) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`, `menu_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;

DROP TABLE IF EXISTS `sys_user_role`;

CREATE TABLE `sys_user_role`
(
`user_id` varchar(32) NOT NULL COMMENT '用户id',
`role_id` varchar(32) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`, `role_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;

实体类

使用 Mybatis-plus 作为 ORM 框架,需要实体类与表名互相对应,创建对应的实体类,对实体类的注解简要说明如下:

  • @Data:提供类的 get,set,equals,hashCode,canEqual,toString 方法

  • @TableName:提供表名映射

  • @NoArgsConstructor:提供类的无参构造

  • @AllArgsConstructor:提供类的全参构造

  • @JsonInclude:指定 Jackson 转换对象为 json 时的处理模式,JsonInclude.Include.NON_NULL仅会将非空的对象包含在生成的 json 中

用户实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
@TableName("sys_user")
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User implements Serializable {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
private String userName;
private String nickName;
private String password;
private String status;
private String email;
private String phoneNumber;
private String sex;
private String avatar;
private String userType;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
private Integer delFlag;
}

角色实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
@TableName("sys_role")
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Role implements Serializable {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
private String name;
private String roleKey;
private String status;
private Integer delFlag;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
private String remark;
}

菜单实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Data
@TableName("sys_menu")
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
private String menuName;
private String path;
private String component;
private String visible;
private String status;
private String perms;
private String icon;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
private Integer delFlag;
private String remark;
}