alluxio-master-2.x/core/server/proxy/src/main/java/alluxio/proxy/s3/signature/StringToSignProducer.java
/*
 * The Alluxio Open Foundation licenses this work under the Apache License, version 2.0
 * (the "License"). You may not use this work except in compliance with the License, which is
 * available at www.apache.org/licenses/LICENSE-2.0
 *
 * This software is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied, as more fully set forth in the License.
 *
 * See the NOTICE file distributed with this work for information regarding copyright ownership.
 */

package alluxio.proxy.s3.signature;

import static alluxio.proxy.s3.S3Constants.AUTHORIZATION_CHARSET;
import static alluxio.proxy.s3.S3Constants.S3_SIGN_CONTENT_SHA256;
import static alluxio.proxy.s3.S3Constants.S3_SIGN_SIGNATURE;
import static alluxio.proxy.s3.S3Constants.TIME_FORMATTER;

import alluxio.proxy.s3.S3ErrorCode;
import alluxio.proxy.s3.S3Exception;
import alluxio.proxy.s3.S3RestUtils;

import org.apache.kerby.util.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.StringJoiner;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.container.ContainerRequestContext;

/**
 * Stateless utility to create stringToSign, the base of the signature.
 */
public final class StringToSignProducer {
  private static final Logger LOG = LoggerFactory.getLogger(StringToSignProducer.class);
  private static final String NEWLINE = "\n";
  private static final String HOST = "host";
  private static final String X_AMZ_DATE = "x-amz-date";
  private static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
  private static final String SHA_256_ALGORITHM = "SHA-256";
  /**
   * Seconds in a week, which is the max expiration time Sig-v4 accepts.
   */
  private static final long PRESIGN_URL_MAX_EXPIRATION_SECONDS =
          60 * 60 * 24 * 7;

  private StringToSignProducer() {
  }

  /**
   * Convert signature info to strToSign.
   *
   * @param signatureInfo
   * @param context
   * @return signature string
   * @throws Exception
   */
  public static String createSignatureBase(
      SignatureInfo signatureInfo,
      ContainerRequestContext context
  ) throws Exception {
    return createSignatureBase(signatureInfo,
        context.getUriInfo().getRequestUri().getScheme(),
        context.getMethod(),
        context.getUriInfo().getRequestUri().getPath(),
        S3RestUtils.fromMultiValueToSingleValueMap(context.getHeaders(), true),
        S3RestUtils.fromMultiValueToSingleValueMap(
            context.getUriInfo().getQueryParameters(), false));
  }

  /**
   * Convert signature info to strToSign.
   *
   * @param signatureInfo
   * @param request
   * @return signature string
   * @throws Exception
   */
  public static String createSignatureBase(
      SignatureInfo signatureInfo,
      HttpServletRequest request
  ) throws Exception {
    return createSignatureBase(signatureInfo,
        request.getScheme(),
        request.getMethod(),
        request.getRequestURI(),
        getHeaders(request),
        getParameterMap(request));
  }

  /**
   * Convert request info to strToSign.
   *
   * Construct String to sign in below format.
   * StringToSign =
   * Algorithm + \n +
   * RequestDateTime + \n +
   * CredentialScope + \n +
   * HashedCanonicalRequest
   *
   * For more details refer to AWS documentation:
   * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
   *
   * @param signatureInfo
   * @param scheme
   * @param method
   * @param uri
   * @param headers
   * @param queryParams
   * @return signature string
   * @throws Exception
   */
  public static String createSignatureBase(
       SignatureInfo signatureInfo,
       String scheme,
       String method,
       String uri,
       Map<String, String> headers,
       Map<String, String> queryParams
  ) throws Exception {
    StringBuilder strToSign = new StringBuilder();
    String credentialScope = signatureInfo.getCredentialScope();

    // If the absolute path is empty, use a forward slash (/)
    uri = (uri.trim().length() > 0) ? uri : "/";
    // Encode URI and preserve forward slashes
    strToSign.append(signatureInfo.getAlgorithm() + NEWLINE);
    strToSign.append(signatureInfo.getDateTime() + NEWLINE);
    strToSign.append(credentialScope + NEWLINE);

    String canonicalRequest = buildCanonicalRequest(
         scheme,
         method,
         uri,
         signatureInfo.getSignedHeaders(),
         headers,
         queryParams,
         !signatureInfo.isSignPayload());
    strToSign.append(hash(canonicalRequest));
    if (LOG.isDebugEnabled()) {
      LOG.debug("canonicalRequest:[{}], StringToSign:[{}]", canonicalRequest, strToSign);
    }

    return strToSign.toString();
  }

  /**
   * Get all headers by given http request, and the result map will ignore case.
   * @param request
   * @return
   */
  private static Map<String, String> getHeaders(HttpServletRequest request) {
    Map<String, String> result = new TreeMap<>(String::compareToIgnoreCase);
    Enumeration<String> headerNames = request.getHeaderNames();
    if (headerNames != null) {
      while (headerNames.hasMoreElements()) {
        String name = headerNames.nextElement();
        String value = request.getHeader(name);
        result.put(name, value);
      }
    }
    return result;
  }

  /**
   * Get all parameters by given http request,
   * if there are multiple values for the same key, the first one will be taken.
   * @param request
   * @return
   */
  private static Map<String, String> getParameterMap(HttpServletRequest request) {
    return request.getParameterMap().entrySet()
        .stream()
        .collect(Collectors.toMap(Entry::getKey, e -> e.getValue()[0]));
  }

