前言:
开发的前后端分离的项目,技术选用SpringBoot,用SpringSecurity控制权限,而后整合了SpringSession - Redis,将Session放到redis支持集群部署。项目一直是没问题的,但最近需要做一个限制用户登录的功能,即单个用户同一时刻仅能一处登录,虽然SpringSecurity很好的支持了这个功能,但是这个功能对集成了SpringSession-Redis项目产生的化学反应,非常的精彩!
首先看一下SpringSecurity 官网限制用户登录使用(单机非Redis)
Spring Security Reference 请查阅10.12.2 Concurrent Session Control
再看一下SpringSession官网给出与SpringSecurity整合
参照官网,代码如下
/**
* @Description
* In order for our Filter to do its magic, Spring needs to load our Config class.
* Since our application is already loading Spring configuration by using our SecurityInitializer class,
* we can add our configuration class to it.
*/
public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
public SecurityInitializer() {
super(SecurityConfig.class);
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig<S extends Session> extends WebSecurityConfigurerAdapter {
@Value("${maple.auth.api.public}")
private String commonPublicPage;
@Value("${maple.auth.api.admin}")
private String adminPage;
@Value("${maple.auth.api.common-get}")
private String commonLoginGetPage;
@Value("${maple.auth.api.common-post}")
private String commonPostPage;
@Value("${maple.auth.api.common-put}")
private String commonPutPage;
// SpringSecurity 重写的 Provider (代码不贴了,涉及业务)
private AuthenticationProvider provider;
// SpringSecurity 验证成功Handler
private AuthenticationSuccessHandler authenticationSuccessHandler;
// SpringSecurity 验证成功Handler (代码不贴了,涉及业务)
private AuthenticationFailureHandler authenticationFailureHandler;
// SpringSecurity 登出成功Handler (代码不贴了,涉及业务)
private LogoutSuccessHandler logoutSuccessHandler;
// SpringSecurity 登录前过滤器 (业务需求,可忽略)
private BeforeLoginFilter beforeLoginFilter;
// SpringSecurity 判断Session过期过滤器,解决Sesssion失效请求拦截 (业务需求,可忽略)
private SessionFilter sessionFilter;
// 自定义Session失效策略
private CustomSessionInformationExpiredStrategy sessionInformationExpiredStrategy;
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(provider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and()
.addFilterBefore(beforeLoginFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(sessionFilter, BeforeLoginFilter.class)
.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), sessionInformationExpiredStrategy), ConcurrentSessionFilter.class)
//用重写的Filter替换掉原有的UsernamePasswordAuthenticationFilter
.addFilterAt(loginByJsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutUrl("/auth/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.invalidateHttpSession(true)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, commonLoginGetPage == null ? new String[]{} : commonLoginGetPage.split(",")).authenticated()
.antMatchers(HttpMethod.POST, commonPostPage == null ? new String[]{} : commonPostPage.split(",")).authenticated()
.antMatchers(HttpMethod.PUT, commonPutPage == null ? new String[]{} : commonPutPage.split(",")).authenticated()
// .antMatchers(HttpMethod.DELETE, commonDeletePage == null ? new String[]{} : commonDeletePage.split(",")).authenticated()
// .antMatchers(PUBLIC_PAGE).access(accessAttrGenerator.getPublicAccessIps())
.antMatchers(commonPublicPage == null ? new String[]{} : commonPublicPage.split(",")).permitAll()
.and()
/* 禁用CSRF */
.csrf().disable();
http.sessionManagement()
// 同一个账号只能在一个地方登陆
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry());
}
@Autowired
private FindByIndexNameSessionRepository<S> sessionRepository;
@Bean
public SpringSessionBackedSessionRegistry<S> sessionRegistry() {
return new SpringSessionBackedSessionRegistry<>(this.sessionRepository);
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
/**
* 注册自定义的UsernamePasswordAuthenticationFilter
*/
@Bean
LoginByJsonAuthenticationFilter loginByJsonAuthenticationFilter() throws Exception {
LoginByJsonAuthenticationFilter filter = new LoginByJsonAuthenticationFilter();
filter.setFilterProcessesUrl("/auth/login");
filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
filter.setAuthenticationFailureHandler(authenticationFailureHandler);
//重用WebSecurityConfigurerAdapter配置的AuthenticationManager
filter.setAuthenticationManager(authenticationManagerBean());
//session并发控制,因为默认的并发控制方法是空方法.这里必须自己配置一个
filter.setSessionAuthenticationStrategy(new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry()));
return filter;
}
@Autowired
public void setProvider(UserPwdAuthenticationProvider provider) {
this.provider = provider;
}
@Autowired
public void setAuthenticationSuccessHandler(UserPwdAuthenticationSuccessHandler authenticationSuccessHandler) {
this.authenticationSuccessHandler = authenticationSuccessHandler;
}
@Autowired
public void setAuthenticationFailureHandler(UserPwdAuthenticationFailureHandler authenticationFailureHandler) {
this.authenticationFailureHandler = authenticationFailureHandler;
}
@Autowired
public void setLogoutSuccessHandler(UserPwdLogoutSuccessHandler logoutSuccessHandler) {
this.logoutSuccessHandler = logoutSuccessHandler;
}
/*@Autowired
public void setAccessAttrGenerator(AccessAttrGenerator accessAttrGenerator) {
this.accessAttrGenerator = accessAttrGenerator;
}*/
@Autowired
public void setBeforeLoginFilter(BeforeLoginFilter beforeLoginFilter) {
this.beforeLoginFilter = beforeLoginFilter;
}
@Autowired
public void setSessionFilter(SessionFilter sessionFilter) {
this.sessionFilter = sessionFilter;
}
@Autowired
public void setSessionInformationExpiredStrategy(CustomSessionInformationExpiredStrategy sessionInformationExpiredStrategy) {
this.sessionInformationExpiredStrategy = sessionInformationExpiredStrategy;
}
}
@Configuration
public class RedisConfiguration extends CachingConfigurerSupport {
//过期时间30分钟
private Duration timeToLive = Duration.ofMinutes(30);
@Value("${redis.host}")
private String redisHost;
@Value("${redis.port}")
private int redisPort;
@Value("${redis.password}")
private String redisPassword;
@Value("${redis.database}")
private int redisDatabase;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
config.setDatabase(redisDatabase);
config.setPassword(RedisPassword.of(redisPassword));
return new JedisConnectionFactory(config);
}
/**
* 注册自定义的 redisCacheManager; 实现自定义JSON 缓存序列化机制
* @param connectionFactory
* @return
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(timeToLive) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory).cacheDefaults(config).build();
}
@Bean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(keySerializer());
redisTemplate.setHashKeySerializer(keySerializer());
redisTemplate.setValueSerializer(valueSerializer());
redisTemplate.setHashValueSerializer(valueSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
private RedisSerializer keySerializer() {
return new StringRedisSerializer();
}
private RedisSerializer valueSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
@EnableRedisHttpSession
@Configuration
public class SpringSessionRedisConfiguration extends AbstractHttpSessionApplicationInitializer {
/**
* 设置 SameSite 为null,解决整合SpringSession, Session不一致问题
* @return
*/
@Bean
public CookieSerializer httpSessionIdResolver() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
// 取消same site
cookieSerializer.setSameSite(null);
return cookieSerializer;
}
}
@Component
public class CustomSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
private JsonUtils jsonUtils;
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
HttpServletResponse response = event.getResponse();
DetailResponse<Object> detailResponse = new DetailResponse<>();
// SESSION失效
detailResponse.setResult(GlobalResponseCode.SESSION_EXPIRED);
jsonUtils.writeJSONResponse(response, detailResponse);
}
@Autowired
public void setJsonUtils(JsonUtils jsonUtils) {
this.jsonUtils = jsonUtils;
}
}
@Component
public class UserPwdAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private JsonUtils jsonUtils;
private WebResourceService webResourceService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
User user = (User) authentication.getPrincipal();
// 用户信息放入session
request.getSession().setAttribute(CommonConstant.SESSION_CURR_USER, user);
// 用户名密码验证通过后, 将userNamr放入Session, key固定,用于框架解析(此为集群版配置)
request.getSession().setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, user.getUrName());
DetailResponse<UserResponse> rsResponse = new DetailResponse<>(GlobalConstant.SUCCESS);
jsonUtils.writeJSONResponse(response, rsResponse);
}
@Autowired
public void setJsonUtils(JsonUtils jsonUtils) {
this.jsonUtils = jsonUtils;
}
}
启动项目,报错
Surprise! 官网的示例报错了! 开始google、stackoverflow上搜,围着这个问题看了一圈,无果后开始查看源码;
FindByIndexNameSessionRepository
的实现类
它要RedisTemplate,但是我再IOC里注入了RedisTemplate
和RedisConnectionFactory
,但是它没有获取到,OK,我手动给你SET,SecurityConfig
配置修改如下:
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
// 集群使用sessionRegistry
@Bean
public SpringSessionBackedSessionRegistry sessionRegistry() {
RedisOperationsSessionRepository redisOperationsSessionRepository = new RedisOperationsSessionRepository(redisTemplate);
return new SpringSessionBackedSessionRegistry(redisOperationsSessionRepository);
}
OK, 项目起来了,运行项目,登录又报错了!
反序列化失败了! 为啥? 正常使用Redis Value的序列化推荐使用JSON格式、因为Jdk序列方式会添加很多不必要的东西,很浪费,可是使用GenericJackson2JsonRedisSerializer
, 却总是报错序列化错误,网上又开始了查找 … 很多人说是ObjectMapper 在read的时候要设置,于是RedisSerializer 修改如下:
private RedisSerializer<Object> valueSerializer() {
// 反序列化问题
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
mapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);
mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true);
mapper.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, true);
mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) ;
mapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return new GenericJackson2JsonRedisSerializer(mapper);
}
嗯,一如既往的报错,于是我开始了研究,各种google、 Bing ,在反序列化的时候,总是报错,我尝试了将 key 的序列化修改,但是在SpringSecurity Redis中读FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME
时就一直读不到,序列化进去后,无法读取出来,就导致限制用户登录无法实现,而后又尝试使用Jackson2JsonRedisSerializer
,但它内部使用的也是ObjectMapper, 答案也是无果。
重新阅读官网,结合查阅的博客,可以通过springSessionDefaultRedisSerializer
来控制他默认的序列化机制, Redis配置修改如下:
@Configuration
public class RedisConfiguration extends CachingConfigurerSupport {
//过期时间30分钟
private Duration timeToLive = Duration.ofMinutes(30);
@Value("${redis.host}")
private String redisHost;
@Value("${redis.port}")
private int redisPort;
@Value("${redis.password}")
private String redisPassword;
@Value("${redis.database}")
private int redisDatabase;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
config.setDatabase(redisDatabase);
config.setPassword(RedisPassword.of(redisPassword));
return new JedisConnectionFactory(config);
}
/**
* 自定义key. 这个可以不用
* 此方法将会根据类名+方法名+所有参数的值生成唯一的一个key,即使@Cacheable中的value属性一样,key也会不一样。
*/
@Override
public KeyGenerator keyGenerator() {
return (o, method, objects) -> {
StringBuilder sb = new StringBuilder();
sb.append(o.getClass().getName());
sb.append(method.getName());
for (Object obj : objects) {
sb.append(obj.toString());
}
return sb.toString();
};
}
/**
* 注册自定义的 redisCacheManager; 实现自定义JSON 缓存序列化机制
*
* @param connectionFactory
* @return
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(timeToLive) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory).cacheDefaults(config).build();
}
@Bean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setDefaultSerializer(valueSerializer());
redisTemplate.setKeySerializer(keySerializer());
redisTemplate.setHashKeySerializer(keySerializer());
redisTemplate.setValueSerializer(valueSerializer());
redisTemplate.setHashValueSerializer(valueSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
private RedisSerializer keySerializer() {
return new StringRedisSerializer();
}
@Bean(name = {"defaultRedisSerializer", "springSessionDefaultRedisSerializer"})
public RedisSerializer<Object> valueSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
又报错了
很明显,序列化的时候懒加载无限循环导致内存爆了,用户模块使用的是SpringData JPA,JPA 和Hibernate 虽然如出一辙,但是JPA的懒加载机制和Hibernate 不同,这边后面的问题就好解决了,但是比较急,没有再往后看,这个问题肯定是可以解决的,我换了另一种方式, 将GenericJackson2JsonRedisSerializer 修改为 JdkSerializationRedisSerializer
,问题解决了,没错,就是它,他会有更多的消耗,这是临时解决方案,当然如果你项目不大,完全可以忽略这个问题,这就是最终解决方案,但是还是推荐大家把懒加载问题解决了,对Hibernate、JPA 或 MybatisPlus的解决方案,相信大家都能找到
最后Redis配置中 - valueSerializer 配置修改如下:
@Bean(name = {"defaultRedisSerializer", "springSessionDefaultRedisSerializer"})
public RedisSerializer<Object> valueSerializer() {
return new JdkSerializationRedisSerializer();
}