简介

Spring Security是一个功能强大且可高度自定义的身份验证和访问控制框架。它是保护基于Spring的应用程序的事实上的标准。

Spring Security是一个专注于为Java应用程序提供身份验证和授权的框架。与所有Spring项目一样,Spring Security的真正强大之处在于它可以轻松扩展以满足自定义要求。

参考视频

总览

  1. 实现前后端分离,按钮级权限控制(接口权限控制)
  2. 围绕 RBAC 权限模型进行设计
  1. IDEA 2022.1
  2. JDK 17.0.2
  3. Gradle 7.4.1
  4. SpringBoot 2.6.4
  5. Spring Security 5.6.2
  1. Spring Security 是一个功能强大且可高度自定义的身份验证和访问控制框架。它是保护基于Spring的应用程序的事实上的标准。
  2. Spring Security 是一个专注于为Java应用程序提供身份验证和授权的框架。与所有Spring项目一样,Spring Security的真正强大之处在于它可以轻松扩展以满足自定义要求。
  3. 一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
  4. 一般Web应用的需要进行认证授权
    1️⃣ 认证: 验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
    2️⃣ 授权: 经过认证后判断当前用户是否有权限进行某个操作
    3️⃣ 而认证和授权也是SpringSecurity作为安全架的核心功能。
  1. Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。
  2. Spring 以简单而闻名,但讽刺的是很多人发现安装Spring Security很难
  3. Spring Security有更好的社区支持
  4. Apache Shiro 在 Spring Security 处理密码学方面有一个额外的模块
  5. Spring Security 对 Spring 结合较好,如果项目用的 SpringMVC,使用起来很方便。但是如果项目中没有用到Spring,那就不要考虑它了。
  1. 在 SpringBoot 工程快速整合 Security
  2. 在 SpringBoot 项目中使用 SpringSecurity 我们只需要引入启动器的依赖后重启项目
  3. 访问 http://localhost:port/login 进行登录
  4. 用户名默认为:user
  5. 密码在启动的控制台有打印
  6. 可以自行实现一下,访问一个接口,如果没有登录会自动重定向到 login 页面,也就是说没有登录就无法访问接口
1
implementation("org.springframework.boot:spring-boot-starter-security")

登录

认证

登录校验流程

想要知道如何实现自己的登陆流程就必须要先知道入门案例中 Spring Security的流程。

SpringSecurity完整流程
  1. Spring Security 的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。
  2. 过滤器链: 多个过滤器组成一个过滤器链

SpringSecurity流程

图中只展示了快速入门中的核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。

ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException,也就是说在处理中捕获到了异常都可以可以用它处理

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

完整的过滤器

通过Debug查看当前系统中 Spring Security 过滤器链中有哪些过滤器及它们的顺序

1
run.getBean(DefaultSecurityFilterChain.class)

过滤器链

登录认证流程详解

概念速查
1️⃣ Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息
2️⃣ AuthenticationManager接口: 定义了认证Authentication的方法
3️⃣ UserDetailsService接口: 加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法
4️⃣ UserDetails接口: 提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中

认证流程

登录改造思路
  1. 上面的流程对我们前后端分离的项目有两个地方需要改造 1️⃣ 5.1 步骤 我们需要去数据库中查询而不是在内存中查询,因此我们要实现接口重新一个实现类 2️⃣ 步骤 1 步骤 10 我们应该让前端来掉我们自己的 controller 然后我们去掉对应的认证,最后这个 controller 返回的时候需要封装一个 token 回去
  2. UsernamePasswordAuthenticationFilter 换成我们自己的登录接口
  3. InMemoryUserDetailManager 换成我们自己实现了 UserDetailsService 接口的实现了去数据查询用户

改造流程图

认证思路
  1. 上面只是改造了登录,登录后,请求接口时我们怎么去判断是否已经登录呢?是当前用户是谁呢?
  2. 其实我们在上面返回的 jwt 生成的 token 用了用户 id 去生成 所以解析一下 token 就知道是谁了

用户认证

用户详情获取
  1. 登录 –> 返回 token –> JWT 认证 –> 如何获取用户的详情?比如权限?
  2. 简单粗暴:登录后每次都带有 token,token 一解析就知道用户 ID,然后根据用户 ID 去数据库查询对应的权限(这种方式就是每次请求都需要去查询用户的权限,对数据库来说并不友好)
  3. 现实实践:如果能在我们每次登录成功以后就把我们的用户信息写到缓存里面就好了,缓存的 key 就是用户 ID, value 就是用户详情 会不会刚好有那么一个东西叫做 Redis

用户详情获取思路

登录
  1. 自定义登录接口: 1️⃣ 调用ProviderManager的方法进行认证 如果认证通过生成jwt 2️⃣ 把用户信息存入redis中
  2. 自定义UserDetailsService,在这个实现类中去查询数据库
校验
  1. 定义Jwt认证过滤器
  2. 获取token
  3. 解析token获取其中的userid
  4. 从redis中获取用户信息
  5. 存入SecurityContextHolder

准备工作

依赖准备

集成完整的环境 Redis fastjson jwt

1
2
3
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.alibaba:fastjson:1.2.80")
implementation("io.jsonwebtoken:jjwt:0.9.1")

配置类

创建 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
package com.adalucky.config;

import com.adalucky.utils.redis.FastJsonRedisSerializer;
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;

/**
* @author ada
* @Version JDK17
* @Description Redis 配置类
* @since 2022/4/22 00:34
*/
@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;
}
}

工具类

创建一些工具类 JWT工具类 FastJson序列化Redis RedisTemplate工具类 respons工具类

JWT工具类
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
package com.adalucky.utils.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

/**
* @author ada
* @Version JDK17
* @Description JWT工具类
* @since 2022/4/22 00:44
*/
public class JwtUtil {
//有效期为
public static final Long JWT_TTL = 60 * 60 * 1000L;// 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "sangeng";

public static String getUUID() {
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}

/**
* 生成jtw
*
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
return builder.compact();
}

/**
* 生成jtw
*
* @param subject token中要存放的数据(json格式)
* @param ttlMillis token超时时间
* @return
*/
public static String createJWT(String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
return builder.compact();
}

private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("sg") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);
}

