目录

一、引入依赖及原理简述
二、多用户维护配置
三、基础自定义配置
四、前后端分离及登录结果管理> 五、角色权限管理基础


从此处开始,为新的原创内容,相关数据结构代码换了一套新的,与之前的代码关系不大了。
建议新建一个项目,将配置文件复制过来,然后按照步骤走。

六、RBAC 结构实现
七、自定义响应式登录与 JWT 配置
八、集成 Redis <— 你在这里 ( •̀ ω •́ )y

Spring Security(八)集成 Redis

博主前言:本以为这个就是代替传统 jwt 的插件,没想到复杂程度如此之高。Spring Security 本身是个高度自定义化的组件,必须花时间重点学习一下。以下为个人配置学习的流程,从零到权限管理、redis嵌入等步骤。
本文基于尚硅谷的 Spring Security 教程学习,文章与原教程有不小出入,仅供参考。
B站视频链接:尚硅谷Java项目SpringSecurity+OAuth2权限管理实战教程

这一篇最大的难点在于,为什么要集成redis,以及要替换掉 Spring Security 上的那一部分。我在网上冲浪了很久,始终不明确集成的意义。都是用一台计算机上的内存,有什么性能上的优势吗?

实际上,问题无外乎以下两点:

- 重启后数据会丢失。
- 无法在分布式环境中共享数据。

对于单部署的小应用来说,确实优势不大。但是对于微服务这种跨机器的后台网络,redis是必不可少的。因为在访问请求后,token的数据存储到本机的线程上,这使得其他机器访问不到该线程,从而拿不到用户的数据,很多业务也就无法进行。指定一个机器运行redis,让其他机器通过该机器拿用户数据,就解决了这个问题。

还有一个问题是,对于token,我们不能在上面存放太多数据,不然会变得很长——存对象的token要比只存idtoken长上数倍,而且每次访问接口都要现场解析复杂的token,浪费性能。更何况解析的时候往往产生序列相关的异常,总之就是十分麻烦。

我们可以只在token内存放id,登录的时候,不仅生成token,同时将用户数据存入redis,设id为键,这样每次访问接口,只需要快速解析出id,就能从redis获取用户数据,同时解决了复杂解析和序列化的两大难题,对于近些年来的后端程序,redis近乎是必备。

这么讲,集成redis的目的就很明确了:将原本和线程一并存储的用户数据分离,需要的时候在调用。


一、配置redis

  1. 引入依赖:
1
2
3
4
5
6
7
8
9
10
11
12
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.3.3</version>
</dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.37</version>
</dependency>
  1. 编辑配置文件(application.yml):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
data:
redis:
host: localhost
port: 6379
database: 0
password: # 没有请留空
timeout: 10s
lettuce:
pool:
min-idle: 0
max-idle: 10
max-active: 200
max-wait: -1ms
  1. 配置redis工具类:

[!IMPORTANT]

DatabaseException()是我自定义的异常类,交由【全局异常管理】监听,读者可按需配置。

详见这篇文章:Spring Boot 全局异常拦截配置

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
/**
* Redis 工具类
*
* @author Amane64
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisUtils {
private final RedisTemplate<String, String> redisTemplate;
private final JsonUtils jsonUtils;

/**
* 存入缓存
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value) {
this.setBySetTime(key, value, 3, TimeUnit.MINUTES);
}

/**
* 存入缓存,自定义过期时间
*
* @param key 键
* @param value 值
* @param timeout 过期时间
* @param timeUnit 时间单位
*/
public void setBySetTime(String key, Object value, long timeout, TimeUnit timeUnit) {
try {
redisTemplate.opsForValue().set(key, jsonUtils.serialize(value), timeout, timeUnit);
} catch (Exception e) {
log.error(e.getMessage());
throw new DatabaseException("存入缓存数据出错:" + key);
}
log.debug("存入缓存数据:{}", key);
}

