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:
*
- * - service - CAS service URL used for service ticket validation
- * - ticketValidatorClass - fully-qualified class name of service ticket validator component
- * - defaultRoles (optional) - comma-delimited list of roles to be added to all authenticated principals
- * - roleAttributeNames (optional) - comma-delimited list of attributes in the CAS assertion that contain role data
- * - principalGroupName (optional) - name of JAAS Group containing caller principal
+ * - service - CAS service URL used for service ticket validation.
+ * - ticketValidatorClass - fully-qualified class name of service ticket validator component.
+ * - defaultRoles (optional) - comma-delimited list of roles to be added to all authenticated principals.
+ * - roleAttributeNames (optional) - comma-delimited list of attributes in the CAS assertion that contain role data.
+ * - principalGroupName (optional) - name of JAAS Group containing caller principal.
* - roleGroupName (optional) - name of JAAS Group containing role data
+ * - cacheAssertions (optional) - whether or not to cache assertions.
+ * Some JAAS providers attempt to reauthenticate users after an indeterminate
+ * period of time. Since the credential used for authentication is a CAS ticket,
+ * which by default are single use, reauthentication fails. Assertion caching addresses this
+ * behavior.
+ * - cacheTimeout (optional) - assertion cache timeout in minutes.
*
*/
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) {