keycloak-aplcache

KEYCLOAK-4204 Extend brute force protection with permanent

1/19/2017 2:20:03 PM

Details

diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
index e552c58..b14e55b 100755
--- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
+++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java
@@ -66,6 +66,7 @@ public class RealmRepresentation {
 
     //--- brute force settings
     protected Boolean bruteForceProtected;
+    protected Boolean permanentLockout;
     protected Integer maxFailureWaitSeconds;
     protected Integer minimumQuickLoginWaitSeconds;
     protected Integer waitIncrementSeconds;
@@ -558,6 +559,14 @@ public class RealmRepresentation {
         this.bruteForceProtected = bruteForceProtected;
     }
 
+    public Boolean isPermanentLockout() {
+        return permanentLockout;
+    }
+
+    public void setPermanentLockout(Boolean permanentLockout) {
+        this.permanentLockout = permanentLockout;
+    }
+
     public Integer getMaxFailureWaitSeconds() {
         return maxFailureWaitSeconds;
     }
diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
index 5e4b579..fef0486 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
@@ -65,6 +65,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
     protected boolean editUsernameAllowed;
     //--- brute force settings
     protected boolean bruteForceProtected;
+    protected boolean permanentLockout;
     protected int maxFailureWaitSeconds;
     protected int minimumQuickLoginWaitSeconds;
     protected int waitIncrementSeconds;
@@ -156,6 +157,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
         editUsernameAllowed = model.isEditUsernameAllowed();
         //--- brute force settings
         bruteForceProtected = model.isBruteForceProtected();
+        permanentLockout = model.isPermanentLockout();
         maxFailureWaitSeconds = model.getMaxFailureWaitSeconds();
         minimumQuickLoginWaitSeconds = model.getMinimumQuickLoginWaitSeconds();
         waitIncrementSeconds = model.getWaitIncrementSeconds();
@@ -314,6 +316,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
         return bruteForceProtected;
     }
 
+    public boolean isPermanentLockout() {
+        return permanentLockout;
+    }
+
     public int getMaxFailureWaitSeconds() {
         return this.maxFailureWaitSeconds;
     }
diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
index 1e6109f..8350f0d 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
@@ -222,6 +222,18 @@ public class RealmAdapter implements CachedRealmModel {
     }
 
     @Override
+    public boolean isPermanentLockout() {
+        if(isUpdated()) return updated.isPermanentLockout();
+        return cached.isPermanentLockout();
+    }
+
+    @Override
+    public void setPermanentLockout(final boolean val) {
+        getDelegateForUpdate();
+        updated.setPermanentLockout(val);
+    }
+
+    @Override
     public int getMaxFailureWaitSeconds() {
         if (isUpdated()) return updated.getMaxFailureWaitSeconds();
         return cached.getMaxFailureWaitSeconds();
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
index cce494f..58aa424 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
@@ -279,6 +279,16 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
     }
 
     @Override
+    public boolean isPermanentLockout() {
+        return getAttribute("permanentLockout", false);
+    }
+
+    @Override
+    public void setPermanentLockout(final boolean val) {
+        setAttribute("permanentLockout", val);
+    }
+
+    @Override
     public int getMaxFailureWaitSeconds() {
         return getAttribute("maxFailureWaitSeconds", 0);
     }
diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
index 7640aad..dc8bff5 100755
--- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java
+++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
@@ -127,6 +127,8 @@ public interface RealmModel extends RoleContainerModel {
     //--- brute force settings
     boolean isBruteForceProtected();
     void setBruteForceProtected(boolean value);
+    boolean isPermanentLockout();
+    void setPermanentLockout(boolean val);
     int getMaxFailureWaitSeconds();
     void setMaxFailureWaitSeconds(int val);
     int getWaitIncrementSeconds();
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
index 360f287..e901929 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java
@@ -267,6 +267,7 @@ public class ModelToRepresentation {
         rep.setRegistrationEmailAsUsername(realm.isRegistrationEmailAsUsername());
         rep.setRememberMe(realm.isRememberMe());
         rep.setBruteForceProtected(realm.isBruteForceProtected());
+        rep.setPermanentLockout(realm.isPermanentLockout());
         rep.setMaxFailureWaitSeconds(realm.getMaxFailureWaitSeconds());
         rep.setMinimumQuickLoginWaitSeconds(realm.getMinimumQuickLoginWaitSeconds());
         rep.setWaitIncrementSeconds(realm.getWaitIncrementSeconds());
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
index 4391341..b427477 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java
@@ -141,6 +141,7 @@ public class RepresentationToModel {
         if (rep.getDisplayNameHtml() != null) newRealm.setDisplayNameHtml(rep.getDisplayNameHtml());
         if (rep.isEnabled() != null) newRealm.setEnabled(rep.isEnabled());
         if (rep.isBruteForceProtected() != null) newRealm.setBruteForceProtected(rep.isBruteForceProtected());
+        if (rep.isPermanentLockout() != null) newRealm.setPermanentLockout(rep.isPermanentLockout());
         if (rep.getMaxFailureWaitSeconds() != null) newRealm.setMaxFailureWaitSeconds(rep.getMaxFailureWaitSeconds());
         if (rep.getMinimumQuickLoginWaitSeconds() != null)
             newRealm.setMinimumQuickLoginWaitSeconds(rep.getMinimumQuickLoginWaitSeconds());
@@ -787,6 +788,7 @@ public class RepresentationToModel {
         if (rep.getDisplayNameHtml() != null) realm.setDisplayNameHtml(rep.getDisplayNameHtml());
         if (rep.isEnabled() != null) realm.setEnabled(rep.isEnabled());
         if (rep.isBruteForceProtected() != null) realm.setBruteForceProtected(rep.isBruteForceProtected());
+        if (rep.isPermanentLockout() != null) realm.setPermanentLockout(rep.isPermanentLockout());
         if (rep.getMaxFailureWaitSeconds() != null) realm.setMaxFailureWaitSeconds(rep.getMaxFailureWaitSeconds());
         if (rep.getMinimumQuickLoginWaitSeconds() != null)
             realm.setMinimumQuickLoginWaitSeconds(rep.getMinimumQuickLoginWaitSeconds());
diff --git a/server-spi-private/src/main/java/org/keycloak/services/managers/BruteForceProtector.java b/server-spi-private/src/main/java/org/keycloak/services/managers/BruteForceProtector.java
index e884b02..02be48d 100755
--- a/server-spi-private/src/main/java/org/keycloak/services/managers/BruteForceProtector.java
+++ b/server-spi-private/src/main/java/org/keycloak/services/managers/BruteForceProtector.java
@@ -30,5 +30,7 @@ import org.keycloak.provider.Provider;
 public interface BruteForceProtector extends Provider {
     void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection);
 
+    void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection);
+
     boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserModel user);
 }
diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java
index 2f23cb8..a34e4ee 100755
--- a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java
+++ b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java
@@ -552,6 +552,22 @@ public class AuthenticationProcessor {
         }
     }
 
