June 26, 2014

OpenSAML and Feide integration

Recently I worked on implementing an integration towards Feide, a Norwegian Single Sign-On login service. This service uses SAML 2.0 for communication between a Service Provider (SP) and an Identity Provider (IdP). There exists a library tailored for Feide, but this project has not been updated since 2011. I've used this library for an earlier Feide integration, but I wanted a more stable solution. The most well-known library out there for communication with a SAML server are currently OpenSAML.

Although OpenSAML comes highly recommended, there are close to no documentation on its usage. Their Wiki has no full example on its usage, and all you have are some tidbits here and there. Thankfully I found a guy that wrote a lot of blog posts on the topic, and I managed to scrape together some working code from these posts and various other sources online.

This is the main login class I ended up with:

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.security.InvalidParameterException;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.opensaml.Configuration;
import org.opensaml.DefaultBootstrap;
import org.opensaml.common.SAMLObject;
import org.opensaml.common.binding.BasicSAMLMessageContext;
import org.opensaml.common.xml.SAMLConstants;
import org.opensaml.saml2.binding.encoding.HTTPRedirectDeflateEncoder;
import org.opensaml.saml2.core.Assertion;
import org.opensaml.saml2.core.Attribute;
import org.opensaml.saml2.core.AttributeStatement;
import org.opensaml.saml2.core.AuthnContextClassRef;
import org.opensaml.saml2.core.AuthnContextComparisonTypeEnumeration;
import org.opensaml.saml2.core.AuthnRequest;
import org.opensaml.saml2.core.Issuer;
import org.opensaml.saml2.core.NameIDPolicy;
import org.opensaml.saml2.core.RequestedAuthnContext;
import org.opensaml.saml2.core.Response;
import org.opensaml.saml2.core.Statement;
import org.opensaml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml2.metadata.IDPSSODescriptor;
import org.opensaml.saml2.metadata.SingleSignOnService;
import org.opensaml.saml2.metadata.provider.MetadataProvider;
import org.opensaml.saml2.metadata.provider.MetadataProviderException;
import org.opensaml.saml2.metadata.provider.ResourceBackedMetadataProvider;
import org.opensaml.security.MetadataCredentialResolver;
import org.opensaml.security.MetadataCredentialResolverFactory;
import org.opensaml.security.MetadataCriteria;
import org.opensaml.util.resource.ClasspathResource;
import org.opensaml.util.resource.ResourceException;
import org.opensaml.ws.message.encoder.MessageEncodingException;
import org.opensaml.ws.transport.http.HttpServletResponseAdapter;
import org.opensaml.xml.ConfigurationException;
import org.opensaml.xml.XMLObject;
import org.opensaml.xml.XMLObjectBuilder;
import org.opensaml.xml.io.Unmarshaller;
import org.opensaml.xml.io.UnmarshallingException;
import org.opensaml.xml.parse.BasicParserPool;
import org.opensaml.xml.schema.XSString;
import org.opensaml.xml.security.CriteriaSet;
import org.opensaml.xml.security.credential.Credential;
import org.opensaml.xml.security.criteria.EntityIDCriteria;
import org.opensaml.xml.util.Base64;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;

public class FeideLogin implements ExternalLogin {

    private static final Log LOGGER = LogFactory.getLog(FeideLogin.class);
    private static final Map<Class<?>, QName> ELEMENT_CACHE = new ConcurrentHashMap<Class<?>, QName>();
    private static final String CALLBACK = "https://***?provider=feide";
    private static final String SAML2_ISSUER = "***";

    public FeideLogin() {
        try {
            DefaultBootstrap.bootstrap();
        } catch (final ConfigurationException e) {
            throw new RuntimeException("Bootstrapping failed");
        }
    }

