/*
* Copyright 2009 Martin Grotzke
*
* 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 de.javakaffee.web.msm;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.Principal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;
import org.apache.catalina.Manager;
import org.apache.catalina.SessionListener;
import org.apache.catalina.authenticator.Constants;
import org.apache.catalina.session.StandardSession;
import de.javakaffee.web.msm.MemcachedSessionService.LockStatus;
import de.javakaffee.web.msm.MemcachedSessionService.SessionManager;
/**
* The session class used by the {@link MemcachedSessionService}.
* <p>
* This class is needed to e.g.
* <ul>
* <li>be able to change the session id without the whole notification lifecycle (which includes the
* application also).</li>
* <li>provide access to internal fields of the session, for serialization/deserialization</li>
* <li>be able to coordinate backup and expirationUpdate</li>
* </ul>
* </p>
*
* @author <a href="mailto:martin.grotzke@javakaffee.de">Martin Grotzke</a>
*/
public class MemcachedBackupSession extends StandardSession {
private static final long serialVersionUID = 1L;
// Indirection for this.attributes is needed for support of different tomcat versions.
// While this class (today) is compiled against latest tomcat 7 where attributes is declared as ConcurrentMap,
// in earlier versions of tomcat this was Map.
// In consequence, accessing this.attributes might result in "java.lang.NoSuchFieldError: attributes".
private static final AttributeAccessor attributeAccessor;
static {
try {
final Field attributesField = StandardSession.class.getDeclaredField("attributes");
attributeAccessor = attributesField.getType() != ConcurrentMap.class
? new AttributeAccessor() {
{ attributesField.setAccessible(true); }
@Override
public ConcurrentMap<String, Object> get(MemcachedBackupSession session) {
try {
return (ConcurrentMap<String, Object>)attributesField.get(session);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
@Override
public void set(MemcachedBackupSession session, ConcurrentMap<String, Object> attributes) {
try {
attributesField.set(session, attributes);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
: new AttributeAccessor() {
@Override
public ConcurrentMap<String, Object> get(MemcachedBackupSession session) {
return session.attributes;
}
@Override
public void set(MemcachedBackupSession session, ConcurrentMap<String, Object> attributes) {
session.attributes = attributes;
}
};
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
}
}
// Cached one parameter StandardSession#exclude(String) method which was deprecated and removed in newer
// Tomcat versions (see CVE-2016-0714). It is called via reflection when it is present (and the two
// parameter variant is not) to maintain compatibility with older Tomcat versions.
private static final Method legacySessionExcludeMethod;
static {
try {
Method method;
try {
// StandardSession#exclude(String, Object) found in newer Tomcat versions is preferred
StandardSession.class.getDeclaredMethod("exclude", String.class, Object.class);
method = null;
} catch (NoSuchMethodException e) {
// Try to fallback to StandardSession#exclude(String) for compatibility reasons
method = StandardSession.class.getDeclaredMethod("exclude", String.class);
}
legacySessionExcludeMethod = method;
} catch (NoSuchMethodException e) {
throw new RuntimeException("Neither StandardSession#exclude(String) nor " +
"StandardSession#exclude(String, Object) method was found.", e);
}
}
/*
* The hash code of the serialized byte[] of this session that is
* used to determine, if the session was modified.
*/
private transient int _dataHashCode;
/*
* Used to determine, if the session was #accessed since it was
* last backup'ed (or checked if it needs to be backup'ed)
*/
private transient long _thisAccessedTimeFromLastBackupCheck;
/*
* Stores the time in millis when this session was stored in memcached, is set
* before session data is serialized.
*/
protected long _lastBackupTime;
/*
* The former value of _lastBackupTimestamp which is restored if the session could not be saved
* in memcached.
*/
private transient long _previousLastBackupTime;
/*
* The expiration time that was sent to memcached with the last backup/touch.
*/
private transient int _lastMemcachedExpirationTime;
/*
* Stores, if the sessions expiration is just being updated in memcached
*/
private transient volatile boolean _expirationUpdateRunning;
/*
* Stores, if the sessions is just being backuped
*/
private transient volatile boolean _backupRunning;
private transient boolean _authenticationChanged;
@edu.umd.cs.findbugs.annotations.SuppressWarnings( "SE_TRANSIENT_FIELD_NOT_RESTORED" )
private transient boolean _attributesAccessed;
private transient boolean _sessionIdChanged;
protected transient boolean _sticky;
private transient volatile LockStatus _lockStatus;
@edu.umd.cs.findbugs.annotations.SuppressWarnings( "SE_TRANSIENT_FIELD_NOT_RESTORED" )
private transient final Set<Long> _refCount;
/**
* Creates a new instance without a given manager. This has to be
* assigned via {@link #setManager(Manager)} before this session is
* used.
*
*/
public MemcachedBackupSession() {
this(null);
}
/**
* Creates a new instance with the given manager.
*
* @param manager
* the manager
*/
public MemcachedBackupSession( final SessionManager manager ) {
super( manager );
_refCount = new HashSet<Long>();
}
@Override
public SessionManager getManager() {
return (SessionManager) super.getManager();
}
/**
* {@inheritDoc}
*/
@Override
public Object getAttribute( final String name ) {
if (filterAttribute(name)) {
_attributesAccessed = true;
}
return super.getAttribute( name );
}
/**
* {@inheritDoc}
*/
@Override
public void setAttribute( final String name, final Object value ) {
if (filterAttribute(name)) {
_attributesAccessed = true;
}
super.setAttribute( name, value );
}
/**
* {@inheritDoc}
*/
@Override
public void setAttribute( final String name, final Object value, final boolean notify ) {
if (filterAttribute(name)) {
_attributesAccessed = true;
}
super.setAttribute( name, value, notify );
}
@Override
public void removeAttribute(final String name) {
if (filterAttribute(name)) {
_attributesAccessed = true;
}
super.removeAttribute(name);
}
@Override
public void recycle() {
super.recycle();
_attributesAccessed = false;
_dataHashCode = 0;
_expirationUpdateRunning = false;
_backupRunning = false;
_lockStatus = null;
}
/**
* Check whether the given attribute name matches our name pattern and shall be stored in memcached.
*
* @return true if the name matches
*/
private boolean filterAttribute( final String name ) {
if ( this.manager == null ) {
throw new IllegalStateException( "There's no manager set." );
}
final Pattern pattern = ((SessionManager)manager).getMemcachedSessionService().getSessionAttributePattern();
if ( pattern == null ) {
return true;
}
return pattern.matcher(name).matches();
}
/**
* Calculates the expiration time that must be sent to memcached,
* based on the sessions maxInactiveInterval and the time the session
* is already idle (based on thisAccessedTime).
* <p>
* Calculating this time instead of just using maxInactiveInterval is
* important for the update of the expiration time: if we would just use
* maxInactiveInterval, the session would exist longer in memcached than it would
* be valid in tomcat.
* </p>
*
* @return the expiration time in seconds
*/
int getMemcachedExpirationTimeToSet() {
/* SRV.13.4 ("Deployment Descriptor"): If the timeout is 0 or less, the container
* ensures the default behaviour of sessions is never to time out.
*/
if ( maxInactiveInterval <= 0 ) {
return 0;
}
if ( !_sticky ) {
return 2 * maxInactiveInterval;
}
final long timeIdleInMillis = System.currentTimeMillis() - getThisAccessedTimeInternal();
/* rounding is just for tests, as they are using actually seconds for testing.
* with a default setup 1 second difference wouldn't matter...
*/
final int timeIdle = Math.round( (float)timeIdleInMillis / 1000L );
final int expirationTime = getMaxInactiveInterval() - timeIdle;
final int processExpiresOffset = getManager().getProcessExpiresFrequency() * getManager().getContext().getBackgroundProcessorDelay();
return expirationTime + processExpiresOffset;
}
/**
* Gets the time in seconds when this session will expire in memcached.
* If the session was stored in memcached with expiration 0 this method will just
* return 0.
*
* @return the time in seconds
*/
int getMemcachedExpirationTime() {
if ( !_sticky ) {
throw new IllegalStateException( "The memcached expiration time cannot be determined in non-sticky mode." );
}
if ( _lastMemcachedExpirationTime == 0 ) {
return 0;
}
final long timeIdleInMillis = _lastBackupTime == 0 ? 0 : System.currentTimeMillis() - _lastBackupTime;
/* rounding is just for tests, as they are using actually seconds for testing.
* with a default setup 1 second difference wouldn't matter...
*/
final int timeIdle = Math.round( (float)timeIdleInMillis / 1000L );
final int expirationTime = _lastMemcachedExpirationTime - timeIdle;
return expirationTime;
}
/**
* Store the time in millis when this session was successfully stored in memcached. This timestamp
* is stored in memcached also to support non-sticky session mode. It's reset on backup failure
* (see {@link #backupFailed()}), not on skipped backup, therefore it must be set after skip checks
* before session serialization.
* @param lastBackupTime the lastBackupTimestamp to set
*/
void setLastBackupTime( final long lastBackupTime ) {
_previousLastBackupTime = _lastBackupTime;
_lastBackupTime = lastBackupTime;
}
/**
* The time in millis when this session was the last time successfully stored in memcached. There's a short time when
* this timestamp represents just the current time (right before backup) - it's updated just after
* {@link #setBackupRunning(boolean)} was set to false and is reset in the case of backup failure, therefore when
* {@link #isBackupRunning()} is <code>false</code>, it definitely represents the timestamp when this session was
* stored in memcached the last time.
*/
long getLastBackupTime() {
return _lastBackupTime;
}
/**
* The time in seconds that passed since this session was stored in memcached.
*/
int getSecondsSinceLastBackup() {
final long timeNotUpdatedInMemcachedInMillis = System.currentTimeMillis() - _lastBackupTime;
return Math.round( (float)timeNotUpdatedInMemcachedInMillis / 1000L );
}
/**
* The expiration time that was sent to memcached with the last backup/touch.
*
* @return the lastMemcachedExpirationTime
*/
int getLastMemcachedExpirationTime() {
return _lastMemcachedExpirationTime;
}
/**
* Set the expiration time that was sent to memcached with this backup/touch.
* @param lastMemcachedExpirationTime the lastMemcachedExpirationTime to set
*/
void setLastMemcachedExpirationTime( final int lastMemcachedExpirationTime ) {
_lastMemcachedExpirationTime = lastMemcachedExpirationTime;
}
/**
* Determines, if the session was accessed since the last backup.
* @return <code>true</code>, if <code>thisAccessedTime > lastBackupTimestamp</code>.
*/
boolean wasAccessedSinceLastBackup() {
return this.thisAccessedTime > _lastBackupTime;
}
/**
* Stores the current value of {@link #getThisAccessedTimeInternal()} in a private,
* transient field. You can check with {@link #wasAccessedSinceLastBackupCheck()}
* if the current {@link #getThisAccessedTimeInternal()} value is different
* from the previously stored value to see if the session was accessed in
* the meantime.
*
* @deprecated the session is always accessed for a request that comes with a session id. Therefore
* {@link #wasAccessedSinceLastBackup()} should always return <code>true</code>.
*/
@Deprecated
void storeThisAccessedTimeFromLastBackupCheck() {
_thisAccessedTimeFromLastBackupCheck = this.thisAccessedTime;
}
/**
* Determines, if the current request accessed the session. This is provided,
* if the current value of {@link #getThisAccessedTimeInternal()}
* differs from the value stored by {@link #storeThisAccessedTimeFromLastBackupCheck()}.
* @return <code>true</code> if the session was accessed since the invocation
* of {@link #storeThisAccessedTimeFromLastBackupCheck()}.
*
* @deprecated the session is always accessed for a request that comes with a session id. Therefore
* {@link #wasAccessedSinceLastBackup()} should always return <code>true</code>.
*/
@Deprecated
boolean wasAccessedSinceLastBackupCheck() {
return _thisAccessedTimeFromLastBackupCheck != this.thisAccessedTime;
}
/**
* Determines, if attributes were accessed via {@link #getAttribute(String)},
* {@link #setAttribute(String, Object)} or {@link #setAttribute(String, Object, boolean)}
* since the last request.
*
* @return <code>true</code> if attributes were accessed.
*/
boolean attributesAccessedSinceLastBackup() {
return _attributesAccessed;
}
/**
* Determines, if the sessions expiration is just being updated in memcached.
*
* @return the expirationUpdateRunning
*/
boolean isExpirationUpdateRunning() {
return _expirationUpdateRunning;
}
/**
* Store, if the sessions expiration is just being updated in memcached.
*
* @param expirationUpdateRunning the expirationUpdateRunning to set
*/
void setExpirationUpdateRunning( final boolean expirationUpdateRunning ) {
_expirationUpdateRunning = expirationUpdateRunning;
}
/**
* Determines, if the sessions is just being backuped.
*
* @return the backupRunning
*/
boolean isBackupRunning() {
return _backupRunning;
}
/**
* Store, if the sessions is just being backuped.
*
* @param backupRunning the backupRunning to set
*/
void setBackupRunning( final boolean backupRunning ) {
_backupRunning = backupRunning;
}
/**
* Set a new id for this session.<br/>
* Before setting the new id, it removes itself from the associated
* manager. After the new id is set, this session adds itself to the
* session manager.
*
* @param id
* the new session id
*/
protected void setIdForRelocate( final String id ) {
if ( this.id == null ) {
throw new IllegalStateException( "There's no session id set." );
}
if ( this.manager == null ) {
throw new IllegalStateException( "There's no manager set." );
}
/*
* and mark it as a node-failure-session, so that remove(session) does
* not try to remove it from memcached... (the session is removed and
* added when the session id is changed)
*/
setNote( MemcachedSessionService.NODE_FAILURE, Boolean.TRUE );
manager.remove( this );
removeNote( MemcachedSessionService.NODE_FAILURE );
this.id = id;
manager.add( this );
}
/**
* Performs some initialization of this session that is required after
* deserialization. This must be invoked by custom serialization strategies
* that do not rely on the {@link StandardSession} serialization.
*/
public void doAfterDeserialization() {
if ( listeners == null ) {
listeners = new ArrayList<SessionListener>();
}
if ( notes == null ) {
notes = new ConcurrentHashMap<String, Object>();
}
}
/**
* The hash code of the serialized byte[] of this sessions attributes that is
* used to determine, if the session was modified.
* @return the hashCode
*/
int getDataHashCode() {
return _dataHashCode;
}
/**
* Set the hash code of the serialized session attributes.
*
* @param attributesDataHashCode the hashCode of the serialized byte[].
*/
void setDataHashCode( final int attributesDataHashCode ) {
_dataHashCode = attributesDataHashCode;
}
@Override
public long getCreationTimeInternal() {
return this.creationTime;
}
void setCreationTimeInternal( final long creationTime ) {
this.creationTime = creationTime;
}
boolean isNewInternal() {
return this.isNew;
}
void setIsNewInternal( final boolean isNew ) {
this.isNew = isNew;
}
@Override
public boolean isValidInternal() {
return this.isValid;
}
void setIsValidInternal( final boolean isValid ) {
this.isValid = isValid;
}
/**
* The timestamp (System.currentTimeMillis) of the last {@link #access()} invocation,
* this is the timestamp when the application requested the session.
*
* @return the timestamp of the last {@link #access()} invocation.
*/
@Override
public long getThisAccessedTimeInternal() {
return this.thisAccessedTime;
}
void setThisAccessedTimeInternal( final long thisAccessedTime ) {
this.thisAccessedTime = thisAccessedTime;
}
void setLastAccessedTimeInternal( final long lastAccessedTime ) {
this.lastAccessedTime = lastAccessedTime;
}
void setIdInternal( final String id ) {
this.id = id;
}
boolean isExpiring() {
return this.expiring;
}
@SuppressWarnings( "unchecked" )
public ConcurrentMap<String, Object> getAttributesInternal() {
return attributeAccessor.get(this);
}
/**
* Filter map of attributes using our name pattern.
*
* @return the filtered attribute map that only includes attributes that shall be stored in memcached.
*/
public ConcurrentMap<String, Object> getAttributesFiltered() {
if ( this.manager == null ) {
throw new IllegalStateException( "There's no manager set." );
}
final Pattern pattern = ((SessionManager)manager).getMemcachedSessionService().getSessionAttributePattern();
final ConcurrentMap<String, Object> attributes = getAttributesInternal();
if ( pattern == null ) {
return attributes;
}
final ConcurrentMap<String, Object> result = new ConcurrentHashMap<String, Object>( attributes.size() );
for ( final Map.Entry<String, Object> entry: attributes.entrySet() ) {
if ( pattern.matcher(entry.getKey()).matches() ) {
result.put( entry.getKey(), entry.getValue() );
}
}
return result;
}
void setAttributesInternal( final ConcurrentMap<String, Object> attributes ) {
attributeAccessor.set(this, attributes);
}
/**
* {@inheritDoc}
*/
@Override
public void removeAttributeInternal( final String name, final boolean notify ) {
super.removeAttributeInternal( name, notify );
}
/**
* {@inheritDoc}
*/
@Override
protected boolean exclude( final String name, Object value ) {
try {
if (legacySessionExcludeMethod != null) {
return (Boolean) legacySessionExcludeMethod.invoke(this, name);
} else {
return super.exclude(name, value);
}
} catch (InvocationTargetException e) {
if (e.getCause() instanceof RuntimeException) {
throw (RuntimeException) e.getCause();
}
throw new RuntimeException("Failed to invoke StandardSession#exclude(String) method.", e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Can not access StandardSession#exclude(String) method.", e);
}
}
/**
* Determines, if the {@link #getAuthType()} or {@link #getPrincipal()}
* properties have changed or if the session has set the principal as note (which
* is the case during form based authentication).
* @return <code>true</code> if authentication details have changed.
*/
boolean authenticationChanged() {
return _authenticationChanged || getNote(Constants.FORM_PRINCIPAL_NOTE) != null;
}
/**
* {@inheritDoc}
*/
@Override
public void setAuthType( final String authType ) {
if ( !equals( authType, this.authType ) ) {
_authenticationChanged = true;
}
super.setAuthType( authType );
}
/**
* Set the authType without modifying the {@link #authenticationChanged()} property.
* @param authType the auth type to set.
*/
public void setAuthTypeInternal( final String authType ) {
super.setAuthType( authType );
}
/**
* {@inheritDoc}
*/
@Override
public void setPrincipal( final Principal principal ) {
if ( !equals( principal, this.principal ) ) {
_authenticationChanged = true;
}
super.setPrincipal( principal );
}
/**
* Set the principal without modifying the {@link #authenticationChanged()} property.
* @param principal the principal to set.
*/
public void setPrincipalInternal( final Principal principal ) {
super.setPrincipal( principal );
}
private static boolean equals( final Object one, final Object another ) {
return one == null && another == null || one != null && one.equals( another );
}
/**
* Is invoked after this session has been successfully stored with
* {@link BackupResultStatus#SUCCESS}.
*/
public void backupFinished() {
_authenticationChanged = false;
_attributesAccessed = false;
_sessionIdChanged = false;
}
/**
* Returns the value previously set by {@link #setSessionIdChanged(boolean)}.
*/
public boolean isSessionIdChanged() {
return _sessionIdChanged;
}
/**
* Store that the session id was changed externally.
* @param sessionIdChanged
*/
public void setSessionIdChanged( final boolean sessionIdChanged ) {
_sessionIdChanged = sessionIdChanged;
}
/**
* Is invoked when session backup failed for this session (result was {@link BackupResultStatus#SUCCESS}).
*/
public void backupFailed() {
_lastBackupTime = _previousLastBackupTime;
}
/**
* Sets the current operation mode of msm, which is important for determining
* the {@link #getMemcachedExpirationTimeToSet()}.
*/
public void setSticky( final boolean sticky ) {
_sticky = sticky;
}
/**
* Returns the stickyness mode of this session.
*/
public boolean isSticky() {
return _sticky;
}
/**
* Returns if there was a lock created in memcached.
*/
public LockStatus getLockStatus() {
return _lockStatus;
}
/**
* Stores if there's a lock created in memcached.
*/
public void setLockStatus( final LockStatus locked ) {
_lockStatus = locked;
}
/**
* Returns if there was a lock created in memcached.
*/
public synchronized boolean isLocked() {
return _lockStatus == LockStatus.LOCKED;
}
/**
* Resets the lock status.
*/
public void releaseLock() {
_lockStatus = null;
}
/**
* Register the current thread to hold a reference on this session.
* @return <code>true</code> if this thread did not hold already the reference,
* otherwise <code>false</code>.
*
* @see #releaseReference()
* @see #getRefCount()
*/
public synchronized boolean registerReference() {
return _refCount.add(Thread.currentThread().getId());
}
/**
* The number of registered references.
*
* @see #registerReference()
* @see #releaseReference()
*/
public int getRefCount() {
return _refCount.size();
}
/**
* Decrement the refcount and return the number of references left.
*
* @see #registerReference()
* @see #getRefCount()
*/
public synchronized int releaseReference() {
_refCount.remove(Thread.currentThread().getId());
return _refCount.size();
}
static abstract interface AttributeAccessor {
ConcurrentMap<String, Object> get(MemcachedBackupSession session);
void set(MemcachedBackupSession session, ConcurrentMap<String, Object> attributes);
}
}