/* 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());
}
}
}
}