    @Override
    public final void doLogin(final HttpServletResponse response) {
        final MetadataProvider metadataProvider = this.getMetadataProvider();
        final EntityDescriptor entityDescriptor = this.getEntityDescriptor(metadataProvider);
        if (entityDescriptor == null) {
            try {
                response.sendRedirect("/login");
            } catch (final IOException ex) {
                LOGGER.warn("Failed to send user back to login page", ex);
            }
            return;
        }

        final AuthnRequest authnRequest = this.generateAuthnRequest(entityDescriptor);

        final HttpServletResponseAdapter responseAdapter = new HttpServletResponseAdapter(response, true);
        final BasicSAMLMessageContext<SAMLObject, AuthnRequest, SAMLObject> context = new BasicSAMLMessageContext<>();
        context.setPeerEntityEndpoint(this.getSingleSignOnService(entityDescriptor));
        context.setOutboundSAMLMessage(authnRequest);
        context.setOutboundSAMLMessageSigningCredential(this.generateCredential(metadataProvider));
        context.setOutboundMessageTransport(responseAdapter);
        context.setRelayState(CALLBACK);

        final HTTPRedirectDeflateEncoder encoder = new HTTPRedirectDeflateEncoder();

        try {
            encoder.encode(context);
        } catch (final MessageEncodingException ex) {
            LOGGER.warn("Error while performing Feide login", ex);
        }
    }

    @Override
    public final ExternalUserMetadata getUserMetadata(final HttpServletRequest request) {
        final String samlResponse = request.getParameter("SAMLResponse");
        if (samlResponse == null) {
            // throw new IllegalStateException("SAMLResponse parameter cannot be null");
            return null;
        }

        try {
            final String xml = new String(Base64.decode(samlResponse), "UTF-8");
            final XMLObject object = this.unmarshallElementFromString(xml);
            if (!(object instanceof Response)) {
                throw new IllegalArgumentException("SAMLResponse must be of type Response. Was " + object);
            }

            final ExternalUserMetadata metadata = new ExternalUserMetadata();
            metadata.setProvider(LoginProvider.FEIDE);
            final Response response = (Response) object;

            for (final Assertion assertion : response.getAssertions()) {
                for (final Statement statement : assertion.getStatements()) {
                    if (statement instanceof AttributeStatement) {
                        final AttributeStatement attributeStatement = (AttributeStatement) statement;
                        for (final Attribute attribute : attributeStatement.getAttributes()) {
                            if ("displayName".equals(attribute.getName())) {
                                metadata.setName(this.getValue(attribute));
                            }
                            if ("eduPersonTargetedID".equals(attribute.getName())) {
                                metadata.setId(this.getValue(attribute));
                            }
                        }
                    }
                }
            }

            return metadata;
        } catch (final UnsupportedEncodingException ex) {
            throw new RuntimeException(ex);
        }
    }

    private String getValue(final Attribute attribute) {
        for (final XMLObject object : attribute.getAttributeValues()) {
            if (object instanceof XSString) {
                return ((XSString) object).getValue();
            }
        }
        return null;
    }

    private MetadataProvider getMetadataProvider() {
        final String basePath = FeideLogin.class.getPackage().getName().replaceAll("\\.", "/");
        final String path = "/" + basePath + "/feide-idp-metadata.xml";

        try {
            final ClasspathResource resource = new ClasspathResource(path);
            final ResourceBackedMetadataProvider metadataProvider =
                    new ResourceBackedMetadataProvider(new Timer(true), resource);
            metadataProvider.setRequireValidMetadata(true);
            metadataProvider.setParserPool(new BasicParserPool());
            metadataProvider.initialize();
            return metadataProvider;
        } catch (final ResourceException | MetadataProviderException ex) {
            LOGGER.warn("Failed to read service metadata", ex);
        }

        return null;
    }

    private EntityDescriptor getEntityDescriptor(final MetadataProvider metadataProvider) {
        if (metadataProvider != null) {
            try {
                return metadataProvider.getEntityDescriptor("https://idp.feide.no");
            } catch (final MetadataProviderException ex) {
                LOGGER.warn("Failed to read service metadata", ex);
            }
        }

        return null;
    }

