/*
 * Copyright 2017 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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 org.keycloak.policy;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 * Creates {@link BlacklistPasswordPolicyProvider} instances.
 * <p>
 * Password blacklists are simple text files where every line is a blacklisted password delimited by {@code \n}.
 * Blacklist files are discovered and registered at startup.
 * <p>Blacklists can be configured via the <em>Authentication: Password Policy</em> section in the admin-console.
 * A blacklist-file is referred to by its name in the policy configuration.
 * <p>Users can provide custom blacklists by adding a blacklist password file to the configured blacklist folder.
 * <p>
 * <p>The location of the password-blacklists folder is derived as follows</p>
 * <ol>
 * <li>the value of the System property {@code keycloak.password.blacklists.path} if configured - fails if folder is missing</li>
 * <li>the value of the SPI config property: {@code blacklistsPath} when explicitly configured - fails if folder is missing</li>
 * <li>otherwise {@code ${jboss.server.data.dir}/password-blacklists/} if nothing else is configured - the folder is created automatically if not present</li>
 * </ol>
 * <p>Note that the preferred way for configuration is to copy the password file to the {@code ${jboss.server.data.dir}/password-blacklists/} folder</p>
 * <p>To configure a password blacklist via the SPI configuration, run the following jboss-cli script:</p>
 * <pre>{@code
 * /subsystem=keycloak-server/spi=password-policy:add()
 * /subsystem=keycloak-server/spi=password-policy/provider=passwordBlacklist:add(enabled=true)
 * /subsystem=keycloak-server/spi=password-policy/provider=passwordBlacklist:write-attribute(name=properties.blacklistsPath, value=/data/keycloak/blacklists/)
 * }</pre>
 * <p>A password blacklist with the filename {@code 10_million_password_list_top_1000000-password-blacklist.txt}
 * that is located beneath {@code /data/keycloak/blacklists/} can be referred to
 * as {@code 10_million_password_list_top_1000000-password-blacklist.txt} in the <em>Authentication: Password Policy</em> configuration.
 *
 * @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
 */
public class BlacklistPasswordPolicyProviderFactory implements PasswordPolicyProviderFactory {

  private static final Logger LOG = Logger.getLogger(BlacklistPasswordPolicyProviderFactory.class);

  public static final String ID = "passwordBlacklist";

  public static final String SYSTEM_PROPERTY = "keycloak.password.blacklists.path";

  public static final String BLACKLISTS_PATH_PROPERTY = "blacklistsPath";

  public static final String JBOSS_SERVER_DATA_DIR = "jboss.server.data.dir";

  public static final String PASSWORD_BLACKLISTS_FOLDER = "password-blacklists/";

  private ConcurrentMap<String, FileBasedPasswordBlacklist> blacklistRegistry = new ConcurrentHashMap<>();

  private Path blacklistsBasePath;

  @Override
  public PasswordPolicyProvider create(KeycloakSession session) {
    return new BlacklistPasswordPolicyProvider(session.getContext(), this);
  }

  @Override
  public void init(Config.Scope config) {
    this.blacklistsBasePath = FileBasedPasswordBlacklist.detectBlacklistsBasePath(config);
  }

  @Override
  public void postInit(KeycloakSessionFactory factory) {
  }

  @Override
  public void close() {
  }

  @Override
  public String getDisplayName() {
    return "Password Blacklist";
  }

  @Override
  public String getConfigType() {
    return PasswordPolicyProvider.STRING_CONFIG_TYPE;
  }

  @Override
  public String getDefaultConfigValue() {
    return "";
  }

  @Override
  public boolean isMultiplSupported() {
    return false;
  }

  @Override
  public String getId() {
    return ID;
  }


  /**
   * Resolves and potentially registers a {@link PasswordBlacklist} for the given {@code blacklistName}.
   *
   * @param blacklistName
   * @return
   */
  public PasswordBlacklist resolvePasswordBlacklist(String blacklistName) {

    Objects.requireNonNull(blacklistName, "blacklistName");

    String cleanedBlacklistName = blacklistName.trim();
    if (cleanedBlacklistName.isEmpty()) {
      throw new IllegalArgumentException("Password blacklist name must not be empty!");
    }

    return blacklistRegistry.computeIfAbsent(cleanedBlacklistName, (name) -> {
      FileBasedPasswordBlacklist pbl = new FileBasedPasswordBlacklist(this.blacklistsBasePath, name);
      pbl.lazyInit();
      return pbl;
    });
  }

  /**
   * A {@link PasswordBlacklist} describes a list of too easy to guess
   * or potentially leaked passwords that users should not be able to use.
   */
  public interface PasswordBlacklist {


    /**
     * @return the logical name of the {@link PasswordBlacklist}
     */
    String getName();

