/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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
 *
 *     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 org.apache.knox.gateway.services.security.impl;

import static org.apache.knox.gateway.services.security.AliasService.NO_CLUSTER_NAME;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.filefilter.PrefixFileFilter;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.knox.gateway.GatewayMessages;
import org.apache.knox.gateway.GatewayResources;
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.i18n.resources.ResourcesFactory;
import org.apache.knox.gateway.services.ServiceLifecycleException;
import org.apache.knox.gateway.services.security.KeystoreService;
import org.apache.knox.gateway.services.security.KeystoreServiceException;
import org.apache.knox.gateway.services.security.MasterService;
import org.apache.knox.gateway.util.X509CertificateUtil;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.GeneralSecurityException;
import java.security.Key;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.text.MessageFormat;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import javax.crypto.spec.SecretKeySpec;

public class DefaultKeystoreService implements KeystoreService {
  private static final String DN_TEMPLATE = "CN={0},OU=Test,O=Hadoop,L=Test,ST=Test,C=US";
  public static final String CREDENTIALS_SUFFIX = "-credentials.";
  private static final String CERT_GEN_MODE = "hadoop.gateway.cert.gen.mode";
  private static final String CERT_GEN_MODE_LOCALHOST = "localhost";
  private static final String CERT_GEN_MODE_HOSTNAME = "hostname";
  private static GatewayMessages LOG = MessagesFactory.get(GatewayMessages.class);
  private static GatewayResources RES = ResourcesFactory.get(GatewayResources.class);

  Cache<CacheKey, String> cache;

  private GatewayConfig config;

  private MasterService masterService;
  private Path keyStoreDirPath;

  private String credentialStoreAlgorithm;
  private String credentialStoreType;
  private String credentialsSuffix;

  public void setMasterService(MasterService ms) {
    this.masterService = ms;
  }

  @Override
  public void init(GatewayConfig config, Map<String, String> options)
      throws ServiceLifecycleException {
    this.config = config;

    this.keyStoreDirPath = Paths.get(config.getGatewayKeystoreDir());
    if (Files.notExists(keyStoreDirPath)) {
      try {
        // This will attempt to create all missing directories.  No failures will occur if the
        // directories already exist.
        Files.createDirectories(keyStoreDirPath);
      } catch (IOException e) {
        throw new ServiceLifecycleException(RES.failedToCreateKeyStoreDirectory(keyStoreDirPath.toString()));
      }
    }

    if (this.cache == null) {
      this.cache = Caffeine.newBuilder().expireAfterAccess(config.getKeystoreCacheEntryTimeToLiveInMinutes(), TimeUnit.MINUTES).maximumSize(config.getKeystoreCacheSizeLimit()).build();
    }

    this.credentialStoreAlgorithm = config.getCredentialStoreAlgorithm();
    this.credentialStoreType = config.getCredentialStoreType();
    this.credentialsSuffix = CREDENTIALS_SUFFIX + this.credentialStoreType.toLowerCase(Locale.ROOT);
  }

  @Override
  public void start() throws ServiceLifecycleException {
  }

  @Override
  public void stop() throws ServiceLifecycleException {
  }

  @Override
  public void createKeystoreForGateway() throws KeystoreServiceException {
    createKeyStore(Paths.get(config.getIdentityKeystorePath()), config.getIdentityKeystoreType(),
        getKeyStorePassword(config.getIdentityKeystorePasswordAlias()));
  }

  @Override
  public KeyStore getKeystoreForGateway() throws KeystoreServiceException {
    return getKeystore(Paths.get(config.getIdentityKeystorePath()), config.getIdentityKeystoreType(), config.getIdentityKeystorePasswordAlias(), true);
  }

  @Override
  public KeyStore getTruststoreForHttpClient() throws KeystoreServiceException {
    String trustStorePath = config.getHttpClientTruststorePath();
    if (trustStorePath == null) {
      return null;
    } else {
      return getKeystore(Paths.get(trustStorePath), config.getHttpClientTruststoreType(), config.getHttpClientTruststorePasswordAlias(), true);
    }
  }

  @Override
  public KeyStore getSigningKeystore() throws KeystoreServiceException {
    return getSigningKeystore(null);
  }

