fi.okm.mpass.shibboleth.attribute.resolver.dc.impl.RestDataConnector.java Source code

Java tutorial

Introduction

Here is the source code for fi.okm.mpass.shibboleth.attribute.resolver.dc.impl.RestDataConnector.java

Source

/*
 * The MIT License
 * Copyright (c) 2015 CSC - IT Center for Science, http://www.csc.fi
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package fi.okm.mpass.shibboleth.attribute.resolver.dc.impl;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.apache.commons.lang.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.ParseException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;

import fi.okm.mpass.shibboleth.attribute.resolver.data.OpintopolkuOppilaitosDTO;
import fi.okm.mpass.shibboleth.attribute.resolver.data.UserDTO;
import fi.okm.mpass.shibboleth.attribute.resolver.data.UserDTO.AttributesDTO;
import net.shibboleth.idp.attribute.IdPAttribute;
import net.shibboleth.idp.attribute.IdPAttributeValue;
import net.shibboleth.idp.attribute.StringAttributeValue;
import net.shibboleth.idp.attribute.resolver.AbstractDataConnector;
import net.shibboleth.idp.attribute.resolver.ResolutionException;
import net.shibboleth.idp.attribute.resolver.ResolvedAttributeDefinition;
import net.shibboleth.idp.attribute.resolver.context.AttributeResolutionContext;
import net.shibboleth.idp.attribute.resolver.context.AttributeResolverWorkContext;
import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
import net.shibboleth.utilities.java.support.httpclient.HttpClientBuilder;
import net.shibboleth.utilities.java.support.logic.Constraint;
import net.shibboleth.utilities.java.support.primitive.StringSupport;

/**
 * This class implements a {@link DataConnector} (resolver plugin) that communicates with ECA user data API
 * for resolving OID using IdP ID and ECA Authn ID.
 *
 * Example configuration (in attribute-resolver.xml):
 *
 * <resolver:DataConnector id="calculateAuthnId" xsi:type="ecaid:AuthnIdDataConnector" srcAttributeNames="uid"
 * destAttributeName="authnid"/> 
 */
public class RestDataConnector extends AbstractDataConnector {

    /** The attribute id for the username. */
    public static final String ATTR_ID_USERNAME = "username";

    /** The attribute id for the first name. */
    public static final String ATTR_ID_FIRSTNAME = "firstName";

    /** The attribute id for the last name. */
    public static final String ATTR_ID_SURNAME = "surname";

    /** The attribute id for the roles. */
    public static final String ATTR_ID_ROLES = "roles";

    /** The attribute id for the municipalities. */
    public static final String ATTR_ID_MUNICIPALITIES = "municipalities";

    /** The attribute id for the groups. */
    public static final String ATTR_ID_GROUPS = "groups";

    /** The attribute id for the schools. */
    public static final String ATTR_ID_SCHOOLS = "schools";

    /** The attribute id for the school ids. */
    public static final String ATTR_ID_SCHOOL_IDS = "schoolIds";

    /** The attribute id for the structured roles. */
    public static final String ATTR_ID_STRUCTURED_ROLES = "structuredRoles";

    /** The attribute id for the structured roles with IDs. */
    public static final String ATTR_ID_STRUCTURED_ROLES_WID = "structuredRolesWid";

    /** The attribute id prefix for UserDTO/attribute keys. */
    public static final String ATTR_PREFIX = "attr_";

    /** The default base URL for fetching school info. */
    public static final String DEFAULT_BASE_URL_SCHOOL_INFO = "https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/oppilaitosnumero_";

    /** Class logging. */
    private final Logger log = LoggerFactory.getLogger(RestDataConnector.class);

    /** The endpoint URL for the REST server. */
    private String endpointUrl;

    /** The attribute used for hooking the user object from the REST server. */
    private String hookAttribute;

    /** The attribute id containing the ECA IdP id. */
    private String idpId;

    /** The attribute id prefix for the resulting attributes. */
    private String resultAttributePrefix;

    /** The token used for authenticating to the REST server. */
    private String token;

    /** The base URL for resolving the school name via API. */
    private String nameApiBaseUrl;

    /** The {@link HttpClientBuilder} used for constructing HTTP clients. */
    private HttpClientBuilder httpClientBuilder;