    private AuthnRequest generateAuthnRequest(final EntityDescriptor entityDescriptor) {
        final AuthnRequest authnRequest = this.buildXMLObject(AuthnRequest.class);
        authnRequest.setForceAuthn(true);
        authnRequest.setIsPassive(false);
        authnRequest.setIssueInstant(new DateTime(DateTimeZone.UTC));
        authnRequest.setDestination(this.getSingleSignOnLocation(entityDescriptor));
        authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
        authnRequest.setAssertionConsumerServiceURL(CALLBACK);
        authnRequest.setID("_" + UUID.randomUUID().toString());
        // authnRequest.setProviderName("https://idp.feide.no");

        final Issuer issuer = this.buildXMLObject(Issuer.class);
        issuer.setValue(SAML2_ISSUER);
        authnRequest.setIssuer(issuer);

        final NameIDPolicy nameIDPolicy = this.buildXMLObject(NameIDPolicy.class);
        nameIDPolicy.setSPNameQualifier(SAML2_ISSUER);
        nameIDPolicy.setAllowCreate(true);
        nameIDPolicy.setFormat("urn:oasis:names:tc:SAML:2.0:nameid-format:transient");
        authnRequest.setNameIDPolicy(nameIDPolicy);

        final RequestedAuthnContext requestedAuthnContext = this.buildXMLObject(RequestedAuthnContext.class);
        requestedAuthnContext.setComparison(AuthnContextComparisonTypeEnumeration.MINIMUM);
        final AuthnContextClassRef authnContextClassRef = this.buildXMLObject(AuthnContextClassRef.class);
        authnContextClassRef.setAuthnContextClassRef(
                "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport");
        requestedAuthnContext.getAuthnContextClassRefs().add(authnContextClassRef);
        authnRequest.setRequestedAuthnContext(requestedAuthnContext);

        return authnRequest;
    }

    private Credential generateCredential(final MetadataProvider metadataProvider) {
        final MetadataCredentialResolver resolver = MetadataCredentialResolverFactory.getFactory()
                .getInstance(metadataProvider);

        final CriteriaSet criteriaSet = new CriteriaSet();
        criteriaSet.add(new MetadataCriteria(IDPSSODescriptor.DEFAULT_ELEMENT_NAME, SAMLConstants.SAML20P_NS));
        criteriaSet.add(new EntityIDCriteria("IPDEntityId"));

        try {
            return resolver.resolveSingle(criteriaSet);
        } catch (final org.opensaml.xml.security.SecurityException ex) {
            LOGGER.warn("Failed to lookup credentials", ex);
        }

        return null;
    }

    private SingleSignOnService getSingleSignOnService(final EntityDescriptor entityDescriptor) {
        final List<SingleSignOnService> singleSignOnServices =
                entityDescriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS).getSingleSignOnServices();
        for (final SingleSignOnService singleSignOnService : singleSignOnServices) {
            if (singleSignOnService.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) {
                return singleSignOnService;
            }
        }

