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:
parent
9ea679ff35
commit
b0ef746f39
23 changed files with 362 additions and 114 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
<FormGroup
|
||||
label={t("permanentLockout")}
|
||||
fieldId="permanentLockout"
|
||||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name="permanentLockout"
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="permanentLockout"
|
||||
label={t("on")}
|
||||
labelOff={t("off")}
|
||||
isChecked={field.value}
|
||||
onChange={field.onChange}
|
||||
aria-label={t("permanentLockout")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{!permanentLockout && (
|
||||
{bruteForceMode ===
|
||||
BruteForceMode.PermanentAfterTemporaryLockout && (
|
||||
<FormGroup
|
||||
label={t("maxTemporaryLockouts")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("maxTemporaryLockoutsHelp")}
|
||||
fieldLabelId="maxTemporaryLockouts"
|
||||
/>
|
||||
}
|
||||
fieldId="maxTemporaryLockouts"
|
||||
hasNoPaddingTop
|
||||
>
|
||||
<Controller
|
||||
name="maxTemporaryLockouts"
|
||||
defaultValue={0}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
|
|
|
@ -73,6 +73,7 @@ export default interface RealmRepresentation {
|
|||
loginWithEmailAllowed?: boolean;
|
||||
maxDeltaTimeSeconds?: number;
|
||||
maxFailureWaitSeconds?: number;
|
||||
maxTemporaryLockouts?: number;
|
||||
minimumQuickLoginWaitSeconds?: number;
|
||||
notBefore?: number;
|
||||
oauth2DeviceCodeLifespan?: number;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,18 +181,43 @@ 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) {
|
||||
waitSeconds = Math.min(realm.getMaxFailureWaitSeconds(), waitSeconds);
|
||||
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);
|
||||
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);
|
||||
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue