Sunday, 11 October, 2009

WebSphere Trust Access Interceptor

Background - What is a TAI?

To correlate between external authentication (e.g. SSO with Oracle AM WebGate, IBM TAM, etc.) and the WebSphere Application Server (WAS) User Registry identity, use a Trust Association Interceptor within the WebSphere security layer.

Lightweight Third Party Authentication (LTPA) is the mechanism by which WAS supports SSO. With LTPA a token is created with the user data and expiry time, signed by the LTPA keys (which why multiple nodes in a cell must have their LTPA keys in sync). The LTPA token can be forwarded by authenticated resources. This token passes to other servers, in the same cell or in a different cell, either through cookies (for Web resources) or through the authentication layer Security Authentication Service (SAS) or CSIv2 for EJBs.

A TAI is an identity provider, it takes information from an HTTP request, and, assuming trust is validated, provides either a principal (to be filled in by the User Registry) or a fully populated subject. In either case the user must exist in the WAS User Registry. The option for the TAI to populate the Subject allows the implementer to override existing group membership in the WAS LDAP.

Trust Interceptors operate completely within the WebSphere security layer, usually loaded on server startup from AppServer lib ext directory. The interceptor is only executed when an authenticated resource is requested via HTTP(S), i.e. the web.xml has a security constraint for that URL. If the user has already authenticated to any participating cell in the WebSphere deployment, they will have a valid LTPA token for the resource; the interceptor will skip.

This interceptor implementation is an adjunct to the security system; fallback to the usual authentication mechanism is always possible. Make note that a TAI is available for web authentication only (as opposed to JAAS); there is no intercepting programmatic login or access to Enterprise Beans.

The negotiation stage is only executed only if the target is defined as protected by the implementing class. Targets are evaluated in the isTargetInterceptor method.

For more information, this article is dated now, but a great start to WAS security.

Implementation

I have implemented TAIs for WAS v6.0, v6.1 and v7.0. Slight differences, but I will cover my implementation for v6.1.

Basically, implement the interface com.ibm.wsspi.security.tai.TrustAssociationInterceptor from the websphere_apis.jar.

Order of execution is:

1. initialize(Properties props) - on server startup.
2. boolean isTargetInterceptor (HttpServletRequest req) - on each request - if true, continue to negociate.
3. TAIResult negotiateValidateandEstablishTrust (HttpServletRequest req, HttpServletResponse res)

TAI Result holds either a string user id to be retrieved from the Registry, or the fully qualified Subject.

Initialize

Load your TAI settings, a list of regex patterns to intercept, the name of the SSO Request Header, etc. Will be loaded from the referenced properties file or from the console custom properties. Console settings override.

public int initialize(Properties props) throws WebTrustAssociationFailedException {
// read the Resource Bundle and add to static properties
// *console values* will overwrite props file.
try {
ResourceBundle rb = Constants.getResourceBundle();
Enumeration keys = rb.getKeys();
while (keys.hasMoreElements()) {
String key = (String) keys.nextElement();
log.debug("Prop from " + key + "=" + rb.getObject(key));
taiProps.put(key, rb.getObject(key));
}
} catch (Exception e) {
// check the constants RESOURCE_PKG/RESOURCES values to see
// if the properties files exist!
log.warn("Did not find/load iaatai_en.properties " + e.getMessage());
// No rethrow, console might be used to set properties.
}
try {
// read properties from console config add to static list
if (props != null) {
props.list(System.out);
taiProps.putAll(props);
}
// setup url patterns and header names from props.
configureFromProperties();
} catch (Throwable t) {
log.error("Initialization error (props not loaded)", t);
}
// return zero for success - any other value is failure.
return 0;
}


isTargetInterceptor

Should the target URL be intercepted? For this, setup a Map (cache) of URLs, and from the properties compare to set of Regex Patterns to intercept.

public boolean isTargetInterceptor(HttpServletRequest request) {
log.info("isTargetInterceptor: Check URI " + request.getRequestURI());
try {
// if we can get away without regexing, do so
if (targetUris.contains(request.getRequestURI())) {
log.debug("Found uri in cache");
return true;
}
// iterate over the patterns (values)
Iterator urlPatterns = targetUrlPatterns.values().iterator();
while (urlPatterns.hasNext()) {
Pattern p = (Pattern) urlPatterns.next();
// log.debug("Test URI pattern: " + p.pattern());
Matcher m = p.matcher(request.getRequestURI());
if (m.matches()) {
log.debug("Matched Pattern " + p.pattern());
targetUris.add(request.getRequestURI());
return true;
}
}
} catch (Exception e) {
log.error("ERROR: isTargetInterceptor", e);
}
log.info("TAI will not intercept this target URI");
return false;
}


Negotiate Trust

Quick and easy - direct mapping to a WAS registry ID. The simplest way, if the SSO header exists map it to an existing user.

public TAIResult negotiateValidateandEstablishTrust(HttpServletRequest request,
HttpServletResponse response) throws WebTrustAssociationFailedException {
log.debug("negotiateValidateandEstablishTrust starting.");
String ssoId = null;
TAIResult result = TAIResult.create(401);
ssoId = request.getHeader(Constants.HEADER_USER_ID);
if (ssoId != null && !"".equals(ssoId)) {
return TAIResult.create(HttpServletResponse.SC_OK, wasId);
}
// no header, return the default result, unauthorized 401
return result;
}


Another option for TAI's using Basic Authentication:

public TAIResult negotiateValidateandEstablishTrust(HttpServletRequest req,
HttpServletResponse resp) throws WebTrustAssociationFailedException {
log.debug("negotiateValidateandEstablishTrust");

String basicAuth = req.getHeader("Authorization");
TAIResult tairesult;
if (basicAuth == null || basicAuth.equals("") || !basicAuth.startsWith("Basic ")) {
tairesult = challengeClient(resp);
}
else {
String userId = null;
String passwd = "";
try {
String basicDecoded = new String(Base64.decode(basicAuth.substring(6), '='),
"UTF-8");
int i = basicDecoded.indexOf(':');
if (i == -1) {
userId = basicDecoded;
}
else {
userId = basicDecoded.substring(0, i);
passwd = basicDecoded.substring(i + 1);
}
log.debug("negotiateValidateandEstablishTrust userID:" + userId);
if (userId != null && (passwd == null || passwd.length() == 0)) {
log.warn("negotiateValidateandEstablishTrust FAILED, no password for "
+ userId);
tairesult = challengeClient(resp);
}
else {
Subject subject = doBasicWASLogin(userId, passwd);
tairesult = TAIResult.create(200, userId, subject);
}
} catch (LoginException e) {
log.error("negotiateValidateandEstablishTrust " + userId, e);
tairesult = challengeClient(resp);
} catch (UnsupportedEncodingException e) {
tairesult = null;
}
}
log.info("negotiateValidateandEstablishTrust " + tairesult.getStatus());
return tairesult;
}

private TAIResult challengeClient(HttpServletResponse resp)
throws WebTrustAssociationFailedException {
resp.setHeader("WWW-Authenticate", wwwAuthenticateHeader);
resp.setStatus(401);
return TAIResult.create(401);
}

private Subject doBasicWASLogin(String user, String pass) throws LoginException {
Subject subject = null;
CallbackHandler callbackhandler = WSCallbackHandlerFactory.getInstance()
.getCallbackHandler(user, pass);

LoginContext logincontext = new LoginContext(basicLoginTarget, callbackhandler);
log.debug("doBasicWASLogin built LoginContext");
logincontext.login();
log.debug("doBasicWASLogin login done");
return logincontext.getSubject();
}


For our actual implementation, we break user types into two paths by the application URL. getRealm is a custom function not shown here, it translates URLs to just a context root and maps to a list of Anonymous/MustExistInRegistry type apps.

