/* This code is part of Freenet. It is distributed under the GNU General * Public License, version 2 (or at your option any later version). See * http://www.gnu.org/ for further details of the GPL. */ package freenet.clients.http; import static java.util.concurrent.TimeUnit.HOURS; import static java.util.concurrent.TimeUnit.MILLISECONDS; import java.net.URI; import java.net.URISyntaxException; import java.text.ParseException; import java.util.Date; import java.util.Enumeration; import java.util.HashMap; import java.util.Hashtable; import java.util.Map; import java.util.Set; import java.util.UUID; import freenet.support.CurrentTimeUTC; import freenet.support.LRUMap; import freenet.support.Logger; import freenet.support.StringValidityChecker; /** * A basic session manager for cookie-based HTTP session. * It allows its parent web interface to associate a "UserID" (a string) with a session ID. * * Formal definition of a SessionManager: * A 1:1 mapping of SessionID to UserID. Queries by SessionID and UserID run in O(1). * The Session ID primary key consists of: Cookie path + Cookie name + random "actual" session ID * The user ID is received from the client application. * * Therefore, when multiple client applications want to store sessions, each one is supposed * to create its own SessionManager because user IDs might overlap. * * The sessions of each application then get their {@link Session} by using a different * cookie path OR a different cookie namespace, depending on which constructor you use. * * Paths are used when client applications do NOT share the same path on the server. * For example "/Chat" would cause the browser to only send back the cookie if the user is * browsing "/Chat/", not for "/". * BUT usually we want the menu contents of client applications to be in the logged-in state even if * the user is NOT browsing the client application web interface right now, therefore the "/" path * must be used in most cases. * If client application cookies shall be received from all paths on the server, the client * application should use the constructor which requires a cookie namespace. * * The usage of a namespace gurantees that Sessions of different client applications do not overlap. * * @author xor (xor@freenetproject.org) */ public final class SessionManager { /** * The amount of milliseconds after which a session is deleted due to expiration. */ public static final long MAX_SESSION_IDLE_TIME = HOURS.toMillis(1); public static final String SESSION_COOKIE_NAME = "SessionID"; private final URI mCookiePath; private final String mCookieNamespace; private final String mCookieName; /** * Constructs a new session manager for use with the given cookie path. * Cookies are only sent back if the user is browsing the domain within that path. * * @param myCookiePath The path in which the cookies should be valid. */ public SessionManager(URI myCookiePath) { if(myCookiePath.isAbsolute()) throw new IllegalArgumentException("Illegal cookie path, must be relative: " + myCookiePath); if(myCookiePath.toString().startsWith("/") == false) throw new IllegalArgumentException("Illegal cookie path, must start with /: " + myCookiePath); // FIXME: The new constructor was written at 2010-11-15. Uncomment the following safety check after we gave plugins some time to migrate // if(myCookiePath.getPath().equals("/")) // throw new IllegalArgumentException("Illegal cookie path '/'. You should use the constructor which allows the specification" + // "of a namespace for using the global path."); // TODO: Add further checks. //mCookieDomain = myCookieDomain; mCookiePath = myCookiePath; mCookieNamespace = ""; mCookieName = SESSION_COOKIE_NAME; } /** * Constructs a new session manager for use with the "/" cookie path * * @param myCookieNamespace The name of the client application which uses this cookie. Must not be empty. Must be latin letters and numbers only. */ public SessionManager(String myCookieNamespace) { if(myCookieNamespace.length() == 0) throw new IllegalArgumentException("You must specify a cookie namespace or use the constructor " + "which allows specification of a cookie path."); if(!StringValidityChecker.isLatinLettersAndNumbersOnly(myCookieNamespace)) throw new IllegalArgumentException("The cookie namespace must be latin letters and numbers only."); //mCookieDomain = myCookieDomain; try { mCookiePath = new URI("/"); } catch (URISyntaxException e) { throw new RuntimeException(e); } mCookieNamespace = myCookieNamespace; mCookieName = myCookieNamespace + SESSION_COOKIE_NAME; } public static final class Session { private final UUID mID; private final String mUserID; private final Map<String, Object> mAttributes = new HashMap<String, Object>(); private long mExpiresAtTime; private Session(String myUserID, long currentTime) { mID = UUID.randomUUID(); mUserID = myUserID; mExpiresAtTime = currentTime + SessionManager.MAX_SESSION_IDLE_TIME; } @Override public boolean equals(Object obj) { if(obj == null) return false; if(!(obj instanceof Session)) return false; Session other = ((Session)obj); return other.getID().equals(mID); } @Override public int hashCode() { return mID.hashCode(); } public UUID getID() { return mID; } public String getUserID() { return mUserID; } private long getExpirationTime() { return mExpiresAtTime; } private boolean isExpired(long time) { return time >= mExpiresAtTime; } private void updateExpiresAtTime(long currentTime) { mExpiresAtTime = currentTime + SessionManager.MAX_SESSION_IDLE_TIME; } /** * Returns whether this session contains an attribute with the given * name. * * @param name * The name of the attribute to check for * @return {@code true} if this session contains an attribute with the * given name, {@code false} otherwise */ public boolean hasAttribute(String name) { return mAttributes.containsKey(name); } /** * Returns the value of the attribute with the given name. If there is * no attribute with the given name, {@code null} is returned. * * @param name * The name of the attribute whose value to get * @return The value of the attribute, or {@code null} */ public Object getAttribute(String name) { return mAttributes.get(name); } /** * Sets the value of the attribute with the given name. * * @param name * The name of the attribute whose value to set * @param value * The new value of the attribute */ public void setAttribute(String name, Object value) { mAttributes.put(name, value); } /** * Removes the attribute with the given name. Nothing will happen if * there is no attribute with the given name. * * @param name * The name of the attribute to remove */ public void removeAttribute(String name) { mAttributes.remove(name); } /** * Returns the names of all currently existing attributes. * * @return The names of all attributes */ public Set<String> getAttributeNames() { return mAttributes.keySet(); } } private final LRUMap<UUID, Session> mSessionsByID = new LRUMap<UUID, Session>(); private final Hashtable<String, Session> mSessionsByUserID = new Hashtable<String, Session>(); /** * Returns the cookie path as specified in the constructor. * Returns "/" if the constructor which only requires a namespace was used. */ public URI getCookiePath() { return mCookiePath; } /** * Returns the namespace as specified in the constructor. * Returns an empty string if the constructor which requires a cookie path only was used. */ public String getCookieNamespace() { return mCookieNamespace; } /** * Creates a new session for the given user ID. * * If a session for the given user ID already exists, it is deleted. It is not re-used to ensure that parallel logins with the same user account from * different computers do not work. * * @param context The ToadletContext in which the session cookie shall be stored. */ public synchronized Session createSession(String userID, ToadletContext context) { // We must synchronize around the fetching of the time and mSessionsByID.push() because mSessionsByID is no sorting data structure: It's a plain // LRUMap so to ensure that it stays sorted the operation "getTime(); push();" must be atomic. long time = CurrentTimeUTC.getInMillis(); removeExpiredSessions(time); deleteSessionByUserID(userID); Session session = new Session(userID, time); mSessionsByID.push(session.getID(), session); mSessionsByUserID.put(session.getUserID(), session); setSessionCookie(session, context); return session; } /** * Returns true if the given {@link ToadletContext} contains a session cookie for a valid (existing and not expired) session. * * In opposite to {@link getSessionUserID}, this function does NOT extend the validity of the session. * Therefore, this function can be considered as a way of peeking for a session, to decide which Toadlet links should be visible. */ public synchronized boolean sessionExists(ToadletContext context) { UUID sessionID = getSessionID(context); if(sessionID == null) return false; removeExpiredSessions(CurrentTimeUTC.getInMillis()); return mSessionsByID.containsKey(sessionID); } /** * Retrieves the session ID from the session cookie in the given {@link ToadletContext}, checks if it contains a valid (existing and not expired) session * and if yes, returns the {@link Session}. * * If the session was valid, then its validity is extended by {@link MAX_SESSION_IDLE_TIME}. * * If the session did not exist or is not valid anymore, <code>null</code> is returned. */ public synchronized Session useSession(ToadletContext context) { UUID sessionID = getSessionID(context); if(sessionID == null) return null; // We must synchronize around the fetching of the time and mSessionsByID.push() because mSessionsByID is no sorting data structure: It's a plain // LRUMap so to ensure that it stays sorted the operation "getTime(); push();" must be atomic. long time = CurrentTimeUTC.getInMillis(); removeExpiredSessions(time); Session session = mSessionsByID.get(sessionID); if(session == null) return null; session.updateExpiresAtTime(time); mSessionsByID.push(session.getID(), session); setSessionCookie(session, context); return session; } /** * Retrieves the session ID from the session cookie in the given {@link ToadletContext}, checks if it contains a valid (existing and not expired) session * and if yes, deletes the session. * * @return True if the session was deleted, false if there was no session cookie or no session. */ public boolean deleteSession(ToadletContext context) { UUID sessionID = getSessionID(context); if(sessionID == null) return false; return deleteSession(sessionID); } /** * @return Returns the session ID stored in the cookies of the HTTP headers of the given {@link ToadletContext}. Returns null if there is no session ID stored. */ private UUID getSessionID(ToadletContext context) { if(context == null) return null; try { ReceivedCookie sessionCookie = context.getCookie(null, mCookiePath, mCookieName); return sessionCookie == null ? null : UUID.fromString(sessionCookie.getValue()); } catch(ParseException e) { Logger.error(this, "Getting session cookie failed", e); return null; } catch(IllegalArgumentException e) { Logger.error(this, "Getting the value of the session cookie failed", e); return null; } } /** * Stores a session cookie for the given session in the given {@link ToadletContext}'s HTTP headers. * @param session * @param context */ private void setSessionCookie(Session session, ToadletContext context) { context.setCookie(new Cookie(mCookiePath, mCookieName, session.getID().toString(), new Date(session.getExpirationTime()))); } /** * Deletes the session with the given ID. * * @return True if a session with the given ID existed. */ private synchronized boolean deleteSession(UUID sessionID) { Session session = mSessionsByID.get(sessionID); if(session == null) return false; mSessionsByID.removeKey(sessionID); mSessionsByUserID.remove(session.getUserID()); return true; } /** * Deletes the session associated with the given user ID. * * @return True if a session with the given ID existed. */ private synchronized boolean deleteSessionByUserID(String userID) { Session session = mSessionsByUserID.remove(userID); if(session == null) return false; mSessionsByID.removeKey(session.getID()); return true; } /** * Garbage-collects any expired sessions. Must be called before client-inteface functions do anything which relies on the existence a session, * that is: creating sessions, using sessions or checking whether sessions exist. * * FIXME: Before putting the session manager into fred, write a thread which periodically garbage collects old sessions - currently, sessions * will only be garbage collected if any client continues using the SessiomManager * * @param time The current time. */ private synchronized void removeExpiredSessions(long time) { for(Session session = mSessionsByID.peekValue(); session != null && session.isExpired(time); session = mSessionsByID.peekValue()) { mSessionsByID.popValue(); mSessionsByUserID.remove(session.getUserID()); } // FIXME: Execute every few hours only. verifyQueueOrder(); verifySessionsByUserIDTable(); } /** * Debug function which checks whether the session LRU queue is in order; */ private synchronized void verifyQueueOrder() { long previousTime = 0; Enumeration<Session> sessions = mSessionsByID.values(); while(sessions.hasMoreElements()) { Session session = sessions.nextElement(); if(session.getExpirationTime() < previousTime) { long sessionAge = HOURS.convert(CurrentTimeUTC.getInMillis() - session.getExpirationTime(), MILLISECONDS); Logger.error(this, "Session LRU queue out of order! Found session which is " + sessionAge + " hour old: " + session); Logger.error(this, "Deleting all sessions..."); mSessionsByID.clear(); mSessionsByUserID.clear(); return; } } } /** * Debug function which checks whether the sessions by user ID table does not contain any sessions which do not exist anymore; */ private synchronized void verifySessionsByUserIDTable() { Enumeration<Session> sessions = mSessionsByUserID.elements(); while(sessions.hasMoreElements()) { Session session = sessions.nextElement(); if(mSessionsByID.containsKey(session.getID()) == false) { Logger.error(this, "Sessions by user ID hashtable contains deleted session, removing it: " + session); mSessionsByUserID.remove(session.getUserID()); } } } }