KEYCLOAK-7421 Support SAML cluster logout for Elytron SAML adapter

This commit is contained in:
Hynek Mlnarik 2018-11-08 14:08:29 +01:00 committed by Hynek Mlnařík
parent cd96d6cc35
commit 7703d81389
17 changed files with 706 additions and 47 deletions

View file

@ -75,6 +75,14 @@
<groupId>org.wildfly.security</groupId>
<artifactId>wildfly-elytron</artifactId>
</dependency>
<dependency>
<groupId>org.infinispan</groupId>
<artifactId>infinispan-core</artifactId>
</dependency>
<dependency>
<groupId>org.infinispan</groupId>
<artifactId>infinispan-cachestore-remote</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>

View file

@ -44,6 +44,7 @@ import org.keycloak.adapters.spi.AuthenticationError;
import org.keycloak.adapters.spi.HttpFacade;
import org.keycloak.adapters.spi.LogoutError;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import org.wildfly.security.auth.callback.AnonymousAuthorizationCallback;
import org.wildfly.security.auth.callback.AuthenticationCompleteCallback;
import org.wildfly.security.auth.callback.SecurityIdentityCallback;
@ -68,16 +69,16 @@ class ElytronHttpFacade implements HttpFacade {
private boolean restored;
private SamlSession samlSession;
public ElytronHttpFacade(HttpServerRequest request, SessionIdMapper idMapper, SamlDeploymentContext deploymentContext, CallbackHandler handler) {
public ElytronHttpFacade(HttpServerRequest request, SessionIdMapper idMapper, SessionIdMapperUpdater idMapperUpdater, SamlDeploymentContext deploymentContext, CallbackHandler handler) {
this.request = request;
this.deploymentContext = deploymentContext;
this.callbackHandler = handler;
this.responseConsumer = response -> {};
this.sessionStore = createTokenStore(idMapper);
this.sessionStore = createTokenStore(idMapper, idMapperUpdater);
}
private SamlSessionStore createTokenStore(SessionIdMapper idMapper) {
return new ElytronSamlSessionStore(this, idMapper, getDeployment());
private SamlSessionStore createTokenStore(SessionIdMapper idMapper, SessionIdMapperUpdater idMapperUpdater) {
return new ElytronSamlSessionStore(this, idMapper, idMapperUpdater, getDeployment());
}
void authenticationComplete(SamlSession samlSession) {

View file

@ -18,13 +18,10 @@
package org.keycloak.adapters.saml.elytron;
import java.net.URI;
import java.security.Principal;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.jboss.logging.Logger;
import org.keycloak.adapters.saml.SamlDeployment;
@ -32,6 +29,7 @@ import org.keycloak.adapters.saml.SamlSession;
import org.keycloak.adapters.saml.SamlSessionStore;
import org.keycloak.adapters.saml.SamlUtil;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.wildfly.security.http.HttpScope;
import org.wildfly.security.http.Scope;
@ -45,13 +43,15 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
public static final String SAML_REDIRECT_URI = "SAML_REDIRECT_URI";
private final SessionIdMapper idMapper;
private final SessionIdMapperUpdater idMapperUpdater;
protected final SamlDeployment deployment;
private final ElytronHttpFacade exchange;
public ElytronSamlSessionStore(ElytronHttpFacade exchange, SessionIdMapper idMapper, SamlDeployment deployment) {
public ElytronSamlSessionStore(ElytronHttpFacade exchange, SessionIdMapper idMapper, SessionIdMapperUpdater idMapperUpdater, SamlDeployment deployment) {
this.exchange = exchange;
this.idMapper = idMapper;
this.idMapperUpdater = idMapperUpdater;
this.deployment = deployment;
}
@ -81,10 +81,11 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
public void logoutAccount() {
HttpScope session = getSession(false);
if (session.exists()) {
log.debug("Logging out - current account");
SamlSession samlSession = (SamlSession)session.getAttachment(SamlSession.class.getName());
if (samlSession != null) {
if (samlSession.getSessionIndex() != null) {
idMapper.removeSession(session.getID());
idMapperUpdater.removeSession(idMapper, session.getID());
}
session.setAttachment(SamlSession.class.getName(), null);
}
@ -96,11 +97,12 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
public void logoutByPrincipal(String principal) {
Set<String> sessions = idMapper.getUserSessions(principal);
if (sessions != null) {
log.debugf("Logging out - by principal: %s", sessions);
List<String> ids = new LinkedList<>();
ids.addAll(sessions);
logoutSessionIds(ids);
for (String id : ids) {
idMapper.removeSession(id);
idMapperUpdater.removeSession(idMapper, id);
}
}
@ -109,12 +111,13 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
@Override
public void logoutBySsoId(List<String> ssoIds) {
if (ssoIds == null) return;
log.debugf("Logging out - by session IDs: %s", ssoIds);
List<String> sessionIds = new LinkedList<>();
for (String id : ssoIds) {
String sessionId = idMapper.getSessionFromSSO(id);
if (sessionId != null) {
sessionIds.add(sessionId);
idMapper.removeSession(sessionId);
idMapperUpdater.removeSession(idMapper, sessionId);
}
}
@ -126,6 +129,8 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
HttpScope scope = exchange.getScope(Scope.SESSION, id);
if (scope.exists()) {
log.debugf("Invalidating session %s", id);
scope.setAttachment(SamlSession.class.getName(), null);
scope.invalidate();
}
});
@ -138,6 +143,13 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
log.debug("session was null, returning null");
return false;
}
if (! idMapper.hasSession(session.getID())) {
log.debugf("Session %s has expired on some other node", session.getID());
session.setAttachment(SamlSession.class.getName(), null);
return false;
}
final SamlSession samlSession = (SamlSession)session.getAttachment(SamlSession.class.getName());
if (samlSession == null) {
log.debug("SamlSession was not in session, returning null");
@ -154,7 +166,7 @@ public class ElytronSamlSessionStore implements SamlSessionStore, ElytronTokeSto
HttpScope session = getSession(true);
session.setAttachment(SamlSession.class.getName(), account);
String sessionId = changeSessionId(session);
idMapper.map(account.getSessionIndex(), account.getPrincipal().getSamlSubject(), sessionId);
idMapperUpdater.map(idMapper, account.getSessionIndex(), account.getPrincipal().getSamlSubject(), sessionId);
}

View file

@ -0,0 +1,106 @@
/*
* Copyright 2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.adapters.saml.elytron;
import org.keycloak.adapters.saml.SamlSession;
import org.keycloak.adapters.spi.SessionIdMapper;
import java.util.Objects;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionAttributeListener;
import javax.servlet.http.HttpSessionBindingEvent;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;
import org.jboss.logging.Logger;
/**
*
* @author hmlnarik
*/
public class IdMapperUpdaterSessionListener implements HttpSessionListener, HttpSessionAttributeListener {
private static final Logger LOG = Logger.getLogger(IdMapperUpdaterSessionListener.class);
private final SessionIdMapper idMapper;
public IdMapperUpdaterSessionListener(SessionIdMapper idMapper) {
this.idMapper = idMapper;
}
@Override
public void sessionCreated(HttpSessionEvent hse) {
LOG.debugf("Session created");
HttpSession session = hse.getSession();
Object value = session.getAttribute(SamlSession.class.getName());
map(session.getId(), value);
}
@Override
public void sessionDestroyed(HttpSessionEvent hse) {
LOG.debugf("Session destroyed");
HttpSession session = hse.getSession();
unmap(session.getId(), session.getAttribute(SamlSession.class.getName()));
}
@Override
public void attributeAdded(HttpSessionBindingEvent hsbe) {
HttpSession session = hsbe.getSession();
if (Objects.equals(hsbe.getName(), SamlSession.class.getName())) {
LOG.debugf("Attribute added");
map(session.getId(), hsbe.getValue());
}
}
@Override
public void attributeRemoved(HttpSessionBindingEvent hsbe) {
HttpSession session = hsbe.getSession();
if (Objects.equals(hsbe.getName(), SamlSession.class.getName())) {
LOG.debugf("Attribute removed");
unmap(session.getId(), hsbe.getValue());
}
}
@Override
public void attributeReplaced(HttpSessionBindingEvent hsbe) {
HttpSession session = hsbe.getSession();
if (Objects.equals(hsbe.getName(), SamlSession.class.getName())) {
LOG.debugf("Attribute replaced");
unmap(session.getId(), hsbe.getValue());
map(session.getId(), session.getAttribute(SamlSession.class.getName()));
}
}
private void map(String sessionId, Object value) {
if (! (value instanceof SamlSession) || sessionId == null) {
return;
}
SamlSession account = (SamlSession) value;
idMapper.map(account.getSessionIndex(), account.getPrincipal().getSamlSubject(), sessionId);
}
private void unmap(String sessionId, Object value) {
if (! (value instanceof SamlSession) || sessionId == null) {
return;
}
SamlSession samlSession = (SamlSession) value;
if (samlSession.getSessionIndex() != null) {
idMapper.removeSession(sessionId);
}
}
}

View file

@ -27,7 +27,6 @@ import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import org.jboss.logging.Logger;
import org.keycloak.adapters.AdapterDeploymentContext;
import org.keycloak.adapters.saml.AdapterConstants;
import org.keycloak.adapters.saml.DefaultSamlDeployment;
import org.keycloak.adapters.saml.SamlConfigResolver;
@ -35,7 +34,17 @@ import org.keycloak.adapters.saml.SamlDeployment;
import org.keycloak.adapters.saml.SamlDeploymentContext;
import org.keycloak.adapters.saml.config.parsers.DeploymentBuilder;
import org.keycloak.adapters.saml.config.parsers.ResourceLoader;
import org.keycloak.adapters.saml.elytron.infinispan.InfinispanSessionCacheIdMapperUpdater;
import org.keycloak.adapters.spi.InMemorySessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import org.keycloak.saml.common.exceptions.ParsingException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.LinkedList;
import java.util.Objects;
/**
* <p>A {@link ServletContextListener} that parses the keycloak adapter configuration and set the same configuration
@ -50,8 +59,14 @@ public class KeycloakConfigurationServletListener implements ServletContextListe
protected static Logger log = Logger.getLogger(KeycloakConfigurationServletListener.class);
static final String ADAPTER_DEPLOYMENT_CONTEXT_ATTRIBUTE = SamlDeploymentContext.class.getName();
static final String ADAPTER_DEPLOYMENT_CONTEXT_ATTRIBUTE_ELYTRON = SamlDeploymentContext.class.getName() + ".elytron";
public static final String ADAPTER_DEPLOYMENT_CONTEXT_ATTRIBUTE = SamlDeploymentContext.class.getName();
public static final String ADAPTER_DEPLOYMENT_CONTEXT_ATTRIBUTE_ELYTRON = SamlDeploymentContext.class.getName() + ".elytron";
public static final String ADAPTER_SESSION_ID_MAPPER_ATTRIBUTE_ELYTRON = SessionIdMapper.class.getName() + ".elytron";
public static final String ADAPTER_SESSION_ID_MAPPER_UPDATER_ATTRIBUTE_ELYTRON = SessionIdMapperUpdater.class.getName() + ".elytron";
private final SessionIdMapper idMapper = new InMemorySessionIdMapper();
private SessionIdMapperUpdater idMapperUpdater = SessionIdMapperUpdater.DIRECT;
private Collection<AutoCloseable> toClose = new LinkedList<>();
@Override
public void contextInitialized(ServletContextEvent sce) {
@ -93,13 +108,23 @@ public class KeycloakConfigurationServletListener implements ServletContextListe
}
}
addTokenStoreUpdaters(servletContext);
servletContext.setAttribute(ADAPTER_DEPLOYMENT_CONTEXT_ATTRIBUTE, deploymentContext);
servletContext.setAttribute(ADAPTER_DEPLOYMENT_CONTEXT_ATTRIBUTE_ELYTRON, deploymentContext);
servletContext.setAttribute(ADAPTER_SESSION_ID_MAPPER_ATTRIBUTE_ELYTRON, idMapper);
servletContext.setAttribute(ADAPTER_SESSION_ID_MAPPER_UPDATER_ATTRIBUTE_ELYTRON, idMapperUpdater);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
for (AutoCloseable c : toClose) {
try {
c.close();
} catch (Exception e) {
log.warnf(e, "Exception while destroying servlet context");
}
}
}
private static InputStream getConfigInputStream(ServletContext context) {
@ -127,4 +152,64 @@ public class KeycloakConfigurationServletListener implements ServletContextListe
}
return new ByteArrayInputStream(json.getBytes());
}
public void addTokenStoreUpdaters(ServletContext servletContext) {
SessionIdMapperUpdater updater = this.idMapperUpdater;
try {
String idMapperSessionUpdaterClasses = servletContext.getInitParameter("keycloak.sessionIdMapperUpdater.classes");
if (idMapperSessionUpdaterClasses == null) {
return;
}
servletContext.addListener(new IdMapperUpdaterSessionListener(idMapper)); // This takes care of HTTP sessions manipulated locally
updater = SessionIdMapperUpdater.DIRECT;
for (String clazz : idMapperSessionUpdaterClasses.split("\\s*,\\s*")) {
if (! clazz.isEmpty()) {
if (Objects.equals("org.keycloak.adapters.saml.wildfly.infinispan.InfinispanSessionCacheIdMapperUpdater", clazz)) {
clazz = InfinispanSessionCacheIdMapperUpdater.class.getName(); // exchange wildfly/undertow for elytron one
}
updater = invokeAddTokenStoreUpdaterMethod(clazz, servletContext, updater);
if (updater instanceof AutoCloseable) {
toClose.add((AutoCloseable) updater);
}
}
}
} finally {
setIdMapperUpdater(updater);
}
}
private SessionIdMapperUpdater invokeAddTokenStoreUpdaterMethod(String idMapperSessionUpdaterClass, ServletContext servletContext,
SessionIdMapperUpdater previousIdMapperUpdater) {
try {
Class<?> clazz = servletContext.getClassLoader().loadClass(idMapperSessionUpdaterClass);
Method addTokenStoreUpdatersMethod = clazz.getMethod("addTokenStoreUpdaters", ServletContext.class, SessionIdMapper.class, SessionIdMapperUpdater.class);
if (! Modifier.isStatic(addTokenStoreUpdatersMethod.getModifiers())
|| ! Modifier.isPublic(addTokenStoreUpdatersMethod.getModifiers())
|| ! SessionIdMapperUpdater.class.isAssignableFrom(addTokenStoreUpdatersMethod.getReturnType())) {
log.errorv("addTokenStoreUpdaters method in class {0} has to be public static. Ignoring class.", idMapperSessionUpdaterClass);
return previousIdMapperUpdater;
}
log.debugv("Initializing sessionIdMapperUpdater class {0}", idMapperSessionUpdaterClass);
return (SessionIdMapperUpdater) addTokenStoreUpdatersMethod.invoke(null, servletContext, idMapper, previousIdMapperUpdater);
} catch (ClassNotFoundException | NoSuchMethodException | SecurityException ex) {
log.warnv(ex, "Cannot use sessionIdMapperUpdater class {0}", idMapperSessionUpdaterClass);
return previousIdMapperUpdater;
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
log.warnv(ex, "Cannot use {0}.addTokenStoreUpdaters(DeploymentInfo, SessionIdMapper) method", idMapperSessionUpdaterClass);
return previousIdMapperUpdater;
}
}
public SessionIdMapperUpdater getIdMapperUpdater() {
return idMapperUpdater;
}
protected void setIdMapperUpdater(SessionIdMapperUpdater idMapperUpdater) {
this.idMapperUpdater = idMapperUpdater;
}
}

View file

@ -31,7 +31,9 @@ import org.keycloak.adapters.saml.SamlDeploymentContext;
import org.keycloak.adapters.spi.AuthChallenge;
import org.keycloak.adapters.spi.AuthOutcome;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import org.wildfly.security.http.HttpAuthenticationException;
import org.wildfly.security.http.HttpScope;
import org.wildfly.security.http.HttpServerAuthenticationMechanism;
import org.wildfly.security.http.HttpServerRequest;
import org.wildfly.security.http.Scope;
@ -48,12 +50,14 @@ class KeycloakHttpServerAuthenticationMechanism implements HttpServerAuthenticat
private final CallbackHandler callbackHandler;
private final SamlDeploymentContext deploymentContext;
private final SessionIdMapper idMapper;
private final SessionIdMapperUpdater idMapperUpdater;
public KeycloakHttpServerAuthenticationMechanism(Map<String, ?> properties, CallbackHandler callbackHandler, SamlDeploymentContext deploymentContext, SessionIdMapper idMapper) {
public KeycloakHttpServerAuthenticationMechanism(Map<String, ?> properties, CallbackHandler callbackHandler, SamlDeploymentContext deploymentContext, SessionIdMapper idMapper, SessionIdMapperUpdater idMapperUpdater) {
this.properties = properties;
this.callbackHandler = callbackHandler;
this.deploymentContext = deploymentContext;
this.idMapper = idMapper;
this.idMapperUpdater = idMapperUpdater;
}
@Override
@ -72,7 +76,7 @@ class KeycloakHttpServerAuthenticationMechanism implements HttpServerAuthenticat
return;
}
ElytronHttpFacade httpFacade = new ElytronHttpFacade(request, idMapper, deploymentContext, callbackHandler);
ElytronHttpFacade httpFacade = new ElytronHttpFacade(request, getSessionIdMapper(request), getSessionIdMapperUpdater(request), deploymentContext, callbackHandler);
SamlDeployment deployment = httpFacade.getDeployment();
if (!deployment.isConfigured()) {
@ -138,6 +142,18 @@ class KeycloakHttpServerAuthenticationMechanism implements HttpServerAuthenticat
return this.deploymentContext;
}
private SessionIdMapper getSessionIdMapper(HttpServerRequest request) {
HttpScope scope = request.getScope(Scope.APPLICATION);
SessionIdMapper res = scope == null ? null : (SessionIdMapper) scope.getAttachment(KeycloakConfigurationServletListener.ADAPTER_SESSION_ID_MAPPER_ATTRIBUTE_ELYTRON);
return res == null ? this.idMapper : res;
}
private SessionIdMapperUpdater getSessionIdMapperUpdater(HttpServerRequest request) {
HttpScope scope = request.getScope(Scope.APPLICATION);
SessionIdMapperUpdater res = scope == null ? null : (SessionIdMapperUpdater) scope.getAttachment(KeycloakConfigurationServletListener.ADAPTER_SESSION_ID_MAPPER_UPDATER_ATTRIBUTE_ELYTRON);
return res == null ? this.idMapperUpdater : res;
}
protected void redirectLogout(SamlDeployment deployment, ElytronHttpFacade exchange) {
sendRedirect(exchange, deployment.getLogoutPage());
}

View file

@ -25,6 +25,8 @@ import javax.security.auth.callback.CallbackHandler;
import org.keycloak.adapters.saml.SamlDeploymentContext;
import org.keycloak.adapters.spi.InMemorySessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import org.jboss.logging.Logger;
import org.wildfly.security.http.HttpAuthenticationException;
import org.wildfly.security.http.HttpServerAuthenticationMechanism;
import org.wildfly.security.http.HttpServerAuthenticationMechanismFactory;
@ -34,7 +36,7 @@ import org.wildfly.security.http.HttpServerAuthenticationMechanismFactory;
*/
public class KeycloakHttpServerAuthenticationMechanismFactory implements HttpServerAuthenticationMechanismFactory {
private SessionIdMapper idMapper = new InMemorySessionIdMapper();
private final SessionIdMapper idMapper = new InMemorySessionIdMapper();
private final SamlDeploymentContext deploymentContext;
/**
@ -62,7 +64,8 @@ public class KeycloakHttpServerAuthenticationMechanismFactory implements HttpSer
mechanismProperties.putAll(properties);
if (KeycloakHttpServerAuthenticationMechanism.NAME.equals(mechanismName)) {
return new KeycloakHttpServerAuthenticationMechanism(properties, callbackHandler, this.deploymentContext, idMapper);
KeycloakHttpServerAuthenticationMechanism mech = new KeycloakHttpServerAuthenticationMechanism(properties, callbackHandler, this.deploymentContext, idMapper, SessionIdMapperUpdater.DIRECT);
return mech;
}
return null;

View file

@ -0,0 +1,130 @@
/*
* Copyright 2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.adapters.saml.elytron.infinispan;
import org.keycloak.adapters.saml.AdapterConstants;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import java.util.*;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.servlet.ServletContext;
import org.infinispan.Cache;
import org.infinispan.configuration.cache.CacheMode;
import org.infinispan.configuration.cache.Configuration;
import org.infinispan.manager.EmbeddedCacheManager;
import org.infinispan.persistence.manager.PersistenceManager;
import org.infinispan.persistence.remote.RemoteStore;
import org.jboss.logging.Logger;
/**
*
* @author hmlnarik
*/
public class InfinispanSessionCacheIdMapperUpdater {
private static final Logger LOG = Logger.getLogger(InfinispanSessionCacheIdMapperUpdater.class);
public static final String DEFAULT_CACHE_CONTAINER_JNDI_NAME = "java:jboss/infinispan/container";
public static SessionIdMapperUpdater addTokenStoreUpdaters(ServletContext servletContext, SessionIdMapper mapper, SessionIdMapperUpdater previousIdMapperUpdater) {
String containerName = servletContext.getInitParameter(AdapterConstants.REPLICATION_CONFIG_CONTAINER_PARAM_NAME);
String cacheName = servletContext.getInitParameter(AdapterConstants.REPLICATION_CONFIG_SSO_CACHE_PARAM_NAME);
// the following is based on https://github.com/jbossas/jboss-as/blob/7.2.0.Final/clustering/web-infinispan/src/main/java/org/jboss/as/clustering/web/infinispan/DistributedCacheManagerFactory.java#L116-L122
String contextPath = servletContext.getContextPath();
if (contextPath == null || contextPath.isEmpty() || "/".equals(contextPath)) {
contextPath = "/ROOT";
}
String deploymentSessionCacheName = contextPath;
if (containerName == null || cacheName == null) {
LOG.warnv("Cannot determine parameters of SSO cache for deployment {0}.", contextPath);
return previousIdMapperUpdater;
}
String cacheContainerLookup = DEFAULT_CACHE_CONTAINER_JNDI_NAME + "/" + containerName;
try {
EmbeddedCacheManager cacheManager = (EmbeddedCacheManager) new InitialContext().lookup(cacheContainerLookup);
Configuration ssoCacheConfiguration = cacheManager.getCacheConfiguration(cacheName);
if (ssoCacheConfiguration == null) {
Configuration cacheConfiguration = cacheManager.getCacheConfiguration(deploymentSessionCacheName);
if (cacheConfiguration == null) {
LOG.debugv("Using default configuration for SSO cache {0}.{1}.", containerName, cacheName);
ssoCacheConfiguration = cacheManager.getDefaultCacheConfiguration();
} else {
LOG.debugv("Using distributed HTTP session cache configuration for SSO cache {0}.{1}, configuration taken from cache {2}",
containerName, cacheName, deploymentSessionCacheName);
ssoCacheConfiguration = cacheConfiguration;
cacheManager.defineConfiguration(cacheName, ssoCacheConfiguration);
}
} else {
LOG.debugv("Using custom configuration of SSO cache {0}.{1}.", containerName, cacheName);
}
CacheMode ssoCacheMode = ssoCacheConfiguration.clustering().cacheMode();
if (ssoCacheMode != CacheMode.REPL_ASYNC && ssoCacheMode != CacheMode.REPL_SYNC) {
LOG.warnv("SSO cache mode is {0}, it is recommended to use replicated mode instead.", ssoCacheConfiguration.clustering().cacheModeString());
}
Cache<String, String[]> ssoCache = cacheManager.getCache(cacheName, true);
final SsoSessionCacheListener listener = new SsoSessionCacheListener(ssoCache, mapper);
ssoCache.addListener(listener);
addSsoCacheCrossDcListener(ssoCache, listener);
LOG.debugv("Added distributed SSO session cache, lookup={0}, cache name={1}", cacheContainerLookup, cacheName);
SsoCacheSessionIdMapperUpdater updater = new SsoCacheSessionIdMapperUpdater(ssoCache, previousIdMapperUpdater) {
@Override
public void close() throws Exception {
ssoCache.stop();
}
};
return updater;
} catch (NamingException ex) {
LOG.warnv("Failed to obtain distributed session cache container, lookup={0}", cacheContainerLookup);
return previousIdMapperUpdater;
}
}
private static void addSsoCacheCrossDcListener(Cache<String, String[]> ssoCache, SsoSessionCacheListener listener) {
if (ssoCache.getCacheConfiguration().persistence() == null) {
return;
}
final Set<RemoteStore> stores = getRemoteStores(ssoCache);
if (stores == null || stores.isEmpty()) {
return;
}
LOG.infov("Listening for events on remote stores configured for cache {0}", ssoCache.getName());
for (RemoteStore store : stores) {
store.getRemoteCache().addClientListener(listener);
}
}
public static Set<RemoteStore> getRemoteStores(Cache ispnCache) {
return ispnCache.getAdvancedCache().getComponentRegistry().getComponent(PersistenceManager.class).getStores(RemoteStore.class);
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright 2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.adapters.saml.elytron.infinispan;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.keycloak.adapters.spi.SessionIdMapperUpdater;
import org.infinispan.Cache;
import org.jboss.logging.Logger;
/**
*
* @author hmlnarik
*/
public abstract class SsoCacheSessionIdMapperUpdater implements SessionIdMapperUpdater, AutoCloseable {
private static final Logger LOG = Logger.getLogger(SsoCacheSessionIdMapperUpdater.class.getName());
private final SessionIdMapperUpdater delegate;
/**
* Cache where key is a HTTP session ID, and value is a pair (user session ID, principal name) of Strings.
*/
private final Cache<String, String[]> httpSessionToSsoCache;
public SsoCacheSessionIdMapperUpdater(Cache<String, String[]> httpSessionToSsoCache, SessionIdMapperUpdater previousIdMapperUpdater) {
this.delegate = previousIdMapperUpdater;
this.httpSessionToSsoCache = httpSessionToSsoCache;
}
// SessionIdMapperUpdater methods
@Override
public void clear(SessionIdMapper idMapper) {
httpSessionToSsoCache.clear();
this.delegate.clear(idMapper);
}
@Override
public void map(SessionIdMapper idMapper, String sso, String principal, String httpSessionId) {
LOG.debugf("Adding mapping (%s, %s, %s)", sso, principal, httpSessionId);
httpSessionToSsoCache.put(httpSessionId, new String[] {sso, principal});
this.delegate.map(idMapper, sso, principal, httpSessionId);
}
@Override
public void removeSession(SessionIdMapper idMapper, String httpSessionId) {
LOG.debugf("Removing session %s", httpSessionId);
httpSessionToSsoCache.remove(httpSessionId);
this.delegate.removeSession(idMapper, httpSessionId);
}
}

View file

@ -0,0 +1,212 @@
/*
* Copyright 2017 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.adapters.saml.elytron.infinispan;
import org.keycloak.adapters.spi.SessionIdMapper;
import java.util.*;
import java.util.concurrent.*;
import org.infinispan.Cache;
import org.infinispan.client.hotrod.annotation.ClientCacheEntryCreated;
import org.infinispan.client.hotrod.annotation.ClientCacheEntryRemoved;
import org.infinispan.client.hotrod.annotation.ClientListener;
import org.infinispan.client.hotrod.event.ClientCacheEntryCreatedEvent;
import org.infinispan.client.hotrod.event.ClientCacheEntryRemovedEvent;
import org.infinispan.notifications.Listener;
import org.infinispan.notifications.cachelistener.annotation.*;
import org.infinispan.notifications.cachelistener.event.*;
import org.infinispan.notifications.cachemanagerlistener.annotation.CacheStarted;
import org.infinispan.notifications.cachemanagerlistener.annotation.CacheStopped;
import org.infinispan.notifications.cachemanagerlistener.event.CacheStartedEvent;
import org.infinispan.notifications.cachemanagerlistener.event.CacheStoppedEvent;
import org.jboss.logging.Logger;
/**
*
* @author hmlnarik
*/
@Listener(sync = false)
@ClientListener()
public class SsoSessionCacheListener {
private static final Logger LOG = Logger.getLogger(SsoSessionCacheListener.class);
private final ConcurrentMap<String, Queue<Event>> map = new ConcurrentHashMap<>();
private final SessionIdMapper idMapper;
private final Cache<String, String[]> ssoCache;
private ExecutorService executor = Executors.newSingleThreadExecutor();
public SsoSessionCacheListener(Cache<String, String[]> ssoCache, SessionIdMapper idMapper) {
this.ssoCache = ssoCache;
this.idMapper = idMapper;
}
@TransactionRegistered
public void startTransaction(TransactionRegisteredEvent event) {
if (event.getGlobalTransaction() == null) {
return;
}
map.put(event.getGlobalTransaction().globalId(), new ConcurrentLinkedQueue<>());
}
@CacheStarted
public void cacheStarted(CacheStartedEvent event) {
this.executor = Executors.newSingleThreadExecutor();
}
@CacheStopped
public void cacheStopped(CacheStoppedEvent event) {
this.executor.shutdownNow();
}
@CacheEntryCreated
@CacheEntryRemoved
public void addEvent(TransactionalEvent event) {
if (event.isOriginLocal()) {
// Local events are processed by local HTTP session listener
return;
}
if (event.isPre()) { // only handle post events
return;
}
if (event.getGlobalTransaction() != null) {
map.get(event.getGlobalTransaction().globalId()).add(event);
} else {
processEvent(event);
}
}
@TransactionCompleted
public void endTransaction(TransactionCompletedEvent event) {
if (event.getGlobalTransaction() == null) {
return;
}
Queue<Event> events = map.remove(event.getGlobalTransaction().globalId());
if (events == null || ! event.isTransactionSuccessful()) {
return;
}
for (final Event e : events) {
processEvent(e);
}
}
private void processEvent(final Event e) {
switch (e.getType()) {
case CACHE_ENTRY_CREATED:
this.executor.submit(new Runnable() {
@Override public void run() {
cacheEntryCreated((CacheEntryCreatedEvent) e);
}
});
break;
case CACHE_ENTRY_REMOVED:
this.executor.submit(new Runnable() {
@Override public void run() {
cacheEntryRemoved((CacheEntryRemovedEvent) e);
}
});
break;
}
}
private void cacheEntryCreated(CacheEntryCreatedEvent event) {
if (! (event.getKey() instanceof String) || ! (event.getValue() instanceof String[])) {
return;
}
String httpSessionId = (String) event.getKey();
String[] value = (String[]) event.getValue();
String ssoId = value[0];
String principal = value[1];
LOG.tracev("cacheEntryCreated {0}:{1}", httpSessionId, ssoId);
this.idMapper.map(ssoId, principal, httpSessionId);
}
private void cacheEntryRemoved(CacheEntryRemovedEvent event) {
if (! (event.getKey() instanceof String)) {
return;
}
LOG.tracev("cacheEntryRemoved {0}", event.getKey());
this.idMapper.removeSession((String) event.getKey());
}
@ClientCacheEntryCreated
public void remoteCacheEntryCreated(ClientCacheEntryCreatedEvent event) {
if (! (event.getKey() instanceof String)) {
return;
}
String httpSessionId = (String) event.getKey();
if (idMapper.hasSession(httpSessionId)) {
// Ignore local events generated by remote store
LOG.tracev("IGNORING remoteCacheEntryCreated {0}", httpSessionId);
return;
}
this.executor.submit(new Runnable() {
@Override
public void run() {
String[] value;
try {
value = ssoCache.get((String) httpSessionId);
if (value != null) {
String ssoId = value[0];
String principal = value[1];
LOG.tracev("remoteCacheEntryCreated {0}:{1}", httpSessionId, ssoId);
idMapper.map(ssoId, principal, httpSessionId);
} else {
LOG.tracev("remoteCacheEntryCreated {0}", event.getKey());
}
} catch (Exception ex) {
LOG.debugf(ex, "Cannot get remote cache entry %s", httpSessionId);
}
}
});
}
@ClientCacheEntryRemoved
public void remoteCacheEntryRemoved(ClientCacheEntryRemovedEvent event) {
LOG.tracev("remoteCacheEntryRemoved {0}", event.getKey());
this.executor.submit(new Runnable() {
@Override
public void run() {
idMapper.removeSession((String) event.getKey());
}
});
}
}

View file

@ -171,6 +171,10 @@ public class SsoSessionCacheListener {
return;
}
this.executor.submit(new Runnable() {
@Override
public void run() {
String[] value = ssoCache.get((String) httpSessionId);
if (value != null) {
@ -179,12 +183,14 @@ public class SsoSessionCacheListener {
LOG.tracev("remoteCacheEntryCreated {0}:{1}", httpSessionId, ssoId);
this.idMapper.map(ssoId, principal, httpSessionId);
idMapper.map(ssoId, principal, httpSessionId);
} else {
LOG.tracev("remoteCacheEntryCreated {0}", event.getKey());
}
}
});
}
@ClientCacheEntryRemoved
public void remoteCacheEntryRemoved(ClientCacheEntryRemovedEvent event) {

View file

@ -21,6 +21,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.jboss.logging.Logger;
/**
* Maps external principal and SSO id to internal local http session id
@ -29,6 +30,9 @@ import java.util.concurrent.ConcurrentHashMap;
* @version $Revision: 1 $
*/
public class InMemorySessionIdMapper implements SessionIdMapper {
private static final Logger LOG = Logger.getLogger(InMemorySessionIdMapper.class.getName());
ConcurrentHashMap<String, String> ssoToSession = new ConcurrentHashMap<>();
ConcurrentHashMap<String, String> sessionToSso = new ConcurrentHashMap<>();
ConcurrentHashMap<String, Set<String>> principalToSession = new ConcurrentHashMap<>();
@ -63,6 +67,8 @@ public class InMemorySessionIdMapper implements SessionIdMapper {
@Override
public void map(String sso, String principal, String session) {
LOG.debugf("Adding mapping (%s, %s, %s)", sso, principal, session);
if (sso != null) {
ssoToSession.put(sso, session);
sessionToSso.put(session, sso);
@ -86,6 +92,8 @@ public class InMemorySessionIdMapper implements SessionIdMapper {
@Override
public void removeSession(String session) {
LOG.debugf("Removing session %s", session);
String sso = sessionToSso.remove(session);
if (sso != null) {
ssoToSession.remove(sso);

View file

@ -1,4 +1,5 @@
embed-server --server-config=${server.config:standalone.xml}
/subsystem=logging/logger=org.keycloak.adapters:add(level=DEBUG)
/subsystem=logging/logger=org.keycloak.subsystem.adapter:add(level=DEBUG)
/subsystem=logging/console-handler=CONSOLE:change-log-level(level=DEBUG)

View file

@ -25,4 +25,16 @@ embed-server --server-config=standalone-ha.xml
/subsystem=infinispan/cache-container=web/replicated-cache=employee-distributable-cache/store=remote:add(remote-servers=[cache-server],cache=employee-distributable-cache,passivation=false,purge=false,preload=false,shared=true)
/subsystem=infinispan/cache-container=web/replicated-cache=employee-distributable-cache.ssoCache:add(statistics-enabled=true,mode=SYNC)
/subsystem=infinispan/cache-container=web/replicated-cache=employee-distributable-cache.ssoCache/store=remote:add(remote-servers=[cache-server],cache=employee-distributable-cache.ssoCache,passivation=false,purge=false,preload=false,shared=true)
/subsystem=infinispan/cache-container=web/replicated-cache=employee-distributable-cache.ssoCache/store=remote:add( \
remote-servers=["cache-server"], \
cache=employee-distributable-cache.ssoCache, \
passivation=false, \
purge=false, \
preload=false, \
shared=true, \
fetch-state=false, \
properties={ \
rawValues=true, \
protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion:2.6} \
} \
)

View file

@ -76,7 +76,7 @@ public class CrossDCTestEnricher {
if (annotation == null) {
Class<?> annotatedClass = getNearestSuperclassWithAnnotation(event.getTestClass().getJavaClass(), InitialDcState.class);
annotation = annotatedClass.getAnnotation(InitialDcState.class);
annotation = annotatedClass == null ? null : annotatedClass.getAnnotation(InitialDcState.class);
}
if (annotation == null) {

View file

@ -120,13 +120,16 @@ public abstract class AbstractSAMLAdapterClusteredTest extends AbstractServletsA
Assume.assumeThat(PORT_OFFSET_NODE_1, not(is(-1)));
Assume.assumeThat(PORT_OFFSET_NODE_2, not(is(-1)));
Assume.assumeThat(PORT_OFFSET_NODE_REVPROXY, not(is(-1)));
assumeNotElytronAdapter();
}
@Before
public void prepareReverseProxy() throws Exception {
loadBalancerToNodes = new LoadBalancingProxyClient().addHost(NODE_1_URI, NODE_1_NAME).setConnectionsPerThread(10);
reverseProxyToNodes = Undertow.builder().addHttpListener(HTTP_PORT_NODE_REVPROXY, "localhost").setIoThreads(2).setHandler(new ProxyHandler(loadBalancerToNodes, 5000, ResponseCodeHandler.HANDLE_404)).build();
int maxTime = 3600000; // 1 hour for proxy request timeout, so we can debug the backend keycloak servers
reverseProxyToNodes = Undertow.builder()
.addHttpListener(HTTP_PORT_NODE_REVPROXY, "localhost")
.setIoThreads(2)
.setHandler(new ProxyHandler(loadBalancerToNodes, maxTime, ResponseCodeHandler.HANDLE_404)).build();
reverseProxyToNodes.start();
}
@ -232,20 +235,6 @@ public abstract class AbstractSAMLAdapterClusteredTest extends AbstractServletsA
log.infov("Logged out via admin console");
}
private static void assumeNotElytronAdapter() {
if (!AppServerTestEnricher.isUndertowAppServer()) {
try {
boolean contains = FileUtils.readFileToString(Paths.get(System.getProperty("app.server.home"), "standalone", "configuration", "standalone.xml").toFile(), "UTF-8").contains("<security-domain name=\"KeycloakDomain\"");
if (contains) {
Logger.getLogger(AbstractSAMLAdapterClusteredTest.class).debug("Elytron adapter installed: skipping");
}
Assume.assumeFalse(contains);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@Test
public void testAdminInitiatedBackchannelLogout(@ArquillianResource
@OperateOnDeployment(value = EmployeeServletDistributable.DEPLOYMENT_NAME) URL employeeUrl) throws Exception {

View file

@ -26,8 +26,10 @@ import org.keycloak.testsuite.adapter.page.EmployeeServletDistributable;
import org.keycloak.testsuite.adapter.AbstractSAMLAdapterClusteredTest;
import org.keycloak.testsuite.adapter.servlet.SendUsernameServlet;
import org.keycloak.testsuite.arquillian.annotation.AppServerContainer;
import org.keycloak.testsuite.arquillian.annotation.InitialDcState;
import org.keycloak.testsuite.arquillian.containers.ContainerConstants;
import org.keycloak.testsuite.crossdc.ServerSetup;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.keycloak.testsuite.adapter.AbstractServletsAdapterTest.samlServletDeployment;
@ -39,6 +41,7 @@ import static org.keycloak.testsuite.adapter.AbstractServletsAdapterTest.samlSer
@AppServerContainer(ContainerConstants.APP_SERVER_WILDFLY_CLUSTER)
@AppServerContainer(ContainerConstants.APP_SERVER_WILDFLY_DEPRECATED_CLUSTER)
@AppServerContainer(ContainerConstants.APP_SERVER_EAP_CLUSTER)
@InitialDcState(authServers = ServerSetup.FIRST_NODE_IN_EVERY_DC, cacheServers = ServerSetup.FIRST_NODE_IN_EVERY_DC)
public class SAMLAdapterCrossDCTest extends AbstractSAMLAdapterClusteredTest {
@BeforeClass