com.akamai.edgegrid.auth.EdgeGridV1Signer.java Source code

Java tutorial

Introduction

Here is the source code for com.akamai.edgegrid.auth.EdgeGridV1Signer.java

Source

/*
 * Copyright 2013 Akamai Technologies, Inc. All Rights Reserved.
 * 
 * Licensed 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
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * 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 com.akamai.edgegrid.auth;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.api.client.http.ByteArrayContent;
import com.google.api.client.http.HttpContent;
import com.google.api.client.http.HttpHeaders;
import com.google.api.client.http.HttpRequest;

/**
 * Class representing the EdgeGrid version 1 signer that implements the {@link RequestSigner}.
 * 
 * <p>
 * The signer sets the Authorization header in the request as algorithm name, ' ' (space), followed by
 * an ordered list of name=value fields separated with ';'.
 * </p>
 * 
 * <p>
 * The names of the fields are:
 * </p>
 * 
 * <ol>
 * <li>
 * client_token: for the client token;
 * </li>
 * <li>
 * access_token: for the access token;
 * </li>
 * <li>
 * timestamp: for the timestamp when the request is signed;
 * </li>
 * <li>
 * </li>
 * nonce: for possible nonce checking;
 * <li>
 * signature: for the request signature.
 * </li>
 * </ol>
 *
 */
public class EdgeGridV1Signer implements RequestSigner {

    /**
     * The logger used for logging.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(EdgeGridV1Signer.class);

    /**
     * The signing algorithm for the EdgeGrid version 1 protocol.
     */
    private static final String ALGORHTM = "EG1-HMAC-SHA256";

    /**
     * The HMAC algorithm used.
     */
    private static final String HMAC_ALG = "HmacSHA256";

    /**
     * The message digest algorithm used.
     */
    private static final String MD_ALG = "SHA-256";

    /**
     * The charset used for String to bytes conversions.
     */
    private static final String CHARSET = "UTF-8";

    /**
     * The field name for the client token in the authorization header.
     */
    private static final String AUTH_CLIENT_TOKEN_NAME = "client_token";

    /**
     * The field name for the access token in the authorization header.
     */
    private static final String AUTH_ACCESS_TOKEN_NAME = "access_token";

    /**
     * The field name for the time stamp in the authorization header.
     */
    private static final String AUTH_TIMESTAMP_NAME = "timestamp";

    /**
     * The field name for the nonce in the authorization header.
     */
    private static final String AUTH_NONCE_NAME = "nonce";

    /**
     * The field name for the signature in the authorization header.
     */
    private static final String AUTH_SIGNATURE_NAME = "signature";

    /**
     * The ordered list of header names to include in the signature.
     */
    private final List<String> headersToInclude;

    /**
     * The maximum allowed body size in bytes for POST and PUT requests.
     */
    private final int maxBodySize;

    /**
     * Constructor
     * 
     * <p>
     * Note: the parameters should be published by the service provider when the service
     * is published. Refer to the API documentation for any special instructions.
     * </p>
     * 
     * @param headers the ordered list of header names to include in the signature.
     * @param maxBodySize the maximum allowed body size in bytes for POST and PUT requests.
     */
    public EdgeGridV1Signer(List<String> headers, int maxBodySize) {
        this.headersToInclude = headers;
        this.maxBodySize = maxBodySize;
    }