    /**
     * Constructor.
     */
    public RestDataConnector() {
        this(null);
    }

    /**
     * Constructor.
     * @param clientBuilder The {@link HttpClientBuilder} used for constructing HTTP clients.
     */
    public RestDataConnector(final HttpClientBuilder clientBuilder) {
        super();
        if (clientBuilder == null) {
            httpClientBuilder = new HttpClientBuilder();
        } else {
            httpClientBuilder = clientBuilder;
        }
    }

    /** {@inheritDoc} */
    @Nullable
    @Override
    protected Map<String, IdPAttribute> doDataConnectorResolve(
            @Nonnull final AttributeResolutionContext attributeResolutionContext,
            @Nonnull final AttributeResolverWorkContext attributeResolverWorkContext) throws ResolutionException {
        final Map<String, IdPAttribute> attributes = new HashMap<>();

        log.debug("Calling {} for resolving attributes", endpointUrl);

        String authnIdValue = collectSingleAttributeValue(
                attributeResolverWorkContext.getResolvedIdPAttributeDefinitions(), hookAttribute);
        log.debug("AuthnID before URL encoding = {}", authnIdValue);
        if (authnIdValue == null) {
            log.error("Could not resolve hookAttribute value");
            throw new ResolutionException("Could not resolve hookAttribute value");
        }
        try {
            authnIdValue = URLEncoder.encode(collectSingleAttributeValue(
                    attributeResolverWorkContext.getResolvedIdPAttributeDefinitions(), hookAttribute), "UTF-8");
        } catch (UnsupportedEncodingException e) {
            log.error("Could not use UTF-8 for encoding authnID");
            throw new ResolutionException("Could not use UTF-8 for encoding authnID", e);
        }
        log.debug("AuthnID after URL encoding = {}", authnIdValue);
        final String idpIdValue = collectSingleAttributeValue(
                attributeResolverWorkContext.getResolvedIdPAttributeDefinitions(), idpId);
        if (StringSupport.trimOrNull(idpIdValue) == null) {
            log.error("Could not resolve idpId value");
            throw new ResolutionException("Could not resolve idpId value");
        }
        final String attributeCallUrl = endpointUrl + "?" + idpIdValue + "=" + authnIdValue;

        final HttpClient httpClient;
        try {
            httpClient = buildClient();
        } catch (Exception e) {
            log.error("Could not build HTTP client, skipping attribute resolution", e);
            return attributes;
        }
        log.debug("Calling URL {}", attributeCallUrl);
        final HttpContext context = HttpClientContext.create();
        final HttpUriRequest getMethod = RequestBuilder.get().setUri(attributeCallUrl)
                .setHeader("Authorization", "Token " + token).build();
        final HttpResponse restResponse;
        final long timestamp = System.currentTimeMillis();
        try {
            restResponse = httpClient.execute(getMethod, context);
        } catch (Exception e) {
            log.error("Could not open connection to REST API, skipping attribute resolution", e);
            return attributes;
        }

        final int status = restResponse.getStatusLine().getStatusCode();
        log.info("API call took {} ms, response code {}", System.currentTimeMillis() - timestamp, status);

        if (log.isTraceEnabled()) {
            if (restResponse.getAllHeaders() != null) {
                for (Header header : restResponse.getAllHeaders()) {
                    log.trace("Header {}: {}", header.getName(), header.getValue());
                }
            }
        }

        try {
            final String restResponseStr = EntityUtils.toString(restResponse.getEntity(), "UTF-8");
            log.trace("Response {}", restResponseStr);
            if (status == HttpStatus.SC_OK) {
                final Gson gson = new Gson();
                final UserDTO ecaUser = gson.fromJson(restResponseStr, UserDTO.class);
                populateAttributes(attributes, ecaUser);
                log.debug("{} attributes are now populated", attributes.size());
            } else {
                log.warn("No attributes found for session with idpId {}, http status {}", idpIdValue, status);
            }
        } catch (Exception e) {
            log.error("Error in connection to Data API", e);
        } finally {
            EntityUtils.consumeQuietly(restResponse.getEntity());
        }
        return attributes;
    }

