From 74a8cff651b607f829dcda1a5e2a4fed1a123eb8 Mon Sep 17 00:00:00 2001 From: Scott Battaglia Date: Mon, 16 Aug 2010 03:15:45 +0000 Subject: [PATCH] CASC-115 applied Marvin's patch. Made changes to check for debug enabled before doing any concatenation and to renable the initCause --- .../cas/client/jaas/AssertionPrincipal.java | 1 + .../jasig/cas/client/jaas/CasLoginModule.java | 214 ++++++++++++++---- .../cas/client/jaas/TicketCredential.java | 59 +++++ .../cas/client/jaas/CasLoginModuleTests.java | 93 +++++++- 4 files changed, 314 insertions(+), 53 deletions(-) create mode 100644 cas-client-core/src/main/java/org/jasig/cas/client/jaas/TicketCredential.java diff --git a/cas-client-core/src/main/java/org/jasig/cas/client/jaas/AssertionPrincipal.java b/cas-client-core/src/main/java/org/jasig/cas/client/jaas/AssertionPrincipal.java index 3bc232a..8b888a0 100644 --- a/cas-client-core/src/main/java/org/jasig/cas/client/jaas/AssertionPrincipal.java +++ b/cas-client-core/src/main/java/org/jasig/cas/client/jaas/AssertionPrincipal.java @@ -23,6 +23,7 @@ public class AssertionPrincipal extends SimplePrincipal implements Serializable /** AssertionPrincipal.java */ private static final long serialVersionUID = 2288520214366461693L; + /** CAS assertion describing authenticated state */ private Assertion assertion; /** 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 3f3d7a0..2890737 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 @@ -12,6 +12,8 @@ import java.beans.PropertyDescriptor; import java.io.IOException; import java.security.acl.Group; import java.util.*; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import javax.security.auth.Subject; import javax.security.auth.callback.Callback; @@ -58,6 +60,10 @@ import org.jasig.cas.client.validation.TicketValidator; * which is suitable for JBoss. *
  • roleGroupName (optional) - The name of a group principal containing all role data. * The default value is "Roles", which is suitable for JBoss.
  • + *
  • cacheAssertions (optional) - Flag to enable assertion caching. This may be needed + * 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.
  • * * *

    @@ -81,7 +87,7 @@ import org.jasig.cas.client.validation.TicketValidator; * * * @author Marvin S. Addison - * @version $Revision$ + * @version $Revision$ $Date$ * @since 3.1.11 * */ @@ -102,6 +108,22 @@ public class CasLoginModule implements LoginModule { * support other JEE containers. */ public static final String DEFAULT_ROLE_GROUP_NAME = "Roles"; + + /** + * Default assertion cache timeout in minutes. Default is 8 hours. + */ + public static final int DEFAULT_CACHE_TIMEOUT = 480; + + /** + * Stores mapping of ticket to assertion to support 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. + */ + 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()); @@ -121,6 +143,9 @@ public class CasLoginModule implements LoginModule { /** CAS assertion */ protected Assertion assertion; + /** CAS ticket credential */ + protected TicketCredential ticket; + /** Login module shared state */ protected Map sharedState; @@ -136,6 +161,12 @@ public class CasLoginModule implements LoginModule { /** Name of JAAS Group containing role data */ protected String roleGroupName = DEFAULT_ROLE_GROUP_NAME; + /** Enables or disable assertion caching */ + protected boolean cacheAssertions; + + /** Assertion cache timeout in minutes */ + protected int cacheTimeout; + /** * Initializes the CAS login module. @@ -144,15 +175,22 @@ public class CasLoginModule implements LoginModule { * @param state Shared state map. * @param options Login module options. The following are supported: *

    */ public 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 = state; @@ -185,8 +223,17 @@ public class CasLoginModule implements LoginModule { } else if ("roleGroupName".equals(key)) { this.roleGroupName = (String) options.get(key); log.debug("Set roleGroupName=" + this.roleGroupName); + } else if ("cacheAssertions".equals(key)) { + this.cacheAssertions = Boolean.parseBoolean((String) options.get(key)); + log.debug("Set cacheAssertions=" + this.cacheAssertions); + } else if ("cacheTimeout".equals(key)) { + this.cacheTimeout = Integer.parseInt((String) options.get(key)); + log.debug("Set cacheTimeout=" + this.cacheTimeout); } } + if (this.cacheAssertions) { + cacheCleanerExecutor.execute(new CacheCleaner()); + } CommonUtils.assertNotNull(ticketValidatorClass, "ticketValidatorClass is required."); this.ticketValidator = createTicketValidator(ticketValidatorClass, options); @@ -198,41 +245,70 @@ public class CasLoginModule implements LoginModule { final PasswordCallback ticketCallback = new PasswordCallback("ticket", false); try { this.callbackHandler.handle(new Callback[] { ticketCallback, serviceCallback }); - } catch (final Exception e) { - throw (LoginException) new LoginException(e.getMessage()).initCause(e); + } catch (final IOException e) { + log.info("Login failed due to IO exception in callback handler: " + e); + 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); } + if (ticketCallback.getPassword() != null) { - final String ticket = new String(ticketCallback.getPassword()); + this.ticket = new TicketCredential(new String(ticketCallback.getPassword())); final String service = CommonUtils.isNotBlank(serviceCallback.getName()) ? serviceCallback.getName() : this.service; - 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."); + if (this.cacheAssertions) { + synchronized(ASSERTION_CACHE) { + if (ASSERTION_CACHE.get(ticket) != null) { + log.debug("Assertion found in cache."); + this.assertion = (Assertion) ASSERTION_CACHE.get(ticket); + } + } } - try { - log.debug("Attempting ticket validation with service=" + service + " and ticket=" + ticket); - this.assertion = this.ticketValidator.validate(ticket, service); - } catch (final Exception e) { - throw (LoginException) new LoginException(e.getMessage()).initCause(e); - } + + if (this.assertion == null) { + 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."); + } + try { + if (log.isDebugEnabled()) { + log.debug("Attempting ticket validation with service=" + service + " and ticket=" + ticket); + } + this.assertion = this.ticketValidator.validate(this.ticket.getTicket(), service); + + } catch (final Exception e) { + log.info("Login failed due to CAS ticket validation failure: " + e); + throw (LoginException) new LoginException("CAS ticket validation failed: " + e).initCause(e); + } + } + log.info("Login succeeded."); } else { log.info("Login failed because callback handler did not provide CAS ticket."); throw new LoginException("Callback handler did not provide CAS ticket."); } - log.info("Login succeeded."); return true; } public boolean abort() throws LoginException { + if (this.ticket != null) { + this.ticket = null; + } if (this.assertion != null) { this.assertion = null; - return true; } - return false; + return true; } public boolean commit() throws LoginException { if (this.assertion != null) { + if (this.ticket != null) { + this.subject.getPrivateCredentials().add(this.ticket); + } else { + throw new LoginException("Ticket credential not found."); + } + final AssertionPrincipal casPrincipal = new AssertionPrincipal(this.assertion.getPrincipal().getName(), this.assertion); this.subject.getPrincipals().add(casPrincipal); @@ -270,29 +346,40 @@ public class CasLoginModule implements LoginModule { this.sharedState.put(LOGIN_NAME, casPrincipal.getName()); if (log.isDebugEnabled()) { - log.debug("Created JAAS subject with principals: " + subject.getPrincipals()); + if (log.isDebugEnabled()) { + log.debug("Created JAAS subject with principals: " + subject.getPrincipals()); + } + } + + if (this.cacheAssertions) { + if (log.isDebugEnabled()) { + log.debug("Caching assertion for principal " + this.assertion.getPrincipal()); + } + ASSERTION_CACHE.put(this.ticket, this.assertion); + } + } else { + // Login must have failed if there is no assertion defined + // Need to clean up state + if (this.ticket != null) { + this.ticket = null; } - return true; } - return false; + return true; } public boolean logout() throws LoginException { - if (this.assertion != null) { - log.debug("Performing logout."); - this.subject.getPrincipals().remove(this.assertion.getPrincipal()); - // Remove all SimpleGroup principals - final Iterator iter = this.subject.getPrincipals().iterator(); - while (iter.hasNext()) { - if (iter.next() instanceof SimpleGroup) { - iter.remove(); - } - } - this.assertion = null; - log.info("Logout succeeded."); - return true; - } - return false; + log.debug("Performing logout."); + + // Remove all CAS principals + removePrincipalsOfType(AssertionPrincipal.class); + removePrincipalsOfType(SimplePrincipal.class); + removePrincipalsOfType(SimpleGroup.class); + + // Remove all CAS credentials + removeCredentialsOfType(TicketCredential.class); + + log.info("Logout succeeded."); + return true; } @@ -352,9 +439,52 @@ 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()); + } + } + + /** + * Removes all principals of the given type from the JAAS subject. + * @param clazz Type of principal to remove. + */ + private void removePrincipalsOfType(final Class clazz) { + final Iterator iter = this.subject.getPrincipals(clazz).iterator(); + while (iter.hasNext()) { + this.subject.getPrincipals().remove(iter.next()); + } + } + + /** + * Removes all credentials of the given type from the JAAS subject. + * @param clazz Type of principal to remove. + */ + private void removeCredentialsOfType(final Class clazz) { + final Iterator iter = this.subject.getPrivateCredentials(clazz).iterator(); + while (iter.hasNext()) { + this.subject.getPrivateCredentials().remove(iter.next()); + } + } + + /** 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 = (Assertion) ((Map.Entry) 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/main/java/org/jasig/cas/client/jaas/TicketCredential.java b/cas-client-core/src/main/java/org/jasig/cas/client/jaas/TicketCredential.java new file mode 100644 index 0000000..109bc1a --- /dev/null +++ b/cas-client-core/src/main/java/org/jasig/cas/client/jaas/TicketCredential.java @@ -0,0 +1,59 @@ +/* + * Copyright 2010 The JA-SIG Collaborative. All rights reserved. See license + * distributed with this file and available online at + * http://www.ja-sig.org/products/cas/overview/license/index.html + */ +package org.jasig.cas.client.jaas; + +/** + * Strongly-typed wrapper for a ticket credential. + * + * @author Marvin S. Addison + * @version $Revision$ $Date$ + * @since 3.1.12 + * + */ +public final class TicketCredential { + + /** Hash code seed value */ + private static final int HASHCODE_SEED = 17; + + /** Ticket ID string */ + private String ticket; + + /** + * Creates a new instance that wraps the given ticket. + * @param ticket Ticket identifier string. + */ + public TicketCredential(final String ticket) { + this.ticket = ticket; + } + + /** + * @return Ticket identifier string. + */ + public String getTicket() { + return this.ticket; + } + + public String toString() { + return this.ticket; + } + + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + final TicketCredential that = (TicketCredential) o; + + if (ticket != null ? !ticket.equals(that.ticket) : that.ticket != null) return false; + + return true; + } + + public int hashCode() { + int hash = HASHCODE_SEED; + hash = hash * 31 + (ticket == null ? 0 : ticket.hashCode()); + return hash; + } +} 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 298ac69..5f1f721 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 @@ -15,10 +15,10 @@ import java.util.Set; import javax.security.auth.Subject; import javax.security.auth.login.LoginException; -import org.jasig.cas.client.PublicTestHttpServer; - import junit.framework.TestCase; +import org.jasig.cas.client.PublicTestHttpServer; + /** * Unit test for {@link CasLoginModule} class. * @@ -32,6 +32,8 @@ public class CasLoginModuleTests extends TestCase { private CasLoginModule module; private Subject subject; + + private Map options; /** {@inheritDoc} */ protected void setUp() throws Exception { @@ -39,7 +41,7 @@ public class CasLoginModuleTests extends TestCase { module = new CasLoginModule(); subject = new Subject(); - final Map options = new HashMap(); + options = new HashMap(); options.put("service", "https://service.example.com/webapp"); options.put("ticketValidatorClass", "org.jasig.cas.client.validation.Cas20ServiceTicketValidator"); options.put("casServerUrlPrefix", CONST_CAS_SERVER_URL); @@ -48,27 +50,31 @@ public class CasLoginModuleTests extends TestCase { options.put("defaultRoles", "ADMIN"); options.put("principalGroupName", "CallerPrincipal"); options.put("roleGroupName", "Roles"); - module.initialize( - subject, - new ServiceAndTicketCallbackHandler("myService", "myTicket"), - new HashMap(), - options); } /** * Test JAAS login success. + * @throws Exception On errors. */ public void testLoginSuccess() throws Exception { final String USERNAME = "username"; + final String SERVICE = "https://example.com/service"; + final String TICKET = "ST-100000-aA5Yuvrxzpv8Tau1cYQ7-srv1"; final String RESPONSE = "" + "" + USERNAME + ""; - PublicTestHttpServer.instance().content = RESPONSE - .getBytes(PublicTestHttpServer.instance().encoding); + PublicTestHttpServer.instance().content = RESPONSE.getBytes(PublicTestHttpServer.instance().encoding); + + module.initialize( + subject, + new ServiceAndTicketCallbackHandler(SERVICE, TICKET), + new HashMap(), + options); module.login(); module.commit(); assertEquals(this.subject.getPrincipals().size(), 3); + assertEquals(TICKET, this.subject.getPrivateCredentials().iterator().next().toString()); assertTrue(hasPrincipalName(this.subject, AssertionPrincipal.class, USERNAME)); assertTrue(hasPrincipalName(this.subject, Group.class, "CallerPrincipal")); assertTrue(hasPrincipalName(this.subject, Group.class, "Roles")); @@ -76,16 +82,81 @@ public class CasLoginModuleTests extends TestCase { /** * Test JAAS login failure. + * @throws Exception On errors. */ public void testLoginFailure() throws Exception { - final String RESPONSE = "Ticket ST-1856339-aA5Yuvrxzpv8Tau1cYQ7 not recognized"; + final String SERVICE = "https://example.com/service"; + final String TICKET = "ST-200000-aA5Yuvrxzpv8Tau1cYQ7-srv1"; + final String RESPONSE = "Ticket ST-200000-aA5Yuvrxzpv8Tau1cYQ7-srv1 not recognized"; PublicTestHttpServer.instance().content = RESPONSE.getBytes(PublicTestHttpServer.instance().encoding); + module.initialize( + subject, + new ServiceAndTicketCallbackHandler(SERVICE, TICKET), + new HashMap(), + options); try { module.login(); fail("Login did not throw LoginException as expected."); } catch (Exception e) { assertTrue(e instanceof LoginException); } + module.commit(); + assertNull(module.ticket); + assertNull(module.assertion); + } + + /** + * Test JAAS logout after successful login to ensure subject cleanup. + * @throws Exception On errors. + */ + public void testLogout() throws Exception { + testLoginSuccess(); + module.logout(); + assertEquals(0, subject.getPrincipals().size()); + assertEquals(0, subject.getPrivateCredentials().size()); + } + + /** + * Test assertion cache allows successive logins with same ticket to succeed. + * @throws Exception On errors. + */ + public void testAssertionCaching() throws Exception { + final String USERNAME = "username"; + final String SERVICE = "https://example.com/service"; + final String TICKET = "ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1"; + final String RESPONSE1 = "" + + "" + + USERNAME + + ""; + final String RESPONSE2 = "Ticket ST-300000-aA5Yuvrxzpv8Tau1cYQ7-srv1 not recognized"; + PublicTestHttpServer.instance().content = RESPONSE1.getBytes(PublicTestHttpServer.instance().encoding); + + options.put("cacheAssertions", "true"); + options.put("cacheTimeout", "1"); + module.initialize( + subject, + new ServiceAndTicketCallbackHandler(SERVICE, TICKET), + new HashMap(), + options); + module.login(); + module.commit(); + assertEquals(this.subject.getPrincipals().size(), 3); + assertEquals(TICKET, this.subject.getPrivateCredentials().iterator().next().toString()); + + Thread.sleep(2000); + module.logout(); + assertEquals(0, subject.getPrincipals().size()); + assertEquals(0, subject.getPrivateCredentials().size()); + PublicTestHttpServer.instance().content = RESPONSE2.getBytes(PublicTestHttpServer.instance().encoding); + module.initialize( + subject, + new ServiceAndTicketCallbackHandler(SERVICE, TICKET), + new HashMap(), + options); + module.login(); + module.commit(); + assertEquals(this.subject.getPrincipals().size(), 3); + assertEquals(TICKET, this.subject.getPrivateCredentials().iterator().next().toString()); } private boolean hasPrincipalName(final Subject subject, final Class principalClass, final String name) {