/*
 * Copyright 1999-2001,2004 The Apache Software Foundation.
 * 
 * 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.jboss.web.tomcat.service.sso;


import java.io.IOException;
import java.security.Principal;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import javax.management.MBeanServer;
import javax.management.ObjectName;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;

import org.apache.catalina.Container;
import org.apache.catalina.Lifecycle;
import org.apache.catalina.LifecycleEvent;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleListener;
import org.apache.catalina.Manager;
import org.apache.catalina.Realm;
import org.apache.catalina.Session;
import org.apache.catalina.SessionEvent;
import org.apache.catalina.authenticator.Constants;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.session.ManagerBase;
import org.apache.tomcat.util.modeler.Registry;
import org.jboss.web.tomcat.service.session.JBossManager;
import org.jboss.web.tomcat.service.sso.spi.FullyQualifiedSessionId;
import org.jboss.web.tomcat.service.sso.spi.SSOClusterManager;
import org.jboss.web.tomcat.service.sso.spi.SSOCredentials;
import org.jboss.web.tomcat.service.sso.spi.SSOLocalManager;


/**
 * A <strong>Valve</strong> that supports a "single sign on" user experience,
 * where the security identity of a user who successfully authenticates to one
 * web application is propagated to other web applications in the same
 * security domain.  For successful use, the following requirements must
 * be met:
 * <ul>
 * <li>This Valve must be configured on the Container that represents a
 * virtual host (typically an implementation of <code>Host</code>).</li>
 * <li>The <code>Realm</code> that contains the shared user and role
 * information must be configured on the same Container (or a higher
 * one), and not overridden at the web application level.</li>
 * <li>The web applications themselves must use one of the standard
 * Authenticators found in the
 * <code>org.apache.catalina.authenticator</code> package.</li>
 * </ul>
 *
 * @author Brian E. Stansberry based on the work of Craig R. McClanahan
 * @version $Revision: 82920 $ $Date: 2009-01-15 12:29:45 -0500 (Thu, 15 Jan 2009) $
 */
