org.apereo.portal.security.oauth.IdTokenFactory.java Source code

Java tutorial

Introduction

Here is the source code for org.apereo.portal.security.oauth.IdTokenFactory.java

Source

/**
 * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information regarding copyright ownership. Apereo
 * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
 * this file except in compliance with the License. You may obtain a copy of the License at the
 * following location:
 *
 * <p>http://www.apache.org/licenses/LICENSE-2.0
 *
 * <p>Unless required by applicable law or agreed to in writing, software distributed under the
 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apereo.portal.security.oauth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
import org.apereo.portal.groups.IEntityGroup;
import org.apereo.portal.groups.IGroupMember;
import org.apereo.portal.security.IPerson;
import org.apereo.portal.services.GroupService;
import org.apereo.portal.soffit.Headers;
import org.apereo.portal.soffit.service.AbstractJwtService;
import org.apereo.services.persondir.IPersonAttributeDao;
import org.apereo.services.persondir.IPersonAttributes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;

/**
 * Produces OIDC ID Tokens for the OIDC userinfo endpoint. Supports nearly all the Standard Claims
 * as defined by OpenID Connect Core 1.0 (http://openid.net/specs/openid-connect-core-1_0.html).
 *
 * @since 5.1
 */
@Component
public class IdTokenFactory {

    private static final String LIST_SEPARATOR = ",";

    @Value("${portal.protocol.server.context}")
    private String issuer;

    @Autowired
    private IPersonAttributeDao personAttributeDao;

