Quite recently I wrote a WAS 7 utility that needed to access the JEE security Subject (read more on JEE Security) and its private credentials. The project, an auditing tool, accesses the Subject via IBM's WSSubject feature and uses some of its methods.
While trying to unit test it I ran into several problems, especially around trying to mock container-provided classes like Subject or LoginModules, and especially getting WSSubject to work.
Solving post-login unit test nightmares
When dealing with container-provided services like JAAS and the security model it can be very hard to write unit tests that exercise the code out-of-container, aka automated-build-tool-friendly tests. You can most probably start up an embedded container, but even so, setting up security to the point where it will populate your Subject can be tricky.
After a lot of research and trial-and-error, I created a test utility that allows you to:
After a lot of research and trial-and-error, I created a test utility that allows you to:
- Let WSSubject return correct values for getRunAsSubject(), getCallerSubject() and getCallerPrincipal();
- Allow a unit test to add test private credentials to the Subject before testing a code unit that uses it;
- Allow a unit test to add a custom LoginModule and/or test custom LoginModules without the need of a container (not even a lightweight one);
- Allow the whole lot to run nicely from e.g. Jenkins in an automated build process.
Get to the code already
The samples contain 3x java classes:
- A LoggedinTestContext with helpers to facilitate the login in out-of-container unit testing scenarios;
- A MockLoginModule to show how to test JAAS Modules and/or add stuff to a Subject;
- Sample unit test (LoggedinSubjectTest) to illustrate the LoggedinTestContext's usage and what it can provide.
The example code is not a full project as it depends on your own setup and is specific to your IBM WAS installation. The test data is also samples added as private credentials, but you can add anything your unit of code requires out of the Subject.
Dependencies needed
The code samples require you to (either in maven dependencies or elsewhere) have the following libraries available.
WAS (7+) dependencies
All the jars below are relative to your WAS install root (e.g. IBM/Websphere/AppServer).
- /lib/bootstrap.jar
- /plugins/com.ibm.ws.runtime.jar
- /runtimes/com.ibm.ws.admin.client_<version>.jar
- /lib/j2ee.jar
- /plugins/com.ibm.ws.emf.jar
- /plugins/org.eclipse.emf.ecore.jar
- /plugins/org.eclipse.emf.common.jar
- /java/jre/lib/ibmcfw.jar
Where <version> is your WAS version, e.g. 7.
JUnit dependencies
For the rest of the dependencies, the test code sample uses normal junit dependencies (junit mockito etc).
Code
I've added comments in the code so you should be able to follow along. I included the Gist listing in the blog, but you can it also get it from here.
Happy hunting!
Happy hunting!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package thecodingglass.testing.postloginsamples; | |
import static org.unitils.reflectionassert.ReflectionAssert.assertLenientEquals; | |
import java.util.HashMap; | |
import java.util.Map; | |
import org.junit.Test; | |
/** | |
* Shows how you can grab/verify that your stuff is populated on the Subject. In the real world the unit tests will test some unit of code | |
* that internally interrogates the Subject. | |
*/ | |
public class PostLoginSampleTests { | |
@Test | |
public void verifyPrivateCredentialsAreBlank() { | |
// log in using a default user | |
new LoggedinTestContext().login(); | |
// WSSubject should return correct value | |
assertNotNull(WSSubject.getCallerSubject())); | |
assertNotNull(WSSubject.getRunAsSubject())); | |
assertEquals(WSSubject.getCallerPrincipal(), LoggedinTestContext.DEFAULT_USERNAME); | |
// you could test some sample code that needs a Subject and specific private credentials here. | |
} | |
@Test | |
public void privateCredentialsMustBePopulatedOnSubject { | |
LoggedinTestContext loggedinContext = new LoggedinTestContext(); | |
Map<String, String> testCredentials = loggedinContext.createDefaultPrivateCredentials(); | |
loggedinContext.withCustomCredentials(testCredentials, LoggedinTestContext.DEFAULT_USERNAME).login(); | |
// you can also use loggedinContext.withDefaultCredentials().login(); if you're OK with the defaults. | |
Set<Object> privateCredentialSet = WSSubject.getCallerSubject().getPrivateCredentials(); | |
Map<String, String> myPrivateCredentials = (Map<String, String>) privateCredentialSet.get(0); | |
assertLenientEquals(testCredentials, myPrivateCredentials); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package thecodingglass.testing.utils; | |
import static org.mockito.Mockito.mock; | |
import static org.mockito.Mockito.when; | |
import java.security.Principal; | |
import java.util.HashMap; | |
import java.util.HashSet; | |
import java.util.Map; | |
import java.util.Set; | |
import javax.security.auth.Subject; | |
import javax.security.auth.callback.CallbackHandler; | |
import javax.security.auth.login.AppConfigurationEntry; | |
import javax.security.auth.login.Configuration; | |
import javax.security.auth.login.CredentialExpiredException; | |
import javax.security.auth.login.LoginContext; | |
import javax.security.auth.login.LoginException; | |
import momentum.retail.audit.messages.LoggedinAuditMessage.PrivateCredentialKey; | |
import com.ibm.websphere.security.WSSecurityException; | |
import com.ibm.websphere.security.auth.CredentialDestroyedException; | |
import com.ibm.websphere.security.auth.WSSubject; | |
import com.ibm.websphere.security.cred.WSCredential; | |
import com.ibm.ws.security.core.ContextManagerFactory; | |
/** | |
* Creates a LoggedinTestContext where you can simulate user login. | |
* | |
* Some sample usage: | |
* <ul> | |
* | |
* <li><code>new LoggedinTestContext().login(); // logs you in with no username or private credentials</code> | |
* <li><code>new LoggedinTestContext().withDefaultCredentials().login(); //logs you in with 'derek' username and example private credentials as the SalesLoginModule will provide.</code> | |
* <li> | |
* <code> | |
* LoggedinTestContext loggedinContext = new LoggedinTestContext(); | |
* Map<String, String> defaultCredentials = loggedinContext.createDefaultPrivateCredentials(); // modify or add to defaultCredentials your own values | |
* loggedinContext.withCustomCredentials(defaultCredentials, "derek").login(); // log in with your modified credentials | |
* | |
* </code> | |
* <li> <code>new LoggedinTestContext().withCustomCredentials(<your own Map here>, <own username>).login();// log in with your own stuff.</code> | |
* </ul> | |
* | |
* You can then expect the following to be valid: | |
* <ul> | |
* <li>WSSubject.getCallerPrincipal() returns your username; | |
* <li>WSSubject.getCallerSubject() gets you a populated Subject; | |
* <li>WSWSubject.getRunAsSubject() gets you the same subject. | |
* </ul> | |
* @author RBester | |
* | |
*/ | |
public class LoggedinTestContext { | |
public static final String DEFAULT_USERNAME = "joedirt"; | |
public static final String DEFAULT_IP_ADDRESS = "10.1.1.1"; | |
public static final String DEFAULT_UNIQUE_ID = "SF_GUID_123"; | |
public static final String DEFAULT_IMPERSONATING_USER = "no-one"; | |
public static final String DEFAULT_IMPERSONATING_USER_GUID = "no-one123"; | |
public static final String DEFAULT_SESSION_ID = "joedirt_session_id"; | |
private static final String LOGIN_MODULE_NAME = "thecodingglass.testing.utils.MockLoginModule";// The Login process creates an instance of this via reflection. | |
private LoginContext context; | |
private Set<Object> privateCredentials = new HashSet<Object>(); | |
private Set<Object> publicCredentials = new HashSet<Object>(); | |
private Subject subject = null; | |
public LoggedinTestContext() { | |
} | |
/** | |
* Will add default private credentials to the Subject (see static Strings in LoggedinTestContext) | |
* @return Returns this instance for chaining. | |
*/ | |
public LoggedinTestContext withDefaultCredentials() { | |
return withCustomCredentials(createDefaultPrivateCredentials(), DEFAULT_USERNAME); | |
} | |
/** | |
* Will add your custom private credentials (as Map) and username to the Subject. | |
* @param privateCredentialMap ustom private credentials. This can be accessed from the Subject via the subject.getPrivateCredentials (returns Set) grabbing the first object you can find. | |
* @param username Your custom username. | |
* @return Returns this instance for chaining. | |
*/ | |
public LoggedinTestContext withCustomCredentials(Map<String, String> privateCredentialMap, String username) { | |
WSCredential userCredential = mock(WSCredential.class); | |
try { | |
when(userCredential.getSecurityName()).thenReturn(username); | |
} catch (CredentialExpiredException e) { | |
e.printStackTrace(); | |
} catch (CredentialDestroyedException e) { | |
e.printStackTrace(); | |
} | |
privateCredentials.add(privateCredentialMap); | |
publicCredentials.add(userCredential); | |
return this; | |
} | |
/** | |
* Expose creation of the default private credentials if you want to use this as basis for your test expectations or custom input. | |
* @return Returns a Map with default credentials. | |
*/ | |
public Map<String, String> createDefaultPrivateCredentials() { | |
Map<String, String> credentialMap = new HashMap<String, String>(); | |
credentialMap.put(PrivateCredentialKey.IMPERSONATING_USERNAME.name(), DEFAULT_IMPERSONATING_USER); | |
credentialMap.put(PrivateCredentialKey.IMPERSONATING_USERGUID.name(), DEFAULT_IMPERSONATING_USER_GUID); | |
credentialMap.put(PrivateCredentialKey.IP_ADDRESS.name(), DEFAULT_IP_ADDRESS); | |
credentialMap.put(PrivateCredentialKey.UNIQUE_ID.name(), DEFAULT_UNIQUE_ID); | |
credentialMap.put(PrivateCredentialKey.SESSION_ID.name(), DEFAULT_UNIQUE_ID); | |
return credentialMap; | |
} | |
/** | |
* Trigger the login process. | |
*/ | |
public void login() { | |
try { | |
init(); | |
context.login(); | |
} catch (LoginException e) { | |
e.printStackTrace(); | |
} | |
} | |
private void init() throws LoginException { | |
CallbackHandler handler = mock(CallbackHandler.class); | |
subject = createSubject(); | |
try { | |
// the business-end: create LoginContext and configure it with modules. | |
context = new LoginContext("AuditLoggerTest", subject, handler, new Configuration() { | |
@Override | |
public AppConfigurationEntry[] getAppConfigurationEntry(String arg0) { | |
AppConfigurationEntry[] toReturn = new AppConfigurationEntry[] { | |
// Our login module. You can add more here. | |
new AppConfigurationEntry(LOGIN_MODULE_NAME, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, new HashMap<String, Object>()) | |
}; | |
return toReturn; | |
} | |
}); | |
} catch (LoginException e) { | |
e.printStackTrace(); | |
} | |
} | |
private Subject createSubject() { | |
Subject subject = new Subject(true, new HashSet<Principal>(), publicCredentials, privateCredentials); | |
try { | |
ContextManagerFactory.getInstance().setCallerSubject(subject); | |
WSSubject.setRunAsSubject(subject); | |
} catch (WSSecurityException e) { | |
e.printStackTrace(); | |
} | |
return subject; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package thecodingglass.testing.utils; | |
import java.util.Map; | |
import javax.security.auth.Subject; | |
import javax.security.auth.callback.CallbackHandler; | |
import javax.security.auth.login.LoginException; | |
import javax.security.auth.spi.LoginModule; | |
/** | |
* A JAAS Module that you can use to test post-login situations. The LoginModule is allowed to modify credentials etc. | |
* Refer to the LoggedinTestContext: this class is instantiated by the javax.security.auth.login.LoginContext via reflection. | |
*/ | |
public class MockLoginModule implements LoginModule { | |
/** | |
* Simply prints out the provided private credentials, but a login module can also add credentials. These will then be available | |
* on the Subject. | |
* @param subject The JAAS subject in the process of logging in. | |
* @param callbackHandler JAAS chain callback handler (see the JEE spec for more on this). | |
* @param sharedState State shared between login modules. | |
* @param options Options get passed in to the LoginModule via the container on initialization. | |
*/ | |
@Override | |
public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) { | |
if (subject.getPrivateCredentials() != null && !subject.getPrivateCredentials().isEmpty()) { | |
Map<String, ? extends Object> privateCredentials = (Map<String, ? extends Object>) subject.getPrivateCredentials().toArray()[0]; | |
System.out.println("MockLoginModule: Subject private credentials: " + privateCredentials.toString()); | |
} else { | |
System.out.println("MockLoginModule: No private credentials."); | |
} | |
} | |
/* | |
* Simply allow login to proceed. You can return false if login shoud fail based on what happened on initialize. | |
*/ | |
@Override | |
public boolean login() throws LoginException { | |
return true; | |
} | |
/** | |
* All modules executed with true; commit. | |
*/ | |
@Override | |
public boolean commit() throws LoginException { | |
return true; | |
} | |
/* | |
* Rollback as a commit or something else failed. | |
*/ | |
@Override | |
public boolean abort() throws LoginException { | |
return true; | |
} | |
/* | |
* On the logout leg. True if logout was OK, false otherwise. | |
*/ | |
@Override | |
public boolean logout() throws LoginException { | |
return true; | |
} | |
} |
Very nice, Roan. This is gold for devs.
ReplyDelete