/* * JBoss, the OpenSource WebOS * * Distributable under LGPL license. * See terms of license at gnu.org. */ package org.jboss.web.tomcat.tc5.sso; import java.io.Serializable; import java.util.ArrayList; import java.util.HashSet; import java.util.Set; import javax.naming.InitialContext; import javax.naming.NamingException; import javax.transaction.UserTransaction; import org.apache.catalina.Host; import org.apache.catalina.LifecycleException; import org.apache.catalina.Session; import org.apache.catalina.authenticator.SSOClusterManagerBase; import org.jboss.cache.Fqn; import org.jboss.cache.TreeCache; import org.jboss.cache.TreeCacheListener; import org.jboss.cache.lock.IsolationLevel; import org.jboss.logging.Logger; import org.jgroups.View; /** * An implementation of Tomcat's SSOClusterManager that uses a TreeCache * to share SSO information between cluster nodes. */ public final class JBossSSOClusterManager extends SSOClusterManagerBase implements TreeCacheListener { // ------------------------------------------------------------- Constants /** * Final segment of any FQN that names a TreeCache node storing * SSO credential information. */ private static final String CREDENTIALS = "credentials"; /** * Final segment of any FQN that names a TreeCache node storing * the set of Sessions associated with an SSO. */ private static final String SESSIONS = "sessions"; /** * Key under which data is stored to the TreeCache. */ private static final String KEY = "key"; /** * Maximum time between cycles of the CredentialUpdater thread. */ private static final int UPDATE_THREAD_INTERVAL = 30000; /** * Maximum time between cycles of the LogoutHandler thread. */ private static final int LOGOUT_THREAD_INTERVAL = 30000; // ------------------------------------------------------- Instance Fields /** * List of SSO ids which this object is currently storing to the cache */ private ArrayList beingLocallyAdded = new ArrayList(); /** * List of SSO ids which this object is currently removing from the cache */ private ArrayList beingLocallyRemoved = new ArrayList(); /** * InitialContext used for JNDI lookups */ private InitialContext initialContext = null; /** * The Log-object for this class */ private Logger log; /** * Whether we have been started */ private boolean started = false; /** * The TreeCache in which sso data is stored. */ private TreeCache treeCache; /** * CredentialUpdater used to allow asynchronous updates of * SSO credentials */ private CredentialUpdater credentialUpdater = null; /** * LogoutHandle used to allow asynchronous handling of logouts */ private LogoutHandler logoutHandler = null; // ---------------------------------------------------------- Constructors /** * Creates a new JBossSSOClusterManager */ public JBossSSOClusterManager() { log = Logger.getLogger(getClass()); } // ----------------------------------------------------- SSOClusterManager /** * Notify the cluster of the addition of a Session to an SSO session. * * @param ssoId the id of the SSO session * @param session the Session that has been added */ public void addSession(String ssoId, Session session) { if (log.isTraceEnabled()) { log.trace("addSession(): adding Session " + session.getId() + " to cached session set for SSO " + ssoId); } Fqn fqn = new Fqn(new Object[] {ssoId, SESSIONS}); UserTransaction tx = null; try { tx = getNewTransaction(); tx.begin(); Set sessions = (Set) treeCache.get(fqn, KEY); if (sessions != null) { sessions.add(session.getId()); treeCache.put(fqn, KEY, sessions); } tx.commit(); } catch (Exception e) { if (tx != null) { try { tx.rollback(); } catch(Throwable t) {} } String sessId = (session == null ? "NULL" : session.getId()); log.error("caught exception adding session " + sessId + " to SSO id " + ssoId, e); } } /** * Notifies the cluster that a single sign on session has been terminated * due to a user logout. * * @param ssoId */ public void logout(String ssoId) { if (log.isTraceEnabled()) { log.trace("Registering logout of SSO " + ssoId + " in clustered cache"); } Fqn fqn = new Fqn(ssoId); // Add this SSO to our list of in-process local removals so // this.nodeRemoved() will ignore the removal synchronized (beingLocallyRemoved) { beingLocallyRemoved.add(ssoId); } UserTransaction tx = null; try { tx = getNewTransaction(); tx.begin(); treeCache.remove(fqn); tx.commit(); } catch (Exception e) { if (tx != null) { try { tx.rollback(); } catch(Throwable t) {} } log.error("Exception attempting to remove node " + fqn.toString() + " from TreeCache", e); } finally { synchronized (beingLocallyRemoved) { beingLocallyRemoved.remove(ssoId); } } } /** * Notifies the cluster of the creation of a new SSO entry. * * @param ssoId the id of the SSO session * @param authType the type of authenticator (BASIC, CLIENT-CERT, DIGEST * or FORM) used to authenticate the SSO. * @param username the username (if any) used for the authentication * @param password the password (if any) used for the authentication */ public void register(String ssoId, String authType, String username, String password) { if (log.isTraceEnabled()) { log.trace("Registering SSO " + ssoId + " in clustered cache"); } storeSSOData(ssoId, authType, username, password, true); } /** * Notify the cluster of the removal of a Session from an SSO session. * * @param ssoId the id of the SSO session * @param session the Session that has been removed */ public void removeSession(String ssoId, Session session) { if (log.isTraceEnabled()) { log.trace("removeSession(): removing Session " + session.getId() + " from cached session set for SSO " + ssoId); } Fqn fqn = new Fqn(new Object[] {ssoId, SESSIONS}); UserTransaction tx = null; boolean removing = false; try { tx = getNewTransaction(); tx.begin(); Set sessions = (Set) treeCache.get(fqn, KEY); if (sessions != null) { sessions.remove(session.getId()); if (sessions.size() == 0) { // Add this SSO to our list of in-process local removals so // this.nodeRemoved() will ignore the removal synchronized (beingLocallyRemoved) { beingLocallyRemoved.add(ssoId); } removing = true; // No sessions left; remove node treeCache.remove(new Fqn(ssoId)); } else { treeCache.put(fqn, KEY, sessions); } } tx.commit(); } catch (Exception e) { if (tx != null) { try { tx.rollback(); } catch(Throwable t) {} } String sessId = (session == null ? "NULL" : session.getId()); log.error("caught exception removing session " + sessId + " from SSO id " + ssoId, e); } finally { if (removing) { synchronized (beingLocallyRemoved) { beingLocallyRemoved.remove(ssoId); } } } } /** * Notifies the cluster of an update of the security credentials * associated with an SSO session. * * @param ssoId the id of the SSO session * @param authType the type of authenticator (BASIC, CLIENT-CERT, DIGEST * or FORM) used to authenticate the SSO. * @param username the username (if any) used for the authentication * @param password the password (if any) used for the authentication */ public void updateCredentials(String ssoId, String authType, String username, String password) { if (log.isTraceEnabled()) { log.trace("Updating credentials for SSO " + ssoId + " in clustered cache"); } storeSSOData(ssoId, authType, username, password, false); } // ------------------------------------------------------ TreeCacheListener /** * Does nothing */ public void nodeAdded(Fqn fqn) { ; // do nothing } /** * Does nothing */ public void nodeVisited(Fqn fqn) { ; // do nothing } /** * Does nothing */ public void cacheStarted(TreeCache cache) { ; // do nothing } /** * Does nothing */ public void cacheStopped(TreeCache cache) { ; // do nothing } /** * Extracts an SSO session id from the Fqn and uses it in an invocation of * {@link #receiveLogout receiveLogout}. *

* Ignores invocations resulting from TreeCache changes originated by * this object. * * @param fqn the fully-qualified name of the node that was removed */ public void nodeRemoved(Fqn fqn) { String ssoId = getIdFromFqn(fqn); // Ignore messages generated by our own activity if (beingLocallyRemoved.contains(ssoId)) { return; } // Ignore SSOs our local valve does not know about if (doLocalLookup(ssoId) == null) { return; } if (log.isTraceEnabled()) { log.trace("received a node removed message for SSO " + ssoId); } // Handle the logout in a separate thread so that if it starts // invalidating sessions, etc, we avoid deadlocks logoutHandler.enqueue(ssoId); } /** * Extracts an SSO session id from the Fqn and uses it in an invocation of * {@link #receiveUpdateCredentials receiveUpdateCredentials}. *

* Only responds to modifications of nodes whose FQN's final segment is * "credentials". *

* Ignores invocations resulting from TreeCache changes originated by * this object. *

* Ignores invocations for SSO session id's that are not registered * with the local SingleSignOn valve. * * @param fqn the fully-qualified name of the node that was modified */ public void nodeModified(Fqn fqn) { // We are only interested in changes to the CREDENTIALS node if (CREDENTIALS.equals(getTypeFromFqn(fqn)) == false) { return; } String ssoId = getIdFromFqn(fqn); // Ignore invocations that come as a result of our additions if (beingLocallyAdded.contains(ssoId)) { return; } // Ignore invocations for SSOs the local valve knows nothing about if (doLocalLookup(ssoId) == null) { return; } if (log.isTraceEnabled()) { log.trace("received a credentials modified message for SSO " + ssoId); } // Put this SSO in the queue of those to be updated credentialUpdater.enqueue(ssoId); } /** * Does nothing */ public void viewChange(View new_view) { ; // do nothing } // ------------------------------------------------------------- Lifecycle /** * Prepare for the beginning of active use of the public methods of this * component. This method should be called before any of the public * methods of this component are utilized. It should also send a * LifecycleEvent of type START_EVENT to any registered listeners. * * @exception LifecycleException if this component detects a fatal error * that prevents this component from being used */ public void start() throws LifecycleException { // Validate and update our current component state if (started) throw new LifecycleException ("JBossSSOClusterManager already Started"); try { createTreeCache(); } catch (Exception e) { throw new LifecycleException("cannot start TreeCache", e); } started = true; // Notify our interested LifecycleListeners getLifecycleSupport().fireLifecycleEvent(START_EVENT, null); } /** * Gracefully terminate the active use of the public methods of this * component. This method should be the last one called on a given * instance of this component. It should also send a LifecycleEvent * of type STOP_EVENT to any registered listeners. * * @exception LifecycleException if this component detects a fatal error * that needs to be reported */ public void stop() throws LifecycleException { // Validate and update our current component state if (!started) throw new LifecycleException ("JBossSSOClusterManager not Started"); treeCache.stop(); credentialUpdater.stop(); logoutHandler.stop(); started = false; // Notify our interested LifecycleListeners getLifecycleSupport().fireLifecycleEvent(STOP_EVENT, null); } // ----------------------------------------- Overridden Superclass Methods protected final SSOClusterManagerBase.SSOCredentials doCredentialLookup(String ssoId) { SSOClusterManagerBase.SSOCredentials result = null; Fqn fqn = new Fqn(new Object[] {ssoId, CREDENTIALS}); UserTransaction tx = null; try { tx = getNewTransaction(); tx.begin(); SSOData data = (SSOData) treeCache.get(fqn, KEY); if (data != null) { result = new SSOClusterManagerBase.SSOCredentials(data.getAuthType(), data.getUsername(), data.getPassword()); } tx.commit(); } catch (Exception e) { if (tx != null) { try { tx.rollback(); } catch(Throwable t) {} } log.error("caught exception looking up SSOData for SSO id " + ssoId, e); } return result; } // ------------------------------------------------------- Private Methods /** * Configures and starts the TreeCache used to share SSOData objects * between cluster members. * * @throws Exception if TreeCache does */ private void createTreeCache() throws Exception { try { TreeCache cache = new TreeCache(); Host host = (Host) getSingleSignOnValve().getContainer(); String hostName = host.getName(); String clusterName = "sso-cluster/" + hostName; cache.setClusterName(clusterName); if (log.isDebugEnabled()) { log.debug("clusterName set to " + clusterName); } cache.setClusterProperties("sso-channel.xml"); cache.setCacheMode(TreeCache.REPL_SYNC); // TODO: 2004/02/09 we tried this with READ_COMMITTED, but kept // getting inexplicable locking problems. May have been due to issues // in Branch_3_2 TreeCache??? Should try again w/ HEAD cache.setIsolationLevel(IsolationLevel.REPEATABLE_READ); cache.setTransactionManagerLookupClass("org.jboss.cache.JBossTransactionManagerLookup"); cache.addTreeCacheListener(this); cache.start(); treeCache = cache; // Start the thread we use to clear nodeModified events credentialUpdater = new CredentialUpdater(); // Start the thread we use to handle logout notifications logoutHandler = new LogoutHandler(); } catch (Exception e) { log.error("Exception starting TreeCache", e); throw e; } } /** * Extracts an SSO session id from a fully qualified name object. * @param fqn the Fully Qualified Name used by TreeCache * @return the last element in the Fqn -- the SSO session id */ private String getIdFromFqn(Fqn fqn) { return (String) fqn.get(0); } private InitialContext getInitialContext() throws NamingException { if (initialContext == null) { initialContext = new InitialContext(); } return initialContext; } /** * Extracts an SSO session id from a fully qualified name object. * @param fqn the Fully Qualified Name used by TreeCache * @return the last element in the Fqn -- the SSO session id */ private String getTypeFromFqn(Fqn fqn) { return (String) fqn.get(fqn.size() - 1); } private UserTransaction getNewTransaction() throws NamingException { try { UserTransaction t = (UserTransaction) getInitialContext().lookup("UserTransaction"); return t; } catch (NamingException n) { // Discard the cached initial context // in case there is a problem with it initialContext = null; throw n; } } /** * Stores the given data to the clustered cache in a tree branch whose FQN * is the given SSO id. Stores the given credential data in a child node * named "credentials". If parameter storeSessions is * true, also stores an empty HashSet in a sibling node * named "sessions". This HashSet will later be used to hold session ids * associated with the SSO. *

* Any items stored are stored under the key "key". * * @param ssoId the id of the SSO session * @param authType the type of authenticator (BASIC, CLIENT-CERT, DIGEST * or FORM) used to authenticate the SSO. * @param username the username (if any) used for the authentication * @param password the password (if any) used for the authentication * @param storeSessions true if a new, empty HashSet should be * also be stored */ private void storeSSOData(String ssoId, String authType, String username, String password, boolean storeSessions) { SSOData data = new SSOData(authType, username, password); // Add this SSO to our list of in-process local adds so // this.nodeModified() will ignore the addition synchronized (beingLocallyAdded) { beingLocallyAdded.add(ssoId); } UserTransaction tx = null; try { tx = getNewTransaction(); tx.begin(); treeCache.put(new Fqn(new Object[] {ssoId, CREDENTIALS}), KEY, data); if (storeSessions) { treeCache.put(new Fqn(new Object[] {ssoId, SESSIONS}), KEY, new HashSet()); } tx.commit(); } catch (Exception e) { if (tx != null) { try { tx.rollback(); } catch(Throwable t) {} } log.error("Exception attempting to add TreeCache nodes for SSO " + ssoId, e); } finally { synchronized (beingLocallyAdded) { beingLocallyAdded.remove(ssoId); } } } // --------------------------------------------------------- Inner Classes /** * Spawns a thread to handle updates of credentials */ private class CredentialUpdater implements Runnable { private HashSet awaitingUpdate = new HashSet(); private Thread updateThread; private boolean stopped = false; CredentialUpdater() { updateThread = new Thread(this); updateThread.start(); } // --------------------------------------- Package Protected Methods /** * Adds an SSO id to the set of those awaiting credential updating, and * interrupts the update handler thread to notify it of the addition. * * @param ssoId the id of the SSO session whose local credentials * are to be updated */ void enqueue(String ssoId) { awaitingUpdate.add(ssoId); updateThread.interrupt(); } /** * Stops the update handler thread. */ void stop() { stopped = true; } // ------------------------------------------------- Private Methods private void processUpdate(String ssoId) { Fqn fqn = new Fqn(new Object[] {ssoId, CREDENTIALS}); UserTransaction tx = null; try { tx = getNewTransaction(); tx.begin(); SSOData data = (SSOData) treeCache.get(fqn, KEY); if (data != null) { // We want to release our read lock quickly, so get the needed // data from the cache, commit the tx, and then use the data String authType = data.getAuthType(); String username = data.getUsername(); String password = data.getPassword(); tx.commit(); if (log.isTraceEnabled()) { log.trace("CredentialUpdater: Updating credentials for SSO " + ssoId); } receiveUpdateCredentials(ssoId, authType, username, password); } else { tx.commit(); } } catch (Exception e) { if (tx != null) { try { tx.rollback(); } catch(Throwable t) {} } log.error("Exception attempting to get SSOData from TreeCache node " + fqn.toString(), e); } } // ------------------------------------------------------ Runnable public void run() { while (!stopped) { // Get the current list of ids awaiting processing String[] ids = null; synchronized(awaitingUpdate) { ids = new String[awaitingUpdate.size()]; ids = (String[]) awaitingUpdate.toArray(ids); awaitingUpdate.clear(); } // Handle the credential update for (int i = 0; i < ids.length; i++) { processUpdate(ids[i]); } // Wait for another invocation of enqueue(). But, // first have to check in case it was invoked while we // were processing the previous bunch if (!Thread.interrupted()) { try { updateThread.sleep(UPDATE_THREAD_INTERVAL); } catch (InterruptedException e) { if (log.isTraceEnabled()) { log.trace("LogoutHandler: interrupted"); } ; // process the next bunch } } else if (log.isTraceEnabled()) { log.trace("CredentialUpdater: interrupted while handling " + "updates"); } } } } // end CredentialUpdater /** * Spawns a thread to handle logouts */ private class LogoutHandler implements Runnable { private HashSet awaitingLogout = new HashSet(); private Thread logoutThread; private boolean stopped = false; LogoutHandler() { logoutThread = new Thread(this); logoutThread.start(); } // --------------------------------------- Package Protected Methods /** * Adds an SSO id to the set of those awaiting logout handling, and * interrupts the logout handler thread to notify it of the addition. * * @param ssoId the id of the SSO session to logout */ void enqueue(String ssoId) { awaitingLogout.add(ssoId); logoutThread.interrupt(); } /** * Stops the logout handler thread */ void stop() { stopped = true; } // ------------------------------------------------------ Runnable public void run() { while (!stopped) { // Get the current list of ids awaiting logout handling String[] ids = null; synchronized(awaitingLogout) { ids = new String[awaitingLogout.size()]; ids = (String[]) awaitingLogout.toArray(ids); awaitingLogout.clear(); } // Handle the logout for (int i = 0; i < ids.length; i++) { if (log.isTraceEnabled()) { log.trace("LogoutHandler: Handling logout for SSO " + ids[i]); } receiveLogout(ids[i]); } // Wait for another invocation of enqueue(). But, // first have to check in case it was invoked while we // were processing the previous bunch if (!Thread.interrupted()) { try { logoutThread.sleep(LOGOUT_THREAD_INTERVAL); } catch (InterruptedException e) { if (log.isTraceEnabled()) { log.trace("LogoutHandler: interrupted"); } ; // process the next bunch } } else if (log.isTraceEnabled()) { log.trace("LogoutHandler: interrupted while handling logouts"); } } } } // --------------------------------------------------------- Outer Classes /** * Private class used to store authentication credentials in the TreeCache. *

* For security, all methods are private. */ private static class SSOData implements Serializable { // --------------------------------------------------- Instance Fields private String authType = null; private String password = null; private String username = null; /** * Creates a new SSOData. * * @param authType The authorization method used to authorize the * SSO (BASIC, CLIENT-CERT, DIGEST, FORM or NONE). * @param username The username of the user associated with the SSO * @param password The password of the user associated with the SSO */ SSOData(String authType, String username, String password) { this.authType = authType; this.username = username; this.password = password; } /** * Gets the authorization method used to authorize the SSO. * * @return "BASIC", "CLIENT-CERT", "DIGEST" or "FORM" */ private String getAuthType() { return authType; } /** * Gets the password of the user associated with the SSO. * * @return the password, or null if the authorization * type was DIGEST or CLIENT-CERT. */ private String getPassword() { return password; } /** * Gets the username of the user associated with the SSO. * * @return the username */ private String getUsername() { return username; } } }