Merge pull request #4394 from Infisical/ENG-3506

feat(identities): Universal Auth Login Lockout
This commit is contained in:
x032205
2025-08-29 15:35:17 -04:00
committed by GitHub
27 changed files with 978 additions and 192 deletions
+1 -1
View File
@@ -204,7 +204,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{leftIcon && (
<div
className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all",
"pointer-events-none inline-flex shrink-0 items-center justify-center transition-all",
loadingToggleClass,
size === "xs" ? "mr-1" : "mr-2"
)}
+59
View File
@@ -22,3 +22,62 @@ export const formatDateTime = ({
}
return format(date, dateFormat);
};
// Helper function to convert seconds to value and unit
export const getObjectFromSeconds = (
totalSeconds: number,
activeUnits?: Array<"s" | "m" | "h" | "d" | "w" | "y">
): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => {
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60;
const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24;
const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7;
const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365;
const activeUnitsSet = activeUnits ? new Set(activeUnits) : null;
const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => {
return activeUnitsSet ? activeUnitsSet.has(unit) : true;
};
if (
isUnitActive("y") &&
totalSeconds >= SECONDS_IN_YEAR &&
totalSeconds % SECONDS_IN_YEAR === 0
) {
return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" };
}
if (
isUnitActive("w") &&
totalSeconds >= SECONDS_IN_WEEK &&
totalSeconds % SECONDS_IN_WEEK === 0
) {
return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" };
}
if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) {
return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" };
}
if (
isUnitActive("h") &&
totalSeconds >= SECONDS_IN_HOUR &&
totalSeconds % SECONDS_IN_HOUR === 0
) {
return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" };
}
if (
isUnitActive("m") &&
totalSeconds >= SECONDS_IN_MINUTE &&
totalSeconds % SECONDS_IN_MINUTE === 0
) {
return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" };
}
return {
value: totalSeconds,
unit: "s"
};
};
@@ -40,6 +40,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth",
[EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret",
[EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret",
[EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts",
[EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets",
[EventType.CREATE_ENVIRONMENT]: "Create environment",
[EventType.UPDATE_ENVIRONMENT]: "Update environment",
@@ -46,6 +46,7 @@ export enum EventType {
GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth",
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth",
@@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent {
};
}
interface ClearIdentityUniversalAuthLockoutsEvent {
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS;
metadata: {
identityId: string;
clientSecretId: string;
};
}
interface CreateEnvironmentEvent {
type: EventType.CREATE_ENVIRONMENT;
metadata: {
@@ -892,6 +900,7 @@ export type Event =
| CreateIdentityUniversalAuthClientSecretEvent
| GetIdentityUniversalAuthClientSecretsEvent
| RevokeIdentityUniversalAuthClientSecretEvent
| ClearIdentityUniversalAuthLockoutsEvent
| CreateEnvironmentEvent
| UpdateEnvironmentEvent
| DeleteEnvironmentEvent
@@ -18,6 +18,7 @@ import {
AddIdentityTlsCertAuthDTO,
AddIdentityTokenAuthDTO,
AddIdentityUniversalAuthDTO,
ClearIdentityUniversalAuthLockoutsDTO,
ClientSecretData,
CreateIdentityDTO,
CreateIdentityUniversalAuthClientSecretDTO,
@@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}) => {
const {
data: { identityUniversalAuth }
@@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
return identityUniversalAuth;
},
@@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}) => {
const {
data: { identityUniversalAuth }
@@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
return identityUniversalAuth;
},
@@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => {
});
};
export const useClearIdentityUniversalAuthLockouts = () => {
const queryClient = useQueryClient();
return useMutation<number, object, ClearIdentityUniversalAuthLockoutsDTO>({
mutationFn: async ({ identityId }) => {
const {
data: { deleted }
} = await apiRequest.post<{ deleted: number }>(
`/api/v1/auth/universal-auth/identities/${identityId}/clear-lockouts`
);
return deleted;
},
onSuccess: (_, { identityId }) => {
queryClient.invalidateQueries({
queryKey: identitiesKeys.getIdentityUniversalAuth(identityId)
});
}
});
};
export const useAddIdentityGcpAuth = () => {
const queryClient = useQueryClient();
return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({
@@ -16,6 +16,7 @@ export type Identity = {
name: string;
hasDeleteProtection: boolean;
authMethods: IdentityAuthMethod[];
activeLockoutAuthMethods: IdentityAuthMethod[];
createdAt: string;
updatedAt: string;
isInstanceAdmin?: boolean;
@@ -113,6 +114,10 @@ export type IdentityUniversalAuth = {
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: IdentityTrustedIp[];
accessTokenPeriod: number;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
};
export type AddIdentityUniversalAuthDTO = {
@@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = {
accessTokenTrustedIps: {
ipAddress: string;
}[];
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
};
export type UpdateIdentityUniversalAuthDTO = {
@@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = {
accessTokenTrustedIps?: {
ipAddress: string;
}[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
};
export type DeleteIdentityUniversalAuthDTO = {
@@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = {
clientSecretId: string;
};
export type ClearIdentityUniversalAuthLockoutsDTO = {
identityId: string;
};
export type IdentityTokenAuth = {
identityId: string;
accessTokenTTL: number;
@@ -148,7 +148,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0,
accessTokenPeriod: 0
accessTokenPeriod: 0,
lockoutEnabled: true,
lockoutThreshold: 3,
lockoutDurationSeconds: 300,
lockoutCounterResetSeconds: 30
});
handlePopUpToggle("identity", false);
@@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -11,12 +12,16 @@ import {
FormControl,
IconButton,
Input,
Select,
SelectItem,
Switch,
Tab,
TabList,
TabPanel,
Tabs
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { getObjectFromSeconds } from "@app/helpers/datetime";
import {
useAddIdentityUniversalAuth,
useGetIdentityUniversalAuth,
@@ -60,9 +65,79 @@ const schema = z
ipAddress: z.string().max(50)
})
.array()
.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();
.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 = ms(`${parsedLockoutDuration}${lockoutDurationUnit}`) / 1000;
const lockoutCounterResetInSeconds =
ms(`${parsedLockoutCounterReset}${lockoutCounterResetUnit}`) / 1000;
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"]
});
}
});
export type FormData = z.infer<typeof schema>;
@@ -107,12 +182,25 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenPeriod: "0"
accessTokenPeriod: "0",
lockoutEnabled: true,
lockoutThreshold: "3",
lockoutDurationValue: "5",
lockoutDurationUnit: "m",
lockoutCounterResetValue: "30",
lockoutCounterResetUnit: "s"
}
});
const accessTokenPeriodValue = Number(watch("accessTokenPeriod"));
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: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp,
@@ -126,6 +214,9 @@ export const IdentityUniversalAuthForm = ({
useEffect(() => {
if (data) {
const lockoutDurationObj = getObjectFromSeconds(data.lockoutDurationSeconds);
const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterResetSeconds);
reset({
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
@@ -144,7 +235,13 @@ export const IdentityUniversalAuthForm = ({
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"
});
} else {
reset({
@@ -153,7 +250,13 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit: "0",
accessTokenPeriod: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/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]);
@@ -164,11 +267,21 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit,
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationValue,
lockoutDurationUnit,
lockoutCounterResetValue,
lockoutCounterResetUnit
}: FormData) => {
try {
if (!identityId) return;
const lockoutDurationSeconds = ms(`${lockoutDurationValue}${lockoutDurationUnit}`) / 1000;
const lockoutCounterResetSeconds =
ms(`${lockoutCounterResetValue}${lockoutCounterResetUnit}`) / 1000;
if (data) {
// update universal auth configuration
await updateMutateAsync({
@@ -179,7 +292,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod)
accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
} else {
// create new universal auth configuration
@@ -192,7 +309,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod)
accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDurationSeconds: Number(lockoutDurationSeconds),
lockoutCounterResetSeconds: Number(lockoutCounterResetSeconds)
});
}
@@ -217,16 +338,31 @@ export const IdentityUniversalAuthForm = ({
return (
<form
onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue(
["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0])
? IdentityFormTab.Advanced
: IdentityFormTab.Configuration
);
const firstErrorField = Object.keys(fields)[0];
let tab = IdentityFormTab.Configuration;
if (["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(firstErrorField)) {
tab = IdentityFormTab.Advanced;
} 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}>
@@ -296,6 +432,187 @@ 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
</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>
<TabPanel value={IdentityFormTab.Advanced}>
{clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
@@ -1,4 +1,5 @@
export enum IdentityFormTab {
Advanced = "advanced",
Lockout = "lockout",
Configuration = "configuration"
}
@@ -115,8 +115,10 @@ const Page = () => {
<ViewIdentityAuthModal
isOpen={popUp.viewAuthMethod.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("viewAuthMethod", isOpen)}
authMethod={popUp.viewAuthMethod.data}
authMethod={popUp.viewAuthMethod.data?.authMethod}
lockedOut={popUp.viewAuthMethod.data?.lockedOut || false}
identityId={identityId}
onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity}
/>
</div>
);
@@ -1,8 +1,8 @@
import { faCog, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { Button, Tooltip } from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { IdentityAuthMethod, identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -16,7 +16,7 @@ type Props = {
};
export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => {
const { data } = useGetIdentityById(identityId);
const { data, refetch } = useGetIdentityById(identityId);
return data ? (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -28,12 +28,25 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P
{data.identity.authMethods.map((authMethod) => (
<button
key={authMethod}
onClick={() => handlePopUpOpen("viewAuthMethod", authMethod)}
onClick={() =>
handlePopUpOpen("viewAuthMethod", {
authMethod,
lockedOut: data.identity.activeLockoutAuthMethods.includes(authMethod),
refetchIdentity: refetch
})
}
type="button"
className="flex w-full items-center justify-between bg-mineshaft-900 px-4 py-2 text-sm hover:bg-mineshaft-700 data-[state=open]:bg-mineshaft-600"
>
<span>{identityAuthToNameMap[authMethod]}</span>
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" />
<div className="flex gap-2">
{data.identity.activeLockoutAuthMethods.includes(authMethod) && (
<Tooltip content="Auth method has active lockouts">
<FontAwesomeIcon icon={faLock} size="xs" className="text-red-400/50" />
</Tooltip>
)}
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" />
</div>
</button>
))}
</div>
@@ -37,9 +37,11 @@ import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthCon
type Props = {
identityId: string;
authMethod?: IdentityAuthMethod;
lockedOut: boolean;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onDeleteAuthMethod: () => void;
onResetAllLockouts: () => void;
};
type TRevokeOptions = {
@@ -50,8 +52,13 @@ type TRevokeOptions = {
export const Content = ({
identityId,
authMethod,
onDeleteAuthMethod
}: Pick<Props, "authMethod" | "identityId" | "onDeleteAuthMethod">) => {
lockedOut,
onDeleteAuthMethod,
onResetAllLockouts
}: Pick<
Props,
"authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod" | "onResetAllLockouts"
>) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
@@ -159,9 +166,11 @@ export const Content = ({
<Component
identityId={identityId}
onDelete={handleDelete}
onResetAllLockouts={onResetAllLockouts}
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
lockedOut={lockedOut}
/>
<DeleteActionModal
isOpen={popUp?.revokeAuthMethod?.isOpen}
@@ -184,7 +193,9 @@ export const ViewIdentityAuthModal = ({
isOpen,
onOpenChange,
authMethod,
identityId
identityId,
lockedOut,
onResetAllLockouts
}: Omit<Props, "onDeleteAuthMethod">) => {
if (!identityId || !authMethod) return null;
@@ -194,7 +205,9 @@ export const ViewIdentityAuthModal = ({
<Content
identityId={identityId}
authMethod={authMethod}
lockedOut={lockedOut}
onDeleteAuthMethod={() => onOpenChange(false)}
onResetAllLockouts={() => onResetAllLockouts()}
/>
</ModalContent>
</Modal>
@@ -1,9 +1,15 @@
import { useState } from "react";
import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { useTimedReset } from "@app/hooks";
import {
useClearIdentityUniversalAuthLockouts,
useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api";
@@ -19,16 +25,40 @@ export const ViewIdentityUniversalAuthContent = ({
handlePopUpToggle,
handlePopUpOpen,
onDelete,
popUp
popUp,
lockedOut,
onResetAllLockouts
}: ViewAuthMethodProps) => {
const { data, isPending } = useGetIdentityUniversalAuth(identityId);
const { data: clientSecrets = [], isPending: clientSecretsPending } =
useGetIdentityUniversalAuthClientSecrets(identityId);
const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } =
useClearIdentityUniversalAuthLockouts();
const [lockedOutState, setLockedOutState] = useState(lockedOut);
const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({
initialState: "Copy Client ID to clipboard"
});
async function clearLockouts() {
try {
const deleted = await clearLockoutsFn({ identityId });
createNotification({
text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`,
type: "success"
});
setLockedOutState(false);
onResetAllLockouts();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to clear lockouts. Please try again.",
type: "error"
});
}
}
if (isPending || clientSecretsPending) {
return (
<div className="flex w-full items-center justify-center">
@@ -85,6 +115,41 @@ export const ViewIdentityUniversalAuthContent = ({
<IdentityAuthFieldDisplay label="Client Secret Trusted IPs">
{data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout">
{data.lockoutEnabled ? "Enabled" : "Disabled"}
</IdentityAuthFieldDisplay>
{data.lockoutEnabled && (
<>
<div className="col-span-2 mt-3 flex justify-between border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Lockout Options</span>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed || !lockedOutState || isClearLockoutsPending}
size="xs"
onClick={() => clearLockouts()}
isLoading={isClearLockoutsPending}
colorSchema="secondary"
>
Reset All Lockouts
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthFieldDisplay label="Lockout Threshold">
{data.lockoutThreshold}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Duration">
{ms(data.lockoutDurationSeconds * 1000, { long: true })}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Counter Reset">
{ms(data.lockoutCounterResetSeconds * 1000, { long: true })}
</IdentityAuthFieldDisplay>
</>
)}
<div className="col-span-2 my-3">
<div className="mb-3 border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Client ID</span>
@@ -9,4 +9,6 @@ export type ViewAuthMethodProps = {
state?: boolean
) => void;
popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>;
lockedOut: boolean;
onResetAllLockouts: () => void;
};
@@ -1,70 +1,19 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { getObjectFromSeconds } from "@app/helpers/datetime";
import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
// Helper function to convert duration to seconds
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
switch (unit) {
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
return 0;
}
};
// Helper function to convert seconds to form lifetime value and unit
const getFormLifetimeFromSeconds = (
totalSeconds: number | null | undefined
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
const DEFAULT_LIFETIME_VALUE = 30;
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
if (totalSeconds == null || totalSeconds <= 0) {
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
}
const secondsInDay = 24 * 60 * 60;
const secondsInHour = 60 * 60;
const secondsInMinute = 60;
if (totalSeconds % secondsInDay === 0) {
const value = totalSeconds / secondsInDay;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
}
if (totalSeconds % secondsInHour === 0) {
const value = totalSeconds / secondsInHour;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
}
if (totalSeconds % secondsInMinute === 0) {
const value = totalSeconds / secondsInMinute;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
}
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
};
const formSchema = z
.object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
@@ -77,34 +26,20 @@ const formSchema = z
.superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data;
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
const durationInSeconds = ms(`${maxLifetimeValue}${maxLifetimeUnit}`) / 1000;
// Check max limit
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
let message = "Duration exceeds maximum allowed limit";
if (maxLifetimeUnit === "m") {
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
} else if (maxLifetimeUnit === "h") {
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
} else if (maxLifetimeUnit === "d") {
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
message: "Duration exceeds a maximum of 30 days",
path: ["maxLifetimeValue"]
});
}
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
message: "Duration must be at least 5 minutes",
path: ["maxLifetimeValue"]
});
}
@@ -122,10 +57,14 @@ export const OrgSecretShareLimitSection = () => {
const { currentOrg } = useOrganization();
const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
const initialLifetime = getObjectFromSeconds(currentOrg?.maxSharedSecretLifetime, [
"m",
"h",
"d"
]);
return {
maxLifetimeValue: initialLifetime.maxLifetimeValue,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
maxLifetimeValue: initialLifetime.value,
maxLifetimeUnit: initialLifetime.unit as "m" | "h" | "d",
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1",
shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit)
};
@@ -152,10 +91,8 @@ export const OrgSecretShareLimitSection = () => {
const handleFormSubmit = async (formData: TForm) => {
try {
const maxSharedSecretLifetimeSeconds = durationToSeconds(
formData.maxLifetimeValue,
formData.maxLifetimeUnit
);
const maxSharedSecretLifetimeSeconds =
ms(`${formData.maxLifetimeValue}${formData.maxLifetimeUnit}`) / 1000;
await mutateAsync({
orgId: currentOrg.id,