SpringSecurity
此笔记基于哔哩哔哩三更草堂的SpringSecurity框架视频教程和网友的笔记SpringSecurity从入门到精通: SpringSecurity基础学习
课程地址:SpringSecurity框架教程-Spring Security+JWT实现项目级前端分离认证授权-挑战黑马&尚硅谷_哔哩哔哩_bilibili
我的源码地址:IAmYuanyu/SpringSecurityLearning: SpringSecurity入门学习
介绍
Spring Security是一个功能强大的Java安全框架,它提供了全面的安全认证和授权的支持,包括身份验证、授权、防止攻击等功能。
Spring Security基于过滤器链的概念,可以轻松地集成到任何基于Spring的应用程序中。它支持多种身份验证选项和授权策略,开发人员可以根据需要选择适合的方式。此外,Spring Security还提供了一些附加功能,如集成第三方身份验证提供商和单点登录,以及会话管理和密码编码等。
Spring Security的两大核心概念分别是认证和授权
1)认证(Authentication)
认证就像用户登录时提交的用户名和密码,系统通过这些信息来验证“你是谁”
Spring Security不仅支持传统的用户名和密码认证,还支持OAuth2、JWT等现代认证方式
2)授权(Authorization)
在Spring Security中,授权是确认用户在通过认证之后,是否有权限执行某些操作或访问特定资源
快速入门
0.环境说明
我使用的是SpringBoot3.5.8,jdk17来演示
1.添加依赖
创建一个SpringBoot工程,在pom.xml文件中导入核心web依赖和SpringSecurity的依赖
1
2
3
4
5
6
7
8
|
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
|
2.创建Controller用于测试
1
2
3
4
5
6
7
8
9
10
11
12
13
|
package com.yuanyu.springsecurityquickstart.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DemoController {
@RequestMapping("/test1")
public String Demo1() {
return "Hello World!";
}
}
|
3.启动工程
启动工程后浏览器访问localhost:8080/test1会自动跳转到localhost:8080/login进行登录
默认的账号是user,密码会在控制台生成

登录后即可正常访问其他页面
认证
登录校验流程

原理初步了解
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器
下图展示了入门案例中的几个核心过滤器

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException
FilterSecurityInterceptor:负责权限校验的过滤器
可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序
1
|
run.getBean(DefaultSecurityFilterChain.class)
|

默认认证流程
为了后续能够按照自己的需求对于认证流程进行修改,需要先大致了解下默认认证流程
后面如果觉得有点晕建议再看看流程

概念速查:
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回,然后将这些信息封装到Authentication对象中
初步修改认证与校验流程
流程概览
登录:
-
自定义登录接口
-
自定义UserDetailsService
- 在UserDetailsService的实现类中查询数据库

校验:
- 定义JWT认证过滤器
- 获取token
- 解析token获取其中的userId
- 根据userId在Redis中获取用户信息
- 将用户信息存入SecurityContextHolder中