/**
* 获取缓存数据
*
* @param key 键
* @param clazz 值类型
* @return
*/
public <T> T get(String key, Class<T> clazz) {
T res;
try {
res = jsonUtils.deserialize(redisTemplate.opsForValue().get(key), clazz);
} catch (Exception e) {
log.error(e.getMessage());
throw new DatabaseException("获取缓存数据出错:" + key);
}
log.debug("获取缓存数据:{}", key);
return res;
}

/**
* 判断缓存是否存在
*
* @param key 键
* @return 存在返回 true,不存在返回 false
*/
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}

/**
* 删除缓存
*
* @param key 键
*/
public void delete(String key) {
if (!hasKey(key)) throw new DatabaseException("缓存数据不存在:" + key);
redisTemplate.delete(key);
log.debug("删除缓存数据:{}", key);
}
}
  1. 配置序列化,供redis使用

[!IMPORTANT]

一般情况下是不需要序列化的,设置泛型为RedisTemplate<String, Object>,手动强转类型即可。

我有原创的监听Mybatis-Plus与Redis实现缓存同步的构造,必须使用双String的方式实现,故配置序列化。

对应文章传送门:[Mybatis-Plus 与 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
/**
* 序列化与反序列化转换器
*
* @author Amane64
*/
public class JacksonObjectMapper extends ObjectMapper {

public JacksonObjectMapper() {
super();
// 前端参数蛇形命名法
this.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

// 收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

// 反序列化时,属性不存在的兼容处理
DeserializationConfig deserializationConfig = this.getDeserializationConfig().withoutFeatures(FAIL_ON_UNKNOWN_PROPERTIES);

//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(new JavaTimeModule());
}
}

/**
* Json 序列化工具
*
* @author Amane64
*/
@Component
public class JsonUtils {

private final ObjectMapper objectMapper;

public JsonUtils() {
objectMapper = new JacksonObjectMapper();
}

public String serialize(Object object) throws Exception {
return objectMapper.writeValueAsString(object);
}

public <T> T deserialize(String json, Class<T> clazz) throws Exception {
return objectMapper.readValue(json, clazz);
}
}
  1. 配置redisConfig类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* redis 配置
*
* @author Amane64
*/
@Configuration
@Slf4j
public class RedisConfig {

@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
log.info("注册 redis 序列化器...");
var template = new RedisTemplate<String, String>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}

}

二、登录时抽离用户数据,访问时配置数据到线程

  1. 修改登录逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService {
private final JWTProperties jwtProperties;
private final AuthenticationManager authenticationManager;
private final RedisUtils redisUtils;

@Override
public LoginVO login(String account, String password) {
// 验证登录,并存入缓存
var authenticationToken = new UsernamePasswordAuthenticationToken(account, password);
Authentication authentication = authenticationManager.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);

// 生成 token
var user = (User) authentication.getPrincipal();
var claims = new HashMap<String, Object>();
claims.put("id", user.getId());
var token = JWTUtils.createJWT(claims, jwtProperties.getKey(), jwtProperties.getTtl());

// 记录用户数据
var key = "login:" + user.getId().toString();
if (!redisUtils.hasKey(key))
redisUtils.setBySetTime(key, user, jwtProperties.getTtl(), TimeUnit.MILLISECONDS);

// 封装返回数据
return new LoginVO(user.getId(), user.getUsername(), token);
}
}
  1. 修改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
