本文已参与「新人创作礼」活动,一起开启掘金创作之路
目录
前言
在java开发中一般的安全开发框架比较常用的就是shiro和spring security。shiro的特点就是轻量级,上手比较简单,在网上的文档比较多;spring security的功能比较强大,属于spring 生态下的一个组件能很好的和spring旗下别的组件进行搭配,尤其是在目前微服务架构比较火热的大环境下,spring security天然就能融入spring cloud的微服务治理架构的优势就愈发的明显。
在shiro中比较广泛使用的一种提升性能的方式是将权限缓存起来,GitHub上大家比较通用的一个就是org.crazycake的shiro-redis插件,它很好的将shiro和redis缓存组合了起来,而且redis方便横向扩展,在分布式缓存的优势比较明显。那么同样是安全框架spring security能否有一个将认证权限和缓存组合起来的解决方案呢?
之前spring security的认证权限流程
目前前后端分离的开发模式比较通用,默认的情况下spring security通过SecurityContextHolder组件来保存登录用户的信息,那么在没有依赖外部载体的情况下这些信息都是和session挂钩保存在内存中的。在shiro前后端分离的项目中有一种做法是重写sessionId,即使用一个token这个token的值就是用户第一次认证成功的sessionId,那么就能在前后端分离的项目中保证sessionid的不变,保证了用户的认证状态。
spring secutity在前后端分离的模式下认证状态的保持一般比较通用的做法是使用jwt(当然普通的token也可以),即将认证信息于请求头携带。首先创建一个过滤器并且继承于OncePerRequestFilter,这个过滤器确保在一次请求中,过滤器只通过一次不重复执行
然后可以根据header中所携带的信息获取用户的认证信息
private void authUser(String username,HttpServletRequest request){
//加载主体
UserDetails userDetails = selfUserDetailService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
这里的代码就是通过我们重写的userDetailSerivce,调用loadUserByUsername方法获取用户的认证和授权信息
然后因为这个过滤器只拦截认证后的请求,也就是不需要校验密码,所以在生成UsernamePasswordAuthenticationToken时可以不设置密码。然后回到我们spring security的配置类中在configure方法中加上一句
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
表示在UsernamePasswordAuthenticationFilter这个认证过滤器之前加一个我们自定义的过滤器,因为我们在之前已经手动对用户进行了认证那么之后的认证过滤器是可以通过放行的。到此spring security 的认证流程结束了。
那么问题来了,可以看到在我们自定义的过滤器中,每一次的请求都会导致调用userDetailService的loadUserByUsername方法。而这个方法的内部实现是需要从数据库中获取用户信息和授权信息,这就导致每次请求会需要额外调用数据库,造成一个性能问题。
解决的方案一般来说就是构建缓存。
使用缓存构建spring security的认证和授权
首先引入redis的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
然后我们从UserDetailsService这个接口入手,可以发现UserDetailsService下有个类CachingUserDetailsService实现了该接口通过名称可以判断这个是一个结合缓存的UserDetaisService实现
public class CachingUserDetailsService implements UserDetailsService {
private UserCache userCache = new NullUserCache();
private final UserDetailsService delegate;
public CachingUserDetailsService(UserDetailsService delegate) {
this.delegate = delegate;
}
public UserCache getUserCache() {
return this.userCache;
}
public void setUserCache(UserCache userCache) {
this.userCache = userCache;
}
public UserDetails loadUserByUsername(String username) {
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
user = this.delegate.loadUserByUsername(username);
}
Assert.notNull(user, () -> {
return "UserDetailsService " + this.delegate + " returned null for username " + username + ". This is an interface contract violation";
});
this.userCache.putUserInCache(user);
return user;
}
}
这里的实现也比较简单,首先重写了loadUserByUsername方法,在方法中先从缓存中获取用户的认证授权信息,如果没有获取到那么再从原先的userDetailsService中获取。所以我们只要继承这个CachingUserDetailsService即可。
@Slf4j
public class SelfUserDetailService extends CachingUserDetailsService {
@Autowired
private UserDao userDao;
@Autowired
private SpringCacheBasedUserCache userCache;
public SelfUserDetailService(UserDetailsService delegate) {
super(delegate);
}
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
UserDetails userFromCache = userCache.getUserFromCache(userName);
if (null != userFromCache){
return userFromCache;
}
SecurityUserPO userPO = userDao.queryUserByName(userName);
if (null == userPO){
log.warn("not found user:[{}]",userName);
throw new UsernameNotFoundException("账号不存在");
}
if (!userPO.isAccountNonLocked()){
log.warn("user has been locked :[{}]",userName);
throw new LockedException("账号被锁定");
}
Set<RolePO> rolePOSet = new HashSet<>(userDao.getAllRoles(userPO.getUserId()));
userPO.setAllRoles(rolePOSet);
Set<PermissionPO> permissionPOSet = new HashSet<>(userDao.getAllPermission(rolePOSet.stream()
.map(RolePO::getRoleId).collect(Collectors.toList())));
userPO.setAllPermissions(permissionPOSet);
Collection<? extends GrantedAuthority> authorities = userPO.getAuthorities();
User user = new User(userName, userPO.getPassword(), authorities);
SelfUser selfUser = new SelfUser(user);
userCache.putUserInCache(selfUser);
return user;
}
}
这里分几步
-
声明构造方法,因为当缓存失效时,我们需要通过原先的UserDetailsService来从数据库中或用户的信息。
-
在父类中可以看到使用了一个UserCache的实现,UserCache总共有三个实现分别是
EhCacheBasedUserCache、NullUserCache、SpringCacheBasedUserCache。这里我们选择第三个,里面提供的是缓存的存取和删除的方法。 -
我们可以按照不同的业务逻辑去重写我们新的loadUserByUsername方法,但是有一点是不变的,就是在方法的开头我们需要先判断缓存中是否有我们需要的信息,如果有我们可以直接使用,如果没有那么我们需要从数据库中获取后返回给上一层,同时将这份信息存入缓存中。
下面就是配置工作了,我们回到spring security这个配置类中这里会复杂一点,首先我们先看下旧的UserDetailsService的配置是怎么样的。
@Bean
public SelfUserDetailService selfUserDetailService() {
return new SelfUserDetailService();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(selfUserDetailService())
.passwordEncoder(passwordEncoder());
}
我们直接将我们自定义的userDetailService创建成了spring的一个容器,然后注入到授权认证的配置中,即告诉了spring security我们需要使用我们自己配置的认证实现。
那么首先需要修改的就是selfUserDetailService容器,因为这个时候它的构造方法多了一个参数,这个参数是在缓存失效时起作用的
@Override
protected UserDetailsService userDetailsService() {
return super.userDetailsService();
}
@Bean
public SelfUserDetailService selfUserDetailService() {
return new SelfUserDetailService(userDetailsService());
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(selfUserDetailService())
.passwordEncoder(passwordEncoder());
}
这里首先重写我们这个配置类的userDetailsService,没有特殊需要可以直接使用父类的。然后我们改造我们之前的构造方法,增加一个UserDetailsService参数,这个构造器在我们自定义的UserDetailsService中已经写明了。这样UserDetailService的重写就完成了
下面是配置缓存,我们需要达到两个目的,
- 完成缓存的配置,例如这次是使用的redis,那就需要列出一系列redis的地址,序列化反序列化配置;如果是EhCache,那么也要列出对应的参数配置。
- 完成缓存和userDetailsService的绑定,之前我们在自定义的UserDetailsService中已经声明了这个UserCache,但是我们还没有具体的指定这个缓存由谁来实现。
首先我们需要创建一个 SpringCacheBasedUserCache的bean,这个的构造方法需要注入一个Cache类型的参数。因为我们是使用的redis,那么对应Cache的是实现就是RedisCache,也就是我们需要实例化一个RedisCache。下面这个是它的构造方法
protected RedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfiguration cacheConfig) {
super(cacheConfig.getAllowCacheNullValues());
Assert.notNull(name, "Name must not be null!");
Assert.notNull(cacheWriter, "CacheWriter must not be null!");
Assert.notNull(cacheConfig, "CacheConfig must not be null!");
this.name = name;
this.cacheWriter = cacheWriter;
this.cacheConfig = cacheConfig;
this.conversionService = cacheConfig.getConversionService();
}
我们一般使用RedisManager来创建RedisCache。可以看到在RedisManager中有一个创建RedisCache的方法
protected RedisCache createRedisCache(String name, @Nullable RedisCacheConfiguration cacheConfig) {
return new RedisCache(name, this.cacheWriter, cacheConfig != null ? cacheConfig : this.defaultCacheConfig);
}
这个比之前的少了一个参数,只需要提供缓存key,redis的配置即可。因此我们创建一个类来继承这个RedisManager
public class SelfRedisCacheManager extends RedisCacheManager {
public SelfRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
super(cacheWriter, defaultCacheConfiguration);
}
@Override
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
return super.createRedisCache(name, cacheConfig);
}
}
如果没有特殊需求的话就默认实现父类的方法。然后将这个类和spring security的配置类放在同一个包下。配置类中代码如下
private RedisCacheConfiguration redisCacheConfiguration(){
RedisSerializationContext.SerializationPair<Object> pair = RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer());
return RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(pair);
}
@Bean
public SelfRedisCacheManager selfRedisCacheManager(LettuceConnectionFactory connectionFactory){
return new SelfRedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory),this.redisCacheConfiguration());
}
@Bean
public RedisCache redisCache(LettuceConnectionFactory connectionFactory){
return selfRedisCacheManager(connectionFactory).createRedisCache("redis-user-cache",this.redisCacheConfiguration());
}
@Bean
public SpringCacheBasedUserCache springCacheBasedUserCache(LettuceConnectionFactory connectionFactory){
return new SpringCacheBasedUserCache(redisCache(connectionFactory));
}
- 创建一个RedisCacheConfiguration的方法设置序列化参数,那么启动后会读取spring yml文件中的redis配置。
- 创建一个自定义的RedisManager
- 通过自定义个RedisManager创建一个前缀为redis-user-cache的缓存
- 将构造的缓存注入到SpringCacheBaseUserCache
这个时候我们再使用请求的话,日志上就不会打印出请求数据库的日志了。
总结
其实spring security和shiro的缓存配置从方案来看是类似的,主要就是替换了关于数据库查这块的认证授权实现。