    /**
     * Checks whether a given {@code password} is contained in this {@link PasswordBlacklist}.
     *
     * @param password
     * @return
     */
    boolean contains(String password);
  }

  /**
   * A {@link FileBasedPasswordBlacklist} uses password-blacklist files as
   * to construct a {@link PasswordBlacklist}.
   * <p>
   * This implementation uses a dynamically sized {@link BloomFilter}
   * to provide a false positive probability of 1%.
   *
   * @see BloomFilter
   */
  public static class FileBasedPasswordBlacklist implements PasswordBlacklist {

    private static final double FALSE_POSITIVE_PROBABILITY = 0.01;

    private static final int BUFFER_SIZE_IN_BYTES = 512 * 1024;

    /**
     * The name of the blacklist filename.
     */
    private final String name;

    /**
     * The concrete path to the password-blacklist file.
     */
    private final Path path;

    /**
     * Initialized lazily via {@link #lazyInit()}
     */
    private BloomFilter<String> blacklist;

    public FileBasedPasswordBlacklist(Path blacklistBasePath, String name) {

      this.name = name;
      this.path = blacklistBasePath.resolve(name);


      if (name.contains("/")) {
        // disallow '/' to avoid accidental filesystem traversal
        throw new IllegalArgumentException("" + name + " must not contain slashes!");
      }

      if (!Files.exists(this.path)) {
        throw new IllegalArgumentException("Password blacklist " + name + " not found!");
      }
    }

    public String getName() {
      return name;
    }

    public boolean contains(String password) {
      return blacklist != null && blacklist.mightContain(password);
    }

    void lazyInit() {

      if (blacklist != null) {
        return;
      }

      this.blacklist = load();
    }

    /**
     * Loads the referenced blacklist into a {@link BloomFilter}.
     *
     * @return the {@link BloomFilter} backing a password blacklist
     */
    private BloomFilter<String> load() {

      try {
        LOG.infof("Loading blacklist with name %s from %s - start", name, path);

        long passwordCount = getPasswordCount();

        BloomFilter<String> filter = BloomFilter.create(
          Funnels.stringFunnel(StandardCharsets.UTF_8),
          passwordCount,
          FALSE_POSITIVE_PROBABILITY);

        try (BufferedReader br = newReader(path)) {
          br.lines().forEach(filter::put);
        }

        LOG.infof("Loading blacklist with name %s from %s - end", name, path);

        return filter;
      } catch (IOException e) {
        throw new RuntimeException("Could not load password blacklist from path: " + path, e);
      }
    }

    /**
     * Determines password blacklist size to correctly size the {@link BloomFilter} backing this blacklist.
     *
     * @return
     * @throws IOException
     */
    private long getPasswordCount() throws IOException {

      /*
       * TODO find a more efficient way to determine the password count,
       * e.g. require a header-line in the password-blacklist file
       */
      try (BufferedReader br = newReader(path)) {
        return br.lines().count();
      }
    }

    private static BufferedReader newReader(Path path) throws IOException {
      return new BufferedReader(Files.newBufferedReader(path), BUFFER_SIZE_IN_BYTES);
    }

    /**
     * Discovers password blacklists location.
     * <p>
     * <ol>
     * <li>
     * system property {@code keycloak.password.blacklists.path} if present
     * </li>
     * <li>SPI config property {@code blacklistsPath}</li>
     * </ol>
     * and fallback to the {@code /data/password-blacklists} folder of the currently
     * running wildfly instance.
     *
     * @param config
     * @return the detected blacklist path
     * @throws IllegalStateException if no blacklist folder could be detected
     */
    private static Path detectBlacklistsBasePath(Config.Scope config) {

      String pathFromSysProperty = System.getProperty(SYSTEM_PROPERTY);
      if (pathFromSysProperty != null) {
        return ensureExists(Paths.get(pathFromSysProperty));
      }

      String pathFromSpiConfig = config.get(BLACKLISTS_PATH_PROPERTY);
      if (pathFromSpiConfig != null) {
        return ensureExists(Paths.get(pathFromSpiConfig));
      }

      String pathFromJbossDataPath = System.getProperty(JBOSS_SERVER_DATA_DIR) + "/" + PASSWORD_BLACKLISTS_FOLDER;
      if (!Files.exists(Paths.get(pathFromJbossDataPath))) {
        if (!Paths.get(pathFromJbossDataPath).toFile().mkdirs()) {
          LOG.errorf("Could not create folder for password blacklists: %s", pathFromJbossDataPath);
        }
      }
      return ensureExists(Paths.get(pathFromJbossDataPath));
    }

    private static Path ensureExists(Path path) {

      Objects.requireNonNull(path, "path");

      if (Files.exists(path)) {
        return path;
      }

      throw new IllegalStateException("Password blacklists location does not exist: " + path);
    }
  }
}
