/*
 * Copyright 2013-2019 the original author or authors.
 *
 * 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
 *
 *      https://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 org.springframework.cloud.config.server.support;

import java.net.URI;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;

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

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.transport.CredentialItem;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.URIish;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;

import org.springframework.util.Assert;

import static org.springframework.util.StringUtils.hasText;

/**
 * Provides a jgit {@link CredentialsProvider} implementation that can provide the
 * appropriate credentials to connect to an AWS CodeCommit repository.
 * <p>
 * From the command line, you can configure git to use AWS code commit with a credential
 * helper. However, jgit does not support credential helper commands, but it does provide
 * a CredentialsProvider abstract class we can extend. Connecting to an AWS CodeCommit
 * (codecommit) repository requires an AWS access key and secret key. These are used to
 * calculate a signature for the git request. The AWS access key is used as the codecommit
 * username, and the calculated signature is used as the password. The process for
 * calculating this signature is documented very well at
 * https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html.
 * </p>
 * Connecting to an AWS CodeCommit (codecommit) repository requires an AWS access key and
 * secret key. These are used to calculate a signature for the git request. The AWS access
 * key is used as the codecommit username, and the calculated signature is used as the
 * password. The process for calculating this signature is documented very well at
 * https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html.
 *
 * @author Don Laidlaw
 */
public class AwsCodeCommitCredentialProvider extends CredentialsProvider {

	private static final String SHA_256 = "SHA-256"; //$NON-NLS-1$

	private static final String UTF8 = "UTF8"; //$NON-NLS-1$

	private static final String HMAC_SHA256 = "HmacSHA256"; //$NON-NLS-1$

	private static final char[] hexArray = "0123456789abcdef".toCharArray(); //$NON-NLS-1$

	protected Log logger = LogFactory.getLog(getClass());

	/**
	 * The AWSCredentialsProvider will be used to provide the access key and secret key if
	 * they are not specified.
	 */
	private AwsCredentialsProvider awsCredentialProvider;

	/**
	 * If the access and secret keys are provided, then the AWSCredentialsProvider will
	 * not be used. The username is the awsAccessKeyId.
	 */
	private String username;

	/**
	 * If the access and secret keys are provided, then the AWSCredentialsProvider will
	 * not be used. The password is the awsSecretKey.
	 */
	private String password;

