Merge pull request #13 from serac/casc-166
CASC-166 Fix race condition in cached assertion cleanup.
This commit is contained in:
commit
8e9ddebce1
|
|
@ -26,9 +26,15 @@ import java.beans.PropertyDescriptor;
|
|||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.security.acl.Group;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.security.auth.Subject;
|
||||
import javax.security.auth.callback.Callback;
|
||||
|
|
@ -79,6 +85,8 @@ import org.jasig.cas.client.validation.TicketValidator;
|
|||
* for JAAS providers that attempt to periodically reauthenticate to renew principal.
|
||||
* Since CAS tickets are one-time-use, a cached assertion must be provided on reauthentication.</li>
|
||||
* <li>cacheTimeout (optional) - Assertion cache timeout in minutes.</li>
|
||||
* <li>cacheTimeoutUnit (optional) - Assertion cache timeout unit. Must be one of {@link TimeUnit} enumeration
|
||||
* names, e.g. DAYS, HOURS, MINUTES, SECONDS, MILLISECONDS. Default unit is MINUTES.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
|
|
@ -129,6 +137,9 @@ public class CasLoginModule implements LoginModule {
|
|||
*/
|
||||
public static final int DEFAULT_CACHE_TIMEOUT = 480;
|
||||
|
||||
/** Default assertion cache timeout unit is minutes. */
|
||||
public static final TimeUnit DEFAULT_CACHE_TIMEOUT_UNIT = TimeUnit.MINUTES;
|
||||
|
||||
/**
|
||||
* Stores mapping of ticket to assertion to support JAAS providers that
|
||||
* attempt to periodically re-authenticate to renew principal. Since
|
||||
|
|
@ -137,9 +148,6 @@ public class CasLoginModule implements LoginModule {
|
|||
*/
|
||||
protected static final Map<TicketCredential,Assertion> ASSERTION_CACHE = new HashMap<TicketCredential,Assertion>();
|
||||
|
||||
/** Executor responsible for assertion cache cleanup */
|
||||
protected static Executor cacheCleanerExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
/** Logger instance */
|
||||
protected final Log log = LogFactory.getLog(getClass());
|
||||
|
||||
|
|
@ -182,8 +190,12 @@ public class CasLoginModule implements LoginModule {
|
|||
/** Assertion cache timeout in minutes */
|
||||
protected int cacheTimeout = DEFAULT_CACHE_TIMEOUT;
|
||||
|
||||
/** Units of cache timeout. */
|
||||
protected TimeUnit cacheTimeoutUnit = DEFAULT_CACHE_TIMEOUT_UNIT;
|
||||
|
||||
/**
|
||||
* Initializes the CAS login module.
|
||||
*
|
||||
* @param subject Authentication subject.
|
||||
* @param handler Callback handler.
|
||||
* @param state Shared state map.
|
||||
|
|
@ -201,15 +213,20 @@ public class CasLoginModule implements LoginModule {
|
|||
* which by default are single use, reauthentication fails. Assertion caching addresses this
|
||||
* behavior.</li>
|
||||
* <li>cacheTimeout (optional) - assertion cache timeout in minutes.</li>
|
||||
* <li>cacheTimeoutUnit (optional) - Assertion cache timeout unit. Must be one of {@link TimeUnit} enumeration
|
||||
* names, e.g. DAYS, HOURS, MINUTES, SECONDS, MILLISECONDS. Default unit is MINUTES.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final void initialize(
|
||||
final Subject subject,
|
||||
final CallbackHandler handler,
|
||||
final Map<String,?> state,
|
||||
final Map<String, ?> options) {
|
||||
|
||||
|
||||
public final void initialize(final Subject subject, final CallbackHandler handler, final Map<String,?> state, final Map<String, ?> options) {
|
||||
this.assertion = null;
|
||||
this.callbackHandler = handler;
|
||||
this.subject = subject;
|
||||
this.sharedState = (Map<String,Object>) state;
|
||||
this.sharedState = (Map<String, Object>) state;
|
||||
this.sharedState = new HashMap<String, Object>(state);
|
||||
|
||||
String ticketValidatorClass = null;
|
||||
|
|
@ -245,11 +262,14 @@ public class CasLoginModule implements LoginModule {
|
|||
} else if ("cacheTimeout".equals(key)) {
|
||||
this.cacheTimeout = Integer.parseInt((String) options.get(key));
|
||||
log.debug("Set cacheTimeout=" + this.cacheTimeout);
|
||||
} else if ("cacheTimeoutUnit".equals(key)) {
|
||||
this.cacheTimeoutUnit = Enum.valueOf(TimeUnit.class, (String) options.get(key));
|
||||
log.debug("Set cacheTimeoutUnit=" + this.cacheTimeoutUnit);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cacheAssertions) {
|
||||
cacheCleanerExecutor.execute(new CacheCleaner());
|
||||
cleanCache();
|
||||
}
|
||||
|
||||
CommonUtils.assertNotNull(ticketValidatorClass, "ticketValidatorClass is required.");
|
||||
|
|
@ -293,19 +313,19 @@ public class CasLoginModule implements LoginModule {
|
|||
throw (LoginException) new LoginException("IO exception in callback handler: " + e).initCause(e);
|
||||
} catch (final UnsupportedCallbackException e) {
|
||||
log.info("Login failed due to unsupported callback: " + e);
|
||||
throw (LoginException) new LoginException("Callback handler does not support PasswordCallback and TextInputCallback.").initCause(e);
|
||||
throw (LoginException) new LoginException(
|
||||
"Callback handler does not support PasswordCallback and TextInputCallback.").initCause(e);
|
||||
}
|
||||
|
||||
if (ticketCallback.getPassword() != null) {
|
||||
this.ticket = new TicketCredential(new String(ticketCallback.getPassword()));
|
||||
final String service = CommonUtils.isNotBlank(serviceCallback.getName()) ? serviceCallback.getName() : this.service;
|
||||
final String service = CommonUtils.isNotBlank(
|
||||
serviceCallback.getName()) ? serviceCallback.getName() : this.service;
|
||||
|
||||
if (this.cacheAssertions) {
|
||||
synchronized(ASSERTION_CACHE) {
|
||||
if (ASSERTION_CACHE.get(ticket) != null) {
|
||||
log.debug("Assertion found in cache.");
|
||||
this.assertion = ASSERTION_CACHE.get(ticket);
|
||||
}
|
||||
this.assertion = ASSERTION_CACHE.get(ticket);
|
||||
if (this.assertion != null) {
|
||||
log.debug("Assertion found in cache.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,7 +333,8 @@ public class CasLoginModule implements LoginModule {
|
|||
log.debug("CAS assertion is null; ticket validation required.");
|
||||
if (CommonUtils.isBlank(service)) {
|
||||
log.info("Login failed because required CAS service parameter not provided.");
|
||||
throw new LoginException("Neither login module nor callback handler provided required service parameter.");
|
||||
throw new LoginException(
|
||||
"Neither login module nor callback handler provided required service parameter.");
|
||||
}
|
||||
try {
|
||||
if (log.isDebugEnabled()) {
|
||||
|
|
@ -380,7 +401,8 @@ public class CasLoginModule implements LoginModule {
|
|||
throw new LoginException("Ticket credential not found.");
|
||||
}
|
||||
|
||||
final AssertionPrincipal casPrincipal = new AssertionPrincipal(this.assertion.getPrincipal().getName(), this.assertion);
|
||||
final AssertionPrincipal casPrincipal = new AssertionPrincipal(
|
||||
this.assertion.getPrincipal().getName(), this.assertion);
|
||||
this.subject.getPrincipals().add(casPrincipal);
|
||||
|
||||
// Add group containing principal as sole member
|
||||
|
|
@ -486,10 +508,12 @@ public class CasLoginModule implements LoginModule {
|
|||
* @return Ticket validator with properties set.
|
||||
*/
|
||||
private TicketValidator createTicketValidator(final String className, final Map<String,?> propertyMap) {
|
||||
CommonUtils.assertTrue(propertyMap.containsKey("casServerUrlPrefix"), "Required property casServerUrlPrefix not found.");
|
||||
CommonUtils.assertTrue(
|
||||
propertyMap.containsKey("casServerUrlPrefix"), "Required property casServerUrlPrefix not found.");
|
||||
|
||||
final Class<TicketValidator> validatorClass = ReflectUtils.loadClass(className);
|
||||
final TicketValidator validator = ReflectUtils.newInstance(validatorClass, propertyMap.get("casServerUrlPrefix"));
|
||||
final TicketValidator validator = ReflectUtils.newInstance(
|
||||
validatorClass, propertyMap.get("casServerUrlPrefix"));
|
||||
|
||||
try {
|
||||
final BeanInfo info = Introspector.getBeanInfo(validatorClass);
|
||||
|
|
@ -534,7 +558,8 @@ public class CasLoginModule implements LoginModule {
|
|||
} else if (long.class.equals(pd.getPropertyType())) {
|
||||
return new Long(value);
|
||||
} else {
|
||||
throw new IllegalArgumentException("No conversion strategy exists for property " + pd.getName() + " of type " + pd.getPropertyType());
|
||||
throw new IllegalArgumentException(
|
||||
"No conversion strategy exists for property " + pd.getName() + " of type " + pd.getPropertyType());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,27 +579,28 @@ public class CasLoginModule implements LoginModule {
|
|||
this.subject.getPrivateCredentials().removeAll(this.subject.getPrivateCredentials(clazz));
|
||||
}
|
||||
|
||||
/** Removes expired entries from the assertion cache. */
|
||||
private class CacheCleaner implements Runnable {
|
||||
public void run() {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Cleaning assertion cache of size " + CasLoginModule.ASSERTION_CACHE.size());
|
||||
}
|
||||
final Iterator<Map.Entry<TicketCredential,Assertion>> iter =
|
||||
CasLoginModule.ASSERTION_CACHE.entrySet().iterator();
|
||||
final Calendar cutoff = Calendar.getInstance();
|
||||
cutoff.add(Calendar.MINUTE, -CasLoginModule.this.cacheTimeout);
|
||||
while (iter.hasNext()) {
|
||||
final Assertion assertion = iter.next().getValue();
|
||||
final Calendar created = Calendar.getInstance();
|
||||
created.setTime(assertion.getValidFromDate());
|
||||
if (created.before(cutoff)) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Removing expired assertion for principal " + assertion.getPrincipal());
|
||||
}
|
||||
iter.remove();
|
||||
|
||||
/**
|
||||
* Removes expired entries from the assertion cache.
|
||||
*/
|
||||
private void cleanCache() {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Cleaning assertion cache of size " + ASSERTION_CACHE.size());
|
||||
}
|
||||
final Iterator<Map.Entry<TicketCredential, Assertion>> iter = ASSERTION_CACHE.entrySet().iterator();
|
||||
final Calendar cutoff = Calendar.getInstance();
|
||||
cutoff.setTimeInMillis(System.currentTimeMillis() - this.cacheTimeoutUnit.toMillis(this.cacheTimeout));
|
||||
while (iter.hasNext()) {
|
||||
final Assertion assertion = iter.next().getValue();
|
||||
final Calendar created = Calendar.getInstance();
|
||||
created.setTime(assertion.getValidFromDate());
|
||||
if (created.before(cutoff)) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Removing expired assertion for principal " + assertion.getPrincipal());
|
||||
}
|
||||
iter.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,13 +28,16 @@ import java.util.Set;
|
|||
import javax.security.auth.Subject;
|
||||
import javax.security.auth.login.LoginException;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.jasig.cas.client.PublicTestHttpServer;
|
||||
import org.junit.AfterClass;
|
||||
import org.jasig.cas.client.validation.TicketValidationException;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
/**
|
||||
* Unit test for {@link CasLoginModule} class.
|
||||
*
|
||||
|
|
@ -150,15 +153,16 @@ public class CasLoginModuleTests {
|
|||
final String USERNAME = "username";
|
||||
final String SERVICE = "https://example.com/service";
|
||||
final String TICKET = "ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1";
|
||||
final String RESPONSE1 = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>"
|
||||
final String SUCCESS_RESPONSE = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>"
|
||||
+ "<cas:authenticationSuccess><cas:user>"
|
||||
+ USERNAME
|
||||
+ "</cas:user></cas:authenticationSuccess></cas:serviceResponse>";
|
||||
final String RESPONSE2 = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'><cas:authenticationFailure code=\"INVALID_TICKET\">Ticket ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1 not recognized</cas:authenticationFailure></cas:serviceResponse>";
|
||||
server.content = RESPONSE1.getBytes(server.encoding);
|
||||
|
||||
final String FAILURE_RESPONSE = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'><cas:authenticationFailure code=\"INVALID_TICKET\">Ticket ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1 not recognized</cas:authenticationFailure></cas:serviceResponse>";
|
||||
|
||||
options.put("cacheAssertions", "true");
|
||||
options.put("cacheTimeout", "1");
|
||||
|
||||
server.content = SUCCESS_RESPONSE.getBytes(server.encoding);
|
||||
module.initialize(
|
||||
subject,
|
||||
new ServiceAndTicketCallbackHandler(SERVICE, TICKET),
|
||||
|
|
@ -173,7 +177,7 @@ public class CasLoginModuleTests {
|
|||
module.logout();
|
||||
assertEquals(0, subject.getPrincipals().size());
|
||||
assertEquals(0, subject.getPrivateCredentials().size());
|
||||
server.content = RESPONSE2.getBytes(server.encoding);
|
||||
server.content = FAILURE_RESPONSE.getBytes(server.encoding);
|
||||
module.initialize(
|
||||
subject,
|
||||
new ServiceAndTicketCallbackHandler(SERVICE, TICKET),
|
||||
|
|
@ -184,6 +188,54 @@ public class CasLoginModuleTests {
|
|||
assertEquals(this.subject.getPrincipals().size(), 3);
|
||||
assertEquals(TICKET, this.subject.getPrivateCredentials().iterator().next().toString());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verify that cached assertions that are expired are never be accessible
|
||||
* by {@link org.jasig.cas.client.jaas.CasLoginModule#login()} method.
|
||||
*
|
||||
* @throws Exception On errors.
|
||||
*/
|
||||
@Test
|
||||
public void testAssertionCachingExpiration() throws Exception {
|
||||
final String USERNAME = "hizzy";
|
||||
final String SERVICE = "https://example.com/service";
|
||||
final String TICKET = "ST-12345-ABCDEFGHIJKLMNOPQRSTUVWXYZ-hosta";
|
||||
final String SUCCESS_RESPONSE = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>"
|
||||
+ "<cas:authenticationSuccess><cas:user>"
|
||||
+ USERNAME
|
||||
+ "</cas:user></cas:authenticationSuccess></cas:serviceResponse>";
|
||||
final String FAILURE_RESPONSE = "<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'><cas:authenticationFailure code=\"INVALID_TICKET\">Ticket ST-12345-ABCDEFGHIJKLMNOPQRSTUVWXYZ-hosta not recognized</cas:authenticationFailure></cas:serviceResponse>";
|
||||
|
||||
options.put("cacheAssertions", "true");
|
||||
// Cache timeout is 1 second
|
||||
options.put("cacheTimeoutUnit", "SECONDS");
|
||||
options.put("cacheTimeout", "1");
|
||||
|
||||
server.content = SUCCESS_RESPONSE.getBytes(server.encoding);
|
||||
module.initialize(
|
||||
subject,
|
||||
new ServiceAndTicketCallbackHandler(SERVICE, TICKET),
|
||||
new HashMap<String, Object>(),
|
||||
options);
|
||||
assertTrue(module.login());
|
||||
module.commit();
|
||||
|
||||
Thread.sleep(1100);
|
||||
// Assertion should now be expired from cache
|
||||
server.content = FAILURE_RESPONSE.getBytes(server.encoding);
|
||||
module.initialize(
|
||||
subject,
|
||||
new ServiceAndTicketCallbackHandler(SERVICE, TICKET),
|
||||
new HashMap<String, Object>(),
|
||||
options);
|
||||
try {
|
||||
module.login();
|
||||
fail("Should have thrown login exception.");
|
||||
} catch (LoginException e) {
|
||||
assertTrue(e.getCause() instanceof TicketValidationException);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasPrincipalName(final Subject subject, final Class<? extends Principal> principalClass, final String name) {
|
||||
final Set<? extends Principal> principals = subject.getPrincipals(principalClass);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
#
|
||||
# log4j configuration to get clean console listing during Maven tests
|
||||
#
|
||||
|
||||
#
|
||||
# Licensed to Jasig under one or more contributor license
|
||||
# agreements. See the NOTICE file distributed with this work
|
||||
# for additional information regarding copyright ownership.
|
||||
# Jasig licenses this file to you 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 the following location:
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
|
||||
log4j.rootCategory=INFO, stdout
|
||||
log4j.logger.org.apache.xml.security=OFF
|
||||
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
|
||||
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
|
||||
log4j.appender.stdout.layout.ConversionPattern=%-5p %d{ISO8601} %t::%c{1} - %m%n
|
||||
|
||||
log4j.logger.org.jasig.cas=DEBUG
|
||||
Loading…
Reference in New Issue