/**
* 创建token
*
* @param id
* @param subject
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
return builder.compact();
}

public static void main(String[] args) throws Exception {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
Claims claims = parseJWT(token);
System.out.println(claims);
}

/**
* 生成加密后的秘钥 secretKey
*
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}

/**
* 解析
*
* @param jwt
* @return
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
}
FastJson序列化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
46
47
48
49
50
51
52
package com.adalucky.utils.redis;

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;

/**
* @author ada
* @Version JDK17
* @Description FastJson序列化Redis https://blog.51cto.com/binghe001/5211167
* @since 2022/4/22 00:21
*/
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);
}
}
RedisTemplate工具类
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
package com.adalucky.utils.redis;

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;

/**
* @author ada
* @Version JDK17
* @Description RedisTemplate工具类,通过 @Autowired 注入 RedisCache 即可完成调用
* @since 2022/4/22 00:46
*/
@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);
}
}
respons工具类
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
package com.adalucky.utils.web;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* @author ada
* @Version JDK17
* @Description respons 工具类 往响应中快速写内容
* @since 2022/4/22 00:52
*/
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
# spring 相关集成配置
spring:
# 数据源配置
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:mysql://192.168.1.115:3306/data_auto_endpoint?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8&useSSL=false
username: root
password: 123456
redis:
# Redis本地服务器地址,注意要开启redis服务
host: 192.168.1.115
# Redis服务器端口,默认为6379.若有改动按改动后的来
port: 6379
#Redis服务器连接密码,默认为空,若有设置按设置的来
password: 123456
jedis:
pool:
# 连接池最大连接数,若为负数则表示没有任何限制
max-active: 8
# 连接池最大阻塞等待时间,若为负数则表示没有任何限制
max-wait: -1
# 连接池中的最大空闲连接
max-idle: 8

核心代码实现

库表创建

建立 user 表(密码前先展示用 {noop} )

重写接口的方法

  1. 实现 UserDetails 接口,重写里面的方法
  2. 重写 UserDetailsService 接口下的 loadUserByUsername 方法,因为这个方法默认是从内存中查的用户信息,我们需要改为去数据库查询
实现 UserDetails 接口
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
package com.adalucky.modules.system.service.impl;

import com.adalucky.modules.system.model.entity.SysUser;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

/**
* @author ada
* @Version JDK17
* @Description LoginUser 将用户封装成 UserDetails 对象
* @since 2022/4/22 21:30
*/
public class LoginUser implements UserDetails {
private SysUser sysUser;

public SysUser getSysUser() {
return sysUser;
}

public void setSysUser(SysUser sysUser) {
this.sysUser = sysUser;
}

public LoginUser(SysUser sysUser) {
this.sysUser = sysUser;
}

/**
* 获取权限
*
* @return 返回权限资源
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}

/**
* 获取密码
*
* @return 返回密码
*/
@Override
public String getPassword() {
return sysUser.getPassword();
}

/**
* 获取账号
*
* @return 返回登录账户
*/
@Override
public String getUsername() {
return sysUser.getUserName();
}

/**
* 判断账户是否未过期
*
* @return 未过期返回 true,过期返回 false
*/
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 方法用于判断账户是否未锁定
*
* @return 未锁定返回 true 锁定返回 false
*/
@Override
public boolean isAccountNonLocked() {
return true;
}

/**
* 用于判断用户凭证是否没过期,即密码是否未过期
*
* @return 没过期返回 true 过期返回 false
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 方法用于判断用户是否可用
*
* @return 可用返回 true 不可用返回 false
*/
@Override
public boolean isEnabled() {
return true;
}
}
重写loadUserByUsername方法
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
package com.adalucky.modules.system.service.impl;

import com.adalucky.handler.BusinessException;
import com.adalucky.modules.system.mapper.SysUserMapper;
import com.adalucky.modules.system.model.entity.SysUser;
import com.adalucky.response.ResultCode;
import com.adalucky.utils.str.StringUtils;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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;

/**
* @author ada
* @Version JDK17
* @Description 重新 loadUserByUsername 方法,实现去数据库查询
* @since 2022/4/22 20:29
*/
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
SysUserMapper mapper;

@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
//查询用户信息 如果参数无效抛出自定义异常
if (!StringUtils.isEffective(userName)) {
throw new BusinessException(
ResultCode.PARAM_IS_BLANK.getCode(),
ResultCode.PARAM_IS_BLANK.getMessage()
);
}
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysUser::getUserName, userName);
var user = mapper.selectOne(queryWrapper);
//TODO 查询用户权限
//查询成功后 封装到 UserDetails 对象
return new LoginUser(user);
}
}

启动

测试

  1. 重写了上面的方法后我们可以启动一下进行测试,这个时候就需要输入我们数据的用户名和密码了
  2. 如果数据库直接是明文存储的话这里就会抛出 There is no PasswordEncoder mapped for the id "null"

测试

问题分析

Spring Security 的用户认证流程
1️⃣ 把前端传过来的用户名去数据查询对应的用户 也就是调用我们重写的loadUserByUsername(String userName) 方法
2️⃣ 查询到以后通过 return new LoginUser(user) 封装用户信息到 UserDetails
3️⃣ 会自动调用 UserDetails 的实现类,也就是我们这里的 LoginUser 的 getPassword() 获取用户的密码
4️⃣ 按照 默认或者我们指定的加密方式去和 getPassword() 获取用户的密码 比对
5️⃣ 比对通过登录成功,默认返回用户名或密码错误提示

解决方案

  1. 告诉 Spring Security 我就是要用明文存,你就用明文比对就行了其它的安全不安全,泄露不泄露,与你不相干
  2. 这种方式比较粗暴,直接直接再数据库的密码前面加上 {noop} 例如 123456 –> {noop}123456

方案一

简介
  1. 实际项目中我们不会把密码明文存储在数据库中,采用密码加密存储方式,也就是在我们注册用户的时候把明文密码以 Spring Security 的加密方式进行加密,存储在数据库里面,验证的时候就把前端的明文再按照之前的加密方式加密一般和数据库返回的用户密码比较一次,一样的话密码就是正确的,校验通过
  2. 默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。(也就是要像上面写成 {noop}password )
  3. 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。
  4. 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。
  5. 我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.adalucky.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