    @Value("${" + AbstractJwtService.SIGNATURE_KEY_PROPERTY + ":" + AbstractJwtService.DEFAULT_SIGNATURE_KEY + "}")
    private String signatureKey;

    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.timeoutSeconds:300}")
    private long timeoutSeconds;

    /*
     * OpenID Standard Claims
     *
     * Mapping to uPortal user attributes;  defaults (where specified) are based on the Lightweight
     * Directory Access Protocol (LDAP): Schema for User Applications
     * (https://tools.ietf.org/html/rfc4519) and the eduPerson Object Class Specification
     * (http://software.internet2.edu/eduperson/internet2-mace-dir-eduperson-201310.html), except
     * 'username' and 'displayName' (which are a uPortal standards).
     */

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.name:displayName}")
    private String nameAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.given_name:givenName}")
    private String givenNameAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.family_name:sn}")
    private String familyNameAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.middle_name:}")
    private String middleNameAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.nickname:eduPersonNickname}")
    private String nicknameAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.preferred_username:}")
    private String preferredUsernameAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.profile:}")
    private String profileAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.picture:}")
    private String pictureAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.website:}")
    private String websiteAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.email:mail}")
    private String emailAttr;

    /** JSON data type 'boolean' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.email_verified:}")
    private String emailVerifiedAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.gender:}")
    private String genderAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.birthdate:}")
    private String birthdateAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.zoneinfo:}")
    private String zoneinfoAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.locale:}")
    private String localeAttr;

    /** JSON data type 'string' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.phone_number:telephoneNumber}")
    private String phoneNumberAttr;

    /** JSON data type 'boolean' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.phone_number_verified:}")
    private String phoneNumberVerifiedAttr;

    /*
     * NB:  The 'address' claim requires additional complexity b/c it's type is JSON object.  In
     * light of that, and because most portals don't have address info in the user attributes
     * collection, we'll skip it (for now),
     */

    /** JSON data type 'number' */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.mapping.updated_at:}")
    private String updatedAtAttributeName;

    /*
     * uPortal Custom Claims
     */

    /**
     * The custom claim <code>groups</code> may contain some or all of the user's group
     * affiliations. Use the Spring property <code>
     * org.apereo.portal.security.oauth.IdTokenFactory.groups.whitelist</code> to control which
     * portal groups are included in the claim.
     */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.groups.whitelist:Students,Faculty,Staff,Portal Administrators}")
    private String groupsWhitelistProperty;

    /**
     * Additional user attributes in the portal may be included in the ID Token as custom claims.
     * Use the Spring property <code>org.apereo.portal.security.oauth.IdTokenFactory.customClaims
     * </code> to specify which additional attributes to include. The claim name will always be the
     * same as the attribute name. The JSON type of a custom claim will be inferred from it's value.
     */
    @Value("${org.apereo.portal.security.oauth.IdTokenFactory.customClaims:}")
    private String customClaimsProperty;

    private Set<ClaimMapping> mappings;

    private Set<String> groupsWhitelist;

    private Set<String> customClaims;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @PostConstruct
    public void init() {

        // Mappings for Standard Claims
        final Set<ClaimMapping> set = new HashSet<>();
        set.add(new ClaimMapping("name", nameAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("given_name", givenNameAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("family_name", familyNameAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("middle_name", middleNameAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("nickname", nicknameAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("preferred_username", preferredUsernameAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("profile", profileAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("picture", pictureAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("website", websiteAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("email", emailAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("email_verified", emailVerifiedAttr, DataTypeConverter.BOOLEAN));
        set.add(new ClaimMapping("gender", genderAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("birthdate", birthdateAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("zoneinfo", zoneinfoAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("locale", localeAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("phone_number", phoneNumberAttr, DataTypeConverter.STRING));
        set.add(new ClaimMapping("phone_number_verified", phoneNumberVerifiedAttr, DataTypeConverter.BOOLEAN));
        set.add(new ClaimMapping("updated_at", updatedAtAttributeName, DataTypeConverter.NUMBER));
        mappings = Collections.unmodifiableSet(set);

        if (logger.isInfoEnabled()) {
            final StringBuilder msg = new StringBuilder();
            msg.append("Using the following mappings for OIDC Standard Claims:");
            set.forEach(mapping -> msg.append("\n\t").append(mapping));
            logger.info(msg.toString());
        }

        // Portal Groups ('groups' custom claim)
        groupsWhitelist = Collections.unmodifiableSet(Arrays.stream(groupsWhitelistProperty.split(LIST_SEPARATOR))
                .map(String::trim).filter(item -> item.length() != 0).collect(Collectors.toSet()));
        logger.info("Using the following portal groups to build the custom 'groups' claim:  {}", groupsWhitelist);

        // Other Custom Claims (a.k.a user attributes)
        customClaims = Collections.unmodifiableSet(Arrays.stream(customClaimsProperty.split(LIST_SEPARATOR))
                .map(String::trim).filter(item -> item.length() != 0).collect(Collectors.toSet()));
        logger.info("Using the following custom claims:  {}", customClaims);
    }

    public String createUserInfo(String username) {
        return this.createUserInfo(username, null, null);
    }

    public String createUserInfo(String username, Set<String> claimsToInclude, Set<String> groupsToInclude) {

        final Date now = new Date();
        final Date expires = new Date(now.getTime() + (timeoutSeconds * 1000L));

        final JwtBuilder builder = Jwts.builder().setIssuer(issuer).setSubject(username).setAudience(issuer)
                .setExpiration(expires).setIssuedAt(now);

        final IPersonAttributes person = personAttributeDao.getPerson(username);

        // Attribute mappings
        mappings.stream().filter(mapping -> includeClaim(mapping.getClaimName(), claimsToInclude)).forEach(item -> {
            final Object value = person.getAttributeValue(item.getAttributeName());
            if (value != null) {
                builder.claim(item.getClaimName(), item.getDataTypeConverter().convert(value));
            }
        });

        // Groups
        final List<String> groups = new ArrayList<>();
        final IGroupMember groupMember = GroupService.getGroupMember(username, IPerson.class);
        if (groupMember != null) {
            final Set<IEntityGroup> ancestors = groupMember.getAncestorGroups();
            for (IEntityGroup g : ancestors) {
                if (includeGroup(g, groupsToInclude)) {
                    groups.add(g.getName());
                }
            }
        }
        if (!groups.isEmpty()) {
            /*
             * If a Claim is not returned, that Claim Name SHOULD be omitted from the JSON object
             * representing the Claims; it SHOULD NOT be present with a null or empty string value.
             */
            builder.claim("groups", groups);
        }

        // Default custom claims defined by uPortal.properties
        customClaims.stream().filter(claimName -> includeClaim(claimName, claimsToInclude))
                .map(attributeName -> new CustomClaim(attributeName, person.getAttributeValues(attributeName)))
                .filter(claim -> claim.getClaimValue() != null)
                .forEach(claim -> builder.claim(claim.getClaimName(), claim.getClaimValue()));

        final String rslt = builder.signWith(SignatureAlgorithm.HS512, signatureKey).compact();

        logger.debug("Produced the following JWT for username='{}':  {}", username, rslt);

        return rslt;
    }

    /**
     * Convenience method for obtaining an OIDC Id Token from a request, if present.
     *
     * @return A fully-parsed JWT if a valid bearer token is present in the <code>Authorization
     *     </code> header, otherwise <code>null</code>
     */
    public Jws<Claims> getUserInfo(HttpServletRequest request) {
        final String bearerToken = getBearerToken(request);
        return StringUtils.isNotBlank(bearerToken) ? parseBearerToken(bearerToken) : null;
    }

    public String getBearerToken(HttpServletRequest request) {
        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        logger.debug("{} header value:  {}", HttpHeaders.AUTHORIZATION, authorization);
        return StringUtils.isNotBlank(authorization)
                && authorization.length() > Headers.BEARER_TOKEN_PREFIX.length()
                        ? authorization.substring(Headers.BEARER_TOKEN_PREFIX.length())
                        : null;
    }

    public Jws<Claims> parseBearerToken(String bearerToken) {
        try {
            return Jwts.parser().setSigningKey(signatureKey).parseClaimsJws(bearerToken);
        } catch (Exception e) {
            logger.warn("Unsupported bearerToken:  {}", bearerToken);
            logger.debug("Stack trace", e);
        }
        return null;
    }

    private boolean includeClaim(String claimName, Set<String> claimsToInclude) {
        boolean rslt = true; // default
        if (claimsToInclude != null && !claimsToInclude.contains(claimName)) {
            /*
             * This group is included in the deployed configuration,
             * but is not wanted by the REST request.
             */
            rslt = false;
        }
        return rslt;
    }

    private boolean includeGroup(IEntityGroup group, Set<String> groupsToInclude) {
        boolean rslt = groupsWhitelist.contains(group.getName()); // default
        if (rslt && groupsToInclude != null && !groupsToInclude.contains(group.getName())) {
            /*
             * This group is included in the deployed configuration,
             * but is not wanted by the REST request.
             */
            rslt = false;
        }
        return rslt;
    }

    /*
     * Nested Types
     */

    enum DataTypeConverter {
        STRING {
            @Override
            Object convert(Object inpt) {
                return inpt.toString();
            }
        },

        BOOLEAN {
            @Override
            Object convert(Object inpt) {
                return Boolean.valueOf(inpt.toString());
            }
        },

        NUMBER {
            @Override
            Object convert(Object inpt) {
                return new BigDecimal(inpt.toString());
            }
        };

        abstract Object convert(Object inpt);
    }

    private static final class ClaimMapping {

        private final String claimName;
        private final String attributeName;
        private final DataTypeConverter dataTypeConverter;

        public ClaimMapping(String claimName, String attributeName, DataTypeConverter dataTypeConverter) {
            this.claimName = claimName;
            this.attributeName = attributeName;
            this.dataTypeConverter = dataTypeConverter;
        }

        public String getClaimName() {
            return claimName;
        }

        public String getAttributeName() {
            return attributeName;
        }

        public DataTypeConverter getDataTypeConverter() {
            return dataTypeConverter;
        }

        @Override
        public String toString() {
            return "ClaimMapping{" + "claimName='" + claimName + '\'' + ", attributeName='" + attributeName + '\''
                    + ", dataTypeConverter=" + dataTypeConverter + '}';
        }
    }

    private static final class CustomClaim {

        private final String claimName;
        private final Object claimValue;

        public CustomClaim(String claimName, Object claimValue) {
            this.claimName = claimName;
            this.claimValue = claimValue;
        }

        public String getClaimName() {
            return claimName;
        }

        public Object getClaimValue() {
            return claimValue;
        }
    }
}