Anonymous user mapping. Each user with a valid header is allowed in, and if they don't exist the user is created.

Must-exist users are higher security and must exist in the WAS registry before access.

public TAIResult negotiateValidateandEstablishTrust(HttpServletRequest request,
HttpServletResponse response) throws WebTrustAssociationFailedException {
log.debug("negotiateValidateandEstablishTrust starting.");
String ssoId = null;
String wasId = null;
String realm = "";
String lang = "en";
// http authenticate response
TAIResult result = TAIResult.create(401);
try {

// headers will be set by GA runtime, actual header
// name is configured via properties.
ssoId = request.getHeader(Constants.HEADER_USER_ID);
lang = request.getHeader(Constants.HEADER_LANG_ID);
if (ssoId == null
&& "true".equals(taiProps.getProperty("DEBUG_HEADER_VALUE", "false"))) {
log.warn("SSO_ID null, but DEBUG_HEADER_VALUE found to be true.");
ssoId = "debuguser " + new Random().nextInt();
}

if (ssoId == null) {
// SSO *not* protecting this resource, no SSO_ID header &
// DEBUG_HEADER_VALUE not set.
log.warn("TAI cannot trust, no header. Should we be intercepting this URL?");
throw new WebTrustAssociationUserException("Null SSO user");
}

// retrieve the name of the application, used to decide if anon/mustexist
realm = getRealm(request);
wasId = mapToAnonOrMustExistId(ssoId, realm);

// Anonymous users will be auto-created
log.debug(realm + ":requires that users exist? " + mustexistRealms);
boolean mustExist = (mustexistRealms != null && mustexistRealms.contains(realm));
// Setup the sharedState user credentials
Map sharedState = null;

// Using the sharedState map is an example of creating the subject
// to change the user credentials or state. It may not be necessary
// in your implementation. If not, simply return an ID WAS will
// recognize:
// TAIResult.create(HttpServletResponse.SC_OK, wasId);

UserRegistryLookup userRegLookup = new UserRegistryLookup();
try {
// lookup the user, group, etc 
sharedState = userRegLookup.getSharedState(wasId, lang, realm);
} catch (EntryNotFoundException enf) {
log.warn("No match for wasId " + wasId);
// Check for another chance - create the user
// then attempt to read sharedState for newly created.
if (!ismustexist) {
createNewUser(ssoId, wasId, lang, realm);
log.debug("WAS user created, return TAIResult OK.");
return TAIResult.create(HttpServletResponse.SC_OK, wasId);
}
}
if (sharedState != null) {
// Create the subject and add credentials
Subject authenticatedPrincipal = new Subject();
authenticatedPrincipal.getPublicCredentials().add(sharedState);
// N.B. the principal field (arg1) is unnecesary when
// providing the Subject object.
log.debug("Return the authenticated principal. " + wasId);
return TAIResult.create(HttpServletResponse.SC_OK, wasId,
authenticatedPrincipal);
}

} catch (Throwable e) {
// most actions do not throw, this must be a catastrophic failure
log.error("Trust ERROR for:" + ssoId + "::" + wasId, e);
throw new WebTrustAssociationFailedException(e.getMessage());
}
// return the default result, unauthorized 401
return result;
}


Reading the User Registry - creating a Shared State from scratch. This give the TAI the option to alter user groups/roles.