* @author ada
* @Version JDK17
* @Description Spring Security 配置类
* @since 2022/3/7 21:37
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
//创建 CryptPasswordEncoder 注入到容器中,让 Spring Sercurity 默认以该方式进行加密
return new BCryptPasswordEncoder();
}
}
代码测试
  1. PasswordEncoder 加密encode()

  2. 从下面图片可以看出来,同一个明文通过同一种方式最后加密的结果不一样,究其原因就是每次使用的 盐值 不一样

  3. $2a$10$tSn8ALjHUOtWPUziO8xIM.Auer5/yLLWsZspo3tsWI5OyBDea5mRS
    1️⃣ $2a$10$ + 22位盐值 + 加密后的密文
    2️⃣ $2a 是 BCrypt 的版本
    3️⃣ $10 是 10 次哈希

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    package com.adalucky.auth;

    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.security.crypto.password.PasswordEncoder;

    @SpringBootTest
    class WebSecurityConfigTest {
    @Autowired
    PasswordEncoder passwordEncoder;

    @Test
    void passwordEncoder() {
    var encode = passwordEncoder.encode("123456");
    var encode1 = passwordEncoder.encode("123456");
    System.out.println(encode);
    System.out.println(encode1);
    }
    }

    encode加密

  4. PasswordEncoder.matches(rawPassword,encodedPassword) 认证密码

  5. 密码认证结果返回布尔值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.adalucky.auth;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;

@SpringBootTest
class WebSecurityConfigTest {
@Autowired
PasswordEncoder passwordEncoder;

@Test
void passwordEncoder() {
System.out.println(passwordEncoder.matches("123456", "$2a$10$tSn8ALjHUOtWPUziO8xIM.Auer5/yLLWsZspo3tsWI5OyBDea5mRS"));
System.out.println(passwordEncoder.matches("123654", "$2a$10$tSn8ALjHUOtWPUziO8xIM.Auer5/yLLWsZspo3tsWI5OyBDea5mRS"));
}
}

matches 认证

  1. 找一个刚才加密后密文替换数据库之前的 {noop}123456
  2. 重启项目用明文进行登录即可

其实很简单,就是在实体类的构造方法中对 密码进行一次加密即可

1
2
3
4
5
6
7
8
9
10
public SysUser(UserDto userDto) {
this.userId = userDto.userId();
this.username = userDto.username();
this.nikename = userDto.nikename();
this.email = userDto.email();
this.phone = userDto.phone();
this.sex = userDto.sex();
this.avatar = userDto.avatar();
this.password = new BCryptPasswordEncoder().encode(userDto.password());
}

登录接口实现

注意:登录 登出的接口地址不要用默认的 /login /logout 会和 Spring Security 的重合防止出现一些意向不到的错误,可以命名为 /user/login 这种

分析

  1. 再理一下思路,上面我们的 登录认证流程详解图中: 前端请求登录 –> 默认用 AbstractAuthenticationProcessingFilter 的实现类 UsernamePasswordAuthenticationFilter 会去封装前端的账户和密码 –> 调用 ProviderManagerauthentication 方法认证 –> xxx… –> 调用重写的 UserDetailsService 接口下的 loadUserByUsername 去数据库查询用户 —> 封装到 UserDetails 的实现类 LoginUser –> 返回给 authentication
  2. 在接口中我们通过 AuthenticationManager 的 authenticate 方法来进行用户认证,所以需要在 SecurityConfig 中配置 把AuthenticationManager注入容器
  3. 登录接口实现类注入 AuthenticationManager 调用 authenticate 方法进行认证
  4. 调用认证时需要传入 Authentication 对象,所以我们用 new UsernamePasswordAuthenticationToken() 把前端传过来的数据封装成 Authentication 对象,调用 authenticate 方法进行认证 时候就传这个对象
  5. 认证返回的结果不是空的就表示有用户信息认证写进去了,这个写入 Redis 和返回 JWT 给前端即可即可
  6. 如果认证通过生成jwt写入Redis
  7. 那么我们只要重写实现类,在接口登录的时候调用即可
  8. 在登录接口需要放行,包括注册接口,主要涉及到一些不需要用户进行登录就能操作的接口 都需要进行放行
  9. 接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
  10. 认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

配置类

  1. 重写这个认证方法 authenticationManagerBean() 注入到容器中
  2. 配置请求放行等操作
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
package com.adalucky.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
* @author ada
* @Version JDK17
* @Description SpringBoot Security自动配置类
* @since 2022/3/7 21:37
*/
@Configuration
/*@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)*/
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
//创建 CryptPasswordEncoder 注入到容器中,让 Spring Sercurity 默认以该方式进行加密
return new BCryptPasswordEncoder();
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
//重写这个认证方法,返回父类的认证方法,也就是逻辑还是原来的逻辑,目的是为了注入到 bean 后我们可以在登录的时候去调用这个对象的认证方法
return super.authenticationManagerBean();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口、注册接口 允许匿名访问
.antMatchers("/user/login", "/user/register").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
}

实现

  1. 登录接口实现:1️⃣ new UsernamePasswordAuthenticationToken() 封装前端的传参 2️⃣调用注入容器中的 authenticate 认证 3️⃣ 返回的结果不为空后写入 Redis 缓存和生成 JWT 返回给前端
  2. @Autowired 分别注入 AuthenticationManager RedisCache
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * 登录
    *
    * @param userDto 前端传入的账号和密码
    * @return 封装的统一返回
    */
    public Result login(UserDto userDto) {
    //把前端传过来的用户名和密码封装成 authenticate 认证时候需要的 Authentication 对象
    //UsernamePasswordAuthenticationToken 继承了 AbstractAuthenticationToken AbstractAuthenticationToken又实现了 Authentication
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDto.username(), userDto.password());
    //调用 authenticate 方法认证就会去调用后面的 ProviderManager 后面会走到我们自己重写的 loadUserByUsername 方法去数据库查询用户信息进行认证
    //loadUserByUsername方法 里面会把用户信息封装到 我们自己重写的 UserDetails 的实现类 LoginUser
    //authenticate 认证方法的返回对象就会包含这个 LoginUser 的信息
    var authenticate = authenticationManager.authenticate(authenticationToken);
    if (Objects.isNull(authenticate)) {
    throw new RuntimeException("认证失败,用户名或密码错误");
    }
    var user = (LoginUser) authenticate.getPrincipal();
    var userId = user.getSysUser().getUserId().toString();
    redisCache.setCacheObject("login" + userId, user);
    return Result.ok().message("登录成功").data("token", JwtUtil.createJWT(userId));
    }