    /**
     * Populates the attributes from the given user object to the given result map.
     * 
     * @param attributes The result map of attributes.
     * @param ecaUser The source user object.
     */
    protected void populateAttributes(final Map<String, IdPAttribute> attributes, UserDTO ecaUser) {
        populateAttribute(attributes, ATTR_ID_USERNAME, ecaUser.getUsername());
        populateAttribute(attributes, ATTR_ID_FIRSTNAME, ecaUser.getFirstName());
        populateAttribute(attributes, ATTR_ID_SURNAME, ecaUser.getLastName());
        if (ecaUser.getRoles() != null) {
            for (int i = 0; i < ecaUser.getRoles().length; i++) {
                final String rawSchool = ecaUser.getRoles()[i].getSchool();
                final String mappedSchool = getSchoolName(getHttpClientBuilder(), rawSchool, nameApiBaseUrl);
                if (mappedSchool == null) {
                    if (StringUtils.isNumeric(rawSchool)) {
                        populateAttribute(attributes, ATTR_ID_SCHOOL_IDS, rawSchool);
                        populateStructuredRole(attributes, "", rawSchool, ecaUser.getRoles()[i]);
                    } else {
                        populateAttribute(attributes, ATTR_ID_SCHOOLS, rawSchool);
                        populateStructuredRole(attributes, rawSchool, "", ecaUser.getRoles()[i]);
                    }
                } else {
                    populateAttribute(attributes, ATTR_ID_SCHOOLS, mappedSchool);
                    populateAttribute(attributes, ATTR_ID_SCHOOL_IDS, rawSchool);
                    populateStructuredRole(attributes, mappedSchool, rawSchool, ecaUser.getRoles()[i]);
                }
                populateAttribute(attributes, ATTR_ID_GROUPS, ecaUser.getRoles()[i].getGroup());
                populateAttribute(attributes, ATTR_ID_ROLES, ecaUser.getRoles()[i].getRole());
                populateAttribute(attributes, ATTR_ID_MUNICIPALITIES, ecaUser.getRoles()[i].getMunicipality());
            }
        }
        if (ecaUser.getAttributes() != null) {
            for (int i = 0; i < ecaUser.getAttributes().length; i++) {
                final AttributesDTO attribute = ecaUser.getAttributes()[i];
                populateAttribute(attributes, ATTR_PREFIX + attribute.getName(), attribute.getValue());
            }
        }
    }

    /**
     * Populates an attribute containing a structured role information from the given object. The value is
     * populated to the given map, or appended to its values if the attribute already exists.
     * 
     * @param attributes The result map of attributes.
     * @param schoolName The human-readable name of the school.
     * @param schoolId The id for the school.
     * @param role The role object whose values are added (except school).
     */
    protected void populateStructuredRole(final Map<String, IdPAttribute> attributes, final String schoolName,
            final String schoolId, final UserDTO.RolesDTO role) {
        final String school = schoolName != null ? schoolName : "";
        final String group = role.getGroup() != null ? role.getGroup() : "";
        final String aRole = role.getRole() != null ? role.getRole() : "";
        final String municipality = role.getMunicipality() != null ? role.getMunicipality() : "";
        final String structuredRole = municipality + ";" + school + ";" + group + ";" + aRole;
        populateAttribute(attributes, ATTR_ID_STRUCTURED_ROLES, structuredRole);
        final String structuredRoleWid = municipality + ";" + schoolId + ";" + group + ";" + aRole;
        populateAttribute(attributes, ATTR_ID_STRUCTURED_ROLES_WID, structuredRoleWid);
    }