+    protected void logSuccess() {
+        if (realm.isBruteForceProtected()) {
+            String username = clientSession.getNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);
+            // TODO: as above, need to handle non form success
+
+            if(username == null) {
+                return;
+            }
+
+            UserModel user = KeycloakModelUtils.findUserByNameOrEmail(session, realm, username);
+            if (user != null) {
+                getBruteForceProtector().successfulLogin(realm, user, connection);
+            }
+        }
+    }
+
     public boolean isSuccessful(AuthenticationExecutionModel model) {
         ClientSessionModel.ExecutionStatus status = clientSession.getExecutionStatus().get(model.getId());
         if (status == null) return false;
@@ -853,7 +869,7 @@ public class AuthenticationProcessor {
     public void validateUser(UserModel authenticatedUser) {
         if (authenticatedUser == null) return;
         if (!authenticatedUser.isEnabled()) throw new AuthenticationFlowException(AuthenticationFlowError.USER_DISABLED);
-        if (realm.isBruteForceProtected()) {
+        if (realm.isBruteForceProtected() && !realm.isPermanentLockout()) {
             if (getBruteForceProtector().isTemporarilyDisabled(session, realm, authenticatedUser)) {
                 throw new AuthenticationFlowException(AuthenticationFlowError.USER_TEMPORARILY_DISABLED);
             }
@@ -866,6 +882,8 @@ public class AuthenticationProcessor {
             return redirectToRequiredActions(session, realm, clientSession, uriInfo);
         } else {
             event.detail(Details.CODE_ID, clientSession.getId());  // todo This should be set elsewhere.  find out why tests fail.  Don't know where this is supposed to be set
+            // the user has successfully logged in and we can clear his/her previous login failure attempts.
+            logSuccess();
             return AuthenticationManager.finishedRequiredActions(session,  userSession, clientSession, connection, request, uriInfo, event);
         }
     }
diff --git a/services/src/main/java/org/keycloak/services/managers/DefaultBruteForceProtector.java b/services/src/main/java/org/keycloak/services/managers/DefaultBruteForceProtector.java
index 76c9cc7..c779f3e 100644
--- a/services/src/main/java/org/keycloak/services/managers/DefaultBruteForceProtector.java
+++ b/services/src/main/java/org/keycloak/services/managers/DefaultBruteForceProtector.java
@@ -85,6 +85,14 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
         }
     }
 
+    protected class SuccessfulLogin extends LoginEvent {
+        protected final CountDownLatch latch = new CountDownLatch(1);
+
+        public SuccessfulLogin(String realmId, String userId, String ip) {
+            super(realmId, userId, ip);
+        }
+    }
+
     public DefaultBruteForceProtector(KeycloakSessionFactory factory) {
         this.factory = factory;
     }
@@ -96,44 +104,67 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
 
         String userId = event.userId;
         UserModel user = session.users().getUserById(userId, realm);
+        if (user == null) {
+            return;
+        }
+
         UserLoginFailureModel userLoginFailure = getUserModel(session, event);
-        if (user != null) {
-            if (userLoginFailure == null) {
-                userLoginFailure = session.sessions().addUserLoginFailure(realm, userId);
-            }
-            userLoginFailure.setLastIPFailure(event.ip);
-            long currentTime = Time.currentTimeMillis();
-            long last = userLoginFailure.getLastFailure();
-            long deltaTime = 0;
-            if (last > 0) {
-                deltaTime = currentTime - last;
-            }
-            userLoginFailure.setLastFailure(currentTime);
-            if (deltaTime > 0) {
-                // if last failure was more than MAX_DELTA clear failures
-                if (deltaTime > (long) realm.getMaxDeltaTimeSeconds() * 1000L) {
-                    userLoginFailure.clearFailures();
-                }
-            }
+        if (userLoginFailure == null) {
+            userLoginFailure = session.sessions().addUserLoginFailure(realm, userId);
+        }
+        userLoginFailure.setLastIPFailure(event.ip);
+        long currentTime = Time.currentTimeMillis();
+        long last = userLoginFailure.getLastFailure();
+        long deltaTime = 0;
+        if (last > 0) {
+            deltaTime = currentTime - last;
+        }
+        userLoginFailure.setLastFailure(currentTime);
+
+        if(realm.isPermanentLockout()) {
             userLoginFailure.incrementFailures();
             logger.debugv("new num failures: {0}", userLoginFailure.getNumFailures());
 
-            int waitSeconds = realm.getWaitIncrementSeconds() *  (userLoginFailure.getNumFailures() / realm.getFailureFactor());
-            logger.debugv("waitSeconds: {0}", waitSeconds);
-            logger.debugv("deltaTime: {0}", deltaTime);
-
-            if (waitSeconds == 0) {
-                if (last > 0 && deltaTime < realm.getQuickLoginCheckMilliSeconds()) {
-                    logger.debugv("quick login, set min wait seconds");
-                    waitSeconds = realm.getMinimumQuickLoginWaitSeconds();
-                }
+            if(userLoginFailure.getNumFailures() == realm.getFailureFactor()) {
+                logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
+                user.setEnabled(false);
+                return;
             }
-            if (waitSeconds > 0) {
-                waitSeconds = Math.min(realm.getMaxFailureWaitSeconds(), waitSeconds);
+
+            if (last > 0 && deltaTime < realm.getQuickLoginCheckMilliSeconds()) {
+                logger.debugv("quick login, set min wait seconds");
+                int waitSeconds = realm.getMinimumQuickLoginWaitSeconds();
                 int notBefore = (int) (currentTime / 1000) + waitSeconds;
                 logger.debugv("set notBefore: {0}", notBefore);
                 userLoginFailure.setFailedLoginNotBefore(notBefore);
             }
+            return;
+        }
+
+        if (deltaTime > 0) {
+            // if last failure was more than MAX_DELTA clear failures
+            if (deltaTime > (long) realm.getMaxDeltaTimeSeconds() * 1000L) {
+                userLoginFailure.clearFailures();
+            }
+        }
+        userLoginFailure.incrementFailures();
+        logger.debugv("new num failures: {0}", userLoginFailure.getNumFailures());
+
+        int waitSeconds = realm.getWaitIncrementSeconds() *  (userLoginFailure.getNumFailures() / realm.getFailureFactor());
+        logger.debugv("waitSeconds: {0}", waitSeconds);
+        logger.debugv("deltaTime: {0}", deltaTime);
+
+        if (waitSeconds == 0) {
+            if (last > 0 && deltaTime < realm.getQuickLoginCheckMilliSeconds()) {
+                logger.debugv("quick login, set min wait seconds");
+                waitSeconds = realm.getMinimumQuickLoginWaitSeconds();
+            }
+        }
+        if (waitSeconds > 0) {
+            waitSeconds = Math.min(realm.getMaxFailureWaitSeconds(), waitSeconds);
+            int notBefore = (int) (currentTime / 1000) + waitSeconds;
+            logger.debugv("set notBefore: {0}", notBefore);
+            userLoginFailure.setFailedLoginNotBefore(notBefore);
         }
     }
 
@@ -185,6 +216,8 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
                             for (LoginEvent event : events) {
                                 if (event instanceof FailedLogin) {
                                     failure(session, event);
+                                } else if (event instanceof SuccessfulLogin) {
+                                    success(session, event);
                                 } else if (event instanceof ShutdownEvent) {
                                     run = false;
                                 }
@@ -197,6 +230,8 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
                             for (LoginEvent event : events) {
                                 if (event instanceof FailedLogin) {
                                     ((FailedLogin) event).latch.countDown();
+                                } else if (event instanceof SuccessfulLogin) {
+                                    ((SuccessfulLogin) event).latch.countDown();
                                 }
                             }
                             events.clear();
@@ -214,6 +249,17 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
         }
     }
 
+    private void success(KeycloakSession session, LoginEvent event) {
+        String userId = event.userId;
+        UserModel model = session.users().getUserById(userId, getRealmModel(session, event));
+
+        UserLoginFailureModel user = getUserModel(session, event);
+        if(user == null) return;
+
+        logger.debugv("user {0} successfully logged in, clearing all failures", model.getUsername());
+        user.clearFailures();
+    }
+
     protected void logFailure(LoginEvent event) {
         ServicesLogger.LOGGER.loginFailure(event.userId, event.ip);
         failures++;
@@ -244,6 +290,18 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
     }
 
     @Override
+    public void successfulLogin(final RealmModel realm, final UserModel user, final ClientConnection clientConnection) {
+        try {
+            SuccessfulLogin event = new SuccessfulLogin(realm.getId(), user.getId(), clientConnection.getRemoteAddr());
+            queue.offer(event);
+
+            event.latch.await(5, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+        }
+        logger.trace("sent success event");
+    }
+
+    @Override
     public boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserModel user) {
         UserLoginFailureModel failure = session.sessions().getUserLoginFailure(realm, user.getId());
 
diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java
index b28cf2f..3306bcd 100755
--- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java
@@ -213,6 +213,7 @@ public class RealmManager {
 
         // brute force
         realm.setBruteForceProtected(false); // default settings off for now todo set it on
+        realm.setPermanentLockout(false);
         realm.setMaxFailureWaitSeconds(900);
         realm.setMinimumQuickLoginWaitSeconds(60);
         realm.setWaitIncrementSeconds(60);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java
index d87896e..884762b 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java
@@ -326,6 +326,54 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
     }
 
     @Test
+    public void testPermanentLockout() throws Exception {
+        RealmRepresentation realm = testRealm().toRepresentation();
+
+        try {
+            // arrange
+            realm.setPermanentLockout(true);
+            testRealm().update(realm);
+
+            // act
+            loginInvalidPassword();
+            loginInvalidPassword();
+
+            // assert
+            expectPermanentlyDisabled();
+            Assert.assertFalse(adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0).isEnabled());
+        } finally {
+            realm.setPermanentLockout(false);
+            testRealm().update(realm);
+            UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
+            user.setEnabled(true);
+            updateUser(user);
+        }
+    }
+
+    @Test
+    public void testResetLoginFailureCount() throws Exception {
+        RealmRepresentation realm = testRealm().toRepresentation();
+
+        try {
+            // arrange
+            realm.setPermanentLockout(true);
+            testRealm().update(realm);
+
+            // act
+            loginInvalidPassword();
+            loginSuccess();
+            loginInvalidPassword();
+            loginSuccess();
+
+            // assert
+            Assert.assertTrue(adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0).isEnabled());
+        } finally {
+            realm.setPermanentLockout(false);
+            testRealm().update(realm);
+        }
+    }
+
+    @Test
     public void testNonExistingAccounts() throws Exception {
 
         loginInvalidPassword("non-existent-user");
@@ -358,6 +406,27 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
         event.assertEvent();
     }
 
+    public void expectPermanentlyDisabled() throws Exception {
+        expectPermanentlyDisabled("test-user@localhost", null);
+    }
+
+    public void expectPermanentlyDisabled(String username, String userId) throws Exception {
+        loginPage.open();
+        loginPage.login(username, "password");
+
+        loginPage.assertCurrent();
+        Assert.assertEquals("Account is disabled, contact admin.", loginPage.getError());
+        ExpectedEvent event = events.expectLogin()
+            .session((String) null)
+            .error(Errors.USER_DISABLED)
+            .detail(Details.USERNAME, username)
+            .removeDetail(Details.CONSENT);
+        if (userId != null) {
+            event.user(userId);
+        }
+        event.assertEvent();
+    }
+
     public void loginSuccess() throws Exception {
         loginSuccess("test-user@localhost");
     }
diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index fff8bf2..dc4ef93 100644
--- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -126,6 +126,8 @@ robots-tag=X-Robots-Tag
 robots-tag-tooltip=Prevent pages from appearing in search engines (click label for more information)
 x-xss-protection=X-XSS-Protection
 x-xss-protection-tooltip=This header configures the Cross-site scripting (XSS) filter in your browser. Using the default behavior, the browser will prevent rendering of the page when a XSS attack is detected (click label for more information)
+permanent-lockout=Permanent Lockout
+permanent-lockout.tooltip=Lock the user permanently when the user exceeds the maximum login failures.
 max-login-failures=Max Login Failures
 max-login-failures.tooltip=How many failures before wait is triggered.
 wait-increment=Wait Increment
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html b/themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html
index 33845c0..6ab0092 100755
--- a/themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html
@@ -15,6 +15,14 @@
                 </div>
             </div>
             <div class="form-group" data-ng-show="realm.bruteForceProtected">
+                <label class="col-md-2 control-label" for="permanentLockout">{{:: 'permanent-lockout' | translate}}</label>
+                <div class="col-md-6">
+                    <input ng-model="realm.permanentLockout" name="permanentLockout" id="permanentLockout" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
+                </div>
+                <kc-tooltip>{{:: 'permanent-lockout.tooltip' | translate}}</kc-tooltip>
+            </div>
+
+            <div class="form-group" data-ng-show="realm.bruteForceProtected">
                 <label class="col-md-2 control-label" for="failureFactor">{{:: 'max-login-failures' | translate}}</label>
 
                 <div class="col-md-6">
@@ -23,7 +31,7 @@
                 </div>
                 <kc-tooltip>{{:: 'max-login-failures.tooltip' | translate}}</kc-tooltip>
             </div>
-            <div class="form-group" data-ng-show="realm.bruteForceProtected">
+            <div class="form-group" data-ng-show="realm.bruteForceProtected && !realm.permanentLockout">
                 <label class="col-md-2 control-label" for="waitIncrement">{{:: 'wait-increment' | translate}}</label>
                 <div class="col-md-6 time-selector">
                     <input class="form-control" type="number" required min="1"
@@ -62,7 +70,7 @@
                 </div>
                 <kc-tooltip>{{:: 'min-quick-login-wait.tooltip' | translate}}</kc-tooltip>
             </div>
-            <div class="form-group" data-ng-show="realm.bruteForceProtected">
+            <div class="form-group" data-ng-show="realm.bruteForceProtected && !realm.permanentLockout">
                 <label class="col-md-2 control-label" for="maxFailureWait">{{:: 'max-wait' | translate}}</label>
                 <div class="col-md-6 time-selector">
                     <input class="form-control" type="number" required min="1"
@@ -77,7 +85,7 @@
                 </div>
                 <kc-tooltip>{{:: 'max-wait.tooltip' | translate}}</kc-tooltip>
             </div>
-            <div class="form-group" data-ng-show="realm.bruteForceProtected">
+            <div class="form-group" data-ng-show="realm.bruteForceProtected && !realm.permanentLockout">
                 <label class="col-md-2 control-label" for="maxDeltaTime">{{:: 'failure-reset-time' | translate}}</label>
                 <div class="col-md-6 time-selector">
                     <input class="form-control" type="number" required min="1"