The Challenge of Distributed Session Management
When integrating a CAS (Central Authentication Service) client, a single-server deployment functions seamlessly. However, transitioning to a clustered environment behind a load balancer introduces critical synchronization issues related to session state.
During the CAS authentication callback, the client performs two primary actions: it stores the Ticket Granting Cookie (TGC) and establishes a mapping between the ticket ID and the user's HTTP session. By default, the CAS client utilizes an in-memory HashMap to store these relationships locally.
In a clustered scenario, this architecture fails. Consider a deployment with two nodes, Node A and Node B, accessed via a single domain managed by a load balancer. If the CAS callbcak is routed to Node A, the session mapping is stored in Node A's memory. If a subsequent request is routed to Node B, Node B possesses no knowledge of this mapping. This lack of shared state triggers a classic redirection loop:
- Node B: No authentication info found → Redirect to CAS Login.
- CAS Server: User is authenticated → Redirect back to App with ticket.
- Node B: Still no local mapping for the ticket → Redirect to CAS Login again.
This cycle continues indefinitely. To resolve this, the SessionMappingStorage implementation must be replaced with a distributed solution that persists data across the cluster, such as a relational database, Redis, or Memcached.
Implementing a Distributed Storage Strategy
The following implementation provides a flexible solution that supports both local storage (for development or QA environments) and distributed Redis storage (for staging and production). This approach ensures compatibility regardless of the deployment topology.
We will implement the SessionMappingStorage interface using a strategy pattern that toggles between a local ConcurrentHashMap and a Redis client based on configuration.
import org.jasig.cas.client.session.SessionMappingStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class HybridSessionStorage implements SessionMappingStorage {
private static final Logger logger = LoggerFactory.getLogger(HybridSessionStorage.class);
// Storage strategy enumeration
public enum StorageMode {
LOCAL, REDIS
}
private final Map<String, HttpSession> localSessionIndex = new ConcurrentHashMap<>();
private final Map<String, String> localReverseIndex = new ConcurrentHashMap<>();
private static final String REDIS_KEY_PREFIX = "cas:sso:mapping:";
private StorageMode currentMode = StorageMode.LOCAL;
private RedisService redisService;
public void setRedisService(RedisService redisService) {
this.redisService = redisService;
}
public void setCurrentMode(String modeStr) {
this.currentMode = StorageMode.valueOf(modeStr.toUpperCase());
}
@Override
public HttpSession removeSessionByMappingId(String ticketId) {
if (ticketId == null) return null;
HttpSession session = null;
if (currentMode == StorageMode.LOCAL) {
session = localSessionIndex.get(ticketId);
} else {
String sessionKey = redisService.get(REDIS_KEY_PREFIX + ticketId);
// Note: In a real distributed scenario, deserializing HttpSession
// from Redis might require a custom serializer or storing just the Session ID.
// This assumes the RedisService handles object retrieval appropriately.
session = redisService.getObject(REDIS_KEY_PREFIX + sessionKey);
}
if (session != null) {
removeBySessionById(session.getId());
}
return session;
}
@Override
public void removeBySessionById(String sessionId) {
logger.debug("Invalidating session mapping for Session ID: [{}]", sessionId);
String ticketId = null;
if (currentMode == StorageMode.LOCAL) {
ticketId = localReverseIndex.get(sessionId);
} else {
ticketId = redisService.get(REDIS_KEY_PREFIX + sessionId);
}
if (ticketId != null) {
logger.debug("Mapping found for session. Removing ticket [{}].", ticketId);
if (currentMode == StorageMode.LOCAL) {
localSessionIndex.remove(ticketId);
localReverseIndex.remove(sessionId);
} else {
redisService.delete(REDIS_KEY_PREFIX + sessionId);
redisService.delete(REDIS_KEY_PREFIX + ticketId);
}
} else {
logger.debug("No mapping found for session ID [{}]. No action taken.", sessionId);
}
}
@Override
public void addSessionById(String ticketId, HttpSession session) {
String sessionId = session.getId();
if (currentMode == StorageMode.LOCAL) {
localReverseIndex.put(sessionId, ticketId);
localSessionIndex.put(ticketId, session);
} else {
// Persist the bidirectional mapping in Redis
redisService.set(REDIS_KEY_PREFIX + sessionId, ticketId);
// Assuming serialization of HttpSession is handled or we store a proxy
redisService.setObject(REDIS_KEY_PREFIX + ticketId, session);
}
}
}
Spring Configuration
To integrate this custom storage mechanism, we must inject it into the SingleSignOutFilter. The configuration below uses Spring XML to define the beans and toggle the storage mode via a placeholder property.
<!-- Define the custom storage implementation -->
<bean id="distributedSessionStorage" class="com.example.cas.storage.HybridSessionStorage">
<property name="redisService" ref="redisTemplate"/>
<!-- Use 'LOCAL' for Dev/QA, 'REDIS' for Stage/Prod -->
<property name="currentMode" value="${app.security.cas.storage.mode}"/>
</bean>
<!-- Inject the storage into the CAS Single Sign Out Filter -->
<bean id="casSingleSignOutFilter" class="org.jasig.cas.client.session.SingleSignOutFilter">
<property name="sessionMappingStorage" ref="distributedSessionStorage"/>
</bean>
This configuration ensures that session mappings are consistent across all nodes in the cluster, eliminating the redirect loop and enabling reliable Single Sign-Out (SSO) behavior in a distributed environment.