0.简介

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

​ 一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。

​ 一般Web应用的需要进行认证授权

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

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

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

1.入门Demo

1.1新建项目

创建项目不用多说,创建maven或者spring项目都行。端口默认8080就行,配置文件先不用问,先来个小Demo,没什么好说的。

我这里项目名称叫SecurityDemo1

① 设置父工程 添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.12</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

② 启动类

1
2
3
4
5
6
@SpringBootApplication
public class SecurityDemo1Application {
public static void main(String[] args) {
SpringApplication.run(SecurityDemo1Application.class, args);
}
}

③ 创建Controller

写一个测试接口(/hello),用RestController返回一个字符串就行。

1
2
3
4
5
6
7
8
9
@RestController
public class HelloController {

@RequestMapping("/hello")
public String hello(){
return "Hello World~";
}

}

访问http://localhost:8080/hello,接口运行正常:

image-20221204134632027

1.2 引入SpringSecurity

注意spring版本和security版本的兼容性问题就行了,最好是按照我给的版本进行测试。

目前推荐security版本最好是2.5.14

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

​ 引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面。

image-20221204135113453

默认用户名是user,密码会输出在控制台。必须登陆之后才能对接口进行访问。

image-20221204135222813

2. 认证

2.1 登陆校验流程

image-20211214145824901

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。

image-20211214145824903

这里我们可以看看入门Demo中的过滤器。

image-20211214144425527

ps:图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter:负责处理登陆页面填写用户名密码后的登陆请求。入门Demo的认证工作主要由它负责。

ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。

FilterSecurityInterceptor:负责权限校验的过滤器。

2.2认证流程

image-20211214151515385

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法

UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

2.3项目演示

2.3.1构建项目

更多详情前往github查看项目SecurityDemo3

