feat(machine-identities): LDAP Auth Lockout

This commit is contained in:
x032205
2025-08-26 03:10:38 -04:00
parent 57c667f0b1
commit 931abea2bb
21 changed files with 956 additions and 353 deletions
@@ -25,6 +25,7 @@ import {
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime";
import {
MachineIdentityAuthMethod,
useAddIdentityLdapAuth,
@@ -35,6 +36,8 @@ import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { useGetAvailableTemplates } from "@app/hooks/api/identityAuthTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { LockoutTab } from "./lockout/LockoutTab";
import { superRefineLockout } from "./lockout/super-refine";
import { IdentityFormTab } from "./types";
const schema = z
@@ -74,9 +77,28 @@ const schema = z
ipAddress: z.string().max(50)
})
)
.min(1)
.min(1),
lockoutEnabled: z.boolean().default(true),
lockoutThreshold: z
.string()
.refine(
(value) => Number(value) <= 30 && Number(value) >= 1,
"Lockout threshold must be between 1 and 30"
),
lockoutDurationValue: z.string(),
lockoutDurationUnit: z.enum(["s", "m", "h", "d"], {
invalid_type_error: "Please select a valid time unit"
}),
lockoutCounterResetValue: z.string(),
lockoutCounterResetUnit: z.enum(["s", "m", "h"], {
invalid_type_error: "Please select a valid time unit"
})
})
.required()
.superRefine((data, ctx) => {
superRefineLockout(data, ctx);
// Validation based on scope
if (data.scope === "template") {
if (!data.templateId) {
@@ -178,12 +200,25 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
lockoutEnabled: true,
lockoutThreshold: "3",
lockoutDurationValue: "5",
lockoutDurationUnit: "m",
lockoutCounterResetValue: "30",
lockoutCounterResetUnit: "s"
}
});
const scope = watch("scope");
const lockoutEnabledWatch = watch("lockoutEnabled");
const lockoutThresholdWatch = watch("lockoutThreshold");
const lockoutDurationValueWatch = watch("lockoutDurationValue");
const lockoutDurationUnitWatch = watch("lockoutDurationUnit");
const lockoutCounterResetValueWatch = watch("lockoutCounterResetValue");
const lockoutCounterResetUnitWatch = watch("lockoutCounterResetUnit");
const {
fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp,
@@ -210,6 +245,9 @@ export const IdentityLdapAuthForm = ({
if (data) {
const detectedScope = determineScope(data);
const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration);
const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset);
reset({
scope: detectedScope,
templateId: data.templateId || "",
@@ -229,7 +267,13 @@ export const IdentityLdapAuthForm = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
),
lockoutEnabled: data.lockoutEnabled,
lockoutThreshold: String(data.lockoutThreshold),
lockoutDurationValue: String(lockoutDurationObj.value),
lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d",
lockoutCounterResetValue: String(lockoutCounterResetObj.value),
lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h"
});
return;
}
@@ -247,7 +291,13 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0",
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
lockoutEnabled: true,
lockoutThreshold: "3",
lockoutDurationValue: "5",
lockoutDurationUnit: "m",
lockoutCounterResetValue: "30",
lockoutCounterResetUnit: "s"
});
}, [data, reset]);
@@ -275,9 +325,21 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationValue,
lockoutDurationUnit,
lockoutCounterResetValue,
lockoutCounterResetUnit
} = formData;
const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit);
const lockoutCounterReset = durationToSeconds(
Number(lockoutCounterResetValue),
lockoutCounterResetUnit
);
const basePayload = {
organizationId: orgId,
identityId,
@@ -287,7 +349,11 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps
accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDuration,
lockoutCounterReset
};
// Add scope-specific fields
@@ -327,7 +393,10 @@ export const IdentityLdapAuthForm = ({
return (
<form
onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue(
const firstErrorField = Object.keys(fields)[0];
let tab = IdentityFormTab.Advanced;
if (
[
"scope",
"templateId",
@@ -340,15 +409,29 @@ export const IdentityLdapAuthForm = ({
"allowedFields",
"accessTokenMaxTTL",
"accessTokenNumUsesLimit"
].includes(Object.keys(fields)[0])
? IdentityFormTab.Configuration
: IdentityFormTab.Advanced
);
].includes(firstErrorField)
) {
tab = IdentityFormTab.Configuration;
} else if (
[
"lockoutEnabled",
"lockoutThreshold",
"lockoutDurationValue",
"lockoutDurationUnit",
"lockoutCounterResetValue",
"lockoutCounterResetUnit"
].includes(firstErrorField)
) {
tab = IdentityFormTab.Lockout;
}
setTabValue(tab);
})}
>
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}>
<TabList>
<Tab value={IdentityFormTab.Configuration}>Configuration</Tab>
<Tab value={IdentityFormTab.Lockout}>Lockout</Tab>
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
@@ -691,6 +774,15 @@ export const IdentityLdapAuthForm = ({
)}
/>
</TabPanel>
<LockoutTab
control={control}
lockoutEnabled={lockoutEnabledWatch}
lockoutThreshold={lockoutThresholdWatch}
lockoutDurationValue={lockoutDurationValueWatch}
lockoutDurationUnit={lockoutDurationUnitWatch}
lockoutCounterResetValue={lockoutCounterResetValueWatch}
lockoutCounterResetUnit={lockoutCounterResetUnitWatch}
/>
<TabPanel value={IdentityFormTab.Advanced}>
<Controller
control={control}
@@ -11,9 +11,6 @@ import {
FormControl,
IconButton,
Input,
Select,
SelectItem,
Switch,
Tab,
TabList,
TabPanel,
@@ -29,6 +26,8 @@ import {
import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { LockoutTab } from "./lockout/LockoutTab";
import { superRefineLockout } from "./lockout/super-refine";
import { IdentityFormTab } from "./types";
const schema = z
@@ -82,63 +81,7 @@ const schema = z
})
})
.required()
.superRefine((data, ctx) => {
const {
lockoutDurationValue,
lockoutCounterResetValue,
lockoutDurationUnit,
lockoutCounterResetUnit,
lockoutEnabled
} = data;
if (!lockoutEnabled) return;
let isAnyParseError = false;
const parsedLockoutDuration = parseInt(lockoutDurationValue, 10);
if (Number.isNaN(parsedLockoutDuration)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout duration must be a number",
path: ["lockoutDurationValue"]
});
isAnyParseError = true;
}
const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10);
if (Number.isNaN(parsedLockoutCounterReset)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout counter reset must be a number",
path: ["lockoutCounterResetValue"]
});
isAnyParseError = true;
}
if (isAnyParseError) return;
const lockoutDurationInSeconds = durationToSeconds(parsedLockoutDuration, lockoutDurationUnit);
const lockoutCounterResetInSeconds = durationToSeconds(
parsedLockoutCounterReset,
lockoutCounterResetUnit
);
if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout duration must be between 30 seconds and 1 day",
path: ["lockoutDurationValue"]
});
}
if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout counter reset must be between 5 seconds and 1 hour",
path: ["lockoutCounterResetValue"]
});
}
});
.superRefine(superRefineLockout);
export type FormData = z.infer<typeof schema>;
@@ -315,8 +258,8 @@ export const IdentityUniversalAuthForm = ({
accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDuration: Number(lockoutDuration),
lockoutCounterReset: Number(lockoutCounterReset)
lockoutDuration,
lockoutCounterReset
});
}
@@ -435,187 +378,15 @@ export const IdentityUniversalAuthForm = ({
)}
/>
</TabPanel>
<TabPanel value={IdentityFormTab.Lockout}>
<div className="mb-3 flex flex-col">
<Controller
control={control}
name="lockoutEnabled"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={`The lockout feature will prevent login attempts for ${lockoutDurationValueWatch}${lockoutDurationUnitWatch} after ${lockoutThresholdWatch} consecutive login failures. If ${lockoutCounterResetValueWatch}${lockoutCounterResetUnitWatch} pass after the most recent failure, the lockout counter resets.`}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="ml-0 mr-3 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="lockout-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
Lockout {value ? "Enabled" : "Disabled"}
</Switch>
</FormControl>
);
}}
/>
<div className="flex flex-col gap-2">
<Controller
control={control}
name="lockoutThreshold"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Threshold"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The amount of times login must fail before locking the identity auth method"
>
<Input
{...field}
placeholder="Enter lockout threshold..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutDurationValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Duration"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long an identity auth method lockout lasts"
>
<Input
{...field}
placeholder="Enter lockout duration..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutDurationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabledWatch ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabledWatch}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
<SelectItem
value="d"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Days</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutCounterResetValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Counter Reset"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long to wait from the most recent failed login until resetting the lockout counter"
>
<Input
{...field}
placeholder="Enter lockout counter reset..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutCounterResetUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabledWatch ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabledWatch}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
</TabPanel>
<LockoutTab
control={control}
lockoutEnabled={lockoutEnabledWatch}
lockoutThreshold={lockoutThresholdWatch}
lockoutDurationValue={lockoutDurationValueWatch}
lockoutDurationUnit={lockoutDurationUnitWatch}
lockoutCounterResetValue={lockoutCounterResetValueWatch}
lockoutCounterResetUnit={lockoutCounterResetUnitWatch}
/>
<TabPanel value={IdentityFormTab.Advanced}>
{clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
@@ -0,0 +1,206 @@
import { Control, Controller } from "react-hook-form";
import { FormControl, Input, Select, SelectItem, Switch, TabPanel } from "@app/components/v2";
import { IdentityFormTab } from "../types";
export const LockoutTab = ({
control,
lockoutEnabled,
lockoutThreshold,
lockoutDurationValue,
lockoutDurationUnit,
lockoutCounterResetValue,
lockoutCounterResetUnit
}: {
control: Control<any>;
lockoutEnabled: boolean;
lockoutThreshold: string;
lockoutDurationValue: string;
lockoutDurationUnit: "s" | "m" | "h" | "d";
lockoutCounterResetValue: string;
lockoutCounterResetUnit: "s" | "m" | "h";
}) => {
return (
<TabPanel value={IdentityFormTab.Lockout}>
<div className="mb-3 flex flex-col">
<Controller
control={control}
name="lockoutEnabled"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={`The lockout feature will prevent login attempts for ${lockoutDurationValue}${lockoutDurationUnit} after ${lockoutThreshold} consecutive login failures. If ${lockoutCounterResetValue}${lockoutCounterResetUnit} pass after the most recent failure, the lockout counter resets.`}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="ml-0 mr-3 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="lockout-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
Lockout {value ? "Enabled" : "Disabled"}
</Switch>
</FormControl>
);
}}
/>
<div className="flex flex-col gap-2">
<Controller
control={control}
name="lockoutThreshold"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabled ? "" : "opacity-70"}`}
label="Lockout Threshold"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The amount of times login must fail before locking the identity auth method"
>
<Input
{...field}
placeholder="Enter lockout threshold..."
isDisabled={!lockoutEnabled}
/>
</FormControl>
);
}}
/>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutDurationValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabled ? "" : "opacity-70"}`}
label="Lockout Duration"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long an identity auth method lockout lasts"
>
<Input
{...field}
placeholder="Enter lockout duration..."
isDisabled={!lockoutEnabled}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutDurationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabled ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabled}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
<SelectItem
value="d"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Days</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutCounterResetValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabled ? "" : "opacity-70"}`}
label="Lockout Counter Reset"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long to wait from the most recent failed login until resetting the lockout counter"
>
<Input
{...field}
placeholder="Enter lockout counter reset..."
isDisabled={!lockoutEnabled}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutCounterResetUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabled ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabled}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
</TabPanel>
);
};
@@ -0,0 +1,73 @@
import { z } from "zod";
import { durationToSeconds } from "@app/helpers/datetime";
export function superRefineLockout(
data: {
lockoutDurationValue: string;
lockoutCounterResetValue: string;
lockoutDurationUnit: "s" | "m" | "h" | "d";
lockoutCounterResetUnit: "s" | "m" | "h";
lockoutEnabled: boolean;
},
ctx: z.RefinementCtx
) {
const {
lockoutDurationValue,
lockoutCounterResetValue,
lockoutDurationUnit,
lockoutCounterResetUnit,
lockoutEnabled
} = data;
if (lockoutEnabled) {
let isAnyParseError = false;
const parsedLockoutDuration = parseInt(lockoutDurationValue, 10);
if (Number.isNaN(parsedLockoutDuration)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout duration must be a number",
path: ["lockoutDurationValue"]
});
isAnyParseError = true;
}
const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10);
if (Number.isNaN(parsedLockoutCounterReset)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout counter reset must be a number",
path: ["lockoutCounterResetValue"]
});
isAnyParseError = true;
}
if (!isAnyParseError) {
const lockoutDurationInSeconds = durationToSeconds(
parsedLockoutDuration,
lockoutDurationUnit
);
const lockoutCounterResetInSeconds = durationToSeconds(
parsedLockoutCounterReset,
lockoutCounterResetUnit
);
if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout duration must be between 30 seconds and 1 day",
path: ["lockoutDurationValue"]
});
}
if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout counter reset must be between 5 seconds and 1 hour",
path: ["lockoutCounterResetValue"]
});
}
}
}
}