keycloak-aplcache

KEYCLOAK-5244 Add BlacklistPasswordPolicyProvider (#4370) *

10/17/2017 4:41:44 PM

Changes

pom.xml 10(+10 -0)

Details

diff --git a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml
index be103dd..978718b 100755
--- a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml
+++ b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml
@@ -38,5 +38,6 @@
         <module name="javax.transaction.api"/>
         <module name="com.fasterxml.jackson.core.jackson-databind"/>
         <module name="com.fasterxml.jackson.core.jackson-core"/>
+        <module name="com.google.guava"/>
     </dependencies>
 </module>

pom.xml 10(+10 -0)

diff --git a/pom.xml b/pom.xml
index 87b288b..22846e2 100755
--- a/pom.xml
+++ b/pom.xml
@@ -91,6 +91,10 @@
         <apacheds.version>2.0.0-M21</apacheds.version>
         <apacheds.codec.version>1.0.0-M33</apacheds.codec.version>
         <google.zxing.version>3.2.1</google.zxing.version>
+
+        <!-- Same version as ships with wildfly. -->
+        <google.guava.version>20.0</google.guava.version>
+
         <freemarker.version>2.3.23</freemarker.version>
         <jetty9.version>9.1.0.v20131115</jetty9.version>
         <liquibase.version>3.4.1</liquibase.version>
@@ -440,6 +444,12 @@
                 <version>${google.zxing.version}</version>
             </dependency>
 
+            <dependency>
+                <groupId>com.google.guava</groupId>
+                <artifactId>guava</artifactId>
+                <version>${google.guava.version}</version>
+            </dependency>
+
             <!-- Email Test Servers -->
             <dependency>
                 <groupId>com.icegreen</groupId>
diff --git a/server-spi-private/pom.xml b/server-spi-private/pom.xml
index 1fb137b..3f9dc4f 100755
--- a/server-spi-private/pom.xml
+++ b/server-spi-private/pom.xml
@@ -77,6 +77,11 @@
             <scope>provided</scope>
         </dependency>
         <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
             <groupId>junit</groupId>
             <artifactId>junit</artifactId>
             <scope>test</scope>
diff --git a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProvider.java b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProvider.java
new file mode 100644
index 0000000..f114c24
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProvider.java
@@ -0,0 +1,74 @@
+package org.keycloak.policy;
+
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.policy.BlacklistPasswordPolicyProviderFactory.FileBasedPasswordBlacklist;
+import org.keycloak.policy.BlacklistPasswordPolicyProviderFactory.PasswordBlacklist;
+
+/**
+ * Checks a password against a configured password blacklist.
+ *
+ * @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
+ */
+public class BlacklistPasswordPolicyProvider implements PasswordPolicyProvider {
+
+  public static final String ERROR_MESSAGE = "invalidPasswordBlacklistedMessage";
+
+  private final KeycloakContext context;
+
+  private final BlacklistPasswordPolicyProviderFactory factory;
+
+  public BlacklistPasswordPolicyProvider(KeycloakContext context, BlacklistPasswordPolicyProviderFactory factory) {
+    this.context = context;
+    this.factory = factory;
+  }
+
+  /**
+   * Checks whether the provided password is contained in the configured blacklist.
+   *
+   * @param username
+   * @param password
+   * @return {@literal null} if the password is not blacklisted otherwise a {@link PolicyError}
+   */
+  @Override
+  public PolicyError validate(String username, String password) {
+
+    Object policyConfig = context.getRealm().getPasswordPolicy().getPolicyConfig(BlacklistPasswordPolicyProviderFactory.ID);
+    if (policyConfig == null) {
+      return null;
+    }
+
+    if (!(policyConfig instanceof PasswordBlacklist)) {
+      return null;
+    }
+
+    PasswordBlacklist blacklist = (FileBasedPasswordBlacklist) policyConfig;
+
+    if (!blacklist.contains(password)) {
+      return null;
+    }
+
+    return new PolicyError(ERROR_MESSAGE);
+  }
+
+  @Override
+  public PolicyError validate(RealmModel realm, UserModel user, String password) {
+    return validate(user.getUsername(), password);
+  }
+
+  @Override
+  public Object parseConfig(String blacklistName) {
+
+    if (blacklistName == null) {
+      return null;
+    }
+
+    return factory.resolvePasswordBlacklist(blacklistName);
+  }
+
+  @Override
+  public void close() {
+    //noop
+  }
+}
diff --git a/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java
new file mode 100644
index 0000000..cb7af62
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/policy/BlacklistPasswordPolicyProviderFactory.java
@@ -0,0 +1,331 @@
+/*
+ * 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);
+    }
+  }
+}
diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory
index a436fe9..ac72ac5 100644
--- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory
+++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory
@@ -26,3 +26,4 @@ org.keycloak.policy.NotUsernamePasswordPolicyProviderFactory
 org.keycloak.policy.RegexPatternsPasswordPolicyProviderFactory
 org.keycloak.policy.SpecialCharsPasswordPolicyProviderFactory
 org.keycloak.policy.UpperCasePasswordPolicyProviderFactory
+org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
diff --git a/testsuite/integration-arquillian/pom.xml b/testsuite/integration-arquillian/pom.xml
index c21ea8f..9b162e1 100644
--- a/testsuite/integration-arquillian/pom.xml
+++ b/testsuite/integration-arquillian/pom.xml
@@ -56,7 +56,8 @@
         <migration.70.version>1.9.8.Final</migration.70.version>
         <migration.70.authz.version>2.2.1.Final</migration.70.authz.version>
         <migration.71.version>2.5.5.Final</migration.71.version>
-
+        <google.guava.version>23.0</google.guava.version>
+        
         <maven.compiler.target>1.8</maven.compiler.target>
         <maven.compiler.source>1.8</maven.compiler.source>
     </properties>
diff --git a/testsuite/integration-arquillian/servers/auth-server/undertow/pom.xml b/testsuite/integration-arquillian/servers/auth-server/undertow/pom.xml
index 26c82a1..578b86a 100644
--- a/testsuite/integration-arquillian/servers/auth-server/undertow/pom.xml
+++ b/testsuite/integration-arquillian/servers/auth-server/undertow/pom.xml
@@ -29,6 +29,12 @@
     <name>Auth Server - Undertow</name>
 
     <dependencies>
+
+        <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-testsuite-utils</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.jboss.arquillian.junit</groupId>
             <artifactId>arquillian-junit-container</artifactId>
diff --git a/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java b/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java
index b77d9e0..dac8ea7 100644
--- a/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java
+++ b/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java
@@ -41,23 +41,21 @@ import org.jboss.shrinkwrap.api.spec.WebArchive;
 import org.jboss.shrinkwrap.descriptor.api.Descriptor;
 import org.jboss.shrinkwrap.undertow.api.UndertowWebArchive;
 import org.keycloak.common.util.reflections.Reflections;
-import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
 import org.keycloak.models.KeycloakSession;
 import org.keycloak.models.KeycloakSessionFactory;
 import org.keycloak.services.filters.KeycloakSessionServletFilter;
 import org.keycloak.services.managers.ApplianceBootstrap;
 import org.keycloak.services.resources.KeycloakApplication;
-
+import org.keycloak.testsuite.KeycloakServer;
 import org.keycloak.util.JsonSerialization;
-import java.io.IOException;
+
 import javax.servlet.DispatcherType;
 import javax.servlet.ServletException;
-
+import java.io.IOException;
 import java.lang.reflect.Field;
 import java.util.Collection;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.logging.Level;
 
 public class KeycloakOnUndertow implements DeployableContainer<KeycloakOnUndertowConfiguration> {
 
@@ -171,6 +169,8 @@ public class KeycloakOnUndertow implements DeployableContainer<KeycloakOnUnderto
             return;
         }
 
+        KeycloakServer.configureDataDirectory();
+
         log.infof("Starting auth server on embedded Undertow on: http://%s:%d", configuration.getBindAddress(), configuration.getBindHttpPort());
         long start = System.currentTimeMillis();
 
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/policy/PasswordPolicyTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/policy/PasswordPolicyTest.java
index 4b7b419..041ff36 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/policy/PasswordPolicyTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/policy/PasswordPolicyTest.java
@@ -20,11 +20,11 @@ package org.keycloak.testsuite.policy;
 import org.jboss.arquillian.container.test.api.Deployment;
 import org.jboss.shrinkwrap.api.spec.WebArchive;
 import org.junit.Assert;
-import org.junit.Before;
 import org.junit.Test;
 import org.keycloak.models.ModelException;
 import org.keycloak.models.PasswordPolicy;
 import org.keycloak.models.RealmModel;
+import org.keycloak.policy.BlacklistPasswordPolicyProvider;
 import org.keycloak.policy.PasswordPolicyManagerProvider;
 import org.keycloak.representations.idm.RealmRepresentation;
 import org.keycloak.testsuite.AbstractKeycloakTest;
@@ -32,7 +32,6 @@ import org.keycloak.testsuite.runonserver.RunOnServerDeployment;
 import org.keycloak.testsuite.util.RealmBuilder;
 
 import java.util.List;
-import java.util.regex.PatternSyntaxException;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -142,6 +141,25 @@ public class PasswordPolicyTest extends AbstractKeycloakTest {
         });
     }
 
+    /**
+     * KEYCLOAK-5244
+     */
+    @Test
+    public void testBlacklistPasswordPolicyWithTestBlacklist() throws Exception {
+
+        testingClient.server("passwordPolicy").run(session -> {
+
+            RealmModel realmModel = session.getContext().getRealm();
+            PasswordPolicyManagerProvider policyManager = session.getProvider(PasswordPolicyManagerProvider.class);
+
+            realmModel.setPasswordPolicy(PasswordPolicy.parse(session, "passwordBlacklist(test-password-blacklist.txt)"));
+
+            Assert.assertEquals(BlacklistPasswordPolicyProvider.ERROR_MESSAGE, policyManager.validate("jdoe", "blacklisted1").getMessage());
+            Assert.assertEquals(BlacklistPasswordPolicyProvider.ERROR_MESSAGE, policyManager.validate("jdoe", "blacklisted2").getMessage());
+            assertNull(policyManager.validate("jdoe", "notblacklisted"));
+        });
+    }
+
     @Test
     public void testNotUsername() {
         testingClient.server("passwordPolicy").run(session -> {
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/password-blacklists/test-password-blacklist.txt b/testsuite/integration-arquillian/tests/base/src/test/resources/password-blacklists/test-password-blacklist.txt
new file mode 100644
index 0000000..922808c
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/password-blacklists/test-password-blacklist.txt
@@ -0,0 +1,2 @@
+blacklisted1
+blacklisted2
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/pom.xml b/testsuite/integration-arquillian/tests/pom.xml
index f4dcc81..7771159 100755
--- a/testsuite/integration-arquillian/tests/pom.xml
+++ b/testsuite/integration-arquillian/tests/pom.xml
@@ -314,6 +314,9 @@
                             <keycloak.testsuite.logging.pattern>${keycloak.testsuite.logging.pattern}</keycloak.testsuite.logging.pattern>
 
                             <keycloak.connectionsJpa.url.crossdc>${keycloak.connectionsJpa.url.crossdc}</keycloak.connectionsJpa.url.crossdc>
+
+                            <!-- used by PasswordPolicyTest.testBlacklistPasswordPolicyWithTestBlacklist, see KEYCLOAK-5244 -->
+                            <keycloak.password.blacklists.path>${project.build.directory}/test-classes/password-blacklists</keycloak.password.blacklists.path>
                         </systemPropertyVariables>
                         <properties>
                             <property>
diff --git a/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java b/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java
index 0ebc0d8..9ab36b0 100755
--- a/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java
+++ b/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java
@@ -40,17 +40,18 @@ import org.keycloak.services.resources.KeycloakApplication;
 import org.keycloak.testsuite.util.cli.TestsuiteCLI;
 import org.keycloak.util.JsonSerialization;
 
+import javax.net.ssl.SSLContext;
 import javax.servlet.DispatcherType;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.file.Files;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Properties;
-import javax.net.ssl.SSLContext;
 
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@@ -58,6 +59,7 @@ import javax.net.ssl.SSLContext;
 public class KeycloakServer {
 
     private static final Logger log = Logger.getLogger(KeycloakServer.class);
+    public static final String JBOSS_SERVER_DATA_DIR = "jboss.server.data.dir";
 
     private boolean sysout = false;
 
@@ -211,6 +213,8 @@ public class KeycloakServer {
             config.setWorkerThreads(undertowWorkerThreads);
         }
 
+        configureDataDirectory();
+
         detectNodeName(config);
 
         final KeycloakServer keycloak = new KeycloakServer(config);
@@ -241,6 +245,52 @@ public class KeycloakServer {
         return keycloak;
     }
 
+    public static void configureDataDirectory() {
+        String dataPath = detectDataDirectory();
+        System.setProperty(JBOSS_SERVER_DATA_DIR, dataPath);
+        log.infof("Using %s %s", JBOSS_SERVER_DATA_DIR,  dataPath);
+    }
+
+  /**
+   * Detects the {@code jboss.server.data.dir} to use.
+   * If the System property {@code jboss.server.data.dir} is already set then the property value is used,
+   * otherwise a temporary data dir is created that will be deleted on JVM exit.
+   *
+   * @return
+   */
+  public static String detectDataDirectory() {
+
+        String dataPath = System.getProperty(JBOSS_SERVER_DATA_DIR);
+
+        if (dataPath != null){
+            // we assume jboss.server.data.dir is managed externally so just use it as is.
+            File dataDir = new File(dataPath);
+            if (!dataDir.exists() || !dataDir.isDirectory()) {
+                throw new RuntimeException("Invalid " + JBOSS_SERVER_DATA_DIR + " resources directory: " + dataPath);
+            }
+
+            return dataPath;
+        }
+
+        // we generate a dynamic jboss.server.data.dir and remove it at the end.
+        try {
+          File tempKeycloakFolder = Files.createTempDirectory("keycloak-server-").toFile();
+          File tmpDataDir = new File(tempKeycloakFolder, "/data");
+
+          if (tmpDataDir.mkdirs()) {
+            tmpDataDir.deleteOnExit();
+          } else {
+            throw new IOException("Could not create directory " + tmpDataDir);
+          }
+
+          dataPath = tmpDataDir.getAbsolutePath();
+        } catch (IOException ioe){
+          throw new RuntimeException("Could not create temporary " + JBOSS_SERVER_DATA_DIR, ioe);
+        }
+
+        return dataPath;
+    }
+
     private KeycloakServerConfig config;
 
     private KeycloakSessionFactory sessionFactory;
diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties
index c5ce32a..e98874b 100755
--- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties
+++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties
@@ -152,6 +152,7 @@ invalidPasswordMinSpecialCharsMessage=Invalid password: must contain at least {0
 invalidPasswordNotUsernameMessage=Invalid password: must not be equal to the username.
 invalidPasswordRegexPatternMessage=Invalid password: fails to match regex pattern(s).
 invalidPasswordHistoryMessage=Invalid password: must not be equal to any of last {0} passwords.
+invalidPasswordBlacklistedMessage=Invalid password: password is blacklisted.
 invalidPasswordGenericMessage=Invalid password: new password doesn''t match password policies.
 
 locale_ca=Catal\u00E0
diff --git a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties
index 82db91e..ea61dc3 100644
--- a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties
+++ b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties
@@ -6,6 +6,7 @@ invalidPasswordMinSpecialCharsMessage=Invalid password: must contain at least {0
 invalidPasswordNotUsernameMessage=Invalid password: must not be equal to the username.
 invalidPasswordRegexPatternMessage=Invalid password: fails to match regex pattern(s).
 invalidPasswordHistoryMessage=Invalid password: must not be equal to any of last {0} passwords.
+invalidPasswordBlacklistedMessage=Invalid password: password is blacklisted.
 invalidPasswordGenericMessage=Invalid password: new password doesn''t match password policies.
 
 ldapErrorInvalidCustomFilter=Custom configured LDAP filter does not start with "(" or does not end with ")".
diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties
index 9abb4fe..1277f77 100755
--- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties
+++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties
@@ -142,6 +142,7 @@ missingTotpMessage=Please specify authenticator code.
 notMatchPasswordMessage=Passwords don''t match.
 
 invalidPasswordExistingMessage=Invalid existing password.
+invalidPasswordBlacklistedMessage=Invalid password: password is blacklisted.
 invalidPasswordConfirmMessage=Password confirmation doesn''t match.
 invalidTotpMessage=Invalid authenticator code.
 
diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties
index f323249..3391323 100644
--- a/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties
+++ b/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties
@@ -110,6 +110,7 @@ invalidPasswordExistingMessage=Das aktuelle Passwort is ung\u00FCltig.
 invalidPasswordConfirmMessage=Die Passwortbest\u00E4tigung ist nicht identisch.
 invalidTotpMessage=Ung\u00FCltiger One-time Code.
 invalidEmailMessage=Ung\u00FCltige E-Mail Adresse.
+invalidPasswordBlacklistedMessage=Passwort ist nicht erlaubt.
 
 usernameExistsMessage=Der Benutzername existiert bereits.
 emailExistsMessage=Die E-Mail-Adresse existiert bereits.