    /**
     * Signs the given request with the given client credential.
     * 
     * @param request the request to sign.
     * @param credential the credential used in the signing.
     * @return the signed request.
     * @throws RequestSigningException
     */
    public HttpRequest sign(HttpRequest request, ClientCredential credential) throws RequestSigningException {
        long currentTime = System.currentTimeMillis();
        String timeStamp = getTimeStamp(currentTime);

        StringBuilder sb = new StringBuilder();
        sb.append(ALGORHTM);
        sb.append(' ');
        sb.append(AUTH_CLIENT_TOKEN_NAME);
        sb.append('=');
        sb.append(credential.getClientToken());
        sb.append(';');

        sb.append(AUTH_ACCESS_TOKEN_NAME);
        sb.append('=');
        sb.append(credential.getAccessToken());
        sb.append(';');

        sb.append(AUTH_TIMESTAMP_NAME);
        sb.append('=');
        sb.append(timeStamp);
        sb.append(';');

        String nonce = UUID.randomUUID().toString();
        sb.append(AUTH_NONCE_NAME);
        sb.append('=');
        sb.append(nonce);
        sb.append(';');

        String authData = sb.toString();

        try {
            String clientSecret = credential.getClientSecret();

            byte[] signingKeyBytes = sign(timeStamp, clientSecret.getBytes(CHARSET), HMAC_ALG);
            String signingKey = Base64.encodeBase64String(signingKeyBytes);

            CanonicalizerHelper requestResult = getCanonicalizedRequest(request);
            HttpRequest updatedRequest = requestResult.getRequest();

            String requestData = requestResult.getCanonicalizedData();

            StringBuilder signData = new StringBuilder(requestData);
            signData.append(authData);

            String stringToSign = signData.toString();

            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(String.format("String to sign : '%s'", stringToSign));
            }

            byte[] signatureBytes = sign(stringToSign, signingKey.getBytes(CHARSET), HMAC_ALG);
            String signature = Base64.encodeBase64String(signatureBytes);

            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(String.format("Signature : '%s'", signature));
            }

            // add the signature
            sb.append(AUTH_SIGNATURE_NAME);
            sb.append('=');
            sb.append(signature);

            String authHeader = sb.toString();

            HttpHeaders headers = updatedRequest.getHeaders();
            headers.setAuthorization(authHeader);
            HttpRequest signedRequest = updatedRequest.setHeaders(headers);

