目录
一、引入依赖及原理简述
二、多用户维护配置
三、基础自定义配置
四、前后端分离及登录结果管理> 五、角色权限管理基础
从此处开始,为新的原创内容,相关数据结构代码换了一套新的,与之前的代码关系不大了。
建议新建一个项目,将配置文件复制过来,然后按照步骤走。
六、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
- 引入依赖:
1 2 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):
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
|
- 配置
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
|
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 实现缓存同步]
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
|
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
类:
1 2 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; }
}
|
二、登录时抽离用户数据,访问时配置数据到线程
- 修改登录逻辑:
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);
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
拦截逻辑:
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
|
@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
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
|
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;
|
三、创建线程用户提取工具
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
|
@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
的有效时间。
- 创建登出接口及方法:
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); }
|
jwt
拦截逻辑添加对弃用token
的处理:
1 2 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
类中禁用原生登出方法:
1 2 3
| http.logout(AbstractHttpConfigurer::disable)
|
至此,Spring Security 集成redis
已全部配置完毕。