Permanently lock users out after X temporary lockouts during a brute force attack

Closes #26172

Signed-off-by: Douglas Palmer <dpalmer@redhat.com>
This commit is contained in:
Douglas Palmer 2024-01-14 22:40:20 -08:00 committed by Marek Posolda
parent 9ea679ff35
commit b0ef746f39
23 changed files with 362 additions and 114 deletions

View file

@ -91,6 +91,7 @@ public class RealmRepresentation {
//--- brute force settings
protected Boolean bruteForceProtected;
protected Boolean permanentLockout;
protected Integer maxTemporaryLockouts;
protected Integer maxFailureWaitSeconds;
protected Integer minimumQuickLoginWaitSeconds;
protected Integer waitIncrementSeconds;
@ -764,6 +765,14 @@ public class RealmRepresentation {
this.permanentLockout = permanentLockout;
}
public Integer getMaxTemporaryLockouts() {
return maxTemporaryLockouts;
}
public void setMaxTemporaryLockouts(Integer maxTemporaryLockouts) {
this.maxTemporaryLockouts = maxTemporaryLockouts;
}
public Integer getMaxFailureWaitSeconds() {
return maxFailureWaitSeconds;
}

View file

@ -274,6 +274,14 @@ As of this release, {project_name} supports storing and searching by user attrib
For more details, check the
link:{upgradingguide_link}[{upgradingguide_name}].
= Brute Force Protection changes
There have been a couple of enhancements to the Brute Protection:
1. When an attempt to authenticate with an OTP or Recovery Code fails due to Brute Force Protection the active Authentication Session is invalidated. Any further attempts to authenticate with that session will fail.
2. In previous versions of Keycloak the Administrator had to choose between whether to disable users temporarily or permanently due to a Brute Force attack on their account. The administrator now has the option to permanently disable a user after a given number of temporary lockouts.
= Authorization Policy
In previous versions of Keycloak when the last member of a User, Group or Client policy was deleted then that policy would also be deleted. Unfortunately this could lead to an escalation of privileges if the policy was used in an aggregate policy. To avoid privilege escalation the effect policies are no longer deleted and an administrator will need to update those policies.

View file

@ -47,20 +47,6 @@ When a user is temporarily locked and attempts to log in, {project_name} display
|===
*Permanent Lockout Flow*
====
. On successful login
.. Reset `count`
. On failed login
.. Increment `count`
.. If `count` greater than _Max Login Failures_
... Permanently disable user
.. Else if the time between this failure and the last failure is less than _Quick Login Check Milliseconds_
... Temporarily disable user for _Minimum Quick Login Wait_
When {project_name} disables a user, the user cannot log in until an administrator enables the user. Enabling an account resets the `count`.
====
*Temporary Lockout Parameters*
|===
@ -92,6 +78,7 @@ wait time will never reach the value you have set to `Max wait`.
.. Calculate `wait` using _Wait Increment_ * (`count` / _Max Login Failures_). The division is an integer division rounded down to a whole number
.. If `wait` equals 0 and the time between this failure and the last failure is less than _Quick Login Check Milliseconds_, set `wait` to _Minimum Quick Login Wait_.
... Temporarily disable the user for the smallest of `wait` and _Max Wait_ seconds
... Increment the temporary lockout counter
`count` does not increment when a temporarily disabled account commits a login failure.
====
@ -117,6 +104,25 @@ Note that the `Effective Wait Time` at the 5th failed attempt will disable the a
the next multiple of `Max Login Failures`, in this case `10`, will the time increase from `30` to `60`. The time the account will be disabled
is only increased when reaching multiples of `Max Login Failures`.
*Permanent Lockout Parameters*
|===
|Name |Description |Default
|Max temporary Lockouts
|The maximum number of temporary lockouts permitted before permanent lockout occurs.
|0
|===
*Permanent Lockout Flow*
====
. Follow temporary lockout flow
. If temporary lockout counter exceeds Max temporary lockouts
.. Permanently disable user
When {project_name} disables a user, the user cannot log in until an administrator enables the user. Enabling an account resets the `count`.
====
The downside of {project_name} brute force detection is that the server becomes vulnerable to denial of service attacks. When implementing a denial of service attack, an attacker can attempt to log in by guessing passwords for any accounts it knows and eventually causing {project_name} to disable the accounts.
Consider using intrusion prevention software (IPS). {project_name} logs every login failure and client IP address failure. You can point the IPS to the {project_name} server's log file, and the IPS can modify firewalls to block connections from these IP addresses.

View file

@ -251,7 +251,10 @@ describe("Realm settings events tab tests", () => {
cy.findByTestId("brute-force-tab-save").should("be.disabled");
cy.get("#bruteForceProtected").click({ force: true });
cy.get("#kc-brute-force-mode").click();
cy.findByTestId("select-brute-force-mode")
.contains("Lockout temporarily")
.click();
cy.findByTestId("waitIncrementSeconds").type("1");
cy.findByTestId("maxFailureWaitSeconds").type("1");
cy.findByTestId("maxDeltaTimeSeconds").type("1");

View file

@ -158,7 +158,10 @@ describe("Realm settings tabs tests", () => {
sidebarPage.goToRealmSettings();
realmSettingsPage.goToSecurityDefensesTab();
realmSettingsPage.goToSecurityDefensesBruteForceTab();
cy.get("#bruteForceProtected").click({ force: true });
cy.get("#kc-brute-force-mode").click();
cy.findByTestId("select-brute-force-mode")
.contains("Lockout temporarily")
.click();
cy.findByTestId("waitIncrementSeconds").type("1");
cy.findByTestId("maxFailureWaitSeconds").type("1");
cy.findByTestId("maxDeltaTimeSeconds").type("1");

View file

@ -715,6 +715,8 @@ permissionType=Specifies that this permission must be applied to all resources i
policyEnforcementModes.ENFORCING=Enforcing
rowSaveBtnAriaLabel=Save edits for {{messageBundle}}
permanentLockout=Permanent lockout
maxTemporaryLockouts=Maximum temporary lockouts
maxTemporaryLockoutsHelp=The number of temporary lockouts permitted before the user is permanently locked out.
debug=Debug
webAuthnPolicyRequireResidentKey=Require discoverable credential
unlockUsersConfirm=All the users that are temporarily locked will be unlocked.
@ -2903,7 +2905,7 @@ eventTypes.IDENTITY_PROVIDER_FIRST_LOGIN_ERROR.description=Identity provider fir
groups=Groups
emptyStateText=There aren't any realm roles in this realm. Create a realm role to get started.
includeSubGroups=Include sub-group users
permanentLockoutHelp=Lock the user permanently when the user exceeds the maximum login failures.
permanentLockoutHelp=Configures whether a user is temporarily or permanently disabled after too many login failures. Permanent lockout can be configured to occur after a number of login failures or after a number of temporary lockouts.
logicType.positive=Positive
associatedPolicy=Associated policy
accountTheme=Account theme
@ -3053,3 +3055,8 @@ userNotSaved=The user has not been saved\: {{error}}
kcNumberFormat=Number Format
kcNumberUnFormat=Number UnFormat
tokenExpirationHelp=Sets the expiration for tokens. Expired tokens are periodically deleted from the database.
bruteForceMode.Disabled=Disabled
bruteForceMode.PermanentLockout=Lockout permanently
bruteForceMode.TemporaryLockout=Lockout temporarily
bruteForceMode.PermanentAfterTemporaryLockout=Lockout permanently after temporary lockout
bruteForceMode=Brute Force Mode

View file

@ -4,10 +4,12 @@ import {
Button,
FormGroup,
NumberInput,
Switch,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { useEffect } from "react";
import { Controller, FormProvider, useForm, useWatch } from "react-hook-form";
import { useEffect, useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../../components/form/FormAccess";
@ -33,19 +35,41 @@ export const BruteForceDetection = ({
formState: { isDirty },
} = form;
const enable = useWatch({
control,
name: "bruteForceProtected",
});
const [isBruteForceModeOpen, setIsBruteForceModeOpen] = useState(false);
const [isBruteForceModeUpdated, setIsBruteForceModeUpdated] = useState(false);
const permanentLockout = useWatch({
control,
name: "permanentLockout",
});
enum BruteForceMode {
Disabled = "Disabled",
PermanentLockout = "PermanentLockout",
TemporaryLockout = "TemporaryLockout",
PermanentAfterTemporaryLockout = "PermanentAfterTemporaryLockout",
}
const setupForm = () => convertToFormValues(realm, setValue);
const bruteForceModes = [
BruteForceMode.Disabled,
BruteForceMode.PermanentLockout,
BruteForceMode.TemporaryLockout,
BruteForceMode.PermanentAfterTemporaryLockout,
];
const setupForm = () => {
convertToFormValues(realm, setValue);
setIsBruteForceModeUpdated(false);
};
useEffect(setupForm, []);
const bruteForceMode = (() => {
if (!form.getValues("bruteForceProtected")) {
return BruteForceMode.Disabled;
}
if (!form.getValues("permanentLockout")) {
return BruteForceMode.TemporaryLockout;
}
return form.getValues("maxTemporaryLockouts") == 0
? BruteForceMode.PermanentLockout
: BruteForceMode.PermanentAfterTemporaryLockout;
})();
return (
<FormProvider {...form}>
<FormAccess
@ -54,26 +78,58 @@ export const BruteForceDetection = ({
onSubmit={handleSubmit(save)}
>
<FormGroup
label={t("enabled")}
fieldId="bruteForceProtected"
hasNoPaddingTop
label={t("bruteForceMode")}
fieldId="kc-brute-force-mode"
labelIcon={
<HelpItem
helpText={t("bruteForceModeHelpText")}
fieldLabelId="bruteForceMode"
/>
}
>
<Controller
name="bruteForceProtected"
defaultValue={false}
control={control}
render={({ field }) => (
<Switch
id="bruteForceProtected"
label={t("on")}
labelOff={t("off")}
isChecked={field.value}
onChange={field.onChange}
/>
)}
/>
<Select
toggleId="kc-brute-force-mode"
onToggle={() => setIsBruteForceModeOpen(!isBruteForceModeOpen)}
onSelect={(_, value) => {
switch (value as BruteForceMode) {
case BruteForceMode.Disabled:
form.setValue("bruteForceProtected", false);
form.setValue("permanentLockout", false);
form.setValue("maxTemporaryLockouts", 0);
break;
case BruteForceMode.TemporaryLockout:
form.setValue("bruteForceProtected", true);
form.setValue("permanentLockout", false);
form.setValue("maxTemporaryLockouts", 0);
break;
case BruteForceMode.PermanentLockout:
form.setValue("bruteForceProtected", true);
form.setValue("permanentLockout", true);
form.setValue("maxTemporaryLockouts", 0);
break;
case BruteForceMode.PermanentAfterTemporaryLockout:
form.setValue("bruteForceProtected", true);
form.setValue("permanentLockout", true);
form.setValue("maxTemporaryLockouts", 1);
break;
}
setIsBruteForceModeUpdated(true);
setIsBruteForceModeOpen(false);
}}
selections={bruteForceMode}
variant={SelectVariant.single}
isOpen={isBruteForceModeOpen}
data-testid="select-brute-force-mode"
aria-label={t("selectUnmanagedAttributePolicy")}
>
{bruteForceModes.map((mode) => (
<SelectOption key={mode} value={mode}>
{t(`bruteForceMode.${mode}`)}
</SelectOption>
))}
</Select>
</FormGroup>
{enable && (
{bruteForceMode !== BruteForceMode.Disabled && (
<>
<FormGroup
label={t("failureFactor")}
@ -106,29 +162,46 @@ export const BruteForceDetection = ({
)}
/>
</FormGroup>
{bruteForceMode ===
BruteForceMode.PermanentAfterTemporaryLockout && (
<FormGroup
label={t("permanentLockout")}
fieldId="permanentLockout"
label={t("maxTemporaryLockouts")}
labelIcon={
<HelpItem
helpText={t("maxTemporaryLockoutsHelp")}
fieldLabelId="maxTemporaryLockouts"
/>
}
fieldId="maxTemporaryLockouts"
hasNoPaddingTop
>
<Controller
name="permanentLockout"
defaultValue={false}
name="maxTemporaryLockouts"
defaultValue={0}
control={control}
render={({ field }) => (
<Switch
id="permanentLockout"
label={t("on")}
labelOff={t("off")}
isChecked={field.value}
onChange={field.onChange}
aria-label={t("permanentLockout")}
<NumberInput
type="text"
id="maxTemporaryLockouts"
value={field.value}
onPlus={() => field.onChange(field.value + 1)}
onMinus={() => field.onChange(field.value - 1)}
onChange={(event) =>
field.onChange(
Number((event.target as HTMLInputElement).value),
)
}
aria-label={t("maxTemporaryLockouts")}
/>
)}
/>
</FormGroup>
)}
{!permanentLockout && (
{(bruteForceMode === BruteForceMode.TemporaryLockout ||
bruteForceMode ===
BruteForceMode.PermanentAfterTemporaryLockout) && (
<>
<Time name="waitIncrementSeconds" />
<Time name="maxFailureWaitSeconds" />
@ -176,7 +249,7 @@ export const BruteForceDetection = ({
variant="primary"
type="submit"
data-testid="brute-force-tab-save"
isDisabled={!isDirty}
isDisabled={!isDirty && !isBruteForceModeUpdated}
>
{t("save")}
</Button>

View file

@ -73,6 +73,7 @@ export default interface RealmRepresentation {
loginWithEmailAllowed?: boolean;
maxDeltaTimeSeconds?: number;
maxFailureWaitSeconds?: number;
maxTemporaryLockouts?: number;
minimumQuickLoginWaitSeconds?: number;
notBefore?: number;
oauth2DeviceCodeLifespan?: number;

View file

@ -243,6 +243,18 @@ public class RealmAdapter implements CachedRealmModel {
updated.setPermanentLockout(val);
}
@Override
public int getMaxTemporaryLockouts() {
if(isUpdated()) return updated.getMaxTemporaryLockouts();
return cached.getMaxTemporaryLockouts();
}
@Override
public void setMaxTemporaryLockouts(final int val) {
getDelegateForUpdate();
updated.setMaxTemporaryLockouts(val);
}
@Override
public int getMaxFailureWaitSeconds() {
if (isUpdated()) return updated.getMaxFailureWaitSeconds();

View file

@ -74,6 +74,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
//--- brute force settings
protected boolean bruteForceProtected;
protected boolean permanentLockout;
protected int maxTemporaryLockouts;
protected int maxFailureWaitSeconds;
protected int minimumQuickLoginWaitSeconds;
protected int waitIncrementSeconds;
@ -193,6 +194,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
//--- brute force settings
bruteForceProtected = model.isBruteForceProtected();
permanentLockout = model.isPermanentLockout();
maxTemporaryLockouts = model.getMaxTemporaryLockouts();
maxFailureWaitSeconds = model.getMaxFailureWaitSeconds();
minimumQuickLoginWaitSeconds = model.getMinimumQuickLoginWaitSeconds();
waitIncrementSeconds = model.getWaitIncrementSeconds();
@ -372,6 +374,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return permanentLockout;
}
public int getMaxTemporaryLockouts() {
return maxTemporaryLockouts;
}
public int getMaxFailureWaitSeconds() {
return this.maxFailureWaitSeconds;
}

View file

@ -80,6 +80,25 @@ public class UserLoginFailureAdapter implements UserLoginFailureModel {
update(task);
}
@Override
public int getNumTemporaryLockouts() {
return entity.getNumTemporaryLockouts();
}
@Override
public void incrementTemporaryLockouts() {
LoginFailuresUpdateTask task = new LoginFailuresUpdateTask() {
@Override
public void runUpdate(LoginFailureEntity entity) {
entity.setNumTemporaryLockouts(entity.getNumTemporaryLockouts() + 1);
}
};
update(task);
}
@Override
public void clearFailures() {
LoginFailuresUpdateTask task = new LoginFailuresUpdateTask() {

View file

@ -33,17 +33,20 @@ public class LoginFailureEntity extends SessionEntity {
private String userId;
private int failedLoginNotBefore;
private int numFailures;
private int numTemporaryLockouts;
private long lastFailure;
private String lastIPFailure;
public LoginFailureEntity() {
}
private LoginFailureEntity(String realmId, String userId, int failedLoginNotBefore, int numFailures, long lastFailure, String lastIPFailure) {
private LoginFailureEntity(String realmId, String userId, int failedLoginNotBefore, int numFailures, int numTemporaryLockouts, long lastFailure, String lastIPFailure) {
super(realmId);
this.userId = userId;
this.failedLoginNotBefore = failedLoginNotBefore;
this.numFailures = numFailures;
this.numTemporaryLockouts = numTemporaryLockouts;
this.lastFailure = lastFailure;
this.lastIPFailure = lastIPFailure;
}
@ -72,6 +75,14 @@ public class LoginFailureEntity extends SessionEntity {
this.numFailures = numFailures;
}
public int getNumTemporaryLockouts() {
return numTemporaryLockouts;
}
public void setNumTemporaryLockouts(int numTemporaryLockouts) {
this.numTemporaryLockouts = numTemporaryLockouts;
}
public long getLastFailure() {
return lastFailure;
}
@ -91,6 +102,7 @@ public class LoginFailureEntity extends SessionEntity {
public void clearFailures() {
this.failedLoginNotBefore = 0;
this.numFailures = 0;
this.numTemporaryLockouts = 0;
this.lastFailure = 0;
this.lastIPFailure = null;
}
@ -133,6 +145,7 @@ public class LoginFailureEntity extends SessionEntity {
MarshallUtil.marshallString(value.userId, output);
output.writeInt(value.failedLoginNotBefore);
output.writeInt(value.numFailures);
output.writeInt(value.numTemporaryLockouts);
output.writeLong(value.lastFailure);
MarshallUtil.marshallString(value.lastIPFailure, output);
}
@ -153,6 +166,7 @@ public class LoginFailureEntity extends SessionEntity {
MarshallUtil.unmarshallString(input),
input.readInt(),
input.readInt(),
input.readInt(),
input.readLong(),
MarshallUtil.unmarshallString(input)
);

View file

@ -262,6 +262,16 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
setAttribute("permanentLockout", val);
}
@Override
public int getMaxTemporaryLockouts() {
return getAttribute("maxTemporaryLockouts", 0);
}
@Override
public void setMaxTemporaryLockouts(final int val) {
setAttribute("maxTemporaryLockouts", val);
}
@Override
public int getMaxFailureWaitSeconds() {
return getAttribute("maxFailureWaitSeconds", 0);

View file

@ -174,6 +174,7 @@ public class DefaultExportImportManager implements ExportImportManager {
if (rep.isUserManagedAccessAllowed() != null) newRealm.setUserManagedAccessAllowed(rep.isUserManagedAccessAllowed());
if (rep.isBruteForceProtected() != null) newRealm.setBruteForceProtected(rep.isBruteForceProtected());
if (rep.isPermanentLockout() != null) newRealm.setPermanentLockout(rep.isPermanentLockout());
if (rep.getMaxTemporaryLockouts() != null) newRealm.setMaxTemporaryLockouts(rep.getMaxTemporaryLockouts());
if (rep.getMaxFailureWaitSeconds() != null) newRealm.setMaxFailureWaitSeconds(rep.getMaxFailureWaitSeconds());
if (rep.getMinimumQuickLoginWaitSeconds() != null)
newRealm.setMinimumQuickLoginWaitSeconds(rep.getMinimumQuickLoginWaitSeconds());
@ -735,6 +736,7 @@ public class DefaultExportImportManager implements ExportImportManager {
if (rep.isUserManagedAccessAllowed() != null) realm.setUserManagedAccessAllowed(rep.isUserManagedAccessAllowed());
if (rep.isBruteForceProtected() != null) realm.setBruteForceProtected(rep.isBruteForceProtected());
if (rep.isPermanentLockout() != null) realm.setPermanentLockout(rep.isPermanentLockout());
if (rep.getMaxTemporaryLockouts() != null) realm.setMaxTemporaryLockouts(rep.getMaxTemporaryLockouts());
if (rep.getMaxFailureWaitSeconds() != null) realm.setMaxFailureWaitSeconds(rep.getMaxFailureWaitSeconds());
if (rep.getMinimumQuickLoginWaitSeconds() != null)
realm.setMinimumQuickLoginWaitSeconds(rep.getMinimumQuickLoginWaitSeconds());

View file

@ -79,6 +79,7 @@ public class ModelToRepresentation {
REALM_EXCLUDED_ATTRIBUTES.add("defaultSignatureAlgorithm");
REALM_EXCLUDED_ATTRIBUTES.add("bruteForceProtected");
REALM_EXCLUDED_ATTRIBUTES.add("permanentLockout");
REALM_EXCLUDED_ATTRIBUTES.add("maxTemporaryLockouts");
REALM_EXCLUDED_ATTRIBUTES.add("maxFailureWaitSeconds");
REALM_EXCLUDED_ATTRIBUTES.add("waitIncrementSeconds");
REALM_EXCLUDED_ATTRIBUTES.add("quickLoginCheckMilliSeconds");
@ -346,6 +347,7 @@ public class ModelToRepresentation {
rep.setRememberMe(realm.isRememberMe());
rep.setBruteForceProtected(realm.isBruteForceProtected());
rep.setPermanentLockout(realm.isPermanentLockout());
rep.setMaxTemporaryLockouts(realm.getMaxTemporaryLockouts());
rep.setMaxFailureWaitSeconds(realm.getMaxFailureWaitSeconds());
rep.setMinimumQuickLoginWaitSeconds(realm.getMinimumQuickLoginWaitSeconds());
rep.setWaitIncrementSeconds(realm.getWaitIncrementSeconds());

View file

@ -678,6 +678,16 @@ public class IdentityBrokerStateTestHelpers {
}
@Override
public int getMaxTemporaryLockouts() {
return 0;
}
@Override
public void setMaxTemporaryLockouts(int val) {
}
@Override
public int getMaxFailureWaitSeconds() {
return 0;

View file

@ -136,6 +136,8 @@ public interface RealmModel extends RoleContainerModel {
void setBruteForceProtected(boolean value);
boolean isPermanentLockout();
void setPermanentLockout(boolean val);
int getMaxTemporaryLockouts();
void setMaxTemporaryLockouts(int val);
int getMaxFailureWaitSeconds();
void setMaxFailureWaitSeconds(int val);
int getWaitIncrementSeconds();

View file

@ -29,6 +29,8 @@ public interface UserLoginFailureModel {
void setFailedLoginNotBefore(int notBefore);
int getNumFailures();
void incrementFailures();
int getNumTemporaryLockouts();
void incrementTemporaryLockouts();
void clearFailures();
long getLastFailure();
void setLastFailure(long lastFailure);

View file

@ -168,33 +168,6 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
}
userLoginFailure.setLastFailure(currentTime);
if(realm.isPermanentLockout()) {
userLoginFailure.incrementFailures();
logger.debugv("new num failures: {0}", userLoginFailure.getNumFailures());
if(userLoginFailure.getNumFailures() == realm.getFailureFactor()) {
UserModel user = session.users().getUserById(realm, userId);
if (user == null) {
return;
}
logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
user.setEnabled(false);
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT);
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT);
return;
}
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);
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT);
}
return;
}
if (deltaTime > 0) {
// if last failure was more than MAX_DELTA clear failures
if (deltaTime > (long) realm.getMaxDeltaTimeSeconds() * 1000L) {
@ -208,14 +181,22 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
logger.debugv("waitSeconds: {0}", waitSeconds);
logger.debugv("deltaTime: {0}", deltaTime);
boolean quickLoginFailure = false;
if (waitSeconds == 0) {
if (last > 0 && deltaTime < realm.getQuickLoginCheckMilliSeconds()) {
logger.debugv("quick login, set min wait seconds");
waitSeconds = realm.getMinimumQuickLoginWaitSeconds();
quickLoginFailure = true;
}
}
if (waitSeconds > 0) {
if(!realm.isPermanentLockout() || realm.getMaxTemporaryLockouts() > 0) {
waitSeconds = Math.min(realm.getMaxFailureWaitSeconds(), waitSeconds);
}
if (!quickLoginFailure) {
userLoginFailure.incrementTemporaryLockouts();
}
if(quickLoginFailure || !realm.isPermanentLockout() || userLoginFailure.getNumTemporaryLockouts() <= realm.getMaxTemporaryLockouts()) {
int notBefore = (int) (currentTime / 1000) + waitSeconds;
logger.debugv("set notBefore: {0}", notBefore);
userLoginFailure.setFailedLoginNotBefore(notBefore);
@ -223,6 +204,23 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
}
}
if(!realm.isPermanentLockout()) {
return;
}
if(userLoginFailure.getNumTemporaryLockouts() > realm.getMaxTemporaryLockouts()) {
UserModel user = session.users().getUserById(realm, userId);
if (user == null) {
return;
}
logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
user.setEnabled(false);
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT);
// Send event
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT);
}
}
protected UserLoginFailureModel getUserModel(KeycloakSession session, LoginEvent event) {
RealmModel realm = getRealmModel(session, event);

View file

@ -248,6 +248,7 @@ public class RealmManager {
// brute force
realm.setBruteForceProtected(false); // default settings off for now todo set it on
realm.setPermanentLockout(false);
realm.setMaxTemporaryLockouts(0);
realm.setMaxFailureWaitSeconds(900);
realm.setMinimumQuickLoginWaitSeconds(60);
realm.setWaitIncrementSeconds(60);

View file

@ -95,6 +95,7 @@ public class AttackDetectionResource {
Map<String, Object> data = new HashMap<>();
data.put("disabled", false);
data.put("numFailures", 0);
data.put("numTemporaryLockouts", 0);
data.put("lastFailure", 0);
data.put("lastIPFailure", "n/a");
if (!realm.isBruteForceProtected()) return data;
@ -114,6 +115,7 @@ public class AttackDetectionResource {
}
data.put("numFailures", model.getNumFailures());
data.put("numTemporaryLockouts", model.getNumTemporaryLockouts());
data.put("lastFailure", model.getLastFailure());
data.put("lastIPFailure", model.getLastIPFailure());
return data;

View file

@ -55,7 +55,7 @@ public class AttackDetectionResourceTest extends AbstractAdminTest {
AttackDetectionResource detection = adminClient.realm(TEST).attackDetection();
String realmId = adminClient.realm(TEST).toRepresentation().getId();
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 0, 0, false, false);
oauthClient.doLogin("test-user@localhost", "invalid");
oauthClient.doLogin("test-user@localhost", "invalid");
@ -65,26 +65,27 @@ public class AttackDetectionResourceTest extends AbstractAdminTest {
oauthClient.doLogin("test-user2", "invalid");
oauthClient.doLogin("nosuchuser", "invalid");
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 2, true, true);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user2").getId()), 2, true, true);
assertBruteForce(detection.bruteForceUserStatus("nosuchuser"), 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 2, 1, true, true);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user2").getId()), 2, 1, true, true);
assertBruteForce(detection.bruteForceUserStatus("nosuchuser"), 0, 0, false, false);
detection.clearBruteForceForUser(findUser("test-user@localhost").getId());
assertAdminEvents.assertEvent(realmId, OperationType.DELETE, AdminEventPaths.attackDetectionClearBruteForceForUserPath(findUser("test-user@localhost").getId()), ResourceType.USER_LOGIN_FAILURE);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user2").getId()), 2, true, true);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 0, 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user2").getId()), 2, 1, true, true);
detection.clearAllBruteForce();
assertAdminEvents.assertEvent(realmId, OperationType.DELETE, AdminEventPaths.attackDetectionClearAllBruteForcePath(), ResourceType.USER_LOGIN_FAILURE);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user2").getId()), 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user@localhost").getId()), 0, 0, false, false);
assertBruteForce(detection.bruteForceUserStatus(findUser("test-user2").getId()), 0, 0, false, false);
}
private void assertBruteForce(Map<String, Object> status, Integer expectedNumFailures, Boolean expectedFailure, Boolean expectedDisabled) {
assertEquals(4, status.size());
private void assertBruteForce(Map<String, Object> status, Integer expectedNumFailures, Integer expectedNumTemporaryLockouts, Boolean expectedFailure, Boolean expectedDisabled) {
assertEquals(5, status.size());
assertEquals(expectedNumFailures, status.get("numFailures"));
assertEquals(expectedNumTemporaryLockouts, status.get("numTemporaryLockouts"));
assertEquals(expectedDisabled, status.get("disabled"));
if (expectedFailure) {
assertEquals("127.0.0.1", status.get("lastIPFailure"));

View file

@ -479,7 +479,6 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
@Test
public void testPermanentLockout() {
RealmRepresentation realm = testRealm().toRepresentation();
try {
// arrange
realm.setPermanentLockout(true);
@ -525,6 +524,60 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
assertIsContained(events.expect(EventType.LOGIN_ERROR).error(Errors.INVALID_USER_CREDENTIALS), actualEvents);
}
@Test
public void testExceedMaxTemporaryLockouts() {
RealmRepresentation realm = testRealm().toRepresentation();
try {
realm.setPermanentLockout(true);
realm.setMaxTemporaryLockouts(2);
testRealm().update(realm);
loginInvalidPassword();
loginInvalidPassword();
expectTemporarilyDisabled();
testingClient.testing().setTimeOffset(Collections.singletonMap("offset", String.valueOf(6)));
loginInvalidPassword();
expectTemporarilyDisabled();
testingClient.testing().setTimeOffset(Collections.singletonMap("offset", String.valueOf(11)));
loginInvalidPassword();
expectPermanentlyDisabled();
} finally {
realm.setPermanentLockout(false);
realm.setMaxTemporaryLockouts(0);
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 testMaxTemporaryLockoutsReset() {
RealmRepresentation realm = testRealm().toRepresentation();
realm.setPermanentLockout(true);
realm.setMaxTemporaryLockouts(2);
testRealm().update(realm);
try {
loginInvalidPassword();
loginInvalidPassword();
expectTemporarilyDisabled();
testingClient.testing().setTimeOffset(Collections.singletonMap("offset", String.valueOf(6)));
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
Map<String, Object> status = adminClient.realm("test").attackDetection().bruteForceUserStatus(user.getId());
assertEquals(1, status.get("numTemporaryLockouts"));
loginSuccess();
status = adminClient.realm("test").attackDetection().bruteForceUserStatus(user.getId());
assertEquals(0, status.get("numTemporaryLockouts"));
} finally {
realm.setPermanentLockout(false);
realm.setMaxTemporaryLockouts(0);
testRealm().update(realm);
}
}
@Test
public void testResetLoginFailureCount() {
RealmRepresentation realm = testRealm().toRepresentation();
@ -809,6 +862,10 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
events.expect(EventType.LOGIN_ERROR).error(error).assertEvent();
}
private void assertUserPermanentlyDisabledEvent() {
events.expect(EventType.LOGIN_ERROR).error(Errors.USER_DISABLED).assertEvent();
}
private void assertUserDisabledReason(String expected) {
String actual = adminClient.realm("test").users()
.search("test-user@localhost", 0, 1)