用到的数据库实体类sys_user即可,操作不是太多。

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
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '密码',
`type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '用户类型:0代表普通用户,1代表管理员',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
`phone_number` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号',
`sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像',
`create_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
`del_flag` int(0) NULL DEFAULT 0 COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'roydon', 'roydon233', '$2a$10$.C5nLKwbb4VW3qSuqsaykuAj9mKa4XQaSfL.dOmmOr4L2fERgLgtG', '1', '0', '3133010060@qq.com', '18888889999', '1', 'http://rjh778l49.bkt.clouddn.com/2022/10/09/61d283c195064c2dbf9e02e9a609700a.jpg', NULL, '2022-01-05 09:01:56', 1, '2022-01-30 15:37:03', 0);
INSERT INTO `sys_user` VALUES (18, 'weixin', 'weixin', '$2a$10$y3k3fnMZsBNihsVLXWfI8uMNueVXBI08k.LzWYaKsW8CW7xXy18wC', '0', '0', 'weixin@qq.com', NULL, NULL, 'https://img1.imgtp.com/2022/09/01/w4nMeVBG.jpg', -1, '2022-01-30 17:18:44', -1, '2022-01-30 17:18:44', 0);

SET FOREIGN_KEY_CHECKS = 1;

引入必要的的依赖

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
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.12</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.7.4</version>
</dependency>
<!--redis序列化器-fastjson-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.33</version>
</dependency>
<!--jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
</dependencies>

配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server:
port: 8888 # 端口

spring:
datasource:
url: jdbc:mysql://localhost:3306/{database}?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
mapper-locations: classpath*:/mapper/*.xml
configuration:
# 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: delFlag
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto

创建数据表对应User实体类

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
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_user")
public class User {
@TableId
private Long id;
//用户名
private String userName;
//昵称
private String nickName;
//密码
private String password;
//用户类型:0代表普通用户,1代表管理员
private String type;
//账号状态(0正常 1停用)
private String status;
//邮箱
private String email;
//手机号
private String phoneNumber;
//用户性别(0男,1女,2未知)
private String sex;
//头像
private String avatar;
//创建人的用户id
private Long createBy;
//创建时间
private Date createTime;
//更新人
private Long updateBy;
//更新时间
private Date updateTime;
//删除标志(0代表未删除,1代表已删除)
private Integer delFlag;
}

接着是mapper接口

1
public interface UserMapper extends BaseMapper<User> {}

接着是启动类,并加上mapper扫描

1
2
3
4
5
6
7
8
9
@Slf4j
@SpringBootApplication
@MapperScan("com.roydon.securitydemo3.mapper")
public class SecurityDemo3Application {
public static void main(String[] args) {
SpringApplication.run(SecurityDemo3Application.class, args);
log.info("项目启动中...");
}
}

测试MP是否能正常使用

1
2
3
4
5
6
7
8
9
@SpringBootTest
class SecurityDemo3ApplicationTests {

@Resource
private UserMapper userMapper;

@Test
public void testUserMapper(){System.out.println(userMapper.selectList(null));}
}

工具类和一些必要配置在提供的项目中以及给出,本文不再过多赘述。

2.3.2loadUser

创建一个类实现UserDetailsService接口,重写其中的loadUserByUsername方法。

这一步的目的在于根据登录用户名称查询出对应用户,并给此用户赋予相应权限(后续授权模块会完善,此处先TODO),之后封装成LoginUser,这个LoginUser实体类也是继承了security框架提供的UserDetails。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

@Resource
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(StringUtils.isNotEmpty(username), User::getUserName, username);
User user = userMapper.selectOne(queryWrapper);

log.info("查询到数据库用户为:{}",user);

if (Objects.isNull(user)) {
throw new UsernameNotFoundException("用户名或密码错误");
}
// TODO 查询角色权限

return new LoginUser(user);
}
}

因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息和用户的权限(此处权限定义为null,后续授权模块会用到)封装在其中。

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
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

private User user;

public LoginUser(User user) {
this.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 isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

2.3.3SecurityConfig

定义SpringSecurity的配置类,继承WebSecurityConfigurerAdapter。

实际项目中不会把密码明文存储在数据库中。一般使用SpringSecurity为我们提供的BCryptPasswordEncoder

接下需要定义用户登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

在接口中通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。

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

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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 anonymous
.antMatchers("/user/login").anonymous()
.anyRequest().authenticated();

}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

}

2.3.4登录接口

1
2
3
4
5
public interface LoginService {

ResponseResult login(User user);

}
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class LoginController {

@Autowired
private LoginServcie loginServcie;

@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
return loginServcie.login(user);
}
}

LoginService实现类

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
@Slf4j
@Service
public class LoginServiceImpl implements LoginService {

@Resource
private AuthenticationManager authenticationManager;

@Resource
private RedisCache redisCache;

/**
* 用户登录
* 1.根据用户信息获取 Authentication
* 2.根据用户 id 生成 jwt token
* 3.存入 redis
* 4.token 响应给前端
* @param user 登录用户
* @return ResponseResult(CODE_200, " 登陆成功 ", map)
*/
@Override
public ResponseResult login(User user) {

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
log.info("Authentication认证信息:{}", authenticate);

if (Objects.isNull(authenticate)) {
throw new RuntimeException("用户名或密码错误");
}

//使用userid生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);

//authenticate存入redis
redisCache.setCacheObject(LOGIN_KEY + userId, loginUser);

//把token响应给前端
HashMap<String, String> map = new HashMap<>();
map.put("token", jwt);

return new ResponseResult(CODE_200, "登陆成功", map);
}

}

测试接口

image-20221204222224706

2.3.5认证过滤器

这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。使用userid去redis中获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder。

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
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

@Resource
private RedisCache redisCache;

@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//无token,放行
filterChain.doFilter(request, response);
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = LOGIN_KEY + userid;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到 Authentication 中,此处存null
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}

把token校验过滤器添加到过滤器链中

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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 anonymous
.antMatchers("/user/login").anonymous()
.anyRequest().authenticated();

//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

}

2.3.6退出登录

退出登陆接口只需要获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。

1
2
3
4
5
6
7
public interface LoginService {

ResponseResult login(User user);

ResponseResult logout();

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/user")
public class LoginController {

@Resource
private LoginService loginServcie;

@PostMapping("/login")
public ResponseResult login(@RequestBody User user){
return loginServcie.login(user);
}

@RequestMapping("/logout")
public ResponseResult logout(){
return loginServcie.logout();
}

}

在实现类中实现退出登录方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 退出登录
* 1.获取用户信息 SecurityContextHolder.getContext().getAuthentication();
* 2.通过用户 id 清除 redis
* @return ResponseResult(CODE_200, " 退出成功 ");
*/
@Override
public ResponseResult logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userid = loginUser.getUser().getId();
redisCache.deleteObject(LOGIN_KEY + userid);
return new ResponseResult(CODE_200, "退出成功");
}

测试退出登录接口,携带请求头token

image-20221204222304747

测试文档在线地址:apifox

ps:测试文档只是提供参考,具体测试你得运行在本地。

3.授权

未完待续。。。