            return signedRequest;
        } catch (UnsupportedEncodingException uee) {
            throw new RequestSigningException("Failed to sign: invalid string encoding", uee);
        }
    }

    /**
     * Gets the canonicalized data of the given request.
     * 
     * <p>
     * The canonicalized data contains the list of fields separate with a tab '\t':
     * </p>
     * 
     * <ol>
     * <li>
     * the request method (GET/PUT etc.) in upper case;
     * </li>
     * <li>
     * the scheme (http/https) in lower case;
     * </li>
     * <li>
     * the host from the Host header in lower case;
     * </li>
     * <li>
     * the relative URL that contains the path and query portions of the URL,
     * as it appears in the HTTP request line, see {@link #canonicalizeUri};
     * </li>
     * <li>
     * the canonicalized request headers, see {@link #canonicalizeHeaders};
     * </li>
     * <li>
     * the content hash of the request body for POST requests, see {@link #getContentHash}.
     * </li>
     * </ol>
     * 
     * @param request the request.
     * @return the canonicalized data, and the possibly updated request.
     * @throws RequestSigningException
     */
    protected CanonicalizerHelper getCanonicalizedRequest(HttpRequest request) throws RequestSigningException {
        StringBuilder sb = new StringBuilder();

        String method = request.getRequestMethod();
        if (Utils.isNullOrEmpty(method)) {
            throw new RequestSigningException("Invalid request: empty request method");
        }
        sb.append(method.toUpperCase());
        sb.append('\t');

        URI uri = request.getUrl().toURI();

        String scheme = uri.getScheme();
        if (Utils.isNullOrEmpty(scheme)) {
            throw new RequestSigningException("Invalid request: empty request scheme");
        }
        sb.append(scheme.toLowerCase());
        sb.append('\t');

        String host = getHost(request);
        if (Utils.isNullOrEmpty(host)) {
            throw new RequestSigningException("Invalid request: empty host");
        }
        sb.append(host.toLowerCase());
        sb.append('\t');

        String rawUrl = request.getUrl().buildRelativeUrl();
        String relateiveUrl = canonicalizeUri(rawUrl);
        sb.append(relateiveUrl);
        sb.append('\t');

        String canonicalizedHeaders = canonicalizeHeaders(request);
        sb.append(canonicalizedHeaders);
        sb.append('\t');

        CanonicalizerHelper contentHashResult = getContentHash(request);
        String contentHash = contentHashResult.getCanonicalizedData();
        sb.append(contentHash);
        sb.append('\t');

        String data = sb.toString();

        return new CanonicalizerHelper(data, contentHashResult.getRequest());
    }

    /**
     * Get the canonicalized uri.
     * 
     * <p>
     * The canonicalization is done as the following:
     * </p>
     * 
     * <ul>
     * <li>
     * If the path is null or empty, set it to "/".
     * </li>
     * <li>
     * If the path does not start with "/", add "/" to the beginning.
     * </li>
     * </ul>
     * 
     * @param uri the original uri.
     * @return the canonicalized uri.
     */
    protected String canonicalizeUri(String uri) {
        if (Utils.isNullOrEmpty(uri)) {
            return "/";
        }

        if (uri.charAt(0) != '/') {
            uri = "/" + uri;
        }

        return uri;
    }

    /**
     * Get the canonicalized data for the request headers.
     * 
     * <p>
     * The canonicalization is done as the following:
     * </p>
     * 
     * <p>
     * For each entry in the {@link #headersToInclude},
     * </p>
     * 
     * <ul>
     * <li>
     * get the first header value for the name;
     * </li>
     * <li>
     * trim the leading and trailing white spaces;
     * </li>
     * <li>
     * replace all repeated white spaces with a single space;
     * <p>
     * Note: the canonicalized data is used for signature only, as this step might alter the header value.
     * </p>
     * </li>
     * <li>
     * concatenate the name:value pairs with a tab '\t' separator. The name field is all in lower cases.
     * </li>
     * <li>
     * terminate the headers with another tab ('\t') separator.
     * </li>
     * </ul>
     * 
     * @param request the request.
     * @return the canonicalized data for the request headers.
     */
    protected String canonicalizeHeaders(HttpRequest request) {
        StringBuilder sb = new StringBuilder();
        for (String headerName : headersToInclude) {
            // only use the first entry if more than one headers with the same name
            String headerValue = request.getHeaders().getFirstHeaderStringValue(headerName);
            if (headerValue != null) {
                // trim the header value
                headerValue = headerValue.trim();

                if (!headerValue.isEmpty()) {
                    Pattern p = Pattern.compile("\\s+");
                    Matcher matcher = p.matcher(headerValue);
                    headerValue = matcher.replaceAll(" ");

                    sb.append(headerName.toLowerCase());
                    sb.append(':');
                    sb.append(headerValue);
                    sb.append('\t');
                }
            }
        }

        return sb.toString();
    }

    /**
     * Get the SHA-256 hash of the POST body.
     * 
     * @param request the request.
     * @return the canonicalized data, and the possibly updated request.
     * @throws RequestSigningException
     */
    protected CanonicalizerHelper getContentHash(HttpRequest request) throws RequestSigningException {
        String data = "";
        HttpRequest updatedRequest = request;

        // only do hash for POSTs for this version
        if ("POST".equalsIgnoreCase(request.getRequestMethod())) {

            HttpContent content = request.getContent();
            if (content != null) {
                try {
                    ByteArrayOutputStream bos = new ByteArrayOutputStream();
                    content.writeTo(bos);

                    byte[] contentBytes = bos.toByteArray();

                    int lengthToHash = bos.size();
                    if (lengthToHash > maxBodySize) {
                        LOGGER.warn(String.format(
                                "Message body length '%d' is larger than the max '%d'. "
                                        + "Using '%d' bytes for computing the hash.",
                                lengthToHash, maxBodySize, maxBodySize));
                        lengthToHash = maxBodySize;
                    } else {
                        if (LOGGER.isDebugEnabled()) {
                            LOGGER.debug(String.format("Content: %s", Base64.encodeBase64String(contentBytes)));
                        }
                    }

                    byte[] digestBytes = getHash(contentBytes, 0, lengthToHash);

                    if (LOGGER.isDebugEnabled()) {
                        LOGGER.debug(String.format("Content hash: %s", Base64.encodeBase64String(digestBytes)));
                    }

                    // for non-retryable content, reset the content for downstream handlers
                    if (!content.retrySupported()) {
                        HttpContent newContent = new ByteArrayContent(content.getType(), contentBytes);
                        updatedRequest = request.setContent(newContent);
                    }

                    data = Base64.encodeBase64String(digestBytes);
                } catch (IOException ioe) {
                    throw new RequestSigningException("Failed to get content hash: failed to read content", ioe);
                }
            }
        }

        return new CanonicalizerHelper(data, updatedRequest);
    }

    /**
     * Helper method to calculate the message digest.
     * 
     * @param contentBytes the content bytes for digesting.
     * @return the digest.
     * @throws RequestSigningException
     */
    private static byte[] getHash(byte[] contentBytes, int offset, int len) throws RequestSigningException {
        try {
            MessageDigest md = MessageDigest.getInstance(MD_ALG);

            md.update(contentBytes, offset, len);
            byte[] digestBytes = md.digest();
            return digestBytes;
        } catch (NoSuchAlgorithmException nsae) {
            throw new RequestSigningException("Failed to get request hash: algorithm not found", nsae);
        }
    }

    /**
     * Helper method to calculate the HMAC signature of a given string.
     * 
     * @param s the string to sign.
     * @param key the key for the signature.
     * @param algorithm the signing algorithm.
     * @return the HMac signature.
     * @throws RequestSigningException
     */
    private static byte[] sign(String s, byte[] key, String algorithm) throws RequestSigningException {
        try {
            SecretKeySpec signingKey = new SecretKeySpec(key, algorithm);
            Mac mac = Mac.getInstance(algorithm);
            mac.init(signingKey);

            byte[] valueBytes = s.getBytes(CHARSET);
            return mac.doFinal(valueBytes);
        } catch (NoSuchAlgorithmException nsae) {
            throw new RequestSigningException("Failed to sign: algorithm not found", nsae);
        } catch (InvalidKeyException ike) {
            throw new RequestSigningException("Failed to sign: invalid key", ike);
        } catch (UnsupportedEncodingException uee) {
            throw new RequestSigningException("Failed to sign: invalid string encoding", uee);
        }
    }

    /**
     * Helper method to get the host name from the request header.
     * 
     * @param request the request.
     * @return host name.
     */
    private static String getHost(HttpRequest request) {
        String hostName = request.getHeaders().getFirstHeaderStringValue("host");

        return hostName;
    }

    /**
     * Helper to get the formatted time stamp. 
     * 
     * @param time the time stamp as millisecond since the UNIX epoch.
     * @return the formatted time stamp.
     */
    private static String getTimeStamp(long time) {
        SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ssZ");
        Date date = new Date(time);

        format.setTimeZone(TimeZone.getTimeZone("UTC"));
        return format.format(date);
    }

    /**
     * Helper class representing the canonicalized data and possibly updated request.
     *
     */
    private class CanonicalizerHelper {

        /**
         * The canonicalized data.
         */
        private final String canonicalizedData;

        /**
         * The request.
         */
        private final HttpRequest request;

        /**
         * Constructor.
         * 
         * @param data the canonicalized data.
         * @param request the request.
         */
        public CanonicalizerHelper(String data, HttpRequest request) {
            this.canonicalizedData = data;
            this.request = request;
        }

        /**
         * Get the canonicalized data.
         * @return the canonicalized data.
         */
        public String getCanonicalizedData() {
            return canonicalizedData;
        }

        /**
         * Get the request.
         * @return the request.
         */
        public HttpRequest getRequest() {
            return request;
        }
    }
}