DEBUG 分析

Redis 写入情况

JWT认证过滤器

  1. 上面的我们已经生成了JWT返回给前端了,那么前端请求未放行的接口我们就要去解析 token 1️⃣ 是否是正确有效的 2️⃣ 请求的用户是谁 3️⃣ 根据解析的用户 id 去 Redis中查询用户的信息存入 SecurityContextHolder
  2. 为什么叫 JWT 认证过滤器呢,因为 Spring Security 框架有自带的过滤器链,然后我们在某个过滤器前面或者后面加入一个自己的过滤器,组成一个新的过滤器链

创建认证过滤器

  1. 这个过滤器的作用是为了拦截请求 校验用户的身份 1️⃣ 如果 token 为空的话就放行,因为可能是登录接口,或者注册接口,这里放行了后面拦截器中发现不是 登录或者注册接口又没有用户信息传过来就认证失败 403 2️⃣ 如果 token 解析失败证明就是非法的请求,抛出异常用户未登录 3️⃣ 解析成功了的话就去根据用户 ID 在 Redis 中取数据
  2. 注意事项:1️⃣ 登录成功那个接口写入的 key 和这里取时候的 key 要一致 2️⃣ 登录成功写入的数据类型和这里取时候接收的数据类型要兼容最好一致
  3. 过滤器尽量不要用 implements Filter 实现,因为这种方式可能在不同得 Severlet 版本中,一个请求过来过滤器会被调用多次,用 extends OncePerRequestFilter
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
package com.adalucky.filter;

import com.adalucky.modules.system.model.entity.SysUser;
import com.adalucky.utils.redis.RedisCache;
import com.adalucky.utils.str.StringUtils;
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.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

import static com.adalucky.utils.jwt.JwtUtil.parseJWT;

/**
* @author ada
* @Version JDK17
* @Description 自定义的 Jwt 认证过滤器,插入到 Spring Security 的过滤器链
* @since 2022/4/23 20:34
*/
@Component //一般的过滤器可能会去 implements Filter 实现,但是这种方式可能在不同得 Severlet 版本中,一个请求过来过滤器会被调用多次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
RedisCache redisCache;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取 token,前端 Header 头必须要有这个 token 这个参数
var token = request.getHeader("token");
if (!StringUtils.isEffective(token)) {
//如果 token 为空就放行不去做 解析/读Redis/ 写入SecurityContextHolder,这里的放行只是当前拦截器放行,后面的拦截器还是会去走对应的逻辑
filterChain.doFilter(request, response);
return;
}
// 解析 token
String userid;
try {
userid = parseJWT(token).getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
// Redis 取数据
SysUser user = redisCache.getCacheObject("login" + userid);
if (Objects.isNull(user)) {
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到Authentication,第三个参数就是权限
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行到下一个过滤器
filterChain.doFilter(request, response);
}
}

添加JWT 认证过滤器

  1. 将我们写的过滤器添加到 Spring Security 的过滤器链里面
  2. 添加的位置根据具体的业务逻辑而定,我们这里就需要在最前面添加,UsernamePasswordAuthenticationFilter 之前
  3. WebSecurityConfig 这个配置类中.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http
    //关闭csrf
    .csrf().disable()
    //不通过Session获取SecurityContext
    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    .and()
    .authorizeRequests()
    // 对于登录接口、注册接口 允许匿名访问
    .antMatchers("/user/login", "/user/register").anonymous()
    // 除上面外的所有请求全部需要鉴权认证
    .anyRequest().authenticated()
    .and()
    // 在UsernamePasswordAuthenticationFilter 过滤器前添加我们的自己写的 JWT 认证过滤器
    .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

验证

  1. 到这里为止我们的期望是当前端传过token 有效后我们去解析 userId,然后去 Redis 中获取用户的信息,并把查询存到SecurityContextHolder 中
  2. 写入 SecurityContextHolder 的时候需要用 new UsernamePasswordAuthenticationToken 构造对应的对象 authentication 对象
  3. 这个 new 构造函数用两个参数和三个参数的,两个参数是用来构造账号+密码用于认证的,我们用的三个参数的

写入到SecurityContextHolder

登出操作

  1. 注意接口命名之前也提过了不要和自带的重名了
  2. 这一步当我们登出的时候需要做的事情: 删 Redis 里面的用户信息
  3. 注意: 1️⃣ 调用这个接口不需要传参,只需要前端传 Token 2️⃣ 前端调用这个接口时候最先会走我们的 JWT 认证过滤器,这个时候认证通过会把我们的用户信息存在 SecurityContextHolder 没认证通过的话接口根本访问不到这里直接就会抛出 403 或者其它异常 3️⃣ 删除 Redis 缓存的时候也就是从 SecurityContextHolder 获取到用户 ID 然后拼接以后删除
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /**
    * 登出
    *
    * @return 封装的 Result
    */
    @Override
    public Result logout() {
    //退出登录的接口不需要传参,因为会携带 token(没带的话过滤器会拦截) SecurityContextHolder 中获取到 authentication 里面有用户信息(来源就是 JWT 认证过滤器存进去的)
    var authentication = SecurityContextHolder.getContext().getAuthentication();
    var user = (SysUser) authentication.getPrincipal();
    //删除 Redis 中的缓存
    redisCache.deleteObject("login" + user.getUserId());
    return Result.ok().message("退出登录成功");
    }
    DEBUG 说明

缓存删除成功

