BlacklistPasswordPolicyProviderFactory.java

/*
 * 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.File;
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;
import java.util.function.Supplier;

/**
 * Creates {@link BlacklistPasswordPolicyProvider} instances.
 * <p>
 * Password blacklists are simple text files where every line is a blacklisted password delimited by a newline character {@code \n}.
 * <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.
 *
 * <h1>Blacklist location</h1>
 * <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 $KC_HOME/data/password-blacklists/} if nothing else is configured</li>
 * </ol>
 *
 * To configure the blacklist folder via CLI use {@code --spi-password-policy-password-blacklist-blacklists-path=/path/to/blacklistsFolder}
 *
 * <p>Note that the preferred way for configuration is to copy the password file to the {@code $KC_HOME/data/password-blacklists/} folder</p>
 * <p>A password blacklist with the filename {@code 10_million_passwords.txt}
 * that is located beneath {@code $KC_HOME/data/keycloak/blacklists/} can be referred to as {@code 10_million_passwords.txt} in the <em>Authentication: Password Policy</em> configuration.
 *
 * <h1>False positives</h1>
 * <p>
 * The current implementation uses a probabilistic data-structure called {@link BloomFilter} which allows for fast and memory efficient containment checks, e.g. whether a given password is contained in a blacklist,
 * with the possibility for false positives. By default a false positive probability {@link #DEFAULT_FALSE_POSITIVE_PROBABILITY} is used.
 *
 * To change the false positive probability via CLI configuration use {@code --spi-password-policy-password-blacklist-false-positive-probability=0.00001}
 * </p>
 *
 * @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 BLACKLISTS_FALSE_POSITIVE_PROBABILITY_PROPERTY = "falsePositiveProbability";

    public static final double DEFAULT_FALSE_POSITIVE_PROBABILITY = 0.0001;

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

    public static final String PASSWORD_BLACKLISTS_FOLDER = "password-blacklists" + File.separator;

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

    private volatile Path blacklistsBasePath;

    private Config.Scope config;

    @Override
    public PasswordPolicyProvider create(KeycloakSession session) {
        if (this.blacklistsBasePath == null) {
            synchronized (this) {
                if (this.blacklistsBasePath == null) {
                    this.blacklistsBasePath = FileBasedPasswordBlacklist.detectBlacklistsBasePath(config, this::getDefaultBlacklistsBasePath);
                }
            }
        }
        return new BlacklistPasswordPolicyProvider(session.getContext(), this);
    }

    @Override
    public void init(Config.Scope config) {
        this.config = 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;
    }

    /**
     * Method to obtain the default location for the list folder. The method
     * will return the <em>data</em> directory of the Keycloak instance concatenated
     * with <em>/password-blacklists/</em>.
     *
     * @return The default path used by the provider to lookup the lists
     * when no other configuration is in place.
     */
    public String getDefaultBlacklistsBasePath() {
        return System.getProperty(JBOSS_SERVER_DATA_DIR) + File.separator + PASSWORD_BLACKLISTS_FOLDER;
    }

    /**
     * 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 listName = blacklistName.trim();
        if (listName.isEmpty()) {
            throw new IllegalArgumentException("Password blacklist name must not be empty!");
        }

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

    protected double getFalsePositiveProbability() {

        if (config == null) {
            return DEFAULT_FALSE_POSITIVE_PROBABILITY;
        }

        String falsePositiveProbString = config.get(BLACKLISTS_FALSE_POSITIVE_PROBABILITY_PROPERTY);
        if (falsePositiveProbString == null) {
            return DEFAULT_FALSE_POSITIVE_PROBABILITY;
        }

        try {
            return Double.parseDouble(falsePositiveProbString);
        } catch (NumberFormatException nfe) {
            LOG.warnf("Could not parse false positive probability from string %s", falsePositiveProbString);
            return DEFAULT_FALSE_POSITIVE_PROBABILITY;
        }
    }

    /**
     * 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}
     * with a provided default false positive probability.
     *
     * @see BloomFilter
     */
    public static class FileBasedPasswordBlacklist implements PasswordBlacklist {

        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;

        private final double falsePositiveProbability;

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

        /**
         * Creates a new {@link FileBasedPasswordBlacklist} with {@link #DEFAULT_FALSE_POSITIVE_PROBABILITY}.
         *
         * @param blacklistBasePath folder containing the blacklists
         * @param name name of blacklist file
         */
        public FileBasedPasswordBlacklist(Path blacklistBasePath, String name) {
            this(blacklistBasePath, name, DEFAULT_FALSE_POSITIVE_PROBABILITY);
        }

        public FileBasedPasswordBlacklist(Path blacklistBasePath, String name, double falsePositiveProbability) {

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

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

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

        public String getName() {
            return name;
        }

        public double getFalsePositiveProbability() {
            return falsePositiveProbability;
        }

        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 start: name=%s path=%s", name, path);

                long passwordCount = countPasswordsInBlacklistFile();
                double fpp = getFalsePositiveProbability();

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

                insertPasswordsInto(filter);

                double expectedFfp = filter.expectedFpp();
                LOG.infof("Loading blacklist finished: name=%s passwords=%s path=%s falsePositiveProbability=%s expectedFalsePositiveProbability=%s",
                        name, passwordCount, path, fpp, expectedFfp);

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

        protected void insertPasswordsInto(BloomFilter<String> filter) throws IOException {
            try (BufferedReader br = newReader(path)) {
                br.lines().forEach(filter::put);
            }
        }

        /**
         * Determines password blacklist size to correctly size the {@link BloomFilter} backing this blacklist.
         *
         * @return number of passwords found in the blacklist file
         * @throws IOException
         */
        private long countPasswordsInBlacklistFile() 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>
         * The following discovery options are currently implemented:
         * <p>
         * <ol>
         *   <li>system property {@code keycloak.password.blacklists.path} if present</li>
         *   <li>SPI config property {@code blacklistsPath}</li>
         *   <li>fallback to the {@code /data/password-blacklists} folder of the currently running Keycloak instance</li>
         * </ol>
         *
         * @param config spi config
         * @param defaultPathSupplier default path to use if not specified in a system prop or configuration
         * @return the detected blacklist path
         * @throws IllegalStateException if no blacklist folder could be detected
         */
        private static Path detectBlacklistsBasePath(Config.Scope config, Supplier<String> defaultPathSupplier) {

            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 = defaultPathSupplier.get();
            if (pathFromJbossDataPath == null) {
                throw new IllegalStateException("Default path for the blacklist folder was null");
            }

            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);
        }
    }
}