准备工作
创建一个SpringBoot工程,并添加下方依赖
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
|
<!-- SpringSecurity依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- SpringWeb依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--fastjson依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<!--jwt依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok依赖 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- MyBatis Plus的Spring Boot starter,用于简化MyBatis的使用 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- MySQL连接器,用于连接和操作MySQL数据库 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>
<!-- 单元测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
|
配置下方内容时可以参考图中目录结构
配置Redis
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
|
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import java.nio.charset.Charset;
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static {
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonRedisSerializer(Class<T> clazz) {
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException {
if (t == null) {
return new byte[0];
}
return JSON.toJSONString(t,
SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length <= 0) {
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}
protected JavaType getJavaType(Class<?> clazz) {
return TypeFactory.defaultInstance().constructType(clazz);
}
}
|
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
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings(value = {"unchecked", "rawtypes"})
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory
connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
|
编辑响应类
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
|
package com.yuanyu.springsecurityquickstart.domain;
import com.fasterxml.jackson.annotation.JsonInclude;
@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;
}
public ResponseResult(Integer code, T data) {
this.code = code;
this.data = data;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public ResponseResult(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}
|
导入工具类
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
83
84
85
86
87
88
89
|
package com.yuanyu.springsecurityquickstart.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
/**
* 适配JJWT 0.11.5的JWT工具类(解决JAXB依赖问题)
*/
public class JwtUtil {
// 有效期:1小时
public static final Long JWT_TTL = 60 * 60 * 1000L;
// 秘钥(注意:0.11.5要求秘钥长度至少256位,需扩展为足够长度的Base64编码字符串)
public static final String JWT_KEY = "c2FuZ2VuZ3NhbmdlbmdzYW5nZW5nc2FuZ2VuZ3NhbmdlbmdzYW5nZW5n";
/**
* 生成UUID(去掉横线)
*/
public static String getUUID() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
/**
* 生成加密后的秘钥(适配0.11.5)
*/
public static SecretKey generalKey() {
// 解码Base64格式的秘钥
byte[] encodedKey = Base64.getDecoder().decode(JWT_KEY);
// 生成AES秘钥(0.11.5推荐用Keys工具类)
return Keys.hmacShaKeyFor(encodedKey);
}
/**
* 生成JWT
* @param subject 存放的核心数据(如用户ID/用户名,建议JSON格式)
*/
public static String createJWT(String subject) {
return createJWT(subject, JWT_TTL);
}
/**
* 生成JWT(自定义过期时间)
*/
public static String createJWT(String subject, Long ttlMillis) {
// 获取秘钥
SecretKey secretKey = generalKey();
// 当前时间
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
// 过期时间
long expMillis = nowMillis + (ttlMillis == null ? JWT_TTL : ttlMillis);
Date expDate = new Date(expMillis);
// 构建JWT(0.11.5 API)
return Jwts.builder()
.setId(getUUID()) // JWT唯一ID
.setSubject(subject) // 主题(核心数据)
.setIssuer("sg") // 签发者
.setIssuedAt(now) // 签发时间
.setExpiration(expDate) // 过期时间
.signWith(secretKey) // 签名(0.11.5无需指定算法,自动适配秘钥算法)
.compact();
}
/**
* 解析JWT
*/
public static Claims parseJWT(String jwt) {
SecretKey secretKey = generalKey();
return Jwts.parserBuilder() // 0.11.5使用parserBuilder
.setSigningKey(secretKey)
.build()
.parseClaimsJws(jwt)
.getBody();
}
// 测试
public static void main(String[] args) {
String token = createJWT("admin");
System.out.println("生成的JWT:" + token);
Claims claims = parseJWT(token);
System.out.println("解析结果:" + claims);
}
}
|
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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
|
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* Redis操作类
*/
@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache {
@Autowired
public RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
*/
public <T> void setCacheObject(final String key, final T value) {
redisTemplate.opsForValue().set(key, value);
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, final
Integer timeout, final TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout) {
return expire(key, timeout, TimeUnit.SECONDS);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit) {
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获得缓存的基本对象。
*
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
public <T> T getCacheObject(final String key) {
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象
*
* @param key
*/
public boolean deleteObject(final String key) {
return redisTemplate.delete(key);
}
/**
* 删除集合对象
*
* @param collection 多个对象
* @return
*/
public long deleteObject(final Collection collection) {
return redisTemplate.delete(collection);
}
/**
* 缓存List数据
*
* @param key 缓存的键值
* @param dataList 待缓存的List数据
* @return 缓存的对象
*/
public <T> long setCacheList(final String key, final List<T> dataList) {
Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
return count == null ? 0 : count;
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
public <T> List<T> getCacheList(final String key) {
return redisTemplate.opsForList().range(key, 0, -1);
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final
Set<T> dataSet) {
BoundSetOperations<String, T> setOperation =
redisTemplate.boundSetOps(key);
Iterator<T> it = dataSet.iterator();
while (it.hasNext()) {
setOperation.add(it.next());
}
return setOperation;
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
public <T> Set<T> getCacheSet(final String key) {
return redisTemplate.opsForSet().members(key);
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
if (dataMap != null) {
redisTemplate.opsForHash().putAll(key, dataMap);
}
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
public <T> Map<String, T> getCacheMap(final String key) {
return redisTemplate.opsForHash().entries(key);
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value 值
*/
public <T> void setCacheMapValue(final String key, final String hKey, final
T value) {
redisTemplate.opsForHash().put(key, hKey, value);
}
/**
* 获取Hash中的数据
*
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象
*/
public <T> T getCacheMapValue(final String key, final String hKey) {
HashOperations<String, String, T> opsForHash =
redisTemplate.opsForHash();
return opsForHash.get(key, hKey);
}
/**
* 删除Hash中的数据
*
* @param key
* @param hkey
*/
public void delCacheMapValue(final String key, final String hkey) {
HashOperations hashOperations = redisTemplate.opsForHash();
hashOperations.delete(key, hkey);
}
/**
* 获取多个Hash中的数据
*
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合
*/
public <T> List<T> getMultiCacheMapValue(final String key, final
Collection<Object> hKeys) {
return redisTemplate.opsForHash().multiGet(key, hKeys);
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
public Collection<String> keys(final String pattern) {
return redisTemplate.keys(pattern);
}
}
|
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
|
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class WebUtils {
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response, String
string) {
try {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
} catch (IOException e) {
e.printStackTrace();
}
return 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
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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
|
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDateTime;
@TableName("sys_user")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
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 LocalDateTime createTime;
/**
* 更新人
*/
private Long updateBy;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 删除标志(0代表未删除,1代表已删除)
*/
private Integer delFlag;
}
|
建表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`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 '密码',
`type` char(1) DEFAULT '0' COMMENT '用户类型:0代表普通用户,1代表管理员',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`create_by` bigint DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=14787164048663 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表'
|
配置数据库信息
在resourses/application.yml里配置
根据自己的情况修改,请勿直接照搬
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
spring:
application:
name: SpringSecurityQuickStart
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring_security_demo?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
data:
redis:
host: localhost
port: 6379
password: 123456
database: 0
|
定义mapper接口
1
2
3
4
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yuanyu.springsecurityquickstart.domain.User;
public interface UserMapper extends BaseMapper<User> {}
|
在启动类配置mapper扫描
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
@MapperScan("com.yuanyu.springsecurityquickstart.mapper") // 填自己mapper接口所在包名
public class SpringSecurityQuickStartApplication {
public static void main(String[] args) {
SpringApplication.run(SpringSecurityQuickStartApplication.class, args);
}
}
|
测试能否正常运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import com.yuanyu.springsecurityquickstart.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class SpringSecurityQuickStartApplicationTests {
@Autowired
private UserMapper userMapper;
@Test
public void userMapperTest() {
System.out.println(userMapper.selectList(null)); // 自己随便给数据库加点数据再测
}
}
|
实现修改
创建UserDetailsService实现类
参考位置:com/yuanyu/springsecurityquickstart/service/impl/UserDetailsServiceImpl.java
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
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yuanyu.springsecurityquickstart.domain.LoginUser;
import com.yuanyu.springsecurityquickstart.domain.User;
import com.yuanyu.springsecurityquickstart.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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;
import java.util.Objects;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名查询用户信息
LambdaQueryWrapper wrapper = new LambdaQueryWrapper<User>().eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
// 如果没有该用户就抛出异常
if (Objects.isNull(user)) {
throw new RuntimeException("用户名或密码错误");
}
// TODO: 查询权限信息封装到LoginUser中(在后续的授权中补全)
// 将用户信息封装到UserDetails实现类中
return new LoginUser(user);
}
}
|
创建UserDetails实现类
参考位置:com/yuanyu/springsecurityquickstart/domain/LoginUser.java
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
|
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
@Data
@AllArgsConstructor
@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 isAccountNonExpired() {
return true;
}
// 账户是否未锁定
@Override
public boolean isAccountNonLocked() {
return true;
}
// 密码是否未过期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 账户是否可用
@Override
public boolean isEnabled() {
return true;
}
}
|
测试修改结果
由于现在还没有修改PasswordEncoder,现在登录时会对密码进行一定的操作,所以现在要测试的话要给存在数据库里的密码前面加上{noop},后续会详细说明PasswordEncoder
总之,现在要如图存密码才能通过登录校验
当前在数据库中存成{noop}123456才能在登录页面直接输入123456登录
此时,启动工程就可以使用数据库里的账号密码进行登录,说明成功对默认登录认证校验进行了修改
修改密码加密存储模式
实际项目中我们不会把密码明文存储在数据库中
默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式
但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。
我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。
我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码验。
我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承
WebSecurityConfigurerAdapter(已过时,SpringBoot3中使用@EnableWebSecurity注解代替)
创建SpringSecurity配置类
参考位置:com/yuanyu/springsecurityquickstart/config/SecurityConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration // 配置类
@EnableWebSecurity // SpringBoot3中使用该注解代替了SpringBoot2中需要继承WebSecurityConfigurerAdapter的方式
public class SecurityConfig {
// 创建BCrypt密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
|
BCryptPasswordEncoder演示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Test
public void BCryptTest() {
String password = "123456";
// 密码加密,会发现两个密码加密结果不同
String encode1 = bCryptPasswordEncoder.encode(password);
String encode2 = bCryptPasswordEncoder.encode(password);
System.out.println("encode1=" + encode1);
System.out.println("encode2=" + encode2);
// 密码校验发现两个密码校验结果都为true
System.out.println(bCryptPasswordEncoder.matches(password, encode1));
System.out.println(bCryptPasswordEncoder.matches(password, encode2));
}
/**
* 输出结果:
* encode1=$2a$10$5PT6gdC9zJjkVaLhxlVnuetcxtW1GWb/x3a57wA9m3abt1xCQyiLy
* encode2=$2a$10$/txgCmhistFxjd8VlRcgq.gggONq9txfGSFGrw91CPpluPy.O7NbC
* true
* true
*/
|
此时就可以在数据库中存储加密后的密码(注册时直接把加密后的密码存入数据库),前端也能直接使用密码原文进行登录(登录时会自动进行BCrypt校验),极大提高了数据的安全性
编写登录接口
接下需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问
在接口中通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器
认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,需要把用户信息存入redis,可以把用户id作为key
编写登录接口的controller和service接口及其实现类
controller层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.domain.User;
import com.yuanyu.springsecurityquickstart.service.LoginServcie;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LoginController {
@Autowired
private LoginServcie loginServcie;
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
return loginServcie.login(user);
}
}
|
service层:
1
2
3
4
5
6
7
8
|
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.domain.User;
public interface LoginServcie {
ResponseResult login(User user);
}
|
service实现类层:(编写时需要进一步配置SecurityConfig)
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
|
import com.yuanyu.springsecurityquickstart.domain.LoginUser;
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.domain.User;
import com.yuanyu.springsecurityquickstart.service.LoginServcie;
import com.yuanyu.springsecurityquickstart.utils.JwtUtil;
import com.yuanyu.springsecurityquickstart.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Objects;
@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
AuthenticationManager authenticationManager;
@Autowired
RedisCache redisCache;
@Override
public ResponseResult login(User user) {
// 将用户的账号密码封装成Authentication对象
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
// 通过AuthenticationManager的authenticate方法来进行用户认证
// authenticate方法会调用到刚刚重写的UserDetailsServiceImpl里的方法来进行校验
// 若没通过,返回的结果一定是null
Authentication authenticated = authenticationManager.authenticate(authenticationToken);
// 如果认证没通过,给出相应提示
if (Objects.isNull(authenticated)) {
throw new RuntimeException("登录失败");
}
// 在Authentication中获取用户信息
// 可以通过打断点查看authenticated把数据存在哪里,进而获取用户信息
LoginUser loginUser = (LoginUser) authenticated.getPrincipal();
String userId = loginUser.getUser().getId().toString();
// 认证通过生成token
String jwt = JwtUtil.createJWT(userId);
// 用户信息存入redis
redisCache.setCacheObject("login:" + userId, loginUser);
// 把token返回给前端
HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put("token", jwt);
return new ResponseResult(200, "登录成功", hashMap);
}
}
|
配置SecurityConfig
继续配置SecurityConfig:(能看懂即可,不用能完全自己写)
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
83
84
85
86
87
|
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration // 配置类
@EnableWebSecurity // SpringBoot3中使用该注解代替了SpringBoot2中需要继承WebSecurityConfigurerAdapter的方式
public class SecurityConfig {
@Autowired
private AuthenticationConfiguration authenticationConfiguration;
// 创建BCrypt密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 创建AuthenticationManager对象,用于处理用户认证。
*
* @return 返回创建的AuthenticationManager对象。
* @throws Exception 如果创建过程中发生错误,则抛出异常。
*/
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 配置Spring Security的过滤链。
*
* @param http 用于构建安全配置的HttpSecurity对象。
* @return 返回配置好的SecurityFilterChain对象。
* @throws Exception 如果配置过程中发生错误,则抛出异常。
*/
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF保护
// 前后端分离的情况下,如果开启 CSRF,前端需要额外处理 CSRF Token 的获取和携带,增加复杂度
.csrf(csrf -> csrf.disable())
// 设置会话创建策略为无状态(不通过Session获取SecurityContext):服务端不需要存储用户会话,仅通过解析请求头中的 JWT 令牌就能完成认证
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置授权规则:指定user/login路径允许匿名访问(未登录可访问已登陆不能访问),其他路径需要身份认证
.authorizeHttpRequests(auth -> auth
.requestMatchers("/user/login").anonymous() // 登录接口匿名访问(未登录才能访问)
.anyRequest().authenticated() // 其余接口需认证
)
// 开启跨域访问(解决前后端端口不一致问题)
.cors(cors -> cors.configurationSource(corsConfigurationSource()));
// 构建并返回安全过滤链
return http.build();
}
// 跨域配置Bean
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 允许跨域的源(前端域名,生产环境需指定具体域名,如http://localhost:8080)
config.addAllowedOriginPattern("*");
// 允许的请求头(如Authorization、Content-Type)
config.addAllowedHeader("*");
// 允许的请求方法(GET/POST/PUT/DELETE等)
config.addAllowedMethod("*");
// 允许携带凭证(如Cookie,若JWT存在Cookie中需开启)
config.setAllowCredentials(true);
// 跨域预检请求的缓存时间(减少OPTIONS请求次数)
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 对所有路径生效
source.registerCorsConfiguration("/**", config);
return source;
}
}
|
测试登录接口:(我使用的软件是ApiFox)
测试时记得开启Redis
定义并配置JWT认证过滤器
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userId(主要作用于除登录外的请求)
使用userId去redis中获取对应的LoginUser对象
然后封装LoginUser对象进Authentication对象再存入SecurityContextHolder
SpringSecurity中的FilterSecurityInterceptor会获取SecurityContextHolder中的认证信息,如果能获取到信息且为认证状态则放行
自定义过滤器
参考位置:com/yuanyu/springsecurityquickstart/filter/JwtAuthenticationTokenFilter.java
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
|
import com.yuanyu.springsecurityquickstart.domain.LoginUser;
import com.yuanyu.springsecurityquickstart.utils.JwtUtil;
import com.yuanyu.springsecurityquickstart.utils.RedisCache;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Objects;
@Component
// OncePerRequestFilter的特点是在处理单个HTTP请求时确保过滤器的 doFilterInternal 方法只被调用一次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, ServletException, IOException {
// 1.在请求头中获取token
String token = request.getHeader("token");
// 此处需要判断token是否为空
if (!StringUtils.hasText(token)){
// 没有token放行 此时的SecurityContextHolder没有用户信息 会被后面的过滤器拦截
filterChain.doFilter(request,response);
return;
}
// 2.解析token获取用户id
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
// 解析失败
throw new RuntimeException("token非法");
}
// 3.在redis中获取用户信息 注意:redis中的key是login:+userId
String redisKey = "login:" + userId;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
// 此处需要判断loginUser是否为空
if (Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
// 4.将获取到的用户信息存入SecurityContextHolder
// 必须使用需要传入三个参数的构造方法,因为这个构造方法才会调用setAuthenticated(true)设置认证成功
// 三个参数分别是用户信息,密码,权限
// TODO 这里权限暂时设置为null,后面会进行权限控制
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 5.放行
filterChain.doFilter(request,response);
}
}
|
把token校验过滤器(自定义过滤器)添加到过滤器链中
在SecurityConfig中设置
我的SecurityConfig参考位置:com/yuanyu/springsecurityquickstart/config/SecurityConfig.java
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
|
/* 省略部分代码 */
@Configuration // 配置类
@EnableWebSecurity // SpringBoot3中使用该注解代替了SpringBoot2中需要继承WebSecurityConfigurerAdapter的方式
public class SecurityConfig {
/* 省略部分代码 */
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
/* 省略部分代码 */
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/user/login").anonymous()
.anyRequest().authenticated()
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()));
// 在UsernamePasswordAuthenticationFilter过滤器前面,添加JWT过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 构建并返回安全过滤链
return http.build();
}
/* 省略部分代码 */
}
|
测试配置结果

编写退出登录接口
编写登出接口的controller和service接口及其实现类
controller层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.domain.User;
import com.yuanyu.springsecurityquickstart.service.LoginServcie;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LoginController {
@Autowired
private LoginServcie loginServcie;
/* 省略部分代码 */
@PostMapping("/user/logout")
public ResponseResult logout(){
System.out.println("开始登出");
return loginServcie.logout();
}
}
|
service层:
1
2
3
4
5
6
7
8
|
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.domain.User;
public interface LoginServcie {
ResponseResult login(User user);
ResponseResult logout();
}
|
service实现类层:
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
|
import com.yuanyu.springsecurityquickstart.domain.LoginUser;
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.domain.User;
import com.yuanyu.springsecurityquickstart.service.LoginServcie;
import com.yuanyu.springsecurityquickstart.utils.JwtUtil;
import com.yuanyu.springsecurityquickstart.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Objects;
@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
AuthenticationManager authenticationManager;
@Autowired
RedisCache redisCache;
/* 省略部分代码 */
@Override
public ResponseResult logout() {
// 获取SecurityContextHolder中的用户id
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userId = loginUser.getUser().getId();
// 根据取到的用户id删除redis中的用户信息
redisCache.deleteObject("login:" + userId);
return new ResponseResult(200, "退出成功");
}
}
|
结果测试
授权
授权的基本流程
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。判断当前用户是否拥有访问当前资源所需的权限。
所以在项目中只需要把当前登录用户的权限信息也存入Authentication,然后设置资源所需要的权限即可。
授权实现
设置访问所需权限
SpringSecurity提供了基于注解的权限控制方案,这也是项目中主要采用的方式。
可以使用注解去指定访问对应的资源所需的权限,但是要使用它需要先在SpringSecurity配置类中开启相关配置。
在SecurityConfig类上加上下方注解
1
|
@EnableMethodSecurity(prePostEnabled = true)
|

此时就可以在Controller层对应方法上使用@PreAuthorize来控制权限了

现在访问/needLogin这个接口时就会校验访问者是否拥有yes这个权限
封装权限
完善UserDetailsServiceImpl
参考位置:com/yuanyu/springsecurityquickstart/service/impl/UserDetailsServiceImpl.java
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
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yuanyu.springsecurityquickstart.domain.LoginUser;
import com.yuanyu.springsecurityquickstart.domain.User;
import com.yuanyu.springsecurityquickstart.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名查询用户信息
LambdaQueryWrapper wrapper = new LambdaQueryWrapper<User>().eq(User::getUserName, username);
User user = userMapper.selectOne(wrapper);
System.out.println("查询用户:" + user.getUserName() + "的信息");
// 非空判断:如果没有该用户就抛出异常
if (Objects.isNull(user)) {
throw new RuntimeException("用户名或密码错误");
}
// 查询权限信息封装到LoginUser中(暂时先这样写)
ArrayList<String> list = new ArrayList<>(Arrays.asList("yes", "admin"));
// 将用户信息封装到UserDetails实现类中
return new LoginUser(user, list);
}
}
|
修改LoginUser
参考位置:com/yuanyu/springsecurityquickstart/domain/LoginUser.java
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
|
import com.alibaba.fastjson.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;// 封装用户信息
private List<String> permissions; // 存储权限信息
@JSONField(serialize = false) // 忽略此字段,不会存储到Redis中(若不忽略Redis会报错,因为SimpleGrantedAuthority不会被序列化),变量名称必须为authorities,否则会报错
private List<SimpleGrantedAuthority> authorities; // 存储封装好的权限
public LoginUser(User user, List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
// 获取权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 把permissions中权限信息封装成SimpleGrantedAuthority对象(SimpleGrantedAuthority是GrantedAuthority的实现类)
if(authorities == null) {
authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
return authorities;
}
/* 原本的代码 */
}
|
完善JwtAuthenticationTokenFilter
参考位置:com/yuanyu/springsecurityquickstart/filter/JwtAuthenticationTokenFilter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/* 原本的代码 */
@Component
// OncePerRequestFilter的特点是在处理单个HTTP请求时确保过滤器的 doFilterInternal 方法只被调用一次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
/* 原本的代码 */
// 通过刚刚重写的getAuthorities()方法从loginUser中获取封装好的权限信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 5.放行
filterChain.doFilter(request,response);
}
}
|
现在就完成了权限的设置与封装,可以测试看看是否设置成功
可以在Controller层定义两个接口用来测试
1
2
3
4
5
6
7
8
9
10
11
|
@RequestMapping("/needLogin")
@PreAuthorize("hasAuthority('yes') and hasAuthority('admin')")
public String Demo2() {
return "Hello User!";
}
@RequestMapping("/Impossible")
@PreAuthorize("hasAuthority('no')")
public String Demo3() {
return "Impossible!";
}
|
正常情况下,如果同样有token,Demo2是可以访问的,但Demo3无法访问,因为前面在UserDetailsServiceImpl中只给了yes和admin两种权限


实际结果与预期一致,说明权限设置成功
从数据库查询权限
前面在UserDetailsServiceImpl中是直接把所有用户的权限写死的,但在实际中并不会这样设计,正常是会将不同用户所拥有的权限存在数据库中,这样才能区分不同用户的权限
RBAC权限模型
RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。
数据库中的表设计:

在实际开发中,很多用户拥有的权限其实是一样的,如果要为每一位用户一一分配权限过于繁琐,此时就可以通过角色表来为用户赋予权限。
可以把角色表理解为一个权限组,通过设置用户属于哪些角色来一键赋予用户一系列权限,更加方便管理。
准备工作
建表
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
|
CREATE TABLE `sys_menu` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`menu_name` varchar(50) NOT NULL COMMENT '菜单名称',
`parent_id` bigint DEFAULT '0' COMMENT '父菜单ID',
`order_num` int DEFAULT '0' COMMENT '显示顺序',
`path` varchar(200) DEFAULT '' COMMENT '路由地址',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`is_frame` int DEFAULT '1' COMMENT '是否为外链(0是 1否)',
`menu_type` char(1) DEFAULT '' COMMENT '菜单类型(M目录 C菜单 F按钮)',
`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` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT '' COMMENT '备注',
`del_flag` int DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2034 DEFAULT CHARSET=utf8mb3 COMMENT='菜单权限表'
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` varchar(30) NOT NULL COMMENT '角色名称',
`role_key` varchar(100) NOT NULL COMMENT '角色权限字符串',
`role_sort` int NOT NULL COMMENT '显示顺序',
`status` char(1) NOT NULL COMMENT '角色状态(0正常 1停用)',
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
`create_by` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb3 COMMENT='角色信息表'
CREATE TABLE `sys_role_menu` (
`role_id` bigint NOT NULL COMMENT '角色ID',
`menu_id` bigint NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='角色和菜单关联表'
CREATE TABLE `sys_user_role` (
`user_id` bigint NOT NULL COMMENT '用户ID',
`role_id` bigint NOT NULL COMMENT '角色ID',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='用户和角色关联表'
|
创建实体类
参考位置:com/yuanyu/springsecurityquickstart/domain/Menu.java
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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
|
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("sys_menu")
public class Menu implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 菜单ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 菜单名称
*/
private String menuName;
/**
* 父菜单ID
*/
private Long parentId;
/**
* 显示顺序
*/
private Integer orderNum;
/**
* 路由地址
*/
private String path;
/**
* 组件路径
*/
private String component;
/**
* 是否为外链(0是 1否)
*/
private Integer isFrame;
/**
* 菜单类型(M目录 C菜单 F按钮)
*/
private String menuType;
/**
* 菜单状态(0显示 1隐藏)
*/
private String visible;
/**
* 菜单状态(0正常 1停用)
*/
private String status;
/**
* 权限标识
*/
private String perms;
/**
* 菜单图标
*/
private String icon;
/**
* 创建者
*/
private Long createBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新者
*/
private Long updateBy;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 备注
*/
private String remark;
/**
* 删除标志(0代表存在 1代表删除)
*/
private Integer delFlag;
}
|
创建测试数据
自己随意创建即可
下面是我的测试数据

代码实现
创建mapper接口
参考位置:com/yuanyu/springsecurityquickstart/mapper/MenuMapper.java
1
2
3
4
5
6
7
8
9
10
11
12
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yuanyu.springsecurityquickstart.domain.Menu;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface MenuMapper extends BaseMapper<Menu> {
List<String> selectPermsByUserId (Long userId);
}
|
创建mapper映射文件
参考位置:src/main/resources/mapper/MenuMapper.xml
namespace记得改成自己的
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yuanyu.springsecurityquickstart.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
select
distinct perms
from sys_user_role sur
left join sys_role sr on sur.role_id = sr.id
left join sys_role_menu srm on sur.role_id = srm.role_id
left join sys_menu sm on srm.menu_id = sm.id
where user_id = #{userId} and sr.status = 0 and sm.status = 0
</select>
</mapper>
|
可以先在test中测试看看查询返回的结果
1
2
3
4
5
6
7
8
9
10
|
@SpringBootTest
class SpringSecurityQuickStartApplicationTests {
@Autowired
private MenuMapper menuMapper;
@Test
public void menuMapperTest() {
System.out.println(menuMapper.selectPermsByUserId(7L)); // [sys:test:yes, sys:test:admin]
}
}
|
修改UserDetailsServiceImpl
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
|
/* 原本的代码 */
import java.util.List;
import java.util.Objects;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
/* 原本的代码 */
// 查询权限信息封装到LoginUser中
List<String> list = menuMapper.selectPermsByUserId(user.getId());
// 将用户信息封装到UserDetails实现类中
return new LoginUser(user, list);
}
}
|
测试修改结果
(为了和数据库权限保持一致,我已经把Controller层的所需的权限从yes改成了sys:test:yes,admin同理)


自定义失败处理
一般在认证失败或者是授权失败的情况下也能和正常访问接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能需要知道SpringSecurity的异常处理机制。
在SpringSecurity中,如果在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。
所以如果需要自定义异常处理,只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置SpringSecurity即可。
自定义失败处理实现类
处理未完成身份认证的情况
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
|
import com.alibaba.fastjson.JSON;
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.utils.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse
response, AuthenticationException authException) throws IOException {
// 将认证过程中的异常状态码设置为401,并提示认证失败
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");
String json = JSON.toJSONString(result);
// 将json数据写入response中
WebUtils.renderString(response, 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
|
import com.alibaba.fastjson.JSON;
import com.yuanyu.springsecurityquickstart.domain.ResponseResult;
import com.yuanyu.springsecurityquickstart.utils.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
// 将授权过程中的异常状态码设置为403,并提示权限不足
ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
String json = JSON.toJSONString(result);
// 将json数据写入response中
WebUtils.renderString(response, json);
}
}
|
修改配置类
修改配置类SecurityConfig,使刚刚自定义的失败处理方式生效
参考位置:com/yuanyu/springsecurityquickstart/config/SecurityConfig.java
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
|
/* 原本的代码 */
@Configuration // 配置类
@EnableWebSecurity // SpringBoot3中使用该注解代替了SpringBoot2中需要继承WebSecurityConfigurerAdapter的方式
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
/* 原本的代码 */
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
/* 原本的代码 */
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
/* 原本的代码 */
// 配置异常处理器
http.exceptionHandling(e ->
e.authenticationEntryPoint(authenticationEntryPoint) // 配置认证失败处理器
.accessDeniedHandler(accessDeniedHandler)); // 配置权限不足处理器
// 构建并返回安全过滤链
return http.build();
}
/* 原本的代码 */
}
|
测试修改结果
认证失败:

没有权限:

自定义权限校验
创建自定义权限校验类
其中的规则可以按照自己的需求写得更复杂,此处仅作简单演示
参考位置:com/yuanyu/springsecurityquickstart/expression/ExpressionRoot.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import com.yuanyu.springsecurityquickstart.domain.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.List;
@Component("yy")
public class ExpressionRoot {
public boolean hasAuthority(String authority) {
//获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
System.out.println("这是自定义的校验"); // 方便查看是否真的使用
//判断用户权限集合中是否存在authority
return permissions.contains(authority);
}
}
|
测试修改结果
在Controller层定义一个接口用来测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DemoController {
/* 原本的代码 */
@RequestMapping("/customizeHasAuthority")
// 在SPEL表达式中使用@yy相当于获取容器中bean的名字为yy的对象
@PreAuthorize("@yy.hasAuthority('sys:test:admin')")
public String Demo4() {
return "自定义权限校验!";
}
}
|

根据控制台结果可以看出使用的确实是自定义的权限校验
基于配置的权限控制
可以在配置类SecurityConfig中进行权限控制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
/* 原本的代码 */
@Configuration // 配置类
@EnableWebSecurity // SpringBoot3中使用该注解代替了SpringBoot2中需要继承WebSecurityConfigurerAdapter的方式
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
/* 原本的代码 */
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
/* 原本的代码 */
.requestMatchers("/Impossible").hasAuthority("no") // 测试权限
.anyRequest().authenticated() // 其余接口需认证
)
/* 原本的代码 */
return http.build();
}
/* 原本的代码 */
}
|
修改Controller层的接口
1
2
3
4
5
|
@RequestMapping("/Impossible")
// @PreAuthorize("hasAuthority('no')") 注释掉原本的权限校验看看配置是否生效
public String Demo3() {
return "Impossible!";
}
|

发现依旧是权限不足,说明配置类中的设置生效
CSRF简介
CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。
SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。
需要进一步了解可以参考https://blog.csdn.net/freeking101/article/details/86537087,或自行学习