    /**
     * Populates an attribute with the the given id and value to the given result map. If the id already
     * exists, the value will be appended to its values.
     * 
     * @param attributes The result map of attributes.
     * @param attributeId The attribute id.
     * @param attributeValue The attribute value.
     */
    protected void populateAttribute(final Map<String, IdPAttribute> attributes, final String attributeId,
            final String attributeValue) {
        if (StringSupport.trimOrNull(attributeId) == null || StringSupport.trimOrNull(attributeValue) == null) {
            log.debug("Ignoring attirbute {}, null value", attributeId);
            return;
        }
        if (attributes.get(resultAttributePrefix + attributeId) != null) {
            log.trace("Adding a new value to existing attribute {}", resultAttributePrefix + attributeId);
            final IdPAttribute idpAttribute = attributes.get(resultAttributePrefix + attributeId);
            log.trace("Existing values {}", idpAttribute.getValues());
            final List<IdPAttributeValue<String>> values = copyExistingValues(idpAttribute.getValues());
            values.add(new StringAttributeValue(attributeValue));
            idpAttribute.setValues(values);
            log.debug("Added value {} to attribute {}", attributeValue, resultAttributePrefix + attributeId);
        } else {
            final IdPAttribute idpAttribute = new IdPAttribute(resultAttributePrefix + attributeId);
            final List<IdPAttributeValue<String>> values = new ArrayList<>();
            values.add(new StringAttributeValue(attributeValue));
            idpAttribute.setValues(values);
            attributes.put(resultAttributePrefix + attributeId, idpAttribute);
            log.debug("Populated {} with value {}", resultAttributePrefix + attributeId, attributeValue);
        }
    }

    /**
     * Copies the String values from the source list to a new writable list.
     * @param sourceValues The existing values, expected to be Strings.
     * @return A writable list containing existing values.
     */
    @SuppressWarnings("unchecked")
    protected List<IdPAttributeValue<String>> copyExistingValues(final List<IdPAttributeValue<?>> sourceValues) {
        final List<IdPAttributeValue<String>> values = new ArrayList<>();
        final Iterator<IdPAttributeValue<?>> iterator = sourceValues.iterator();
        while (iterator.hasNext()) {
            values.add((IdPAttributeValue<String>) iterator.next());
        }
        return values;
    }

    /**
     * Sets the endpoint URL for the REST server.
     * @param url The endpointUrl.
     */
    public void setEndpointUrl(String url) {
        this.endpointUrl = Constraint.isNotEmpty(url, "The endpoint URL cannot be empty!");
    }

    /**
     * Gets the endpoint URL for the REST server.
     * @return The endpointUrl.
     */
    public String getEndpointUrl() {
        return this.endpointUrl;
    }

    /**
     * Sets the attribute used for hooking the user object from the REST server.
     * @param attribute The hookAttribute.
     */
    public void setHookAttribute(String attribute) {
        this.hookAttribute = Constraint.isNotEmpty(attribute, "The hookAttribute cannot be empty!");
    }

    /**
     * Gets the attribute used for hooking the user object from the REST server.
     * @return The hookAttribute.
     */
    public String getHookAttribute() {
        return this.hookAttribute;
    }

    /**
     * Sets the attribute id containing the ECA IdP id.
     * @param id The idpId.
     */
    public void setIdpId(String id) {
        this.idpId = Constraint.isNotEmpty(id, "The idpId attribute cannot be empty!");
    }

    /**
     * Gets the attribute id containing the ECA IdP id.
     * @return The idpId.
     */
    public String getIdpId() {
        return this.idpId;
    }

    /**
     * Sets the attribute id prefix for the resulting attributes. 
     * @param attributePrefix The resultAttributePrefix.
     */
    public void setResultAttributePrefix(String attributePrefix) {
        this.resultAttributePrefix = attributePrefix;
    }

    /**
     * Gets the attribute id prefix for the resulting attributes.
     * @return The resultAttributePrefix.
     */
    public String getResultAttributePrefix() {
        return this.resultAttributePrefix;
    }

    /**
     * Sets the token used for authenticating to the REST server.
     * @param authzToken The token.
     */
    public void setToken(String authzToken) {
        this.token = Constraint.isNotEmpty(authzToken, "The token cannot be empty!");
    }

    /**
     * Gets the token used for authenticating to the REST server.
     * @return The token.
     */
    public String getToken() {
        return this.token;
    }

    /**
     * Sets whether to disregard the TLS certificate protecting the endpoint URL.
     * @param disregard The flag to disregard the certificate.
     */
    public void setDisregardTLSCertificate(boolean disregard) {
        if (disregard) {
            log.warn("Disregarding TLS certificate in the communication with the REST server!");
        }
        httpClientBuilder.setConnectionDisregardTLSCertificate(disregard);
    }

    /**
     * Gets whether to disregard the TLS certificate protecting the endpoint URL.
     * @return true if disregarding, false otherwise.
     */
    public boolean isDisregardTLSCertificate() {
        return httpClientBuilder.isConnectionDisregardTLSCertificate();
    }