配置项简介

  1. 我们之前重写了 SecurityConfig 配置类,这里做一个配置项,configure 的一些粗浅简介
  2. 重写这个不需要注入到 Bean 中,我估摸这这个配置类重写以后 Security 就会调用我们自己的配置类算是一种多态吧
  3. 上层调用会传入一个 HttpSecurity 对象,那么我们就可以对用对用的方法进行设置了,具体还有那些配置需要用的时候再 Baidu 一下
  4. 下列示例中的没一行表示一个配置项的结束 返回的还是一个 HttpSecurity 所以还是可以继续调用设置
  5. and() 方法可以理解为一组设置结束,返回的还是一个 HttpSecurity 对象,可以继续设置 这是我们常见的链式编程
  6. 设置 .antMatchers("/user/login", "/user/register").anonymous() 对于登录接口、注册接口 允许匿名访问 注意不带 token 才能正常访问带了反而会抛异常 ,这就匿名访问
  7. .antMatchers("/user/login", "/user/register").permitAll() 下面这种 permitAll() 带不带 token 都可以访问
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对于登录接口、注册接口 允许匿名访问
.antMatchers("/user/login", "/user/register").anonymous()
//其它的请求,任意用户认证通过后都可访问
.anyRequest().authenticated()
.and()
//在UsernamePasswordAuthenticationFilter 过滤器前添加我们的自己写的 JWT 认证过滤器
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}

权限

权限系统的作用

  1. 例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
  2. 总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
  3. 我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
  4. 所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。

授权基本流程

  1. 在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息,然后确认当前用户是否拥有访问当前资源所需的权限,如果有权限的话 FilterSecurityInterceptor 才会放行
  2. 所以我们项目在登录的时候需要把用户的权限信息也存入 Authentication 对象,最后写入到 Redis 中。
  3. 其它接口进行请求时经过 JWT 认证锅炉器,去 Redis 取用户信息和和拥有的资源权限 并写入到 SecurityContextHolder

授权实现

  1. 这里记录一下授权的实现,采用自顶向下编写代码
  2. 这里先采用写死权限的方式快速体验下,后面用权限模型优化代码

接口限制访问权限

  1. 就是对我们的 API 接口做权限校验,当你需要有某个权限的时候我才让你访问我的接口,没有这个权限的话,不好意思,你谁啊,403 拜拜
  2. 这里所谓的权限我们就是采用一个字符串来标识,当你有这个标识的时候那我就给你放行,让你请求我的接口
  3. 一般实现的方式有两种:1️⃣ 基于配置的方案(这种一般是针对静态资源,也就是 springboot 项目下的静态资源)2️⃣ 基于注解的方案(现在前后端分离的项目都是用这种)
  4. 具体实现:1️⃣ 在 Spring Security 的配置类上添加一个注解 @EnableGlobalMethodSecurity(prePostEnabled = true) 开启对应的功能 2️⃣ 在接口上添加注解@PreAuthorize("hasAuthority('你的权限关键字')") Spring Security 在运行的时候会去读取这个注解里面的参数当做一个表达式,读取到了 hasAuthority() 后就会去调用这个方法,然后传入你自己的权限关键字去 SecurityContextHolder 的权限里面比对有没有一模一样的关键字,最后返回一个布尔值确定你是否能访问这个接口
    1
    2
    3
    4
    5
    //Spring Security 配置类中开启注解
    @EnableGlobalMethodSecurity(prePostEnabled = true)

    //接口上添加权限访问控制注解,写入表达式和关键字(这个关键字只要是一个字符串都行 )
    @PreAuthorize("hasAuthority('system:user:list')")

接口限制访问权限

封装权限信息

  1. 思路:在登录的时候我们就需要去数据库查询出来一些封装给用户对象,然后写入 Redis,(这里先写死,不去数据查询)
  2. 注意事项:我们是用一个集合来接受,最好是用一个不能重复的集合,并且在数据查询的时候也需要去重双重保障吧(后面查数据库权限的时候会再记录)
  3. 涉及变动:之前我的代码往 Redis 中存的都是 SysUser 的对象,现在加了一个 权限对象 Set<String> permissions 所以存的时候只能把拥有这两个对象的 LoginUser 存进去了, SysUserSet<String> permissions 都是这个对象的属性
    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
    package com.adalucky.modules.system.service.impl;

    import com.adalucky.handler.BusinessException;
    import com.adalucky.modules.system.mapper.SysUserMapper;
    import com.adalucky.modules.system.model.entity.SysUser;
    import com.adalucky.response.ResultCode;
    import com.adalucky.utils.str.StringUtils;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    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.Arrays;
    import java.util.HashSet;
    import java.util.Objects;
    import java.util.Set;

    /**
    * @author ada
    * @Version JDK17
    * @Description 重新 loadUserByUsername 方法,实现去数据库查询
    * @since 2022/4/22 20:29
    */
    @Service
    public class UserDetailServiceImpl implements UserDetailsService {
    @Autowired
    SysUserMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    //查询用户信息 如果参数无效抛出自定义异常
    if (!StringUtils.isEffective(username)) {
    throw new BusinessException(
    ResultCode.PARAM_IS_BLANK.getCode(),
    ResultCode.PARAM_IS_BLANK.getMessage()
    );
    }
    //限制数据库只能有一条,因为手机号唯一,如果不止一条 getOne 就会抛异常,catch 捕获了就抛出自定义异常
    LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(SysUser::getUsername, username);
    SysUser user;
    try {
    user = mapper.selectOne(queryWrapper);
    } catch (Exception e) {
    throw new BusinessException(
    ResultCode.USER_REPEAT.getCode(),
    ResultCode.USER_REPEAT.getMessage()
    );
    }
    //查询数据不为空后 构造到 LoginUser 中(也就是封装到 UserDetails 的对象)
    if (Objects.isNull(user)) {
    throw new BusinessException(
    ResultCode.PARAM_IS_BLANK.getCode(),
    ResultCode.PARAM_IS_BLANK.getMessage()
    );
    }
    //List<String> menu = new ArrayList<>(Arrays.asList("system:user:list"));
    Set<String> permissions = new HashSet<String>(Arrays.asList("system:user:list"));
    return new LoginUser(user, permissions);
    }
    }
    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
    /**
    * 登录
    *
    * @param userDto 前端传入的账号和密码
    * @return 封装的统一返回
    */
    public Result login(UserDto userDto) {
    //把前端传过来的用户名和密码封装成 authenticate 认证时候需要的 Authentication 对象
    //UsernamePasswordAuthenticationToken 继承了 AbstractAuthenticationToken AbstractAuthenticationToken又实现了 Authentication
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDto.username(), userDto.password());
    //调用 authenticate 方法认证就会去调用后面的 ProviderManager 后面会走到我们自己重写的 loadUserByUsername 方法去数据库查询用户信息进行认证
    //loadUserByUsername方法 里面会把用户信息封装到 我们自己重写的 UserDetails 的实现类 LoginUser
    //authenticate 认证方法的返回对象就会包含这个 LoginUser 的信息
    var authenticate = authenticationManager.authenticate(authenticationToken);
    if (Objects.isNull(authenticate)) {
    throw new RuntimeException("认证失败,用户名或密码错误");
    }

    var loginUser = (LoginUser) authenticate.getPrincipal();
    var user = loginUser.getSysUser();
    var userId = user.getUserId().toString();
    //写入 loginUser 时, permissions 必须要有对应的 get set 方法
    redisCache.setCacheObject("login" + userId, loginUser);
    return Result.ok().message("登录成功").data("token", JwtUtil.createJWT(userId));
    }
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
package com.adalucky.modules.system.service.impl;