/**
* JWT 拦截器
*
* @author Amane64
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final JWTProperties jwtProperties;
private final RedisUtils redisUtils;
private final SecurityUtils securityUtils;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
// 放行登录接口
if (request.getMethod().equals("POST")
&& request.getRequestURI().equals("/login")) {
filterChain.doFilter(request, response);
return;
}

// 获取并校验 token
String token = request.getHeader(jwtProperties.getHeader());
if (token == null) {
log.warn("请求头未传递 token");
throw new IllegalArgumentException("请求头未传递 token");
}
Claims claims;
try {
claims = JWTUtils.parseJWT(token, jwtProperties.getKey());
} catch (MalformedJwtException e) {
log.warn("token 签名无效");
throw e;
} catch (SignatureException e) {
log.warn("token 签名错误");
throw e;
} catch (UnsupportedJwtException e) {
log.warn("token 算法不一致");
throw e;
} catch (ExpiredJwtException e) {
log.warn("token 过期");
throw e;
} catch (Exception e) {
log.warn("未知 token 拦截错误 {}", e.getMessage());
throw e;
}

// 获取用户数据
var id = ((Number) claims.get("id")).longValue();
User user = securityUtils.getCurrentUserById(id);

// 配置到线程并然后放行
var authenticationToken = new UsernamePasswordAuthenticationToken(
user.getId(), null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}

  1. 维护权限的反序列化

[!IMPORTANT]

因为GrantedAuthority为一个接口,需要为其特定维护,一般指定为SimpleGrantedAuthority

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
/**
* 针对 GrantedAuthority 的自定义反序列化
*
* @author Amane64
*/
public class GrantedAuthorityDeserializer extends JsonDeserializer<List<GrantedAuthority>> {

@Override
public List<GrantedAuthority> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
List<Map<String, String>> authorityList = jsonParser.readValueAs(List.class);
List<GrantedAuthority> authorities = new ArrayList<>();
authorityList.forEach(authorityMap->{
String authority = authorityMap.get("authority");
if (authority != null)
authorities.add(new SimpleGrantedAuthority(authority));
});
return authorities;
}
}

// User 权限列表字段上添加该注解
@TableField(exist = false)
@JsonDeserialize(using = GrantedAuthorityDeserializer.class)
private List<GrantedAuthority> authorities;


三、创建线程用户提取工具

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
/**
* 线程用户提取工具类
*
* @author Amane64
*/
@Component
@RequiredArgsConstructor
public class SecurityUtils {
private final UserService userService;
private final RedisUtils redisUtils;

/**
* 获取当前用户id
*
* @return 当前用户id
*/
public static Long getCurrentId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
return (Long) authentication.getPrincipal();
}
return null;
}

/**
* 获取当前用户名
*
* @return 当前用户名
*/
public String getCurrentUsername() {
return getCurrentUser().getUsername();
}

/**
* 获取当前用户
*
* @return 当前用户
*/
public User getCurrentUser() {
return getCurrentUserById(getCurrentId());
}

/**
* 根据id获取缓存中的用户
*
* @param id 用户id
* @return 用户
*/
public User getCurrentUserById(Long id) {
var key = "login:" + id;
User user;
if (redisUtils.hasKey(key))
user = redisUtils.get(key, User.class);
else {
user = userService.getById(id);
user.setAuthorities(userService.getUserAuthorities(id));
}
return user;
}

}


四、实现登出接口并维护jwt黑名单

为了防止用户登出之后jwt潜在的滥用风险,我们可以利用redis维护一个黑名单,持续时间为token的有效时间。

  1. 创建登出接口及方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping("/logout")
public Result<?> logout(@RequestHeader Map<String, String> headers) {
loginService.logout(headers.get(jwtProperties.getHeader().toLowerCase()));
return Result.success();
}

@Override
public void logout(String token) {
var tokenKey = "deprecatedToken:" + token;
if (!redisUtils.hasKey(tokenKey))
redisUtils.setBySetTime(tokenKey, token, jwtProperties.getTtl(), TimeUnit.MILLISECONDS);

var userKey = "login:" + SecurityUtils.getCurrentId();
if (redisUtils.hasKey(userKey))
redisUtils.delete(userKey);
}

  1. jwt拦截逻辑添加对弃用token的处理:
1
2
3
4
5
6
7
8
9
10
11
12
// 获取并校验 token
String token = request.getHeader(jwtProperties.getHeader());
if (token == null) {
log.warn("请求头未传递 token");
throw new IllegalArgumentException("请求头未传递 token");
}
if (redisUtils.hasKey("deprecatedToken:" + token)) {
log.warn("token 已被弃用");
throw new IllegalArgumentException("token 已被弃用");
}
// 其他代码...

  1. Config类中禁用原生登出方法:
1
2
3
// 禁用原生登出
http.logout(AbstractHttpConfigurer::disable)

至此,Spring Security 集成redis已全部配置完毕。