        return null;
    }

    private String getSingleSignOnLocation(final EntityDescriptor entityDescriptor) {
        final SingleSignOnService singleSignOnService = this.getSingleSignOnService(entityDescriptor);
        if (singleSignOnService != null) {
            return singleSignOnService.getLocation();
        } else {
            return null;
        }
    }

    @SuppressWarnings("unchecked")
    private <T extends XMLObject> T buildXMLObject(final Class<T> type) {
        try {
            final QName objectQName = this.getElementQName(type);
            final XMLObjectBuilder<T> builder = Configuration.getBuilderFactory().getBuilder(objectQName);
            if (builder == null) {
                throw new InvalidParameterException("No builder exists for object: " + objectQName.getLocalPart());
            }
            return builder.buildObject(objectQName.getNamespaceURI(), objectQName.getLocalPart(),
                    objectQName.getPrefix());
        } catch (final SecurityException e) {
            throw new RuntimeException(e);
        }
    }

    private <T> QName getElementQName(final Class<T> type) {
        if (ELEMENT_CACHE.containsKey(type)) {
            return ELEMENT_CACHE.get(type);
        }

        try {
            Field typeField;
            try {
                typeField = type.getDeclaredField("DEFAULT_ELEMENT_NAME");
            } catch (final NoSuchFieldException ex) {
                typeField = type.getDeclaredField("ELEMENT_NAME");
            }

            final QName objectQName = (QName) typeField.get(null);
            ELEMENT_CACHE.put(type, objectQName);
            return objectQName;
        } catch (final NoSuchFieldException e) {
            throw new RuntimeException(e);
        } catch (final IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    private XMLObject unmarshallElementFromString(final String elementString) {
        try {
            final Element samlElement = this.loadElementFromString(elementString);

            final Unmarshaller unmarshaller = Configuration.getUnmarshallerFactory().getUnmarshaller(samlElement);
            if (unmarshaller == null) {
                LOGGER.error("Unable to retrieve unmarshaller by DOM Element");
                throw new IllegalArgumentException("No unmarshaller for " + elementString);
            }

            return unmarshaller.unmarshall(samlElement);
        } catch (final UnmarshallingException ex) {
            LOGGER.error("Unmarshalling failed when parsing element string " + elementString, ex);
            throw new RuntimeException(ex);
        }
    }

    private Element loadElementFromString(final String elementString) {
        try {
            final DocumentBuilderFactory newFactory = this.getDocumentBuilderFactory();
            newFactory.setNamespaceAware(true);

            final DocumentBuilder builder = newFactory.newDocumentBuilder();

            final Document doc = builder.parse(new ByteArrayInputStream(elementString.getBytes("UTF-8")));
            final Element samlElement = doc.getDocumentElement();

            return samlElement;
        } catch (final ParserConfigurationException ex) {
            LOGGER.error("Unable to parse element string " + elementString, ex);
            throw new RuntimeException(ex);
        } catch (final SAXException ex) {
            LOGGER.error("Unable to parse element string " + elementString, ex);
            throw new RuntimeException(ex);
        } catch (final IOException ex) {
            LOGGER.error("Unable to parse element string " + elementString, ex);
            throw new RuntimeException(ex);
        }
    }

    private DocumentBuilderFactory getDocumentBuilderFactory() throws ParserConfigurationException {
        final DocumentBuilderFactory newFactory = DocumentBuilderFactory.newInstance();
        newFactory.setNamespaceAware(true);

        // External entities has been disabled in order to prevent XML External Entity (XXE) attacks.
        newFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
        return newFactory;
    }
}

As one might see from this code, a lot of manual work goes into the SAML communication. Hopefully this example will help someone else in the same situation.

2 comments:

  1. Im trying to use your code for a project. But the code doesn't seem to compile since the classes ExternalLogin, ExternalUserMetadata and LoginProvider are missing. Can you show what these classes look like?

    Thx, Anders, Denmark

    ReplyDelete
  2. We know that at the moment the phenomenon of increasing the proportion of insects in the Arab countries in general began and all this because of the rise in temperatures and high humidity in many areas bordering on the bodies of water,شركة رش مبيدات بخميس مشيط the insects and pests cause a lot of damage when you show up and be present in the home or any the origin of the work, and also shown no abundance in places where Misc food, and the problems caused by insects and pests in homes and other facilities which are diseases that afflict a resident of the house and a private back on children and lead to the presence of disease they have, and also found in the cupboards that store furniture and clothing and the incidence of their mold.شركة رش مبيدات بالطائف
    We all know the harmful insects but let's ask you more insects scattered far and pathogenic: flies, mosquitoes, fleas, ants, cockroaches and must be disposed of so as not to cause diseases in the residents of the house in order to ensure that the disease, especially in light of the spread of epidemics in the world and found harmful pests in high temperature areas.
    شركة تنظيف بخميس مشيط
    شركة تنظيف خزانات بخميس ميشط
    شركة كشف تسربات المياه بخميس مشيط
    شركة مكافحة حشرات بخميس مشيط
    شركة تنظيف منازل بخميس مشيط
    شركة نقل اثاث وعفش بخميس مشيط
    شركة تسليك مجارى بخميس مشيط

    ReplyDelete