import com.adalucky.modules.system.model.entity.SysUser;
import com.alibaba.fastjson.annotation.JSONField;
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.Set;
import java.util.stream.Collectors;

/**
* @author ada
* @Version JDK17
* @Description LoginUser 将用户封装成 UserDetails 对象
* @since 2022/4/22 21:30
*/
public class LoginUser implements UserDetails {
private SysUser sysUser;
//存储权限集合
private Set<String> permissions;

public SysUser getSysUser() {
return sysUser;
}

public void setSysUser(SysUser sysUser) {
this.sysUser = sysUser;
}

public Set<String> getPermissions() {
return permissions;
}

public void setPermissions(Set<String> permissions) {
this.permissions = permissions;
}

public void setAuthorities(Set<SimpleGrantedAuthority> authorities) {
this.authorities = authorities;
}

public LoginUser() {
}

/**
* 构造 UserDetails
*
* @param user SysUser 对象
* @param permissions 用户的权限资源集合
*/
public LoginUser(SysUser user, Set<String> permissions) {
this.sysUser = user;
this.permissions = permissions;
}

// fastjson 的注解,不进行序列化,因为这个是 Security 提供的序列化会报错 我们存在 Redis 中 存Set<String> permissions 对象
@JSONField(serialize = false)
private Set<SimpleGrantedAuthority> authorities;

/**
* 获取权限
*
* @return 返回权限资源集合 (将 permissions 集合中的 String 标识存入后返回,只有第一次为空的时候才会写,不为空直接返回权限集合)
* 如果修改了用户权限需要删除 Redis 重新登录一次或者重写写一次权限不然这里权限不为空就获取不到最新的权限了
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//这里优化一些代码加个 if 判断,当权限为空的时候先去把权限写入到 Set<SimpleGrantedAuthority> authorities 中,如果不为空直接返回这个权限,不然每次认证权限的时候调用一次 getAuthorities() 方法都会重复写同样的东西
if (authorities != null) {
return authorities;
}
/*
Set<GrantedAuthority> authorities = new HashSet<>();
permissions.forEach(permission->authorities.add(new SimpleGrantedAuthority(permission)));
return authorities;
*/
//函数式编程: 先把 permissions 转换成一个流,然后里面的值到一个 map 里,里面的每一个对象调用一次 SimpleGrantedAuthority 的构造方法,最后再调用 collect() 方法收集成一个 Set 集合
//把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
return authorities;
}

/**
* 获取密码
*
* @return 返回密码
*/
@Override
public String getPassword() {
return sysUser.getPassword();
}

/**
* 获取账号
*
* @return 返回登录账户
*/
@Override
public String getUsername() {
return sysUser.getUsername();
}

/**
* 判断账户是否未过期
*
* @return 未过期返回 true,过期返回 false
*/
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 方法用于判断账户是否未锁定
*
* @return 未锁定返回 true 锁定返回 false
*/
@Override
public boolean isAccountNonLocked() {
return true;
}

/**
* 用于判断用户凭证是否没过期,即密码是否未过期
*
* @return 没过期返回 true 过期返回 false
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 方法用于判断用户是否可用
*
* @return 可用返回 true 不可用返回 false
*/
@Override
public boolean isEnabled() {
return true;
}
}

RBAC权限模型

简介

RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。

RBAC 权限模型

建表