  /**
   * Compute a hash for provided string.
   * @param payload
   * @return hashed string
   * @throws NoSuchAlgorithmException
   */
  public static String hash(String payload) throws NoSuchAlgorithmException {
    MessageDigest md = MessageDigest.getInstance(SHA_256_ALGORITHM);
    md.update(payload.getBytes(AUTHORIZATION_CHARSET));
    return Hex.encode(md.digest()).toLowerCase();
  }

  /**
   * Convert request info to a format string.
   * @param schema request schema
   * @param method request method
   * @param uri request uri
   * @param signedHeaders signed headers
   * @param headers request headers
   * @param queryParams request params
   * @param unsignedPayload whether or not the payload is unsigned
   * @return formatted string
   * @throws S3Exception
   */
  public static String buildCanonicalRequest(
      String schema,
      String method,
      String uri,
      String signedHeaders,
      Map<String, String> headers,
      Map<String, String> queryParams,
      boolean unsignedPayload
  ) throws S3Exception {
    String canonicalUri = getCanonicalUri("/", uri);

    String canonicalQueryStr = getQueryParamString(queryParams);

    StringBuilder canonicalHeaders = new StringBuilder();

    for (String header : signedHeaders.split(";")) {
      canonicalHeaders.append(header);
      canonicalHeaders.append(":");
      if (headers.containsKey(header)) {
        String headerValue = headers.get(header);
        canonicalHeaders.append(headerValue);
        canonicalHeaders.append(NEWLINE);

        // Set for testing purpose only to skip date and host validation.
        validateSignedHeader(schema, header, headerValue);
      } else {
        throw new RuntimeException(String.format("%s %s %s", "Header", header,
                "not present in request but requested to be signed."));
      }
    }

    String payloadHash;
    if (UNSIGNED_PAYLOAD.equals(headers.get(S3_SIGN_CONTENT_SHA256))
            || unsignedPayload) {
      payloadHash = UNSIGNED_PAYLOAD;
    } else {
      payloadHash = headers.get(S3_SIGN_CONTENT_SHA256);
    }
    StringJoiner canonicalRequestJoiner = new StringJoiner(NEWLINE);
    canonicalRequestJoiner.add(method)
            .add(canonicalUri)
            .add(canonicalQueryStr)
            .add(canonicalHeaders)
            .add(signedHeaders)
            .add(payloadHash);
    return canonicalRequestJoiner.toString();
  }

  /**
   * Returns CanonicalUri.
   *
   * @param regex Regular expression to split by
   * @param uri uri string
   * @return CanonicalUri
   */
  private static String getCanonicalUri(String regex, String uri) {
    StringJoiner result = new StringJoiner(regex);
    Pattern p = Pattern.compile(regex);
    Matcher m = p.matcher(uri);
    int pos = 0;
    while (m.find()) {
      result.add(urlEncode(uri.substring(pos, m.start())));
      pos = m.end();
    }
    result.add(urlEncode(uri.substring(pos)));
    return result.toString();
  }

  private static String getQueryParamString(Map<String, String> queryMap) {
    List<String> params = new ArrayList<>(queryMap.keySet());

    // Sort by name, then by value
    Collections.sort(params, (o1, o2) -> o1.equals(o2)
            ? queryMap.get(o1).compareTo(queryMap.get(o2))
            : o1.compareTo(o2));
    params.remove(S3_SIGN_SIGNATURE);

    StringJoiner joiner = new StringJoiner("&");
    params.forEach(key ->
        joiner.add(String.format("%s=%s", urlEncode(key), urlEncode(queryMap.get(key)))));
    return joiner.toString();
  }

  /**
   * Encode url.
   *
   * Spaces must be encoded as %20. Do not encode spaces as plus signs (+).
   * Apply the encoding algorithm and replace the plus sign (+) in the encoded
   * string with %20, the asterisk (*) with %2A, and %7E with the tilde (~).
   *
   * @param str string to be encoded
   * @return the encoded String
   */
  private static String urlEncode(String str) {
    try {
      return URLEncoder.encode(str, "UTF-8")
              .replaceAll("\\+", "%20")
              .replaceAll("%7E", "~");
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
    }
  }

  static void validateSignedHeader(
      String schema,
      String header,
      String headerValue
  ) throws S3Exception {
    switch (header) {
      case HOST:
        try {
          URI hostUri = new URI(String.format("%s://%s", schema, headerValue));
          InetAddress.getByName(hostUri.getHost());
        } catch (UnknownHostException | URISyntaxException e) {
          LOG.error("Host value mentioned in signed header is not valid. "
                  + "Host:{}", headerValue);
          throw new S3Exception(headerValue, S3ErrorCode.AUTHINFO_CREATION_ERROR);
        }
        break;
      case X_AMZ_DATE:
        LocalDateTime dateTime = LocalDateTime.parse(headerValue, TIME_FORMATTER);
        LocalDateTime now = LocalDateTime.now();
        if (dateTime.isBefore(now.minus(PRESIGN_URL_MAX_EXPIRATION_SECONDS, ChronoUnit.SECONDS))
            || dateTime.isAfter(now.plus(PRESIGN_URL_MAX_EXPIRATION_SECONDS, ChronoUnit.SECONDS))) {
          LOG.error("AWS date not in valid range. Request timestamp:{} should "
                 + "not be older than {} seconds.", headerValue,
                 PRESIGN_URL_MAX_EXPIRATION_SECONDS);
          throw new S3Exception(headerValue, S3ErrorCode.AUTHINFO_CREATION_ERROR);
        }
        break;
      default:
        break;
    }
  }
}
