目录
前言
在 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 的缓存配置从方案来看是类似的,主要就是替换了关于数据库查这块的认证授权实现。