	/**
	 * Calculate the AWS CodeCommit password for the provided URI and AWS secret key. This
	 * uses the algorithm published by AWS at
	 * https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
	 * @param uri the codecommit repository uri
	 * @param awsSecretKey the aws secret key
	 * @return the password to use in the git request
	 */
	protected static String calculateCodeCommitPassword(URIish uri, String awsSecretKey) {
		String[] split = uri.getHost().split("\\.");
		if (split.length < 4) {
			throw new CredentialException("Cannot detect AWS region from URI", null);
		}
		String region = split[1];

		Date now = new Date();
		SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
		dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));

		String dateStamp = dateFormat.format(now);
		String shortDateStamp = dateStamp.substring(0, 8);

		String codeCommitPassword;
		try {
			StringBuilder stringToSign = new StringBuilder();
			stringToSign.append("AWS4-HMAC-SHA256\n").append(dateStamp).append("\n").append(shortDateStamp).append("/")
					.append(region).append("/codecommit/aws4_request\n")
					.append(bytesToHexString(canonicalRequestDigest(uri)));

			byte[] signedRequest = sign(awsSecretKey, shortDateStamp, region, stringToSign.toString());
			codeCommitPassword = dateStamp + "Z" + bytesToHexString(signedRequest);
		}
		catch (Exception e) {
			throw new CredentialException("Error calculating AWS CodeCommit password", e);
		}

		return codeCommitPassword;
	}

	private static byte[] hmacSha256(String data, byte[] key) throws Exception {
		String algorithm = HMAC_SHA256;
		Mac mac = Mac.getInstance(algorithm);
		mac.init(new SecretKeySpec(key, algorithm));
		return mac.doFinal(data.getBytes(UTF8));
	}

	private static byte[] sign(String secret, String shortDateStamp, String region, String toSign) throws Exception {
		byte[] kSecret = ("AWS4" + secret).getBytes(UTF8);
		byte[] kDate = hmacSha256(shortDateStamp, kSecret);
		byte[] kRegion = hmacSha256(region, kDate);
		byte[] kService = hmacSha256("codecommit", kRegion);
		byte[] kSigning = hmacSha256("aws4_request", kService);
		return hmacSha256(toSign, kSigning);
	}

	/**
	 * Creates a message digest.
	 * @param uri uri to process
	 * @return a message digest
	 * @throws NoSuchAlgorithmException when the SHA 256 algorithm is not found
	 */
	private static byte[] canonicalRequestDigest(URIish uri) throws NoSuchAlgorithmException {
		StringBuilder canonicalRequest = new StringBuilder();
		canonicalRequest.append("GIT\n") // codecommit uses GIT as the request method
				.append(uri.getPath()).append("\n") // URI request path
				.append("\n") // Query string, always empty for codecommit
				// Next is canonical headers, codecommit only requires the host header
				.append("host:").append(uri.getHost()).append("\n").append("\n") // canonical
																					// headers
																					// are
																					// always
																					// terminated
																					// by
																					// newline
				.append("host\n"); // The list of canonical headers, only one for
									// codecommit

		MessageDigest digest = MessageDigest.getInstance(SHA_256);

		return digest.digest(canonicalRequest.toString().getBytes());
	}

	/**
	 * Convert bytes to a hex string.
	 * @param bytes the bytes
	 * @return a string of hex characters encoding the bytes.
	 */
	private static String bytesToHexString(byte[] bytes) {
		char[] hexChars = new char[bytes.length * 2];
		for (int j = 0; j < bytes.length; j++) {
			int v = bytes[j] & 0xFF;
			hexChars[j * 2] = hexArray[v >>> 4];
			hexChars[j * 2 + 1] = hexArray[v & 0x0F];
		}
		return new String(hexChars);
	}

	/**
	 * This provider can handle uris like
	 * https://git-codecommit.$AWS_REGION.amazonaws.com/v1/repos/$REPO .
	 * @param uri uri to parse
	 * @return {@code true} if the URI can be handled
	 */
	public static boolean canHandle(String uri) {
		if (!hasText(uri)) {
			return false;
		}

		try {
			URL url = new URL(uri);
			URI u = new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(),
					url.getQuery(), url.getRef());
			if (u.getScheme().equals("https")) {
				String host = u.getHost();
				if (host.endsWith(".amazonaws.com") && host.startsWith("git-codecommit.")) {
					return true;
				}
			}
		}
		catch (Throwable t) {
			// ignore all, we can't handle it
		}

		return false;
	}

	/**
	 * This credentials provider cannot run interactively.
	 * @return false
	 * @see org.eclipse.jgit.transport.CredentialsProvider#isInteractive()
	 */
	@Override
	public boolean isInteractive() {
		return false;
	}

	/**
	 * We support username and password credential items only.
	 * @see org.eclipse.jgit.transport.CredentialsProvider#supports(org.eclipse.jgit.transport.CredentialItem[])
	 */
	@Override
	public boolean supports(CredentialItem... items) {
		for (CredentialItem i : items) {
			if (i instanceof CredentialItem.Username) {
				continue;
			}
			else if (i instanceof CredentialItem.Password) {
				continue;
			}
			else {
				return false;
			}
		}
		return true;
	}

	/**
	 * Get the AWSCredentials. If an AWSCredentialProvider was specified, use that,
	 * otherwise, create a new AWSCredentialsProvider. If the username and password are
	 * provided, then use those directly as AWSCredentials. Otherwise us the
	 * {@link DefaultCredentialsProvider} as is standard with AWS applications.
	 * @return the AWS credentials.
	 */
	private AwsCredentials retrieveAwsCredentials() {
		if (this.awsCredentialProvider == null) {
			if (this.username != null && this.password != null) {
				this.logger.debug("Creating a static AWSCredentialsProvider");
				this.awsCredentialProvider = new AWSStaticCredentialsProvider(
						AwsBasicCredentials.create(this.username, this.password));
			}
			else {
				this.logger.debug("Creating a default AWSCredentialsProvider");
				this.awsCredentialProvider = DefaultCredentialsProvider.create();
			}
		}
		return this.awsCredentialProvider.resolveCredentials();
	}

	/**
	 * Get the username and password to use for the given uri.
	 * @see org.eclipse.jgit.transport.CredentialsProvider#get(org.eclipse.jgit.transport.URIish,
	 * org.eclipse.jgit.transport.CredentialItem[])
	 */
	@Override
	public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
		String codeCommitPassword;
		String awsAccessKey;
		String awsSecretKey;
		try {
			AwsCredentials awsCredentials = retrieveAwsCredentials();
			StringBuilder awsKey = new StringBuilder();
			awsKey.append(awsCredentials.accessKeyId());
			awsSecretKey = awsCredentials.secretAccessKey();
			if (awsCredentials instanceof AwsSessionCredentials) {
				AwsSessionCredentials sessionCreds = (AwsSessionCredentials) awsCredentials;
				if (sessionCreds.sessionToken() != null) {
					awsKey.append('%').append(sessionCreds.sessionToken());
				}
			}
			awsAccessKey = awsKey.toString();
		}
		catch (Throwable t) {
			this.logger.warn("Unable to retrieve AWS Credentials", t);
			return false;
		}
		try {
			codeCommitPassword = calculateCodeCommitPassword(uri, awsSecretKey);
		}
		catch (Throwable t) {
			this.logger.warn("Error calculating the AWS CodeCommit password", t);
			return false;
		}

		for (CredentialItem i : items) {
			if (i instanceof CredentialItem.Username) {
				((CredentialItem.Username) i).setValue(awsAccessKey);
				this.logger.trace("Returning username " + awsAccessKey);
				continue;
			}
			if (i instanceof CredentialItem.Password) {
				((CredentialItem.Password) i).setValue(codeCommitPassword.toCharArray());
				this.logger.trace("Returning password " + codeCommitPassword);
				continue;
			}
			if (i instanceof CredentialItem.StringType && i.getPromptText().equals("Password: ")) { //$NON-NLS-1$
				((CredentialItem.StringType) i).setValue(codeCommitPassword);
				this.logger.trace("Returning password string " + codeCommitPassword);
				continue;
			}
			throw new UnsupportedCredentialItem(uri, i.getClass().getName() + ":" + i.getPromptText()); //$NON-NLS-1$
		}

		return true;
	}

	/**
	 * Throw out cached data and force retrieval of AWS credentials.
	 * @param uri This parameter is not used in this implementation.
	 */
	@Override
	public void reset(URIish uri) {
		// Should throw out cached info.
		// Note that even though the credentials (password) we calculate here is
		// valid for 15 minutes, we do not cache it. Instead we just re-calculate
		// it each time we need it. However, the AWSCredentialProvider will cache
		// its AWSCredentials object.
	}

	/**
	 * @return the awsCredentialProvider
	 */
	public AwsCredentialsProvider getAwsCredentialProvider() {
		return this.awsCredentialProvider;
	}

	/**
	 * @param awsCredentialProvider the awsCredentialProvider to set
	 */
	public void setAwsCredentialProvider(AwsCredentialsProvider awsCredentialProvider) {
		this.awsCredentialProvider = awsCredentialProvider;
	}

	/**
	 * @return the username
	 */
	public String getUsername() {
		return this.username;
	}

	/**
	 * @param username the username to set
	 */
	public void setUsername(String username) {
		this.username = username;
	}

	/**
	 * @return the password
	 */
	public String getPassword() {
		return this.password;
	}

	/**
	 * @param password the password to set
	 */
	public void setPassword(String password) {
		this.password = password;
	}

	/**
	 * Simple implementation of AWSCredentialsProvider that just wraps static
	 * AWSCredentials. AWS Actually provides this class in newer versions of the AWS API.
	 */
	public class AWSStaticCredentialsProvider implements AwsCredentialsProvider {

		private final AwsCredentials credentials;

		public AWSStaticCredentialsProvider(AwsCredentials credentials) {
			Assert.notNull(credentials, "credentials");
			this.credentials = credentials;
		}

		@Override
		public AwsCredentials resolveCredentials() {
			return this.credentials;
		}

	}

}