    /**
     * Sets the base URL for resolving the school name via API.
     * @param baseUrl The base URL for resolving the school name via API.
     */
    public void setNameApiBaseUrl(final String baseUrl) {
        if (StringSupport.trimOrNull(baseUrl) == null) {
            nameApiBaseUrl = DEFAULT_BASE_URL_SCHOOL_INFO;
        } else {
            nameApiBaseUrl = baseUrl;
        }
    }

    /**
     * Gets the base URL for resolving the school name via API.
     * @return The base URL for resolving the school name via API.
     */
    public String getNameApiBaseUrl() {
        return nameApiBaseUrl;
    }

    /**
     * Helper method for collecting single attribute value from the map of attribute definitions.
     * @param attributeDefinitions The map of {@link ResolvedAttributeDefinition}s.
     * @param attributeId The attribute id whose single value is collected.
     * @return The single value, null if no or multiple values exist.
     */
    protected String collectSingleAttributeValue(
            @Nonnull final Map<String, ResolvedAttributeDefinition> attributeDefinitions,
            @Nonnull @NotEmpty final String attributeId) {
        final ResolvedAttributeDefinition definition = attributeDefinitions.get(attributeId);
        if (definition == null || definition.getResolvedAttribute() == null) {
            log.warn("Could not find an attribute {} from the context", attributeId);
        } else {
            final List<IdPAttributeValue<?>> values = definition.getResolvedAttribute().getValues();
            if (values.size() == 0) {
                log.warn("No value found for the attribute {}", attributeId);
            } else if (values.size() > 1) {
                log.warn("Multiple values found for the attribute {}, all ignored", attributeId);
            } else {
                log.debug("Found a single value for the attribute {}", attributeId);
                return (String) values.get(0).getValue();
            }
        }
        return null;
    }

    /**
     * Returns the current {@link HttpClientBuilder}.
     * @return httpClientBuilder.
     */
    protected HttpClientBuilder getHttpClientBuilder() {
        return httpClientBuilder;
    }

    /**
     * Builds a {@link HttpClient} using current {@link HttpClientBuilder}.
     * @return The built client.
     * @throws Exception If the building fails.
     */
    protected synchronized HttpClient buildClient() throws Exception {
        return getHttpClientBuilder().buildClient();
    }

    /**
     * Fetch school name from external API.
     * @param clientBuilder The HTTP client builder.
     * @param id The school id whose information is fetched.
     * @param baseUrl The base URL for the external API. It is appended with the ID of the school.
     * @return The name of the school.
     */
    public static synchronized String getSchoolName(final HttpClientBuilder clientBuilder, final String id,
            final String baseUrl) {
        final Logger log = LoggerFactory.getLogger(RestDataConnector.class);
        if (StringSupport.trimOrNull(id) == null || !StringUtils.isNumeric(id) || id.length() > 6) {
            return null;
        }
        final HttpResponse response;
        try {
            final HttpUriRequest get = RequestBuilder.get().setUri(baseUrl + id).build();
            response = clientBuilder.buildClient().execute(get);
        } catch (Exception e) {
            log.error("Could not get school information with id {}", id, e);
            return null;
        }
        if (response == null) {
            log.error("Could not get school information with id {}", id);
            return null;
        }
        final String output;
        try {
            output = EntityUtils.toString(response.getEntity(), "UTF-8");
        } catch (ParseException | IOException e) {
            log.error("Could not parse school information response with id {}", id, e);
            return null;
        } finally {
            EntityUtils.consumeQuietly(response.getEntity());
        }
        log.trace("Fetched the following response body: {}", output);
        final Gson gson = new Gson();
        try {
            final OpintopolkuOppilaitosDTO[] oResponse = gson.fromJson(output, OpintopolkuOppilaitosDTO[].class);
            if (oResponse.length == 1 && oResponse[0].getMetadata() != null
                    && oResponse[0].getMetadata().length == 1) {
                log.debug("Successfully fetched name for id {}", id);
                return oResponse[0].getMetadata()[0].getName();
            }
        } catch (JsonSyntaxException | IllegalStateException e) {
            log.warn("Could not parse the response", e);
            log.debug("The unparseable response was {}", output);
        }
        log.warn("Could not find name for id {}", id);
        return null;
    }
}