  @Override
  public KeyStore getSigningKeystore(String keystoreName) throws KeystoreServiceException {
    Path keyStoreFile;
    String keyStoreType;
    String passwordAlias;
    if(keystoreName != null) {
      keyStoreFile = keyStoreDirPath.resolve(keystoreName + ".jks");
      keyStoreType = "jks";
      passwordAlias = null;
    } else {
      keyStoreFile = Paths.get(config.getSigningKeystorePath());
      keyStoreType = config.getSigningKeystoreType();
      passwordAlias = config.getSigningKeystorePasswordAlias();
    }
    return getKeystore(keyStoreFile, keyStoreType, passwordAlias, true);
  }

  @Override
  public void addSelfSignedCertForGateway(String alias, char[] passphrase) throws KeystoreServiceException {
    addSelfSignedCertForGateway(alias, passphrase, null);
  }

  @Override
  public void addSelfSignedCertForGateway(String alias, char[] passphrase, String hostname)
      throws KeystoreServiceException {
    addCertForGateway(alias, passphrase, hostname);
  }

  private synchronized void addCertForGateway(String alias, char[] passphrase, String hostname)
      throws KeystoreServiceException {
    KeyPairGenerator keyPairGenerator;
    try {
      keyPairGenerator = KeyPairGenerator.getInstance("RSA");
      keyPairGenerator.initialize(2048);
      KeyPair KPair = keyPairGenerator.generateKeyPair();
      if (hostname == null) {
        hostname = System.getProperty(CERT_GEN_MODE, CERT_GEN_MODE_LOCALHOST);
      }
      X509Certificate cert;
      if(hostname.equals(CERT_GEN_MODE_HOSTNAME)) {
        String dn = buildDistinguishedName(InetAddress.getLocalHost().getHostName());
        cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, "SHA1withRSA");
      }
      else {
        String dn = buildDistinguishedName(hostname);
        cert = X509CertificateUtil.generateCertificate(dn, KPair, 365, "SHA1withRSA");
      }

      KeyStore privateKS = getKeystoreForGateway();
      privateKS.setKeyEntry(alias, KPair.getPrivate(),
          passphrase,
          new java.security.cert.Certificate[]{cert});

      writeKeyStoreToFile(privateKS, Paths.get(config.getIdentityKeystorePath()),
          getKeyStorePassword(config.getIdentityKeystorePasswordAlias()));
    } catch (GeneralSecurityException | IOException e) {
      LOG.failedToAddSeflSignedCertForGateway( alias, e );
      throw new KeystoreServiceException(e);
    }
  }

  private String buildDistinguishedName(String hostname) {
    MessageFormat headerFormatter = new MessageFormat(DN_TEMPLATE, Locale.ROOT);
    String[] paramArray = new String[1];
    paramArray[0] = hostname;
    return headerFormatter.format(paramArray);
  }

  @Override
  public void createCredentialStoreForCluster(String clusterName) throws KeystoreServiceException {
    checkExistingCredentialStore(clusterName);
    createKeyStore(keyStoreDirPath.resolve(clusterName + this.credentialsSuffix), this.credentialStoreType, masterService.getMasterSecret());
  }

  private void checkExistingCredentialStore(String clusterName) {
    final File[] existingClusterCredentialStoreFiles = keyStoreDirPath.toFile().listFiles((FileFilter) new PrefixFileFilter(clusterName + CREDENTIALS_SUFFIX));
    if (existingClusterCredentialStoreFiles != null) {
      for (File existingClusterCredentialStoreFile : existingClusterCredentialStoreFiles) {
        String existingCredentialStoreType = FilenameUtils.getExtension(existingClusterCredentialStoreFile.getName());
        if (!this.credentialStoreType.equals(existingCredentialStoreType)) {
          LOG.credentialStoreForClusterFoundWithDifferentType(clusterName, existingCredentialStoreType);
        }
      }
    }
  }

  @Override
  public boolean isCredentialStoreForClusterAvailable(String clusterName) throws KeystoreServiceException {
    final Path keyStoreFilePath = keyStoreDirPath.resolve(clusterName + this.credentialsSuffix);
    try {
      return isKeyStoreAvailable(keyStoreFilePath, this.credentialStoreType, masterService.getMasterSecret());
    } catch (KeyStoreException | IOException e) {
      throw new KeystoreServiceException(e);
    }
  }

  @Override
  public boolean isKeystoreForGatewayAvailable() throws KeystoreServiceException {
    final Path keyStoreFilePath = Paths.get(config.getIdentityKeystorePath());
    try {
      return isKeyStoreAvailable(keyStoreFilePath, config.getIdentityKeystoreType(),
          getKeyStorePassword(config.getIdentityKeystorePasswordAlias()));
    } catch (KeyStoreException | IOException e) {
      throw new KeystoreServiceException(e);
    }
  }

  @Override
  public Key getKeyForGateway(char[] passphrase) throws KeystoreServiceException {
    return getKeyForGateway(config.getIdentityKeyAlias(), passphrase);
  }

  @Override
  public Key getKeyForGateway(String alias, char[] passphrase) throws KeystoreServiceException {
    return getKeyFromKeystore(getKeystoreForGateway(), alias, passphrase);
  }

  @Override
  public Certificate getCertificateForGateway() throws KeystoreServiceException, KeyStoreException {
    KeyStore ks = getKeystoreForGateway();
    return (ks == null) ? null : ks.getCertificate(config.getIdentityKeyAlias());
  }

  @Override
  public Key getSigningKey(String alias, char[] passphrase) throws KeystoreServiceException {
    return getSigningKey(null, alias, passphrase);
  }

  @Override
  public Key getSigningKey(String keystoreName, String alias, char[] passphrase) throws KeystoreServiceException {
    return getKeyFromKeystore(getSigningKeystore(keystoreName), alias, passphrase);
  }

  private Key getKeyFromKeystore(KeyStore ks, String alias, char[] passphrase) {
    Key key = null;
    if (passphrase == null) {
      passphrase = masterService.getMasterSecret();
      LOG.assumingKeyPassphraseIsMaster();
    }
    if (ks != null) {
      try {
        key = ks.getKey(alias, passphrase);
      } catch (UnrecoverableKeyException | NoSuchAlgorithmException | KeyStoreException e) {
        LOG.failedToGetKeyForGateway( alias, e );
      }
    }
    return key;
  }

  @Override
  public KeyStore getCredentialStoreForCluster(String clusterName) throws KeystoreServiceException {
    // Do not fail getting the credential store if the keystore file does not exist.  The returned
    // KeyStore will be empty.  This seems like a potential bug, but is the behavior before KNOX-1812
    return getKeystore(keyStoreDirPath.resolve(clusterName + this.credentialsSuffix), this.credentialStoreType, null, false);
  }

  @Override
  public void addCredentialForCluster(String clusterName, String alias, String value)
      throws KeystoreServiceException {
    addCredentialsForCluster(clusterName, Collections.singletonMap(alias, value));
  }

  @Override
  public void addCredentialsForCluster(String clusterName, Map<String, String> credentials)
      throws KeystoreServiceException {
    // Needed to prevent read then write synchronization issue where alias is not added
    synchronized (this) {
      removeFromCache(clusterName, credentials.keySet());
      KeyStore ks = getCredentialStoreForCluster(clusterName);
      if (ks != null) {
        try {
          // Add all the credential keys to the keystore
          for (Map.Entry<String, String> credential : credentials.entrySet()) {
            final Key key = new SecretKeySpec(credential.getValue().getBytes(StandardCharsets.UTF_8), this.credentialStoreAlgorithm);
            ks.setKeyEntry(credential.getKey(), key, masterService.getMasterSecret(), null);
          }

          // Write all the changes once
          final Path keyStoreFilePath = keyStoreDirPath.resolve(clusterName + this.credentialsSuffix);
          writeKeyStoreToFile(ks, keyStoreFilePath, masterService.getMasterSecret());
          addToCache(clusterName, credentials);
        } catch (KeyStoreException | IOException | CertificateException | NoSuchAlgorithmException e) {
          LOG.failedToAddCredentialForCluster(clusterName, e);
        }
      }
    }
  }

  @Override
  public char[] getCredentialForCluster(String clusterName, String alias)
      throws KeystoreServiceException {
    char[] credential;

    synchronized (this) {
      credential = checkCache(clusterName, alias);
      if (credential == null) {
        KeyStore ks = getCredentialStoreForCluster(clusterName);
        if (ks != null) {
          try {
            credential = getCredentialForCluster(clusterName, alias, ks);
          } catch (KeystoreServiceException e) {
            LOG.failedToGetCredentialForCluster(clusterName, e);
          }
        }
      }
    }

    return credential;
  }

  @Override
  public char[] getCredentialForCluster(String clusterName, String alias, KeyStore ks) throws KeystoreServiceException {
    try {
      char[] credential = null;
      Key credentialKey = ks.getKey(alias, masterService.getMasterSecret());
      if (credentialKey == null) {
        credentialKey = ks.getKey(alias.toLowerCase(Locale.ROOT), masterService.getMasterSecret());
      }
      if (credentialKey != null) {
        final String credentialString = new String(credentialKey.getEncoded(), StandardCharsets.UTF_8);
        credential = credentialString.toCharArray();
        addToCache(clusterName, alias, credentialString);
      }
      return credential;
    } catch (UnrecoverableKeyException | KeyStoreException | NoSuchAlgorithmException e) {
      throw new KeystoreServiceException(e);
    }
  }

  @Override
  public void removeCredentialForCluster(String clusterName, String alias) throws KeystoreServiceException {
    removeCredentialsForCluster(clusterName, Collections.singleton(alias));
  }

  @Override
  public void removeCredentialsForCluster(String clusterName, Set<String> aliases) throws KeystoreServiceException {
    // Needed to prevent read then write synchronization issue where alias is not removed
    synchronized (this) {
      KeyStore ks = getCredentialStoreForCluster(clusterName);
      if (ks != null) {
        try {
          // Delete all the entries
          for (String alias : aliases) {
            if (ks.containsAlias(alias)) {
              ks.deleteEntry(alias);
            }
          }
          removeFromCache(clusterName, aliases);

          // Update the keystore file once to reflect all the alias deletions
          final Path keyStoreFilePath = keyStoreDirPath.resolve(clusterName + this.credentialsSuffix);
          writeKeyStoreToFile(ks, keyStoreFilePath, masterService.getMasterSecret());
        } catch (KeyStoreException | IOException | CertificateException | NoSuchAlgorithmException e) {
          LOG.failedToRemoveCredentialForCluster(clusterName, e);
        }
      }
    }
  }

  /**
   * Called only from within critical sections of other methods above.
   */
  private char[] checkCache(String clusterName, String alias) {
    final String cachedCredential = cache.getIfPresent(CacheKey.of(clusterName, alias));
    return cachedCredential == null ? null : cachedCredential.toCharArray();
  }

  /**
   * Called only from within critical sections of other methods above.
   */
  private void addToCache(String clusterName, String alias, String credentialString) {
    cache.put(CacheKey.of(clusterName, alias), credentialString);
  }

  /**
   * Called only from within critical sections of other methods above.
   */
  private void addToCache(String clusterName, Map<String, String> credentials) {
    for (String alias : credentials.keySet()) {
      cache.put(CacheKey.of(clusterName, alias), credentials.get(alias));
    }
  }

  /**
   * Called only from within critical sections of other methods above.
   */
  private void removeFromCache(String clusterName, Set<String> aliases) {
    Set<CacheKey> keys = new HashSet<>();
    for (String alias : aliases) {
      keys.add(CacheKey.of(clusterName, alias));
    }
    cache.invalidateAll(keys);
  }

  @Override
  public String getKeystorePath() {
    return config.getIdentityKeystorePath();
  }

  /**
   * Loads a keystore file.
   * <p>
   * if <code>failIfNotAccessible</code> is <code>true</code>, then the path to the keystore file
   * (keystorePath) is validated such that it exists, is a file and can be read by the process. If
   * any of these checks fail, a {@link KeystoreServiceException} is thrown in dictating the exact
   * reason.
   * <p>
   * Before the keystore file is loaded, the service's read lock is locked to prevent concurrent
   * reads on the file.
   *
   * @param keystorePath        the path to the keystore file
   * @param keystoreType        the type of keystore file
   * @param alias               the alias for the password to the keystore file (see {@link #getKeyStorePassword(String)})
   * @param failIfNotAccessible <code>true</code> to ensure the keystore file exists and is readable; <code>false</code> to not check
   * @return a {@link KeyStore}, or <code>null</code> if the requested keystore cannot be created
   * @throws KeystoreServiceException if an error occurs loading the keystore file
   */
  private synchronized KeyStore getKeystore(Path keystorePath, String keystoreType, String alias,
                                            boolean failIfNotAccessible)
      throws KeystoreServiceException {
    if (failIfNotAccessible) {
      if (Files.notExists(keystorePath)) {
        LOG.keystoreFileDoesNotExist(keystorePath.toString());
        throw new KeystoreServiceException("The keystore file does not exist: " + keystorePath.toString());
      } else if (!Files.isRegularFile(keystorePath)) {
        LOG.keystoreFileIsNotAFile(keystorePath.toString());
        throw new KeystoreServiceException("The keystore file is not a file: " + keystorePath.toString());
      } else if (!Files.isReadable(keystorePath)) {
        LOG.keystoreFileIsNotAccessible(keystorePath.toString());
        throw new KeystoreServiceException("The keystore file cannot be read: " + keystorePath.toString());
      }
    }

    return loadKeyStore(keystorePath, keystoreType, getKeyStorePassword(alias));
  }

  private synchronized boolean isKeyStoreAvailable(final Path keyStoreFilePath, String storeType,
                                                   char[] password)
      throws KeyStoreException, IOException {
    if (Files.exists(keyStoreFilePath) &&
            Files.isRegularFile(keyStoreFilePath) &&
            Files.isReadable(keyStoreFilePath)) {
      try (InputStream input = Files.newInputStream(keyStoreFilePath)) {
        final KeyStore keyStore = KeyStore.getInstance(storeType);
        keyStore.load(input, password);
        return true;
      } catch (NoSuchAlgorithmException | CertificateException e) {
        LOG.failedToLoadKeystore(keyStoreFilePath.toString(), storeType, e);
      } catch (IOException | KeyStoreException e) {
        LOG.failedToLoadKeystore(keyStoreFilePath.toString(), storeType, e);
        throw e;
      }
    }
    return false;
  }

  // Package private for unit test access
  // We need this to be synchronized to prevent multiple threads from using at once
  synchronized KeyStore createKeyStore(Path keystoreFilePath, String keystoreType, char[] password) throws KeystoreServiceException {
    // Ensure the parent directory exists...
    // This is symlink safe.
    Path parentPath = keystoreFilePath.getParent();
    if (parentPath != null && !Files.isDirectory(parentPath)) {
      try{
        // This will attempt to create all missing directories.  No failures will occur if the
        // directories already exist.
        Files.createDirectories(parentPath);
      } catch (IOException e) {
        LOG.failedToCreateKeystore(keystoreFilePath.toString(), keystoreType, e);
        throw new KeystoreServiceException(e);
      }
    }

    try {
      KeyStore ks = KeyStore.getInstance(keystoreType);
      ks.load(null, null);
      writeKeyStoreToFile(ks, keystoreFilePath, password);
      return ks;
    } catch (NoSuchAlgorithmException | CertificateException | KeyStoreException | IOException e) {
      LOG.failedToCreateKeystore(keystoreFilePath.toString(), keystoreType, e);
      throw new KeystoreServiceException(e);
    }
  }

  // Package private for unit test access
  synchronized KeyStore loadKeyStore(final Path keyStoreFilePath, final String storeType,
                                     final char[] password) throws KeystoreServiceException {
    try {
      final KeyStore keyStore = KeyStore.getInstance(storeType);

      // If the file does not exist, create an empty keystore
      if (Files.exists(keyStoreFilePath)) {
        try (FileChannel fileChannel = FileChannel.open(keyStoreFilePath, StandardOpenOption.READ)) {
          fileChannel.lock(0L, Long.MAX_VALUE, true);
          try (InputStream input = Channels.newInputStream(fileChannel)) {
            keyStore.load(input, password);
          }
        }
      } else {
        keyStore.load(null, password);
      }
      return keyStore;
    } catch (CertificateException | NoSuchAlgorithmException | KeyStoreException | IOException e) {
      LOG.failedToLoadKeystore(keyStoreFilePath.toString(), storeType, e);
      throw new KeystoreServiceException(e);
    }
  }

  // Package private for unit test access
  synchronized void writeKeyStoreToFile(final KeyStore keyStore, final Path path, char[] password)
      throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException {
    // TODO: backup the keystore on disk before attempting a write and restore on failure
    try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.WRITE,
        StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
      fileChannel.lock();
      try (OutputStream out = Channels.newOutputStream(fileChannel)) {
        keyStore.store(out, password);
      }
    }
  }

  private char[] getKeyStorePassword(String alias) throws KeystoreServiceException {
    char[] password = null;
    if (alias != null && !alias.isEmpty()) {
      password = getCredentialForCluster(NO_CLUSTER_NAME, alias);
    }
    return (password == null) ? masterService.getMasterSecret() : password;
  }

  private static class CacheKey {
    private final String clusterName;
    private final String alias;

    private CacheKey(String clusterName, String alias) {
      this.clusterName = clusterName;
      this.alias = alias;
    }

    private static CacheKey of(String clusterName, String alias) {
      return new CacheKey(clusterName, alias);
    }

    @Override
    public int hashCode() {
      return HashCodeBuilder.reflectionHashCode(this);
    }

    @Override
    public boolean equals(Object obj) {
      return EqualsBuilder.reflectionEquals(this, obj);
    }
  }
}