按照简介所示图,一个我们需要 5 张表进行组成一个基本的 RBAC 的模型

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
-- 创建用户表
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`
(
`user_id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`dept_id` int DEFAULT NULL COMMENT '部门ID',
`username` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '登录账号',
`nikename` varchar(10) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户昵称',
`type` varchar(2) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '00' COMMENT '用户类型(00系统用户)',
`email` varchar(50) DEFAULT '' COMMENT '用户邮箱',
`phone` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '' COMMENT '手机号码',
`sex` int DEFAULT '2' COMMENT '用户性别(0女 1男 2保密)',
`avatar` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT 'https://www.adalucky.com/medias/16229390604952.png' COMMENT '头像路径',
`password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '' COMMENT '密码',
`status` int DEFAULT '0' COMMENT '帐号状态(0 启用 1 禁用)',
`deleted` int DEFAULT '0' COMMENT '删除标志(0代表未删除 1代表已删除)',
`project_id` int DEFAULT NULL COMMENT '默认项目ID',
`login_ip` varchar(50) DEFAULT '' COMMENT '最后登陆IP',
`login_date` datetime DEFAULT NULL COMMENT '最后登陆时间',
`create_by` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '创建者',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '更新者',
`update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`version` int DEFAULT '1' COMMENT 'Mybatis-Plus 乐观锁标记',
`remark` varchar(500) DEFAULT '' COMMENT '备注',
PRIMARY KEY (`user_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='用户信息表';

-- 创建权限表
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`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` bigint(20) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(20) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`deleted` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除 1代表已删除)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 2
DEFAULT CHARSET = utf8mb4 COMMENT ='菜单表';

-- 创建角色表
DROP TABLE IF EXISTS `sys_role`;

CREATE TABLE `sys_role`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`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` bigint(200) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(200) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 3
DEFAULT CHARSET = utf8mb4 COMMENT ='角色表';

-- 创建角色权限中间表

DROP TABLE IF EXISTS `sys_role_menu`;

CREATE TABLE `sys_role_menu`
(
`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`, `menu_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 2
DEFAULT CHARSET = utf8mb4 COMMENT ='角色权限中间表';

-- 创建用户角色中间表
DROP TABLE IF EXISTS `sys_user_role`;

CREATE TABLE `sys_user_role`
(
`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`, `role_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='用户角色中间表';

实现

  1. 实现思路:新建一个 sys_menu 的实体类、Mapper 接口、xml
  2. 编写查询用户权限的 SQL 放在 xml 中,Mapper 中声明对应的方法
  3. Mapper 传入的参数类型需要和 实体类中的 userId 兼容
  4. 生成 xml 文件和自定义 SQL 可在类上合方法上进行提示填充(MybatisX 插件)
  5. 最后把我们写死的权限换成查询的(Mapper 的 bean 要注入)
    1
    2
    3
    //Set<String> permissions = new HashSet<String>(Arrays.asList("system:user:list"));
    var permissions = sysMenuMapper.selectPermsByUserId(user.getUserId());
    return new LoginUser(user, permissions);
    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
    package com.adalucky.modules.system.model.entity;

    import com.baomidou.mybatisplus.annotation.FieldFill;
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableField;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import java.io.Serializable;
    import java.time.LocalDateTime;
    import io.swagger.annotations.ApiModel;
    import io.swagger.annotations.ApiModelProperty;
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;

    /**
    * @author ada
    * @Version JDK17
    * @Description 菜单表 实体类
    * @since 2022-04-26 00:06:11
    */
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @TableName("sys_menu")
    @ApiModel(value = "SysMenu对象", description = "菜单表")
    public class SysMenu implements Serializable {

    private static final long serialVersionUID = 1L;

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

    @ApiModelProperty("菜单名")
    private String menuName;

    @ApiModelProperty("路由地址")
    private String path;

    @ApiModelProperty("组件路径")
    private String component;

    @ApiModelProperty("菜单状态(0显示 1隐藏)")
    private String visible;

    @ApiModelProperty("菜单状态(0正常 1停用)")
    private String status;

    @ApiModelProperty("权限标识")
    private String perms;

    @ApiModelProperty("菜单图标")
    private String icon;

    private Long createBy;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    private Long updateBy;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    @ApiModelProperty("删除标志(0代表未删除 1代表已删除)")
    private Integer deleted;

    @ApiModelProperty("备注")
    private String remark;


    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.adalucky.modules.system.mapper;

import com.adalucky.modules.system.model.entity.SysMenu;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

import java.util.Set;

/**
* @author ada
* @Version JDK17
* @Description 菜单表 Mapper 接口
* @since 2022-04-26 00:06:11
*/
@Mapper
public interface SysMenuMapper extends BaseMapper<SysMenu> {
Set<String> selectPermsByUserId(Integer id);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?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.adalucky.modules.system.mapper.SysMenuMapper">

<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT DISTINCT M.perms
FROM `sys_user_role` UR
LEFT JOIN `sys_role` R ON UR.role_id = R.id
LEFT JOIN `sys_role_menu` RM ON UR.role_id = RM.role_id
LEFT JOIN `sys_menu` M ON RM.menu_id = M.id
WHERE UR.user_id = #{id}
AND M.`status` = '0'
AND R.`status` = '0'
</select>
</mapper>

异常处理

简介

  1. 我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。
  2. 在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。
  3. 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
  4. 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandle对象的方法去进行异常处理。
  5. 所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

自定义实现

  1. 需要重写两个实现类
  2. 注意如果有全局异常处理,并且捕获的异常类型为 Exception 或者包含了下面实现类抛出的异常,全局的异常捕获会覆盖掉当前的自定义,给你的感觉就像是没有生效
  3. 记得要注入到容器
    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
    package com.adalucky.handler;

    import com.adalucky.response.Result;
    import com.adalucky.utils.web.WebUtils;
    import com.alibaba.fastjson.JSON;
    import org.springframework.http.HttpStatus;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.web.AuthenticationEntryPoint;
    import org.springframework.stereotype.Component;

    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    /**
    * @author ada
    * @Version JDK17
    * @Description 自定义用户认证失败异常
    * @since 2022/4/27 21:37
    */
    @Component
    public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    var result = Result.error(false, HttpStatus.UNAUTHORIZED.value(), "用户认证失败,请重新登录");
    String json = JSON.toJSONString(result);
    //处理异常
    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
    26
    27
    28
    29
    30
    31
    package com.adalucky.handler;

    import com.adalucky.response.Result;
    import com.adalucky.utils.web.WebUtils;
    import com.alibaba.fastjson.JSON;
    import org.springframework.http.HttpStatus;
    import org.springframework.security.access.AccessDeniedException;
    import org.springframework.security.web.access.AccessDeniedHandler;
    import org.springframework.stereotype.Component;

    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    /**
    * @author ada
    * @Version JDK17
    * @Description 自定义权限认证失败异常
    * @since 2022/4/27 21:40
    */
    @Component
    public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
    var result = Result.error(false, HttpStatus.FORBIDDEN.value(), "您的权限不足,请联系管理员开通权限");
    String json = JSON.toJSONString(result);
    //处理异常
    WebUtils.renderString(response, json);
    }
    }

配置类

在之前的配置类中加入配置容器中要注入接口 AuthenticationEntryPoint AccessDeniedHandler 然后调用 http.exceptionHandling() 下对应的配置项

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
package com.adalucky.auth;

import com.adalucky.filter.JwtAuthenticationTokenFilter;
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.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
* @author ada
* @Version JDK17
* @Description SpringBoot Security自动配置类
* @since 2022/3/7 21:37
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
final JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;

public WebSecurityConfig(JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter, AuthenticationEntryPoint authenticationEntryPoint, AccessDeniedHandler accessDeniedHandler) {
this.jwtAuthenticationTokenFilter = jwtAuthenticationTokenFilter;
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
}

@Bean
public PasswordEncoder passwordEncoder() {
//创建 CryptPasswordEncoder 注入到容器中,让 Spring Sercurity 默认以该方式进行加密
return new BCryptPasswordEncoder();
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
//重写这个认证方法,返回父类的认证方法,也就是逻辑还是原来的逻辑,目的是为了注入到 bean 后我们可以在登录的时候去调用这个对象的认证方法
return super.authenticationManagerBean();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//开始设置请求认证规则
.authorizeRequests()
//对于登录接口、注册接口 允许匿名访问
.antMatchers("/user/login", "/user/register").anonymous()
//其它的请求,任意用户认证通过后都可访问
.anyRequest().authenticated();
//在UsernamePasswordAuthenticationFilter 过滤器前添加我们的自己写的 JWT 认证过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败、授权失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
}
}

跨域

简介

  1. 浏览器出于安全的考虑,使用 XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
  2. 前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。
  3. 所以我们就要处理一下,让前端能进行跨域请求,总体分两步:1️⃣ 配置 SpringBoot 的跨域策略 2️⃣ Security 配置开启允许跨域(由于我们的资源都会收到Spring Security的保护,所以想要跨域访问还要让Spring Security允许跨域访问。)

SpringBoot跨域配置

当前我的版本为 SpringBoot 2.6.4

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
package com.adalucky.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
* @author ada
* @Version JDK17
* @Description 新跨域配置方案不然 SpringBoot2.6.x 访问会有一些报错
* @since 2022/3/19 19:34
*/
@Configuration
public class CrosConfig {
private CorsConfiguration corsConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOriginPattern("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setMaxAge(3600L);
return corsConfiguration;
}

@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig());
return new CorsFilter(source);
}
}

Spring Security跨域开启

实际发现我上面的配置以后这里不开启也是能跨域的,估计和版本以及我上面注入了那个 Bean

1
2
//允许跨域
http.cors();

其它权限认证方式

简介

  1. 在上面我们想认证一个用户是否有接口的访问权限,我们的方式是在入Spring Security 的配置类上添加一个注解 @EnableGlobalMethodSecurity(prePostEnabled = true) 然后在接口上添加注解@PreAuthorize("hasAuthority('你的权限关键字')") 来进行实现的,这里的本质是一种 SPEL 的表达式
  2. 我们前面都是使用@PreAuthorize注解,然后在在其中使用的是hasAuthority方法进行校验。SpringSecurity还为我们提供了其它方法例如:hasAnyAuthority,hasRole,hasAnyRole等。
  3. 可以通过 DEBUG 去理解hasAuthority的原理,hasAuthority方法实际是执行到了SecurityExpressionRoot的hasAuthority,大家只要断点调试既可知道它内部的校验原理。
  4. 它内部其实是调用authentication的getAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据在权限列表中。
  5. hasAuthority 还是最好用的

hasAnyAuthority

hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源

1
2
3
4
@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
public String hello(){
return "hello";
}

hasRole

  1. hasRole 要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
  2. 也就是说如果使用这个方法做权限校验,那么在我们数据库存储的权限标识符 system:dept:list –> ROLE_system:dept:list
    1
    2
    3
    4
    @PreAuthorize("hasRole('system:dept:list')")
    public String hello(){
    return "hello";
    }

hasAnyRole

hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。

1
2
3
4
@PreAuthorize("hasAnyRole('admin','system:dept:list')")
public String hello(){
return "hello";
}

自定义认证方法

思路

  1. 上面的原理就是在@PreAuthorize注解中加入 Spring Security自带的认证方法,然后传入一个或者多个参数
  2. 那么我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法

方法实现

  1. Component 需要起一个别名,到时候获取这个 Bean 的时候就通过这个别名来引用
  2. 这个里面只是一个简单的示例,可以以把 集合是否包含换成模糊匹配最终返回一个布尔值,比如接口中定义调用的时候传入的是 system:* 那么就表示 这个用户有 sytem 下的所有权限(大概就是这种,有可能会用到,我这里暂时不涉及就不实现了)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Component("automation")
    public class ExpressionRoot {

    public boolean hasAuthority(String authority){
    //获取当前用户的权限
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Set<String> permissions = loginUser.getPermissions();
    //判断用户权限集合中是否存在authority
    return permissions.contains(authority);
    }
    }

引用

在SPEL表达式中使用 @automation 相当于获取容器中bean的名字为 automation 的对象。然后再调用这个对象的hasAuthority方法

1
2
3
4
5
@RequestMapping("/hello")
@PreAuthorize("@automation.hasAuthority('system:dept:list')")
public String hello(){
return "hello";
}

配置文件认证

  1. 上面我们都是通过注解来实现,之前也提到过认证一般有两种,一种是注解,一种是配置,配置一般用于对静态文件,但是也可以对接口进行这样的权限认证
  2. 示例如下,这里可以把所有需要权限校验的接口都配上,看个人喜好,可以在这里全配上或者在接口加注解
    1
    http.authorizeRequests().antMatchers("/user/list").hasAuthority("system:user:list");

其它

CSRF

  1. CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一
  2. SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
  3. 我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。所以我们就关掉了,因为我们请求的时候也没带 csrf_token 不关闭反而认证不通过

自定义处理器

以下的这些方案的前是不采用我们上面哪一套设计方案,因为我们上面那一套方案重新了很多过滤器的方法,调用的和默认的不一样,所以在这里可能就无效了,没有去执行父类的 super 默认配置项

认证成功处理器

  1. 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。
  2. 我们也可以自己去自定义成功处理器进行成功后的相应处理。
1
2
3
4
5
6
7
8
@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("认证成功了");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private AuthenticationSuccessHandler successHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin().successHandler(successHandler);

http.authorizeRequests().anyRequest().authenticated();
}
}

认证失败处理器

  1. 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。
  2. 我们也可以自己去自定义失败处理器进行失败后的相应处理。
1
2
3
4
5
6
7
@Component
public class SGFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("认证失败了");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private AuthenticationSuccessHandler successHandler;

@Autowired
private AuthenticationFailureHandler failureHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// 配置认证成功处理器
.successHandler(successHandler)
// 配置认证失败处理器
.failureHandler(failureHandler);

http.authorizeRequests().anyRequest().authenticated();
}
}

登出成功处理器

1
2
3
4
5
6
7
@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("注销成功");
}
}
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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private AuthenticationSuccessHandler successHandler;

@Autowired
private AuthenticationFailureHandler failureHandler;

@Autowired
private LogoutSuccessHandler logoutSuccessHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// 配置认证成功处理器
.successHandler(successHandler)
// 配置认证失败处理器
.failureHandler(failureHandler);

http.logout()
//配置注销成功处理器
.logoutSuccessHandler(logoutSuccessHandler);

http.authorizeRequests().anyRequest().authenticated();
}
}