public class ClusteredSingleSignOn
   extends org.apache.catalina.authenticator.SingleSignOn
   implements LifecycleListener, SSOLocalManager
{
   /** By default we process expired SSOs no more often than once per minute */
   public static final int DEFAULT_PROCESS_EXPIRES_INTERVAL = 60;
   /** By default we let SSOs without active sessions live for 30 mins */
   public static final int DEFAULT_MAX_EMPTY_LIFE = 1800;
   /** The default JBoss Cache to use for storing SSO entries */
   public static final String DEFAULT_CACHE_NAME = "clustered-sso";
   public static final String LEGACY_CACHE_NAME = "jboss.cache:service=TomcatClusteringCache";
   public static final String DEFAULT_CLUSTER_MANAGER_CLASS = "org.jboss.web.tomcat.service.sso.jbc.JBossCacheSSOClusterManager";
   
   // Override the superclass value
   static
   {
      info = ClusteredSingleSignOn.class.getName();
   }
    
   // ----------------------------------------------------- Instance Variables

 
   /**
    * Fully qualified name of a class implementing
    * {@link SSOClusterManager SSOClusterManager} that will be used
    * to manage SSOs across a cluster.
    */
   private String clusterManagerClass = DEFAULT_CLUSTER_MANAGER_CLASS;

   /**
    * Object used to provide cross-cluster support for single sign on.
    */
   private SSOClusterManager ssoClusterManager = null;

   /**
    * Object name of the tree cache used by SSOClusterManager.
    * Only relevant if the SSOClusterManager implementation is
    * TreeCacheSSOClusterManager.
    */
   private String cacheConfigName = DEFAULT_CACHE_NAME;

   /**
    * Object name of the thread pool used by SSOClusterManager.
    * Only relevant if the SSOClusterManager implementation is
    * TreeCacheSSOClusterManager.
    */
   private String threadPoolName = "jboss.system:service=ThreadPool";

   /** Currently started Managers that have associated as session with an SSO */
   private Set activeManagers = Collections.synchronizedSet(new HashSet());
   
   /** Max number of ms an SSO with no active sessions will be usable by a request */
   private int maxEmptyLife = DEFAULT_MAX_EMPTY_LIFE * 1000;
   
   /** 
    * Minimum number of ms since the last processExpires() run 
    * before a new run is allowed.
    */
   private int processExpiresInterval = DEFAULT_PROCESS_EXPIRES_INTERVAL * 1000;
   
   /** Timestamp of the last processExpires() run */
   private long lastProcessExpires = System.currentTimeMillis();
   
   /** 
    * Map<String, Long> containing the ids of SSOs with no active sessions
    * and the time at which they entered that state
    */
   private Map emptySSOs = new ConcurrentHashMap();
   
   /** Used for sync locking of processExpires runs */
   private final Object mutex = new Object();

   // ------------------------------------------------------------- Properties

   /**
    * Gets the object that provides SSO support across a cluster.
    *
    * @return the object provided cluster support, or <code>null</code> if
    *         no such object has been configured.
    */
   public SSOClusterManager getClusterManager()
   {
      return this.ssoClusterManager;
   }


   /**
    * Sets the object that provides SSO support across a cluster.
    *
    * @param clusterManager the object that provides SSO support.
    * @throws IllegalStateException if this method is invoked after this valve
    *                               has been started.
    */
   public void setClusterManager(SSOClusterManager clusterManager)
   {
      if (started && (clusterManager != ssoClusterManager))
      {
         throw new IllegalStateException("already started -- cannot set a " +
            "new SSOClusterManager");
      }

      this.ssoClusterManager = clusterManager;

      if (clusterManager != null)
      {
         clusterManagerClass = clusterManager.getClass().getName();
      }
   }


   /**
    * Gets the name of the class that will be used to provide SSO support
    * across a cluster.
    *
    * @return Fully qualified name of a class implementing
    *         {@link SSOClusterManager SSOClusterManager}
    *         that is being used to manage SSOs across a cluster.
    *         May return <code>null</code> (the default) if clustered
    *         SSO support is not configured.
    */
   public String getClusterManagerClass()
   {
      return clusterManagerClass;
   }


   /**
    * Sets the name of the class that will be used to provide SSO support
    * across a cluster.
    * <p><b>NOTE: </b>
    * If this Valve has already started, and no SSOClusterManager has been
    * configured for it, calling this method will
    *
    * @param managerClass Fully qualified name of a class implementing
    *                     {@link SSOClusterManager SSOClusterManager}
    *                     that will be used to manage SSOs across a cluster.
    *                     Class must declare a public no-arguments
    *                     constructor.  <code>null</code> is allowed.
    */
   public void setClusterManagerClass(String managerClass)
   {
      if (!started)
      {
         clusterManagerClass = managerClass;
      }
      else if (ssoClusterManager == null)
      {
         try
         {
            createClusterManager(managerClass);
         }
         catch (LifecycleException e)
         {
            getContainer().getLogger().error("Exception creating SSOClusterManager " +
               managerClass, e);
         }
      }
      else
      {
          getContainer().getLogger().error("Cannot set clusterManagerClass to " + managerClass +
            "; already started using " + clusterManagerClass);
      }
   }

   /**
    * Name of the cache config used by SSOClusterManager.
    * 
    * @deprecated use {@link #getCacheConfig()}
    */
   @Deprecated
   public String getTreeCacheName()
   {
      return getCacheConfig();
   }

   /**
    * Sets the name of the cache config used by SSOClusterManager.
    * 
    * @deprecated use {@link #setCacheConfig(String)}
    */
   @Deprecated
   public void setTreeCacheName(String cacheName)
      throws Exception
   {
      setCacheConfig(cacheName);
   }
   
   /**
    * Name of the cache config used by SSOClusterManager.
    */
   public String getCacheConfig()
   {
      return cacheConfigName;
   }
   
   /**
    * Sets the name of the cache config used by SSOClusterManager.
    */
   public void setCacheConfig(String cacheConfig)
   {
      this.cacheConfigName = cacheConfig;      
   }
   
   /**
    * Object name of the thread pool used by SSOClusterManager.
    * Only relevant if the SSOClusterManager implementation is
    * TreeCacheSSOClusterManager.
    */
   public String getThreadPoolName()
   {
      return threadPoolName;
   }

   /**
    * Sets the object name of the thread pool used by SSOClusterManager.
    * Only relevant if the SSOClusterManager implementation is
    * TreeCacheSSOClusterManager.
    */
   public void setThreadPoolName(String poolName)
      throws Exception
   {
      this.threadPoolName = poolName;
   }

   /**
    * Gets the max number of seconds an SSO with no active sessions will be 
    * usable by a request.
    * 
    * @return a non-negative number
    * 
    * @see #DEFAULT_MAX_EMPTY_LIFE    * 
    * @see #setMaxEmptyLife() 
    */
   public int getMaxEmptyLife()
   {
      return (maxEmptyLife / 1000);
   }


   /**
    * Sets the maximum number of seconds an SSO with no active sessions will be 
    * usable by a request.
    * <p>
    * A positive value for this property allows a user to continue to use an SSO
    * even after all the sessions associated with it have been expired. It does not
    * keep an SSO alive if a session associated with it has been invalidated due to 
    * an <code>HttpSession.invalidate()</code> call.
    * </p>
    * <p>
    * The primary purpose of this property is to avoid the situation where a server
    * on which all of an SSO's sessions lives is shutdown, thus expiring all the
    * sessions and causing the invalidation of the SSO. A positive value for this
    * property would give the user an opportunity to fail over to another server
    * and maintain the SSO.
    * </p>
    * 
    * @param maxEmptyLife a non-negative number
    * 
    * @throws IllegalArgumentException if <code>maxEmptyLife < 0</code>
    */
   public void setMaxEmptyLife(int maxEmptyLife)
   {
      if (maxEmptyLife < 0)
         throw new IllegalArgumentException("maxEmptyLife must be >= 0");
      
      this.maxEmptyLife = maxEmptyLife * 1000;
   }


   /**
    * Gets the minimum number of seconds since the start of the last check for overaged
    * SSO's with no active sessions before a new run is allowed.
    * 
    * @return a positive number
    * 
    * @see #DEFAULT_PROCESS_EXPIRES_INTERVAL
    * @see #setMaxEmptyLife() 
    * @see #setProcessExpiresInterval(int)
    */
   public int getProcessExpiresInterval()
   {
      return processExpiresInterval / 1000;
   }

   /**
    * Sets the minimum number of seconds since the start of the last check for overaged
    * SSO's with no active sessions before a new run is allowed.  During this check,
    * any such overaged SSOs will be invalidated. 
    * <p>
    * Note that setting this value does not imply that a check will be performed
    * every <code>processExpiresInterval</code> seconds, only that it will not
    * be performed more often than that.
    * </p>
    * 
    * @param processExpiresInterval a non-negative number. <code>0</code> means
    *                               the overage check can be performed whenever
    *                               the container wishes to.
    *                               
    * @throws IllegalArgumentException if <code>processExpiresInterval < 1</code>
    * 
    * @see #setMaxEmptyLife()
    */
   public void setProcessExpiresInterval(int processExpiresInterval)
   {
      if (processExpiresInterval < 0)
         throw new IllegalArgumentException("processExpiresInterval must be >= 0");
      
      this.processExpiresInterval = processExpiresInterval * 1000;
   }


   /**
    * Gets the timestamp of the start of the last check for overaged
    * SSO's with no active sessions.
    * 
    * @see #setProcessExpiresInterval(int)
    */
   public long getLastProcessExpires()
   {
      return lastProcessExpires;
   }


   // ------------------------------------------------------ Lifecycle Methods


   /**
    * Prepare for the beginning of active use of the public methods of this
    * component.  This method should be called after <code>configure()</code>,
    * and before any of the public methods of the component are utilized.
    *
    * @throws 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
            (sm.getString("authenticator.alreadyStarted"));
      }

      // Attempt to create an SSOClusterManager
      createClusterManager(getClusterManagerClass());

      lifecycle.fireLifecycleEvent(START_EVENT, null);
      started = true;

      if (ssoClusterManager != null)
      {
         try
         {
            ssoClusterManager.start();
         }
         catch (LifecycleException e)
         {
            throw e;
         }
         catch (Exception e)
         {
            throw new LifecycleException("Caught exception stopping " + 
                  ssoClusterManager.getClass().getSimpleName(), e);
         }
      }

   }


   /**
    * 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.
    *
    * @throws 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
            (sm.getString("authenticator.notStarted"));
      }

      if (ssoClusterManager != null)
      {
         try
         {
            ssoClusterManager.stop();
         }
         catch (LifecycleException e)
         {
            throw e;
         }
         catch (Exception e)
         {
            throw new LifecycleException("Caught exception stopping " + 
                  ssoClusterManager.getClass().getSimpleName(), e);
         }
      }

      lifecycle.fireLifecycleEvent(STOP_EVENT, null);
      started = false;

   }


   // ------------------------------------------------ SessionListener Methods


   /**
    * Updates the state of a single sign on session to reflect the destruction
    * of a standard HTTP session.
    * <p/>
    * If the given event is a {@link Session#SESSION_DESTROYED_EVENT
    * Session destroyed event}, checks whether the session was destroyed due
    * to timeout or user action (i.e. logout).  If due to timeout, disassociates
    * the Session from the single sign on session.  If due to logout, invokes
    * the {@link #logout} method.
    *
    * @param event SessionEvent that has occurred
    */
   public void sessionEvent(SessionEvent event)
   {
      // We only care about session destroyed events
      if (!Session.SESSION_DESTROYED_EVENT.equals(event.getType()))
         return;

      // Look up the single session id associated with this session (if any)
      Session session = event.getSession();
      if (getContainer().getLogger().isTraceEnabled())
          getContainer().getLogger().trace("Process session destroyed on " + session);

      String ssoId = null;
      synchronized (reverse)
      {
         ssoId = (String) reverse.get(session);
      }
      if (ssoId == null)
         return;

      try
      {
         // Was the session destroyed as the result of a timeout or
         // the undeployment of the containing webapp?
         // If so, we'll just remove the expired session from the
         // SSO.  If the session was logged out, we'll log out
         // of all sessions associated with the SSO.
         if (isSessionTimedOut(session) || isManagerStopped(session))
         {
            removeSession(ssoId, session);

            // Quite poor.  We hijack the caller thread (the Tomcat background thread)
            // to do our cleanup of expired sessions
            processExpires();
         }
         else
         {
            // The session was logged out.
            logout(ssoId);
         }
      }
      catch (Exception e)
      {
         // Don't propagate back to the webapp; we don't want to disrupt
         // the session expiration process
         getContainer().getLogger().error("Caught exception updating SSO " + ssoId +
                                          " following destruction of session " +
                                          session.getIdInternal(), e);
      }
   }

   private boolean isSessionTimedOut(Session session)
   {
      return (session.getMaxInactiveInterval() > 0)
               && (System.currentTimeMillis() - session.getLastAccessedTime() >=
                  session.getMaxInactiveInterval() * 1000);
   }

   private boolean isManagerStopped(Session session)
   {
      boolean stopped = false;
      
      Manager manager = session.getManager();
      
      if (manager instanceof ManagerBase)
      {
         ObjectName mgrName = ((ManagerBase)manager).getObjectName();
         stopped = (!activeManagers.contains(mgrName));
      }
      else if (manager instanceof JBossManager)
      {
         ObjectName mgrName = ((JBossManager)manager).getObjectName();
         stopped = (!activeManagers.contains(mgrName));
      }
      else if (manager instanceof Lifecycle)
      {
         stopped = (!activeManagers.contains(manager));
      }
      // else we have no way to tell, so assume not
      
      return stopped;
   }

   // ----------------------------------------------  LifecycleListener Methods


   public void lifecycleEvent(LifecycleEvent event)
   {
      String type = event.getType();
      if (Lifecycle.BEFORE_STOP_EVENT.equals(type) 
            || Lifecycle.STOP_EVENT.equals(type) 
            || Lifecycle.AFTER_STOP_EVENT.equals(type))
      {
         Lifecycle source = event.getLifecycle();
         boolean removed;
         if (source instanceof ManagerBase)
         {
            removed = activeManagers.remove(((ManagerBase)source).getObjectName());
         }
         else if (source instanceof JBossManager)
         {
            removed = activeManagers.remove(((JBossManager)source).getObjectName());
         }
         else
         {
            removed = activeManagers.remove(source);
         }
         
         if (removed)
         {
            source.removeLifecycleListener(this);
            
            getContainer().getLogger().debug("ClusteredSSO: removed stopped " +
                                             "manager " + source.toString());            
         }
         
         // TODO consider getting the sessions and removing any from our sso's
         // Idea is to cleanup after managers that don't destroy sessions
         
      }      
   }
   

   // ---------------------------------------------------------- Valve Methods


   /**
    * Perform single-sign-on support processing for this request.
    * <p/>
    * Overrides the superclass version by handling the fact that a
    * single sign on may have been originated on another cluster node and
    * thus may not have a <code>Principal</code> object associated with it
    * on this node.
    *
    * @param request  The servlet request we are processing
    * @param response The servlet response we are creating
    * @param context  The valve context used to invoke the next valve
    *                 in the current processing pipeline
    * @throws IOException      if an input/output error occurs
    * @throws ServletException if a servlet error occurs
    */
   public void invoke(Request request, Response response)
      throws IOException, ServletException
   {
      request.removeNote(Constants.REQ_SSOID_NOTE);

      // Has a valid user already been authenticated?
      if (getContainer().getLogger().isTraceEnabled())
         getContainer().getLogger().trace("Process request for '" + request.getRequestURI() + "'");
      if (request.getUserPrincipal() != null)
      {
         if (getContainer().getLogger().isTraceEnabled())
            getContainer().getLogger().trace(" Principal '" + request.getUserPrincipal().getName() +
               "' has already been authenticated");
         getNext().invoke(request, response);
         return;
      }

      // Check for the single sign on cookie
      Cookie cookie = null;
      Cookie cookies[] = request.getCookies();
      if (cookies == null)
         cookies = new Cookie[0];
      for (int i = 0; i < cookies.length; i++)
      {
         if (Constants.SINGLE_SIGN_ON_COOKIE.equals(cookies[i].getName()))
         {
            cookie = cookies[i];
            break;
         }
      }
      if (cookie == null)
      {
         if (getContainer().getLogger().isTraceEnabled())
            getContainer().getLogger().trace(" SSO cookie is not present");
         getNext().invoke(request, response);
         return;
      }

      // Look up the cached Principal associated with this cookie value
      String ssoId = cookie.getValue();
      if (getContainer().getLogger().isTraceEnabled())
          getContainer().getLogger().trace(" Checking for cached principal for " + ssoId);
      JBossSingleSignOnEntry entry = getSingleSignOnEntry(cookie.getValue());
      if (entry != null && isValid(ssoId, entry))
      {
         Principal ssoPrinc = entry.getPrincipal();
         // have to deal with the fact that the entry may not have an
         // associated Principal. SSO entries retrieved via a lookup from a 
         // cluster will not have a Principal, as Principal is not Serializable
         if (getContainer().getLogger().isTraceEnabled())
         {
             getContainer().getLogger().trace(" Found cached principal '" +
               (ssoPrinc == null ? "NULL" : ssoPrinc.getName()) +
               "' with auth type '" + entry.getAuthType() + "'");
         }
         request.setNote(Constants.REQ_SSOID_NOTE, cookie.getValue());
         // Only set security elements if per-request reauthentication is 
         // not required AND the SSO entry had a Principal.  
         if (!getRequireReauthentication() && ssoPrinc != null)
         {
            request.setAuthType(entry.getAuthType());
            request.setUserPrincipal(ssoPrinc);
         }
      }
      else
      {
         if (getContainer().getLogger().isTraceEnabled())
            getContainer().getLogger().trace(" No cached principal found, erasing SSO cookie");
         cookie.setMaxAge(0);
         response.addCookie(cookie);
      }

      // Invoke the next Valve in our pipeline
      getNext().invoke(request, response);
   }


   // ------------------------------------------------------ Protected Methods


   /**
    * Associate the specified single sign on identifier with the
    * specified Session.
    * <p/>
    * Differs from the superclass version in that it notifies the cluster
    * of any new association of SSO and Session.
    *
    * @param ssoId   Single sign on identifier
    * @param session Session to be associated
    */
   public void associate(String ssoId, Session session)
   {
      if (getContainer().getLogger().isTraceEnabled())
          getContainer().getLogger().trace("Associate sso id " + ssoId + " with session " + session);

      JBossSingleSignOnEntry sso = getSingleSignOnEntry(ssoId);
      boolean added = false;
      if (sso != null)
         added = sso.addSession2(this, session);

      synchronized (reverse)
      {
         reverse.put(session, ssoId);
      }

      // If we made a change, track the manager and notify any cluster
      if (added)
      {
         Manager manager = session.getManager();
         
         // Prefer to cache an ObjectName to avoid risk of leaking a manager,
         // so if the manager exposes one, use it
         Object mgrKey = null;
         if (manager instanceof ManagerBase)
         {            
            mgrKey = ((ManagerBase)manager).getObjectName();
         }
         else if (manager instanceof JBossManager)
         {
            mgrKey = ((JBossManager)manager).getObjectName();
         }
         else if (manager instanceof Lifecycle)
         {
            mgrKey = manager;
         }
         else {
            getContainer().getLogger().warn("Manager for session " + 
                      session.getIdInternal() +
                      " does not implement Lifecycle; web app shutdown may " +
                      " lead to incorrect SSO invalidations");
         }
         
         if (mgrKey != null)
         {
            synchronized (activeManagers)
            {
               if (!activeManagers.contains(mgrKey))
               {
                  activeManagers.add(mgrKey);
                  ((Lifecycle) manager).addLifecycleListener(this);
               }
            }
         }
         
         if (ssoClusterManager != null)         
            ssoClusterManager.addSession(ssoId, getFullyQualifiedSessionId(session));
      }
   }


   /**
    * Deregister the specified session.  If it is the last session,
    * then also get rid of the single sign on identifier.
    * <p/>
    * Differs from the superclass version in that it notifies the cluster
    * of any disassociation of SSO and Session.
    *
    * @param ssoId   Single sign on identifier
    * @param session Session to be deregistered
    */
   protected void deregister(String ssoId, Session session)
   {
      synchronized (reverse)
      {
         reverse.remove(session);
      }

      JBossSingleSignOnEntry sso = getSingleSignOnEntry(ssoId);
      if (sso == null)
         return;

      boolean removed = sso.removeSession2(session);
      // If we changed anything, notify any cluster
      if (removed && ssoClusterManager != null)
      {
         ssoClusterManager.removeSession(ssoId, getFullyQualifiedSessionId(session));
      }

      // see if this was the last session on this node, 
      // if remove sso entry from our local cache
      if (sso.getSessionCount() == 0)
      {
         synchronized (cache)
         {
            sso = (JBossSingleSignOnEntry) cache.remove(ssoId);
         }
      }
   }


   /**
    * Deregister the specified single sign on identifier, and invalidate
    * any associated sessions.
    *
    * @param ssoId Single sign on identifier to deregister
    */
   public void deregister(String ssoId)
   {
      if (getContainer().getLogger().isTraceEnabled())
          getContainer().getLogger().trace("Deregistering sso id '" + ssoId + "'");

      
      // It's possible we don't have the SSO locally but it's in
      // the emptySSOs map; if so remove it
      emptySSOs.remove(ssoId);
      
      // Look up and remove the corresponding SingleSignOnEntry
      JBossSingleSignOnEntry sso = null;
      synchronized (cache)
      {
         sso = (JBossSingleSignOnEntry) cache.remove(ssoId);
      }

      if (sso == null)
         return;

      // Expire any associated sessions
      Session sessions[] = sso.findSessions();
      for (int i = 0; i < sessions.length; i++)
      {
         if (getContainer().getLogger().isTraceEnabled())
             getContainer().getLogger().trace(" Invalidating session " + sessions[i]);
         // Remove from reverse cache first to avoid recursion
         synchronized (reverse)
         {
            reverse.remove(sessions[i]);
         }
         // Invalidate this session
         sessions[i].expire();
      }

      // NOTE:  Clients may still possess the old single sign on cookie,
      // but it will be removed on the next request since it is no longer
      // in the cache
   }


   /**
    * Deregister the given SSO, invalidating any associated sessions, then
    * notify any cluster of the logout.
    *
    * @param ssoId the id of the SSO session
    */
   protected void logout(String ssoId)
   {
      deregister(ssoId);
      //  broadcast logout to any cluster
      if (ssoClusterManager != null)
         ssoClusterManager.logout(ssoId);
   }


   /**
    * Look up and return the cached SingleSignOn entry associated with this
    * sso id value, if there is one; otherwise return <code>null</code>.
    *
    * @param ssoId Single sign on identifier to look up
    */
   protected JBossSingleSignOnEntry getSingleSignOnEntry(String ssoId)
   {
	  JBossSingleSignOnEntry sso = localLookup(ssoId);
      // If we don't have one locally and there is a cluster,
      // query the cluster for the SSO
      if (sso == null && ssoClusterManager != null)
      {
         SSOCredentials credentials = ssoClusterManager.lookup(ssoId);
         if (credentials != null)
         {
            sso = new JBossSingleSignOnEntry(null, credentials.getAuthType(), 
                                             credentials.getUsername(), 
                                             credentials.getPassword());
            // Store it locally
            synchronized (cache)
            {
               cache.put(ssoId, sso);
            }
         }
      }

      return sso;
   }


   /**
    * Attempts reauthentication to the given <code>Realm</code> using
    * the credentials associated with the single sign-on session
    * identified by argument <code>ssoId</code>.
    * <p/>
    * If reauthentication is successful, the <code>Principal</code> and
    * authorization type associated with the SSO session will be bound
    * to the given <code>HttpRequest</code> object via calls to
    * {@link HttpRequest#setAuthType HttpRequest.setAuthType()} and
    * {@link HttpRequest#setUserPrincipal HttpRequest.setUserPrincipal()}
    * </p>
    *
    * @param ssoId   identifier of SingleSignOn session with which the
    *                caller is associated
    * @param realm   Realm implementation against which the caller is to
    *                be authenticated
    * @param request the request that needs to be authenticated
    * @return <code>true</code> if reauthentication was successful,
    *         <code>false</code> otherwise.
    */
   public boolean reauthenticate(String ssoId, Realm realm,
      Request request)
   {
      if (ssoId == null || realm == null)
         return false;

      boolean reauthenticated = false;

      JBossSingleSignOnEntry entry = getSingleSignOnEntry(ssoId);
      if (entry != null && entry.getCanReauthenticate())
      {

         String username = entry.getUsername();
         if (username != null)
         {
            Principal reauthPrincipal =
               realm.authenticate(username, entry.getPassword());
            if (reauthPrincipal != null)
            {
               reauthenticated = true;                    
               // Bind the authorization credentials to the request
               request.setAuthType(entry.getAuthType());
               request.setUserPrincipal(reauthPrincipal);
               // JBAS-2314 -- bind principal to the entry as well
               entry.setPrincipal(reauthPrincipal);
            }
         }
      }

      return reauthenticated;
   }


   /**
    * Register the specified Principal as being associated with the specified
    * value for the single sign on identifier.
    * <p/>
    * Differs from the superclass version in that it notifies the cluster
    * of the registration.
    *
    * @param ssoId     Single sign on identifier to register
    * @param principal Associated user principal that is identified
    * @param authType  Authentication type used to authenticate this
    *                  user principal
    * @param username  Username used to authenticate this user
    * @param password  Password used to authenticate this user
    */
   public void register(String ssoId, Principal principal, String authType,
      String username, String password)
   {
      registerLocal(ssoId, principal, authType, username, password);

      // broadcast change to any cluster
      if (ssoClusterManager != null)
         ssoClusterManager.register(ssoId, authType, username, password);
   }


   /**
    * Remove a single Session from a SingleSignOn.  Called when
    * a session is timed out and no longer active.
    * <p/>
    * Differs from the superclass version in that it notifies the cluster
    * of any disassociation of SSO and Session.
    *
    * @param ssoId   Single sign on identifier from which to remove the session.
    * @param session the session to be removed.
    */
   protected void removeSession(String ssoId, Session session)
   {
      if (getContainer().getLogger().isTraceEnabled())
          getContainer().getLogger().trace("Removing session " + session.toString() +
            " from sso id " + ssoId);

      // Get a reference to the SingleSignOn
      JBossSingleSignOnEntry entry = getSingleSignOnEntry(ssoId);
      if (entry == null)
         return;

      // Remove the inactive session from SingleSignOnEntry
      boolean removed = entry.removeSession2(session);
      // If we changed anything, notify any cluster
      if (removed && ssoClusterManager != null)
      {
         ssoClusterManager.removeSession(ssoId, getFullyQualifiedSessionId(session));
      }

      // Remove the inactive session from the 'reverse' Map.
      synchronized (reverse)
      {
         reverse.remove(session);
      }
   }


   /**
    * Updates any <code>SingleSignOnEntry</code> found under key
    * <code>ssoId</code> with the given authentication data.
    * <p/>
    * The purpose of this method is to allow an SSO entry that was
    * established without a username/password combination (i.e. established
    * following DIGEST or CLIENT-CERT authentication) to be updated with
    * a username and password if one becomes available through a subsequent
    * BASIC or FORM authentication.  The SSO entry will then be usable for
    * reauthentication.
    * <p/>
    * <b>NOTE:</b> Only updates the SSO entry if a call to
    * <code>SingleSignOnEntry.getCanReauthenticate()</code> returns
    * <code>false</code>; otherwise, it is assumed that the SSO entry already
    * has sufficient information to allow reauthentication and that no update
    * is needed.
    * <p/>
    * Differs from the superclass version in that it notifies the cluster
    * of any update.
    *
    * @param ssoId     identifier of Single sign to be updated
    * @param principal the <code>Principal</code> returned by the latest
    *                  call to <code>Realm.authenticate</code>.
    * @param authType  the type of authenticator used (BASIC, CLIENT-CERT,
    *                  DIGEST or FORM)
    * @param username  the username (if any) used for the authentication
    * @param password  the password (if any) used for the authentication
    */
   public void update(String ssoId, Principal principal, String authType,
      String username, String password)
   {
      boolean needToBroadcast = updateLocal(ssoId, principal, authType,
         username, password);                                    
                                    
      // if there was a change, broadcast it to any cluster
      if (needToBroadcast && ssoClusterManager != null)
      {
         ssoClusterManager.updateCredentials(ssoId, authType,
            username, password);
      }
   }

   //----------------------------------------------  Package-Protected Methods

   /**
    * Search in our local cache for an SSO entry.
    *
    * @param ssoId the id of the SSO session
    * @return any SingleSignOnEntry associated with the given id, or
    *         <code>null</code> if there is none.
    */
   JBossSingleSignOnEntry localLookup(String ssoId)
   {
      synchronized (cache)
      {
         return ((JBossSingleSignOnEntry) cache.get(ssoId));
      }

   }

   /**
    * Create a SingleSignOnEntry using the passed configuration parameters and
    * register it in the local cache, bound to the given id.
    *
    * @param ssoId     the id of the SSO session
    * @param principal the <code>Principal</code> returned by the latest
    *                  call to <code>Realm.authenticate</code>.
    * @param authType  the type of authenticator used (BASIC, CLIENT-CERT,
    *                  DIGEST or FORM)
    * @param username  the username (if any) used for the authentication
    * @param password  the password (if any) used for the authentication
    */
   void registerLocal(String ssoId, Principal principal, String authType,
      String username, String password)
   {
      if (getContainer().getLogger().isTraceEnabled())
      {
          getContainer().getLogger().trace("Registering sso id '" + ssoId + "' for user '" +
            principal.getName() + "' with auth type '" + authType + "'");
      }

      synchronized (cache)
      {
         cache.put(ssoId, new JBossSingleSignOnEntry(principal, authType,
            username, password));
      }
   }

   /**
    * Updates any <code>SingleSignOnEntry</code> found under key
    * <code>ssoId</code> with the given authentication data.
    *
    * @param ssoId     identifier of Single sign to be updated
    * @param principal the <code>Principal</code> returned by the latest
    *                  call to <code>Realm.authenticate</code>.
    * @param authType  the type of authenticator used (BASIC, CLIENT-CERT,
    *                  DIGEST or FORM)
    * @param username  the username (if any) used for the authentication
    * @param password  the password (if any) used for the authentication
    * @return <code>true</code> if the update resulted in an actual change
    *         to the entry's authType, username or principal properties
    */
   boolean updateLocal(String ssoId, Principal principal, String authType,
      String username, String password)
   {
      boolean shouldBroadcast = false;

      JBossSingleSignOnEntry sso = getSingleSignOnEntry(ssoId);
      // Only update if the entry is missing information
      if (sso != null)
      {
         if (sso.getCanReauthenticate() == false)
         {
            if (getContainer().getLogger().isTraceEnabled())
                getContainer().getLogger().trace("Update sso id " + ssoId + " to auth type " + authType);

            synchronized (sso)
            {
               shouldBroadcast = sso.updateCredentials2(principal, authType,
                  username, password);
            }
         }
         else if (sso.getPrincipal() == null && principal != null)
         {
            if (getContainer().getLogger().isTraceEnabled())
                getContainer().getLogger().trace("Update sso id " + ssoId + " with principal " +
                  principal.getName());

            synchronized (sso)
            {
               sso.setPrincipal(principal);
               // No need to notify cluster; Principals don't replicate
            }
         }

      }

      return shouldBroadcast;

   }

   public void remoteUpdate(String ssoId, SSOCredentials credentials)
   {
	  JBossSingleSignOnEntry sso = localLookup(ssoId);
      // Only update if the entry is missing information
      if (sso != null && sso.getCanReauthenticate() == false)
      {
         if (getContainer().getLogger().isTraceEnabled())
             getContainer().getLogger().trace("Update sso id " + ssoId + 
                   " to auth type " + credentials.getAuthType());

         synchronized (sso)
         {
            // Use the existing principal
            Principal p = sso.getPrincipal();
            sso.updateCredentials(p, credentials.getAuthType(), 
                                  credentials.getUsername(), 
                                  credentials.getPassword());
         }
      }

   }
   
   /**
    * Callback from the SSOManager when it detects an SSO without
    * any active sessions across the cluster
    */
   public void notifySSOEmpty(String ssoId)
   {
      Object obj = emptySSOs.put(ssoId, new Long(System.currentTimeMillis()));
      
      if (obj == null && getContainer().getLogger().isTraceEnabled())
      {
         getContainer().getLogger().trace("Notified that SSO " + ssoId + " is empty");
      }
   }
   
   /**
    * Callback from the SSOManager when it detects an SSO that
    * has active sessions across the cluster
    */
   public void notifySSONotEmpty(String ssoId)
   {
      Object obj = emptySSOs.remove(ssoId);
      
      if (obj != null && getContainer().getLogger().isTraceEnabled())
      {
         getContainer().getLogger().trace("Notified that SSO " + ssoId + 
                                          " is no longer empty");
      }
   }

   /**
    * Get the current Catalina MBean Server.
    * 
    * @return the mbean server
    */
   public MBeanServer getMBeanServer()
   {
      if (mserver == null)
      {
         mserver = Registry.getRegistry(null, null).getMBeanServer();
      }
      return mserver;
   }

   
   // -------------------------------------------------------  Private Methods

   
   /**
    * Instantiates an instance of the given class, making it this valve's
    * SSOClusterManager.
    * <p/>
    * If this valve has been started and the given class implements
    * <code>Lifecycle</code>, starts the new SSOClusterManager.
    *
    * @param className fully qualified class name of an implementation
    *                  of {@link SSOClusterManager SSOClusterManager}.
    * @throws LifecycleException if there is any problem instantiating or
    *                            starting the object, or if the created
    *                            object does not implement
    *                            <code>SSOClusterManger</code>
    */
   private void createClusterManager(String className)
      throws LifecycleException
   {
      if (ssoClusterManager != null)
         return;

      if (className != null)
      {
         SSOClusterManager mgr = null;
         try
         {
            ClassLoader tcl =
               Thread.currentThread().getContextClassLoader();
            Class clazz = tcl.loadClass(className);
            mgr = (SSOClusterManager) clazz.newInstance();
            mgr.setSSOLocalManager(this);
            ssoClusterManager = mgr;
            clusterManagerClass = className;
         }
         catch (Throwable t)
         {
            throw new LifecycleException("Cannot create " +
               "SSOClusterManager using " +
               className, t);
         }

         if (started)
         {
            try
            {
               ssoClusterManager.start();
            }
            catch (LifecycleException e)
            {
               throw e;
            }
            catch (Exception e)
            {
               throw new LifecycleException("Caught exception stopping " + 
                     ssoClusterManager.getClass().getSimpleName(), e);
            }
         }
      }
   }
   
   
   private void processExpires()
   {
      long now = 0L;
      synchronized (mutex)
      {
         now = System.currentTimeMillis();
      
         if (now - lastProcessExpires > processExpiresInterval)
         {
            lastProcessExpires = now;
         }
         else
         {
             return;
         }
      }
      
      clearExpiredSSOs(now);
   }
   
   private synchronized void clearExpiredSSOs(long now)
   {      
      for (Iterator iter = emptySSOs.entrySet().iterator(); iter.hasNext();)
      {
         Map.Entry entry = (Map.Entry) iter.next();
         if ( (now - ((Long) entry.getValue()).longValue()) > maxEmptyLife)
         {
            String ssoId = (String) entry.getKey();
            if (getContainer().getLogger().isTraceEnabled())
            {
               getContainer().getLogger().trace("Invalidating expired SSO " + ssoId);
            }
            logout(ssoId);
         }
      }      
   }
   
   private boolean isValid(String ssoId, JBossSingleSignOnEntry entry)
   {
      boolean valid = true;
      if (entry.getSessionCount() == 0)
      {
         Long expired = (Long) emptySSOs.get(ssoId);
         if (expired != null 
               && (System.currentTimeMillis() - expired.longValue()) > maxEmptyLife)
         {
            valid = false;
            
            if (getContainer().getLogger().isTraceEnabled())
            {
               getContainer().getLogger().trace("Invalidating expired SSO " + ssoId);
            }
            
            logout(ssoId);
         }
      }
      
      return valid;
   }
   
   private FullyQualifiedSessionId getFullyQualifiedSessionId(Session session)
   {
      String id = session.getIdInternal();
      Container context = session.getManager().getContainer();
      String contextName = context.getName(); 
      Container host = context.getParent();
      String hostName = host.getName();
      
      return new FullyQualifiedSessionId(id, contextName, hostName);
   }

}