public Map getSharedState(final String user, final String lang, final String realm)
throws NamingException, RemoteException, EntryNotFoundException,
CustomRegistryException {

Hashtable secTable = new Hashtable();
InitialContext _ctx = VmmServiceManager.getInitialContext();

// Retrieve the local UserRegistry implementation.
// For v6.0 this is WMM-UR (WMM custom registry)
// Impl: com.ibm.websphere.security._UserRegistry_Stub
// v6.1 Use com.ibm.ws.wim.registry.WIMUserRegistry
UserRegistry reg = (UserRegistry) _ctx.lookup("UserRegistry");
log.info("Got User Registry "
+ (reg != null ? reg.getRealm() : "NULL"));
if (reg == null) {
log.error("Failed connnection to WMM User Registry ");
return null;
}

// Retrieves the user registry uniqueID based on the uid specified
// in the NameCallback. Unique ID is the full DN.
String uniqueID = reg.getUniqueUserId(user);
log.debug("From user registry: uniqueID " + uniqueID);

// read the WAS user ID from registry userID@realm
String wsUserId = WSSecurityPropagationHelper
.getUserFromUniqueID(uniqueID);
log.debug("wsUserId from WSSecurityPropagationHelper "
+ wsUserId);

// Retrieves display name from the user registry based on the
// WAS UserId.
String securityName = reg.getUserSecurityName(wsUserId);
// display name from user registry
log.debug("securityName from user registry " + securityName);

// Retrieves the groups associated with the uniqueID.
List groupList = null;
try {
groupList = reg.getUniqueGroupIds(wsUserId);
log.debug("found groupList " + groupList);
} catch (Exception e) {
log.warn("ERROR retrieving groupList " + e.getMessage());
groupList = new ArrayList();
}


// N.B. This is the ideal place to override group membership.
// Example, set all users in the group name "Realmusers".
try {
String usersGroupUniqueId = reg.getUniqueGroupId(realm + "users");
groupList.add(usersGroupUniqueId);
} catch (Exception e) {
log.warn("ERROR finding users group " + realm + " " + e);
}
// Creates the java.util.Hashtable with the information that you
// gathered from the UserRegistry implementation.
log.debug("Now creating the subject shared state. " + realm
+ " " + uniqueID);
secTable.put(AttributeNameConstants.WSCREDENTIAL_USERID, securityName);
secTable.put(AttributeNameConstants.WSCREDENTIAL_UNIQUEID, uniqueID);
secTable.put(AttributeNameConstants.WSCREDENTIAL_SECURITYNAME,
securityName);
// secTable.put(AttributeNameConstants.WSCREDENTIAL_REALM,
// reg.getRealm());
secTable.put(AttributeNameConstants.WSCREDENTIAL_GROUPS, groupList);

// Adds a cache key that is used as part of the lookup mechanism for
// the created Subject. The cache key can be an object, but should have
// an implemented toString() method. Make sure that the cacheKey
// contains enough information to scope it to the user and any
// additional attributes that you are using. If you do not specify this
// property the Subject is scoped to the returned WSCREDENTIAL_UNIQUEID,
// by default.
secTable.put(AttributeNameConstants.WSCREDENTIAL_CACHE_KEY, realm
+ uniqueID);
return secTable;
}


Updating and creating users in the TAI requires a privileged request. The entire class to setup users and update properties is included here:

public class VmmServiceManager implements VmmService {
private static final Logger log = Logger.getLogger(VmmServiceManager.class);

/**
* Provider URL for JNDI lookup - default provider for WAS - first Node only.
*/
public static String providerURL = "corbaloc:iiop:localhost:10031";

/**
* Constructor for Virtual Member Manager Service Manager.
*/
public VmmServiceManager() {
super();
}

/**
* @return InitialContext for the current environment.
* @throws NamingException
*/
public static InitialContext getInitialContext() throws NamingException {

Hashtable env = getEnv();
if (env.size() == 2) {
return new InitialContext(env);
}
return new InitialContext();
}

/**
* @return Hashtable containing INITIAL_CONTEXT_FACTORY and
*         PROVIDER_URL
* @throws NamingException
*/
private static Hashtable getEnv() throws NamingException {
ResourceBundle rb = Constants.getResourceBundle();
// look for a server specific provider url
String serverid = System.getProperty(Constants.SERVER_NAME);
log.info("getInitialContext SERVER_NAME=" + serverid);
String providerURL = null;
try {
providerURL = rb.getString("provider.url." + serverid);
} catch (Exception e) {
// no providerURL for this serverid (serverid may have been null).
try {
// look in properties for the default provider URL
providerURL = rb.getString("provider.url");
} catch (Exception e2) {
// no providerURL, WAS will use the local provider.
}
}
log.info("Init Ctx lookup using providerURL=" + providerURL);
Hashtable env = new Hashtable(2);
if (providerURL != null) {
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.ibm.websphere.naming.WsnInitialContextFactory");
env.put(Context.PROVIDER_URL, providerURL);
}
return env;
}

/**
* @return LocalServiceProvider Wim Client com.ibm.websphere.wim.Service
*/
public LocalServiceProvider getLocalServiceProvider() {
//http://publib.boulder.ibm.com/infocenter/wasinfo/v6r1/index.jsp?topic=
// /com.ibm.websphere.javadoc.doc/vmm/com/ibm/websphere/wim/client/
// LocalServiceProvider.html
try {
// holds reference to "ejb/com/ibm/websphere/wim/ejb/WIMServiceHome"
LocalServiceProvider service = new LocalServiceProvider(getEnv());

return service;
} catch (Exception e) {
log.error("LocalServiceProvider setup fail.", e);
}
return null;
}

/**
* @param userId
* @param lang
* @param containerDn
* @return boolean success flag
*/
public boolean createUser(final String userId, final String lang,
String containerDn) {

LocalServiceProvider service = null;
try {

service = getLocalServiceProvider();
if (service == null)
return false;

// containerDn has to be null - empty string is bad.
if (containerDn != null && containerDn.trim().length() == 0) {
containerDn = null;
}
DataObject root = SDOHelper.createRootDataObject();
// API entity type might be "PersonAccount" or "Person"
// this is unclear to me. using the SchemaConstants field.
// leave the 2nd param blank to use the default container.
DataObject entity = SDOHelper.createEntityDataObject(root, containerDn,
SchemaConstants.DO_PERSON_ACCOUNT);
entity.set(Constants.UID, userId);
entity.set(Constants.CN, userId);
entity.set(Constants.SN, userId);
List displayName = new ArrayList();
displayName.add(userId);
entity.set(Constants.DISPLAY_NAME, displayName);
entity.set(Constants.PREFERRED_LANGUAGE, lang);

log.info("PersonAccount object assembled " + entity);

// Assemble a logged-in subject with admin (create-user) rights.
Subject adminSubject = retrieveAdminSubject();
log.info("VMM AdminSubject logged in " + adminSubject);

root = doSecureCreate(adminSubject, root);

log.info("PersonAccount create completed: " + root);
return true;
} catch (Throwable t) {
log.error("PersonAccount create failed", t);
}
return false;
}

/**
* @param uniqueId
* @param lang
* @return boolean success flag
*/
public boolean updateUser(final String uniqueId, final String lang) {

LocalServiceProvider service = null;
try {

service = getLocalServiceProvider();
if (service == null)
return false;
DataObject root = SDOHelper.createRootDataObject();
DataObject entity = SDOHelper.createEntityDataObject(root, null,
SchemaConstants.DO_PERSON_ACCOUNT);
entity.createDataObject(SchemaConstants.DO_IDENTIFIER).set(
SchemaConstants.PROP_UNIQUE_NAME, uniqueId);

List displayName = new ArrayList();
displayName.add(lang);
entity.set(Constants.DISPLAY_NAME, displayName);
entity.set(Constants.PREFERRED_LANGUAGE, lang);

log.info("PersonAccount object for update " + entity);

// Assemble a logged-in subject with admin (update-user) rights.
Subject adminSubject = retrieveAdminSubject();
log.info("VMM AdminSubject logged in " + adminSubject);

root = doSecureUpdate(adminSubject, root);

log.info("PersonAccount update completed: " + root);
return true;
} catch (Throwable t) {
log.error("PersonAccount update failed", t);
}
return false;
}

/**
* @param adminSubject
* @param userRoot
* @return DataObject
* @throws PrivilegedActionException
*/
@SuppressWarnings("unchecked")
protected DataObject doSecureCreate(final Subject adminSubject, final DataObject userRoot)
throws PrivilegedActionException {

return (DataObject) WSSubject.doAs(adminSubject,
new java.security.PrivilegedExceptionAction() {
public Object run() throws PrivilegedActionException {
// Subject is associated with the current thread context
return AccessController.doPrivileged(new CreateUserPrivileged(
userRoot));
}
// Subject is re-associated with the current thread context
}); // end doAs admin.
}

@SuppressWarnings("unchecked")
protected DataObject doSecureUpdate(final Subject adminSubject, final DataObject userRoot)
throws PrivilegedActionException {

return (DataObject) WSSubject.doAs(adminSubject,
new java.security.PrivilegedExceptionAction() {
public Object run() throws PrivilegedActionException {
// Subject is associated with the current thread context
return AccessController.doPrivileged(new UpdateUserPrivileged(
userRoot));
}
// Subject is re-associated with the current thread context
}); // end doAs
}

/**
* Create new user in the WebSphere Repository. This private class runs
* inside PrivilegedAction; must implement PrivilegedAction interface.
*/
class CreateUserPrivileged implements PrivilegedExceptionAction {
private DataObject rootWithNewUser;

/**
* @param containsUserObj
*/
public CreateUserPrivileged(DataObject containsUserObj) {
this.rootWithNewUser = containsUserObj;
}

/**
* Cannot throw specific exceptions on this method signature, so return
* the DataObject, or general Exception with cause.
* 
* @return Object DataObject if success, null otherwise.
* @throws Exception
*             RemoteException, WIMExcpetion
* @see java.security.PrivilegedExceptionAction#run()
*/
public Object run() throws Exception {
// Subject cut off from the current thread context.
LocalServiceProvider service = getLocalServiceProvider();
if (service == null)
return null;
log.debug("Connected to WIM service " + service.getClass().getName());
return service.create(rootWithNewUser);

}
}

/**
* Update user in the WebSphere Repository. This private class runs inside
* PrivilegedAction; must implement PrivilegedAction interface.
*/
class UpdateUserPrivileged implements PrivilegedExceptionAction {
private DataObject rootWithUser;

/**
* @param containsUserObj
*/
public UpdateUserPrivileged(DataObject containsUserObj) {
this.rootWithUser = containsUserObj;
}

/**
* Cannot throw specific exceptions on this method signature, so return
* the DataObject, or general Exception with cause.
* 
* @return Object DataObject if success, null otherwise.
* @throws Exception
*             RemoteException, WIMExcpetion
* @see java.security.PrivilegedExceptionAction#run()
*/
public Object run() throws Exception {
// Subject cut off from the current thread context.
LocalServiceProvider service = getLocalServiceProvider();
if (service == null)
return null;
log.debug("Connected to WIM service " + service.getClass().getName());
return service.update(rootWithUser);

}
}

/**
* @return Subject logged-in administrator subject.
* @throws NamingException
*/
private Subject retrieveAdminSubject() throws NamingException {
// hard-set the locale to ensure the expected file is always found.
// see also, IaaTrustAssociationInterceptor notes.
ResourceBundle rb = Constants.getResourceBundle();
final String wasAdminUser = rb.getString("admin.user");
final String wasAdminPasswd = rb.getString("admin.pass");
// log.debug("REMOVE ME LATER " + wasAdminUser + " " +
// wasAdminPasswd);

// This will only work with admin credentials, see props
Subject adminSubject = null;
LoginContext lc = null;
try {
lc = new LoginContext("WSLogin", new WSCallbackHandlerImpl(
wasAdminUser, wasAdminPasswd));
lc.login();
adminSubject = lc.getSubject();

} catch (Throwable t) {
log.error("LoginContext error " + t.getMessage());
// everything falls apart
}
return adminSubject;
}

2 comments:

Тодор said...

Thanks for the article!
The only helpful thing on the internet about VMM so far.

Anonymous said...

Really great article, it helped a lot. Thanks for that.