SAML2 Servlet Filter
Posted on October 23, 2010
by Tommy McGuire
I have been working on various forms of authentication for Java-based web applications for quite a while. The latest iteration uses the Security Assertion Markup Language, version 2 to interact with an Identity Provider (IdP), which is supported by the authentication system we get to use. When describing the OSGi-based design I came up with to support this thingamajig I again mentioned the code to handle the interaction; the first part of that code is the topic of this post. I intend to describe the servlet filter which stands in front of application servlets; a later post will describe the Assertion Consumer Service.Because of the way the code is structured, this post must assume that you are familiar with the basic protocol as described in SAML authentication for web applications.
Background
There are two parts to the application-side (or Service Provider, SP side) of the authentication infrastructure: a servlet filter and the Assertion Consumer Service (or ACS), an application that interprets the SAML response and directs the user's request appropriately. The servlet filter intercepts incoming user requests and either
- passes the request as-is, for those that do not need authentication,
- handles redirected requests from the ACS after the authentication,
- handles requests that are already authenticated,
- does something useful in the case when SAML authentication must be disabled, or
- generates an authentication request.
I do not intend to provide complete code for the filter, because the implementation I have is tightly bound to the OSGi container and to our framework and the idea of refactoring it for genericity leaves a bad taste in my mouth. The basic structure of the filter, though, is:
try
{
final HttpServletResponse response = (HttpServletResponse) resp;
final HttpServletRequest request =
filteredRequest(new HideClientSsoHeaderHttpRequest((HttpServletRequest) rqst), response, chain, getConfig(config));
if (request == null) { return; }
else if (handleAcsRedirect(request, response, chain)) { return; }
else if (handleAuthenticatedRequest(request, response, chain)) { return; }
else if (handleSamlDisabled(request, response, chain, getConfig(config))) { return; }
else
{
// Unknown key: send Authentication Request
response.sendRedirect(authnRequest(request));
return;
}
}
catch (...) { ... }
The possible exceptions are SecuritySystemException, a high-level exception thrown by the authentication and identity-related parts of our framework, usually indicating an operational failure of some sort, and DecoderException, from Apache Commons Codec and indicates a Base64 or other encoding error.
Pass through
Passing through requests unfiltered is relatively simple, although there are several related operations and as a result the function is larger than it would seem to be. The filteredRequest function returns either a null, indicating that the request has been handled, or a request which may be the original or may be a modified version. Pass through processing must be done first, since the whole goal is to short-circuit other more expensive processing.
private HttpServletRequest filteredRequest(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Config config) throws IOException, ServletException
{
The basic filter compares the URL from the request with a regular expression pattern; if the URL matches the request is passed on to the next element of the filter chain to be handled and null is returned.
if (config.excludedUrlPattern != null)The second option we currently have is for AMF requests (a.k.a. BlazeDS, as in Adobe Flex), it matches against the destination of the request (which requires grubbing through the request, unfortunately). Corey describes what this does as:
{
final String contextPath = request.getContextPath();
final String requestUri = request.getRequestURI();
final int contextBeg = requestUri.indexOf(contextPath);
final int contextEnd = contextBeg + contextPath.length();
String applicationUrl =
(contextBeg < 0 || contextEnd == (requestUri.length() - 1))
? requestUri : requestUri.substring(contextEnd);
if (!applicationUrl.startsWith("/"))
{
applicationUrl = "/" + applicationUrl;
}
if (config.excludedUrlPattern.matcher(applicationUrl).matches())
{
/* Request matches excluded URL pattern; do not continue with filter. */
chain.doFilter(request, response);
return null;
}
}
When a Flex client makes a remote object call this ends making two seperate server calls. The first is an administration call that doesn't have a normal AMF payload (that can be decoded). The second is the call (with payload) to the service destination. Because I can't tell what service destination this is targeted to I'm always letting these administration request go through unfiltered. I need to add a check here when this happens to make sure they are targeting the Flex broker with the URL.
if (config.excludedAmfDestinationPattern != null
&& AMF_CONTENT_TYPE.equals(request.getHeader(CONTENT_TYPE_HEADER)))
{
final CapturedBytesHttpRequest captureBytesRequest = new CapturedBytesHttpRequest(request);
final AmfDecoder decoder = new AmfDecoder(captureBytesRequest.getCapturedBytes());
if (!decoder.decode() || config.excludedAmfDestinationPattern.matcher(decoder.getDestination()).matches())
{
/* Request matches AMF destination pattern; do not continue with filter. */
chain.doFilter(captureBytesRequest, response);
return null;
}
else
{
return captureBytesRequest;
}
}
return request;
}
ACS redirects
When a request is directed to the ACS which contains a successful authentication, it redirects the browser back to the original URL, meaning that the request is captured by this filter. However, the redirected request (which must be a GET) contains a query parameter matching the regular expression pattern "^ACSREQUESTID=(.*)$". (In-band signaling is kind of unpleasant, but there are few other ways to correctly get the requisite information shared by the ACS, this filter, and the browser.) When a request contains the ACSREQUESTID, the value is picked out and decoded. This value is a token that is used as a key for the authentication information as well as to recover the original request, which is handled by this function.private boolean handleAcsRedirect(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain)The Storage class provides access to two different storage systems:
throws DecoderException, IOException, ServletException
{
final String queryString = request.getQueryString();
if (queryString != null)
{
final Matcher matcher = pat.matcher(queryString);
if (matcher.matches())
{
final String key = new URLCodec().decode(matcher.group(1));
final HttpServletRequestWrapper storedRequest = Storage.getRequestStore().getRequest(key);
final IdentityMap mapping = Storage.getIdentityStore().getIdentity(Arrays.asList(key));
if (storedRequest != null && mapping != null)
{
storedRequest.setRequest(request);
chain.doFilter(new AuthenticatedHttpServletRequest(storedRequest, mapping), response);
Storage.getRequestStore().removeRequest(key);
return true;
}
}
}
return false;
}
- The IdentityStore records information returned from the IdP about a user; the information is returned as an IdentityMap, which is used to construct an AuthenticatedHttpServletRequest.
public interface IdentityStore
{
public abstract void storeIdentity(String key, String identity, Map<String, List<String>> attributes, Date expiry);
public abstract IdentityMap getIdentity(Listkeys);
public abstract IdentityMap getIdentity(Identity identity);
public interface IdentityMap
{
public abstract String getEmployeeNumber();
public abstract Map<String,List<String>> getAttributes();
public abstract Date getExpiration();
public abstract boolean valid();
}
} - The RequestStore records the original request from the user (which will be stored below)
public interface RequestStore
{
public abstract void storeRequest(String key, HttpServletRequest request);
public abstract String getRequestUrl(String key);
public abstract HttpServletRequestWrapper getRequest(String key);
public abstract void removeRequest(String key);
}
- An in-memory store, used for original testing and possibly in case of database failures, and
- A database-backed store, used to share requests between load-balanced servers as well as preserving requests in case of server restarts.
Authenticated requests
Authenticated requests are simple to handle. The token used as a key store the identity information is also added as a cookie to the response by the ACS, so all this method needs to do is to recover the cookie and check it against the IdentityStore (which includes checking if the authentication has timed out).private boolean handleAuthenticatedRequest(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain)
throws DecoderException, IOException, ServletException
{
final ListauthCookie = Misc.getCookie(request, AUTH_COOKIE_NAME);
final IdentityMap mapping = Storage.getIdentityStore().getIdentity(authCookie);
if (mapping != null && mapping.valid())
{
chain.doFilter(new AuthenticatedHttpServletRequest(request, mapping), response);
return true;
}
return false;
}
Disabling SAML
The downside of the SAML2 single-sign-on design is that if the IdP is down, the applications are also unavailable. As an alternative, I added a administratively configurable flag to disable SAML2 processing. If SAML2 is disabled, there are three other options:- Some applications are already able to handle their own authentication in place of SAML2. For these applications, if SAML2 is disabled all requests are passed on directly to the application.
- Some applications may need a generic username and password based authentication scheme; in this case a application-specified page is displayed with a form request a login. Once the user is logged in, normal identity information is available to the application, so the authenticated requests branch above can be taken.
- Any remaining applications should simply display an "unavailable" page.
Generating authentication requests
The remaining possibility requires generating an authentication request, storing the original request, and redirecting the browser via the request back to the IdP.private String authnRequest(HttpServletRequest request) throws SecuritySystemExceptionThe session key generated is used as the token for the SAML2 request as well as the value of the cookie identifying future authenticated requests. The key itself is just a cryptographically secure random number. The String generated is the URL of the IdP with the authentication request added as a query parameter, following the SAML2 specifications. This is passed back to the browser as a redirect.
{
// Generate the session identification key
final String key = Framework.getSessionKeyService().generateSessionKey();
Storage.getRequestStore().storeRequest(key, request);
return new AuthenticationRequest(key).toUriString();
}
An AuthenticationRequest
To interact with the IdP, I used the fmclientsdk.jar and openssoclientsdk.jar libraries from OpenAM, which was OpenSSO from Sun before the Oracle acquisition. I currently need to use both, because some classes are missing from each. (I really should report that as a bug.)public AuthenticationRequest(String key) throws SecuritySystemExceptionThe com.sun.identity.saml2.protocol.ProtocolFactory class provides access to the components needed to generate the authentication request. The created object is a com.sun.identity.saml2.protocol.AuthnRequest, which has a large number of properties which can be set to fill in elements in the request. For precise details of which properties are needed, see the SAML2 specifications as well as something like the Federal ICAM (Identity, Credential, and Access Management) Security Assertion Markup Language (SAML) 2.0 Web Browser Single Sign-on (SSO) Profile. (Man, that thing has an unfortunate logo.) This method calls three additional methods to create other parts of the request:
{
try
{
final String destinationUrl = ...;
final boolean forceAuthn = ...;
final boolean isPassive = ...;
final boolean signAuthnRqst = ...;
final String acsUrl = ...;
final AuthnRequest authnRequest = ProtocolFactory.getInstance().createAuthnRequest();
authnRequest.setID(key);
authnRequest.setVersion(SAML_VERSION);
authnRequest.setIssueInstant(new Date());
authnRequest.setDestination(destinationUrl);
if (forceAuthn)
{
authnRequest.setForceAuthn(forceAuthn);
}
if (isPassive)
{
authnRequest.setIsPassive(isPassive);
}
authnRequest.setProtocolBinding(PROTOCOL_BINDING);
authnRequest.setAssertionConsumerServiceURL(acsUrl);
authnRequest.setIssuer(issuer());
authnRequest.setNameIDPolicy(nameIdPolicy());
authnRequest.setRequestedAuthnContext(requestAuthnContext());
if (signAuthnRqst)
{
sign(authnRequest);
}
authnRequestMessage = authnRequest.toXMLString(true, true);
log.debug("request: " + authnRequestMessage);
}
catch (SAML2Exception e)
{
throw new SecuritySystemException("Unable to create SAML2 AuthnRequest", e);
}
catch (...) { ... }
}
- The issuer identifies the server making the authentication request.
private Issuer issuer() throws SAML2Exception
{
final String issuerName = ...;
final Issuer issuer = AssertionFactory.getInstance().createIssuer();
issuer.setValue(issuerName);
return issuer;
} - The name id policy describes the requested properties of the "name" used to identify the user in the authentication response. In our case, I am actually using an employee number, which is returned as one of the attributes of the user, rather than the SAML2 name.
private NameIDPolicy nameIdPolicy() throws SAML2Exception
{
final String issuerName = ...;
final boolean allowCreate = ...;
final NameIDPolicy nameIdPolicy = ProtocolFactory.getInstance().createNameIDPolicy();
nameIdPolicy.setFormat(NAMEID_POLICY_FORMAT);
nameIdPolicy.setSPNameQualifier(issuerName);
nameIdPolicy.setAllowCreate(allowCreate);
return nameIdPolicy;
} - Finally, the authentication context describes the requested properties of the authentication itself. For example, it is possible to request various levels of confidence in the authentication, from simple user assertions to multi-factor cryptographic confidence.
private RequestedAuthnContext requestAuthnContext() throws SAML2Exception
{
final RequestedAuthnContext requestedAuthnContext = ProtocolFactory.getInstance().createRequestedAuthnContext();
requestedAuthnContext.setComparison(AUTHN_CONTEXT_COMPARISON);
requestedAuthnContext.setAuthnContextClassRef(AUTHN_CONTEXT_CLASSREF_VALUES);
return requestedAuthnContext;
}
public String toEncodedString() throws SecuritySystemExceptionFollowing compression, the string is Base-64 encoded, then URL-encoded to protect non-URL-safe Base-64 characters.
{
try
{
final ByteArrayOutputStream baos = new ByteArrayOutputStream(authnRequestMessage.length());
final DeflaterOutputStream dos = new DeflaterOutputStream(baos, new Deflater(Deflater.BEST_COMPRESSION, true));
dos.write(authnRequestMessage.getBytes());
dos.close();
return encodedString = encode(baos.toByteArray());
}
catch (IOException e)
{
throw new SecuritySystemException("Unable to encode SAML2 AuthnRequest", e);
}
}
private String encode(byte[] byteArray)Finally, the query string is partially assembled, with the SAML request and SigAlg (indicating the signature algorithm) parameters. The resulting string is signed and the signature added as a third query parameter. The whole is assembled with the URL to be returned to the browser.
{
return new String( new URLCodec().encode( Base64.encodeBase64(byteArray) ) );
}
public String toUriString() throws SecuritySystemExceptionTo sign the string, the normal Java cryptography API's are employed. The result is encoded identically to the compressed request.
{
try
{
final String parameters = String.format("%s=%s&%s=%s",
SAML_REQUEST,
toEncodedString(),
"SigAlg", new URLCodec().encode("http://www.w3.org/2000/09/xmldsig#rsa-sha1"));
final String signature = signature(parameters);
return String.format("%s?%s&%s=%s", destinationUrl, parameters, "Signature", signature);
}
catch (EncoderException e)
{
throw new SecuritySystemException("Cannot enecode SigAlg", e);
}
}
private String signature(String unsigned) throws SecuritySystemException
{
String signatureAlgorithm = ...;
try
{
final Pairpair = ...;
final Signature signature = Signature.getInstance(signatureAlgorithm);
signature.initSign(pair.getY());
signature.update(unsigned.getBytes("UTF-8"));
return encode( signature.sign() );
}
catch (...) { ... } ...
}
Conclusion
The filter is relatively complex, although it follows a simple step-by-step procedure. However, most of the complexity comes from generating the authentication request, which requires interaction with the OpenAM client SDK, Java cryptography and compression, and HTTP URL manipulation.
Best of luck, and I hope this helps someone.