目录
一、引入依赖及原理简述
二、多用户维护配置
三、基础自定义配置
四、前后端分离及登录结果管理> 五、角色权限管理基础
从此处开始,为新的原创内容,相关数据结构代码换了一套新的,与之前的代码关系不大了。
建议新建一个项目,将配置文件复制过来,然后按照步骤走。
六、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要比只存id的token长上数倍,而且每次访问接口都要现场解析复杂的token,浪费性能。更何况解析的时候往往产生序列相关的异常,总之就是十分麻烦。
我们可以只在token内存放id,登录的时候,不仅生成token,同时将用户数据存入redis,设id为键,这样每次访问接口,只需要快速解析出id,就能从redis获取用户数据,同时解决了复杂解析和序列化的两大难题,对于近些年来的后端程序,redis近乎是必备。
这么讲,集成redis的目的就很明确了:将原本和线程一并存储的用户数据分离,需要的时候在调用。
一、配置redis
- 引入依赖:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-data-redis</artifactId>
 <version>3.3.3</version>
 </dependency>
 
 <dependency>
 <groupId>com.alibaba</groupId>
 <artifactId>fastjson</artifactId>
 <version>2.0.37</version>
 </dependency>
 
 | 
- 编辑配置文件(application.yml):
| 12
 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
 
 | 
- 配置redis工具类:
[!IMPORTANT]
DatabaseException()是我自定义的异常类,交由【全局异常管理】监听,读者可按需配置。
详见这篇文章:Spring Boot 全局异常拦截配置
| 12
 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
 
 | 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 public void set(String key, Object value) {
 this.setBySetTime(key, value, 3, TimeUnit.MINUTES);
 }
 
 
 
 
 
 
 
 
 
 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);
 }
 
 
 
 
 
 
 
 
 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;
 }
 
 
 
 
 
 
 
 public boolean hasKey(String key) {
 return Boolean.TRUE.equals(redisTemplate.hasKey(key));
 }
 
 
 
 
 
 
 public void delete(String key) {
 if (!hasKey(key)) throw new DatabaseException("缓存数据不存在:" + key);
 redisTemplate.delete(key);
 log.debug("删除缓存数据:{}", key);
 }
 }
 
 | 
- 配置序列化,供redis使用
[!IMPORTANT]
一般情况下是不需要序列化的,设置泛型为RedisTemplate<String, Object>,手动强转类型即可。
我有原创的监听Mybatis-Plus与Redis实现缓存同步的构造,必须使用双String的方式实现,故配置序列化。
对应文章传送门:[Mybatis-Plus 与 Redis 实现缓存同步]
| 12
 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
 
 | 
 
 
 
 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());
 }
 }
 
 
 
 
 
 
 @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);
 }
 }
 
 | 
- 配置redis的Config类:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 
 | 
 
 
 
 @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;
 }
 
 }
 
 | 
二、登录时抽离用户数据,访问时配置数据到线程
- 修改登录逻辑:
| 12
 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);
 
 
 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);
 }
 }
 
 | 
- 修改jwt拦截逻辑:
| 12
 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
 
 | 
 
 
 
 @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;
 }
 
 
 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);
 }
 }
 
 
 | 
- 维护权限的反序列化
[!IMPORTANT]
因为GrantedAuthority为一个接口,需要为其特定维护,一般指定为SimpleGrantedAuthority
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 
 | 
 
 
 
 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;
 }
 }
 
 
 @TableField(exist = false)
 @JsonDeserialize(using = GrantedAuthorityDeserializer.class)
 private List<GrantedAuthority> authorities;
 
 
 | 
三、创建线程用户提取工具
| 12
 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
 
 | 
 
 
 
 @Component
 @RequiredArgsConstructor
 public class SecurityUtils {
 private final UserService userService;
 private final RedisUtils redisUtils;
 
 
 
 
 
 
 public static Long getCurrentId() {
 Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
 if (authentication != null && authentication.isAuthenticated()) {
 return (Long) authentication.getPrincipal();
 }
 return null;
 }
 
 
 
 
 
 
 public String getCurrentUsername() {
 return getCurrentUser().getUsername();
 }
 
 
 
 
 
 
 public User getCurrentUser() {
 return getCurrentUserById(getCurrentId());
 }
 
 
 
 
 
 
 
 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的有效时间。
- 创建登出接口及方法:
| 12
 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);
 }
 
 
 | 
- jwt拦截逻辑添加对弃用- token的处理:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | 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 已被弃用");
 }
 
 
 
 | 
- Config类中禁用原生登出方法:
| 12
 3
 
 | http.logout(AbstractHttpConfigurer::disable)
 
 
 | 
至此,Spring Security 集成redis已全部配置完毕。