Implementing Redis-Based Global Session Mapping for CAS Single Sign-Out in Clustered Environments
In a distributed environment where multiple CAS client instances sit behind a load balancer like Nginx, implementing Single Sign-Out (SLO) presents a significant challenge. By default, the CAS client uses an in-memory HashMap to store the relationship between Service Tickets (ST) and Session IDs. When the CAS server sends a logout request to a cliant node, the load balancer may route that request to an instance that did not originally create the session. Consequently, the local map lookup fails, and the user remains logged in on other nodes.
To resolve this, the session mapping must be externalized to a shared store like Redis. This ensures that any node in the cluster can identify and invalidate the correct session upon receiving a logout signal.
Dependency Configuration
To implement this solution, ensure your project includes the following dependencies (or compatible versions):
spring-data-redis-1.7.4.RELEASEjedis-2.9.0fastjson-1.2.31
Custom Redis Session Mapping Storage
The following implementation replaces the default HashMapBackedSessionMappingStorage. It persists the bidirectional mapping between CAS tickets and Web sessions into a Redis cluster.
package com.distributed.cas.client.session;
import org.jasig.cas.client.session.SessionMappingStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import javax.servlet.http.HttpSession;
import java.util.concurrent.TimeUnit;
public final class RedisSessionMappingStore implements SessionMappingStorage {
private final Logger logger = LoggerFactory.getLogger(getClass());
private static final String KEY_SESSION_TO_TICKET = "CAS_STORE:SESS_TO_TKT:";
private static final String KEY_TICKET_TO_SESSION = "CAS_STORE:TKT_TO_SESS:";
private int expirySeconds = 86400; // 24 hours
private RedisTemplate<String, String> redisTemplate;
public RedisSessionMappingStore() {
ApplicationContext context = new ClassPathXmlApplicationContext("spring/redis-config.xml");
this.redisTemplate = (RedisTemplate<String, String>) context.getBean("redisTemplate");
}
@Override
public synchronized void addSessionById(String mappingId, HttpSession session) {
String sessionId = session.getId();
logger.debug("Mapping ticket {} to session {}", mappingId, sessionId);
try {
redisTemplate.boundValueOps(KEY_SESSION_TO_TICKET + sessionId).set(mappingId, expirySeconds, TimeUnit.SECONDS);
redisTemplate.boundValueOps(KEY_TICKET_TO_SESSION + mappingId).set(sessionId, expirySeconds, TimeUnit.SECONDS);
} catch (Exception e) {
logger.error("Error storing session mapping in Redis", e);
}
}
@Override
public synchronized void removeBySessionById(String sessionId) {
logger.debug("Invalidating session via ID: {}", sessionId);
String ticketKey = KEY_SESSION_TO_TICKET + sessionId;
String ticket = redisTemplate.opsForValue().get(ticketKey);
if (ticket != null) {
redisTemplate.delete(KEY_TICKET_TO_SESSION + ticket);
logger.debug("Removed ticket mapping for {}", ticket);
}
redisTemplate.delete(ticketKey);
}
@Override
public synchronized HttpSession removeSessionByMappingId(String mappingId) {
String sessionKey = KEY_TICKET_TO_SESSION + mappingId;
String sessionId = redisTemplate.opsForValue().get(sessionKey);
if (sessionId != null) {
removeBySessionById(sessionId);
// Note: Since HttpSession objects cannot be serialized easily across JVMs
// in standard CAS client filters, we trigger the manual removal logic here.
}
return null;
}
}
Redis Template Configuration
The RedisTemplate must be configured to handle string-based keys for the session mappings. Below is an example XML configuraton for a Redis Cluster environment.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd">
<context:property-placeholder location="classpath:redis-cluster.properties" />
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxIdle" value="${redis.maxIdle}" />
<property name="maxWaitMillis" value="${redis.maxWait}" />
<property name="testOnBorrow" value="true" />
</bean>
<bean id="redisClusterConfig" class="org.springframework.data.redis.connection.RedisClusterConfiguration">
<constructor-arg name="propertySource">
<bean class="org.springframework.core.io.support.ResourcePropertySource">
<constructor-arg value="classpath:redis-cluster.properties" />
</bean>
</constructor-arg>
</bean>
<bean id="jeidConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
<constructor-arg ref="redisClusterConfig" />
<constructor-arg ref="jedisPoolConfig" />
<property name="password" value="${redis.password}" />
<property name="timeout" value="${redis.timeout}" />
</bean>
<bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
<property name="connectionFactory" ref="jeidConnectionFactory" />
<property name="keySerializer">
<bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
</property>
<property name="valueSerializer">
<bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
</property>
</bean>
</beans>
Cluster Properties Definition
Configure your Redis nodes and connection pool settings in the redis-cluster.properties file. Ensure the cluster node list is complete for optimal redirection handling.
# Redis Pool Settings
redis.password=your_secure_password
redis.maxIdle=50
redis.maxWait=2000
redis.timeout=5000
# Redis Cluster Nodes
spring.redis.cluster.nodes=10.0.0.1:6379,10.0.0.2:6379,10.0.0.3:6379,10.0.0.1:6380,10.0.0.2:6380,10.0.0.3:6380
spring.redis.cluster.max-redirects=3
By integrating this RedisSessionMappingStore into your SingleSignOutFilter configuration, the logout requests from the CAS server will successfully locate and remove session identifiers regardless of which cluster node receives the callback.