diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts index 7b27296dd..a0e661d2d 100644 --- a/backend/src/db/migrations/20250815022242_identity-lockouts.ts +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -4,22 +4,48 @@ import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { - await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { - t.boolean("lockoutEnabled").notNullable().defaultTo(true); - t.integer("lockoutThreshold").notNullable().defaultTo(3); - t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) - t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, async (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + } }); } } export async function down(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { - t.dropColumn("lockoutEnabled"); - t.dropColumn("lockoutThreshold"); - t.dropColumn("lockoutDuration"); - t.dropColumn("lockoutCounterReset"); + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDuration"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterReset"); + } }); } } diff --git a/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts new file mode 100644 index 000000000..535e6de7c --- /dev/null +++ b/backend/src/db/migrations/20250819081226_identity-lockouts-ldap.ts @@ -0,0 +1,51 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityLdapAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityLdapAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityLdapAuth, (t) => { + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDuration"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterReset"); + } + }); + } +} diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts index 3e4d88649..3a89fdd51 100644 --- a/backend/src/db/schemas/identity-ldap-auths.ts +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -26,7 +26,11 @@ export const IdentityLdapAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), accessTokenPeriod: z.coerce.number().default(0), - templateId: z.string().uuid().nullable().optional() + templateId: z.string().uuid().nullable().optional(), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z.number().default(3), + lockoutDuration: z.number().default(300), + lockoutCounterReset: z.number().default(30) }); export type TIdentityLdapAuths = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 622bac031..21c5d6ba1 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -199,6 +199,7 @@ export enum EventType { 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", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", @@ -1369,6 +1370,10 @@ interface AddIdentityLdapAuthEvent { allowedFields?: TAllowedFields[]; url: string; templateId?: string | null; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; } @@ -1383,6 +1388,10 @@ interface UpdateIdentityLdapAuthEvent { allowedFields?: TAllowedFields[]; url?: string; templateId?: string | null; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; }; } @@ -1400,6 +1409,13 @@ interface RevokeIdentityLdapAuthEvent { }; } +interface ClearIdentityLdapAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOidcAuthEvent { type: EventType.LOGIN_IDENTITY_OIDC_AUTH; metadata: { @@ -3553,6 +3569,7 @@ export type Event = | UpdateIdentityLdapAuthEvent | GetIdentityLdapAuthEvent | RevokeIdentityLdapAuthEvent + | ClearIdentityLdapAuthLockoutsEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f520d87da..81bac6f85 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -240,7 +240,11 @@ export const LDAP_AUTH = { accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.", - accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDuration: "How long an identity auth method lockout lasts.", + lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { identityId: "The ID of the identity to update the configuration for.", @@ -255,13 +259,20 @@ export const LDAP_AUTH = { accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", - templateId: "The ID of the identity auth template to update the configuration to." + templateId: "The ID of the identity auth template to update the configuration to.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDuration: "How long an identity auth method lockout lasts.", + lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the configuration for." }, REVOKE: { identityId: "The ID of the identity to revoke the configuration for." + }, + CLEAR_CLIENT_LOCKOUTS: { + identityId: "The ID of the identity to clear the client lockouts from." } } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0455a4da0..c6ec3e3ec 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1612,7 +1612,8 @@ export const registerRoutes = async ( identityOrgMembershipDAL, licenseService, identityDAL, - identityAuthTemplateDAL + identityAuthTemplateDAL, + keyStore }); const dynamicSecretProviders = buildDynamicSecretProviders({ diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 5d3612bf5..5931204c1 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -135,19 +135,41 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) }) } }, - preValidation: passport.authenticate("ldapauth", { - failWithError: true, - session: false - }) as any, + preValidation: [ + async (req, res) => { + await server.services.identityLdapAuth.checkLdapLockout({ + identityId: req.body.identityId, + username: req.body.username + }); - errorHandler: (error) => { - if (error.name === "AuthenticationError") { - throw new UnauthorizedError({ message: "Invalid credentials" }); + try { + const passportRes = await ( + passport.authenticate("ldapauth", { + failWithError: true, + session: false + }) as any + )(req, res); + + await server.services.identityLdapAuth.resetLdapLockoutCounter({ + identityId: req.body.identityId, + username: req.body.username + }); + + return passportRes; + } catch (error) { + if ((error as any).status === 401) { + await server.services.identityLdapAuth.incrementLdapLockout({ + identityId: req.body.identityId, + username: req.body.username + }); + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } + + throw error; + } } - - throw error; - }, - + ], handler: async (req) => { if (!req.passportMachineIdentity?.identityId) { throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." }); @@ -241,7 +263,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .int() .min(0) .default(0) - .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), + lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).default(300).describe(LDAP_AUTH.ATTACH.lockoutDuration), + lockoutCounterReset: z.number().min(5).max(3600).default(30).describe(LDAP_AUTH.ATTACH.lockoutCounterReset) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -291,7 +317,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .int() .min(0) .default(0) - .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit) + .describe(LDAP_AUTH.ATTACH.accessTokenNumUsesLimit), + lockoutEnabled: z.boolean().default(true).describe(LDAP_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(LDAP_AUTH.ATTACH.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).default(300).describe(LDAP_AUTH.ATTACH.lockoutDuration), + lockoutCounterReset: z.number().min(5).max(3600).default(30).describe(LDAP_AUTH.ATTACH.lockoutCounterReset) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -331,7 +361,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, allowedFields: req.body.allowedFields, - templateId: identityLdapAuth.templateId + templateId: identityLdapAuth.templateId, + lockoutEnabled: identityLdapAuth.lockoutEnabled, + lockoutThreshold: identityLdapAuth.lockoutThreshold, + lockoutDuration: identityLdapAuth.lockoutDuration, + lockoutCounterReset: identityLdapAuth.lockoutCounterReset } } }); @@ -395,7 +429,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) .max(315360000) .min(0) .optional() - .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL) + .describe(LDAP_AUTH.UPDATE.accessTokenMaxTTL), + lockoutEnabled: z.boolean().optional().describe(LDAP_AUTH.UPDATE.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).optional().describe(LDAP_AUTH.UPDATE.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).optional().describe(LDAP_AUTH.UPDATE.lockoutDuration), + lockoutCounterReset: z.number().min(5).max(3600).optional().describe(LDAP_AUTH.UPDATE.lockoutCounterReset) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -434,7 +472,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], allowedFields: req.body.allowedFields, - templateId: identityLdapAuth.templateId + templateId: identityLdapAuth.templateId, + lockoutEnabled: identityLdapAuth.lockoutEnabled, + lockoutThreshold: identityLdapAuth.lockoutThreshold, + lockoutDuration: identityLdapAuth.lockoutDuration, + lockoutCounterReset: identityLdapAuth.lockoutCounterReset } } }); @@ -553,4 +595,53 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) return { identityLdapAuth }; } }); + + server.route({ + method: "POST", + url: "/ldap-auth/identities/:identityId/clear-lockouts", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Clear LDAP Auth Lockouts for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(LDAP_AUTH.CLEAR_CLIENT_LOCKOUTS.identityId) + }), + response: { + 200: z.object({ + deleted: z.number() + }) + } + }, + handler: async (req) => { + const clearLockoutsData = await server.services.identityLdapAuth.clearLdapAuthLockouts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: clearLockoutsData.orgId, + event: { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS, + metadata: { + identityId: clearLockoutsData.identityId + } + } + }); + + return clearLockoutsData; + } + }); }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 47188e26d..9445b42e8 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -15,9 +15,10 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -32,8 +33,12 @@ import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal"; import { AllowedFieldsSchema, TAttachLdapAuthDTO, + TCheckLdapAuthLockoutDTO, + TClearLdapAuthLockoutsDTO, TGetLdapAuthDTO, + TIncrementLdapAuthLockoutDTO, TLoginLdapAuthDTO, + TResetLdapAuthLockoutCounterDTO, TRevokeLdapAuthDTO, TUpdateLdapAuthDTO } from "./identity-ldap-auth-types"; @@ -50,10 +55,16 @@ type TIdentityLdapAuthServiceFactoryDep = { kmsService: TKmsServiceFactory; identityDAL: TIdentityDALFactory; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; + keyStore: Pick; }; export type TIdentityLdapAuthServiceFactory = ReturnType; +type LockoutObject = { + lockedOut: boolean; + failedAttempts: number; +}; + export const identityLdapAuthServiceFactory = ({ identityAccessTokenDAL, identityDAL, @@ -62,7 +73,8 @@ export const identityLdapAuthServiceFactory = ({ licenseService, permissionService, kmsService, - identityAuthTemplateDAL + identityAuthTemplateDAL, + keyStore }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); @@ -126,13 +138,17 @@ export const identityLdapAuthServiceFactory = ({ const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) { - throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { - throw new NotFoundError({ message: `Failed to find LDAP auth for identity with ID ${identityId}` }); + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } const plan = await licenseService.getPlan(identityMembershipOrg.orgId); @@ -204,7 +220,11 @@ export const identityLdapAuthServiceFactory = ({ actor, actorOrgId, isActorSuperAdmin, - allowedFields + allowedFields, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: TAttachLdapAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -337,7 +357,11 @@ export const identityLdapAuthServiceFactory = ({ accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, - templateId + templateId, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }, tx ); @@ -363,7 +387,11 @@ export const identityLdapAuthServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: TUpdateLdapAuthDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -511,7 +539,11 @@ export const identityLdapAuthServiceFactory = ({ accessTokenNumUsesLimit, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) - : undefined + : undefined, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }); return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; @@ -611,12 +643,104 @@ export const identityLdapAuthServiceFactory = ({ return revokedIdentityLdapAuth; }; + const checkLdapLockout = async ({ identityId, username }: TCheckLdapAuthLockoutDTO) => { + const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}`; + + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as LockoutObject; + + if (lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + } + }; + + const incrementLdapLockout = async ({ identityId, username }: TIncrementLdapAuthLockoutDTO) => { + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + if (!identityLdapAuth) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + + if (identityLdapAuth.lockoutEnabled) { + const LOCKOUT_KEY = `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}`; + + let lockout: LockoutObject = { + lockedOut: false, + failedAttempts: 0 + }; + + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityLdapAuth.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityLdapAuth.lockoutDuration : identityLdapAuth.lockoutCounterReset, + JSON.stringify(lockout) + ); + } + }; + + const resetLdapLockoutCounter = async ({ identityId, username }: TResetLdapAuthLockoutCounterDTO) => { + await keyStore.deleteItem( + `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:${username.trim().toLowerCase()}` + ); + }; + + const clearLdapAuthLockouts = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TClearLdapAuthLockoutsDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have ldap auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const deleted = await keyStore.deleteItems({ + pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*` + }); + + return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + }; + return { attachLdapAuth, getLdapConfig, updateLdapAuth, login, revokeIdentityLdapAuth, - getLdapAuth + getLdapAuth, + checkLdapLockout, + incrementLdapLockout, + resetLdapLockoutCounter, + clearLdapAuthLockouts }; }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index 8629763bb..543df1b2a 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -27,6 +27,10 @@ export type TAttachLdapAuthDTO = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; } & Omit; export type TUpdateLdapAuthDTO = { @@ -43,6 +47,10 @@ export type TUpdateLdapAuthDTO = { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; } & Omit; export type TGetLdapAuthDTO = { @@ -56,3 +64,22 @@ export type TLoginLdapAuthDTO = { export type TRevokeLdapAuthDTO = { identityId: string; } & Omit; + +export type TClearLdapAuthLockoutsDTO = { + identityId: string; +} & Omit; + +export type TCheckLdapAuthLockoutDTO = { + identityId: string; + username: string; +}; + +export type TIncrementLdapAuthLockoutDTO = { + identityId: string; + username: string; +}; + +export type TResetLdapAuthLockoutCounterDTO = { + identityId: string; + username: string; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 186a8e539..9b9280cf0 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -192,6 +192,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity", [EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved LDAP Auth for identity", [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity", + [EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS]: "Clear LDAP Auth lockouts", [EventType.SECRET_SCANNING_DATA_SOURCE_LIST]: "List Secret Scanning Data Sources", [EventType.SECRET_SCANNING_DATA_SOURCE_CREATE]: "Create Secret Scanning Data Source", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 4fe8948dd..13af79788 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -54,6 +54,7 @@ export enum EventType { UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth", + CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-ldap-auth-lockouts", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 1ca819e60..a0b1ff50a 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -874,6 +874,13 @@ interface IntegrationSyncedEvent { }; } +interface ClearIdentityLdapAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -958,7 +965,8 @@ export type Event = | GetCertificateTemplateEstConfig | UpdateProjectWorkflowIntegrationConfig | GetProjectWorkflowIntegrationConfig - | IntegrationSyncedEvent; + | IntegrationSyncedEvent + | ClearIdentityLdapAuthLockoutsEvent; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index ca903bb48..2cf50d0d0 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -18,6 +18,7 @@ import { AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, + ClearIdentityLdapAuthLockoutsDTO, ClearIdentityUniversalAuthLockoutsDTO, ClientSecretData, CreateIdentityDTO, @@ -1432,7 +1433,11 @@ export const useAddIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }) => { const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, @@ -1448,7 +1453,11 @@ export const useAddIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset } ); return data.identityLdapAuth; @@ -1481,7 +1490,11 @@ export const useUpdateIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }) => { const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( `/api/v1/auth/ldap-auth/identities/${identityId}`, @@ -1497,7 +1510,11 @@ export const useUpdateIdentityLdapAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset } ); return data.identityLdapAuth; @@ -1532,3 +1549,22 @@ export const useDeleteIdentityLdapAuth = () => { } }); }; + +export const useClearIdentityLdapAuthLockouts = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { deleted } + } = await apiRequest.post<{ deleted: number }>( + `/api/v1/auth/ldap-auth/identities/${identityId}/clear-lockouts` + ); + return deleted; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityLdapAuth(identityId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 8f5ec2b8e..ba9bb63fc 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -603,6 +603,11 @@ export type AddIdentityLdapAuthDTO = { accessTokenTrustedIps: { ipAddress: string; }[]; + + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; export type UpdateIdentityLdapAuthDTO = { @@ -625,6 +630,11 @@ export type UpdateIdentityLdapAuthDTO = { accessTokenTrustedIps?: { ipAddress: string; }[]; + + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; }; export type DeleteIdentityLdapAuthDTO = { @@ -650,6 +660,15 @@ export type IdentityLdapAuth = { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; + + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; +}; + +export type ClearIdentityLdapAuthLockoutsDTO = { + identityId: string; }; export type AddIdentityTokenAuthDTO = { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx index ee0cddc5c..1930f34c9 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -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 (
{ - 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); })} > setTabValue(value as IdentityFormTab)}> Configuration + Lockout Advanced @@ -691,6 +774,15 @@ export const IdentityLdapAuthForm = ({ )} /> + { - 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; @@ -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 = ({ )} /> - -
- { - return ( - - - Lockout {value ? "Enabled" : "Disabled"} - - - ); - }} - /> -
- { - return ( - - - - ); - }} - /> -
- { - return ( - - - - ); - }} - /> - ( - - - - )} - /> -
-
- { - return ( - - - - ); - }} - /> - ( - - - - )} - /> -
-
-
-
- + {clientSecretTrustedIpsFields.map(({ id }, index) => (
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx new file mode 100644 index 000000000..5b6acf6f1 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/LockoutTab.tsx @@ -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; + lockoutEnabled: boolean; + lockoutThreshold: string; + lockoutDurationValue: string; + lockoutDurationUnit: "s" | "m" | "h" | "d"; + lockoutCounterResetValue: string; + lockoutCounterResetUnit: "s" | "m" | "h"; +}) => { + return ( + +
+ { + return ( + + + Lockout {value ? "Enabled" : "Disabled"} + + + ); + }} + /> +
+ { + return ( + + + + ); + }} + /> +
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts new file mode 100644 index 000000000..e84989e82 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/lockout/super-refine.ts @@ -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"] + }); + } + } + } +} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx new file mode 100644 index 000000000..b2f39224e --- /dev/null +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { UseMutationResult } from "@tanstack/react-query"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; + +import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; + +export const LockoutFields = ({ + clearLockoutsResult, + lockedOut, + identityId, + data +}: { + clearLockoutsResult: UseMutationResult; + lockedOut: boolean; + identityId: string; + data: { + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; + }; +}) => { + const { mutateAsync, isPending } = clearLockoutsResult; + + const [lockedOutState, setLockedOutState] = useState(lockedOut); + + async function clearLockouts() { + try { + const deleted = await mutateAsync({ identityId }); + createNotification({ + text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, + type: "success" + }); + setLockedOutState(false); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to clear lockouts. Please try again.", + type: "error" + }); + } + } + + return ( + <> +
+ Lockout Options + + {(isAllowed) => ( + + )} + +
+ + {data.lockoutEnabled ? "Enabled" : "Disabled"} + + + {data.lockoutThreshold} + + + {data.lockoutDuration} seconds + + + {data.lockoutCounterReset} seconds + + + ); +}; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx index f732fd5ed..2d6773484 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx @@ -2,11 +2,12 @@ import { faBan, faEye } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Badge, EmptyState, Spinner, Tooltip } from "@app/components/v2"; -import { useGetIdentityLdapAuth } from "@app/hooks/api"; +import { useClearIdentityLdapAuthLockouts, useGetIdentityLdapAuth } from "@app/hooks/api"; import { IdentityLdapAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm"; import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; +import { LockoutFields } from "./IdentityAuthLockoutFields"; import { ViewAuthMethodProps } from "./types"; export const ViewIdentityLdapAuthContent = ({ @@ -14,9 +15,11 @@ export const ViewIdentityLdapAuthContent = ({ handlePopUpToggle, handlePopUpOpen, onDelete, - popUp + popUp, + lockedOut }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityLdapAuth(identityId); + const clearLockoutsResult = useClearIdentityLdapAuthLockouts(); if (isPending) { return ( @@ -98,6 +101,12 @@ export const ViewIdentityLdapAuthContent = ({ )} + ); }; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index f31e59cd8..7ef8790c2 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -1,11 +1,7 @@ -import { useState } from "react"; import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -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 { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { useClearIdentityUniversalAuthLockouts, @@ -15,6 +11,7 @@ import { import { IdentityUniversalAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; +import { LockoutFields } from "./IdentityAuthLockoutFields"; import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable"; import { ViewAuthMethodProps } from "./types"; import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; @@ -30,32 +27,12 @@ export const ViewIdentityUniversalAuthContent = ({ const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data: clientSecrets = [], isPending: clientSecretsPending } = useGetIdentityUniversalAuthClientSecrets(identityId); - const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } = - useClearIdentityUniversalAuthLockouts(); - - const [lockedOutState, setLockedOutState] = useState(lockedOut); + const clearLockoutsResult = useClearIdentityUniversalAuthLockouts(); const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset({ 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); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to clear lockouts. Please try again.", - type: "error" - }); - } - } - if (isPending || clientSecretsPending) { return (
@@ -112,34 +89,12 @@ export const ViewIdentityUniversalAuthContent = ({ {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} -
- Lockout Options - - {(isAllowed) => ( - - )} - -
- - {data.lockoutEnabled ? "Enabled" : "Disabled"} - - - {data.lockoutThreshold} - - - {data.lockoutDuration} seconds - - - {data.lockoutCounterReset} seconds - +
Client ID