diff --git a/cas-client-core/src/main/java/org/jasig/cas/client/jaas/CasLoginModule.java b/cas-client-core/src/main/java/org/jasig/cas/client/jaas/CasLoginModule.java index 58a3e0e..02454e8 100644 --- a/cas-client-core/src/main/java/org/jasig/cas/client/jaas/CasLoginModule.java +++ b/cas-client-core/src/main/java/org/jasig/cas/client/jaas/CasLoginModule.java @@ -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. *
  • cacheTimeout (optional) - Assertion cache timeout in minutes.
  • + *
  • 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.
  • * * *

    @@ -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 ASSERTION_CACHE = new HashMap(); - /** 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. *

  • cacheTimeout (optional) - assertion cache timeout in minutes.
  • + *
  • 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.
  • * */ + public final void initialize( + final Subject subject, + final CallbackHandler handler, + final Map state, + final Map options) { - - public final void initialize(final Subject subject, final CallbackHandler handler, final Map state, final Map options) { this.assertion = null; this.callbackHandler = handler; this.subject = subject; - this.sharedState = (Map) state; + this.sharedState = (Map) state; this.sharedState = new HashMap(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 propertyMap) { - CommonUtils.assertTrue(propertyMap.containsKey("casServerUrlPrefix"), "Required property casServerUrlPrefix not found."); + CommonUtils.assertTrue( + propertyMap.containsKey("casServerUrlPrefix"), "Required property casServerUrlPrefix not found."); final Class 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> 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> 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(); } } } } + diff --git a/cas-client-core/src/test/java/org/jasig/cas/client/jaas/CasLoginModuleTests.java b/cas-client-core/src/test/java/org/jasig/cas/client/jaas/CasLoginModuleTests.java index 623730a..ede32b0 100644 --- a/cas-client-core/src/test/java/org/jasig/cas/client/jaas/CasLoginModuleTests.java +++ b/cas-client-core/src/test/java/org/jasig/cas/client/jaas/CasLoginModuleTests.java @@ -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 = "" + final String SUCCESS_RESPONSE = "" + "" + USERNAME + ""; - final String RESPONSE2 = "Ticket ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1 not recognized"; - server.content = RESPONSE1.getBytes(server.encoding); - + final String FAILURE_RESPONSE = "Ticket ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1 not recognized"; + 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 = "" + + "" + + USERNAME + + ""; + final String FAILURE_RESPONSE = "Ticket ST-12345-ABCDEFGHIJKLMNOPQRSTUVWXYZ-hosta not recognized"; + + 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(), + 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(), + 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 principalClass, final String name) { final Set principals = subject.getPrincipals(principalClass); diff --git a/cas-client-core/src/test/resources/log4j.properties b/cas-client-core/src/test/resources/log4j.properties new file mode 100644 index 0000000..8ccad3b --- /dev/null +++ b/cas-client-core/src/test/resources/log4j.properties @@ -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