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
@@ -4,22 +4,48 @@ import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) {
await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled");
t.boolean("lockoutEnabled").notNullable().defaultTo(true); const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold");
t.integer("lockoutThreshold").notNullable().defaultTo(3); const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration");
t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset");
t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds
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<void> { export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { 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) => { await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => {
t.dropColumn("lockoutEnabled"); if (hasLockoutEnabled) {
t.dropColumn("lockoutThreshold"); t.dropColumn("lockoutEnabled");
t.dropColumn("lockoutDuration"); }
t.dropColumn("lockoutCounterReset"); if (hasLockoutThreshold) {
t.dropColumn("lockoutThreshold");
}
if (hasLockoutDuration) {
t.dropColumn("lockoutDuration");
}
if (hasLockoutCounterReset) {
t.dropColumn("lockoutCounterReset");
}
}); });
} }
} }
@@ -0,0 +1,51 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
}
});
}
}
@@ -26,7 +26,11 @@ export const IdentityLdapAuthsSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
accessTokenPeriod: z.coerce.number().default(0), 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<typeof IdentityLdapAuthsSchema>; export type TIdentityLdapAuths = z.infer<typeof IdentityLdapAuthsSchema>;
@@ -199,6 +199,7 @@ export enum EventType {
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-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_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_SECRETS = "get-identity-universal-auth-client-secret",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id",
@@ -1369,6 +1370,10 @@ interface AddIdentityLdapAuthEvent {
allowedFields?: TAllowedFields[]; allowedFields?: TAllowedFields[];
url: string; url: string;
templateId?: string | null; templateId?: string | null;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDuration: number;
lockoutCounterReset: number;
}; };
} }
@@ -1383,6 +1388,10 @@ interface UpdateIdentityLdapAuthEvent {
allowedFields?: TAllowedFields[]; allowedFields?: TAllowedFields[];
url?: string; url?: string;
templateId?: string | null; 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 { interface LoginIdentityOidcAuthEvent {
type: EventType.LOGIN_IDENTITY_OIDC_AUTH; type: EventType.LOGIN_IDENTITY_OIDC_AUTH;
metadata: { metadata: {
@@ -3553,6 +3569,7 @@ export type Event =
| UpdateIdentityLdapAuthEvent | UpdateIdentityLdapAuthEvent
| GetIdentityLdapAuthEvent | GetIdentityLdapAuthEvent
| RevokeIdentityLdapAuthEvent | RevokeIdentityLdapAuthEvent
| ClearIdentityLdapAuthLockoutsEvent
| CreateEnvironmentEvent | CreateEnvironmentEvent
| GetEnvironmentEvent | GetEnvironmentEvent
| UpdateEnvironmentEvent | UpdateEnvironmentEvent
+13 -2
View File
@@ -240,7 +240,11 @@ export const LDAP_AUTH = {
accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenTTL: "The lifetime for an access token in seconds.",
accessTokenMaxTTL: "The maximum 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.", 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: { UPDATE: {
identityId: "The ID of the identity to update the configuration for.", 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.", 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.", 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.", 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: { RETRIEVE: {
identityId: "The ID of the identity to retrieve the configuration for." identityId: "The ID of the identity to retrieve the configuration for."
}, },
REVOKE: { REVOKE: {
identityId: "The ID of the identity to revoke the configuration for." 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; } as const;
+2 -1
View File
@@ -1612,7 +1612,8 @@ export const registerRoutes = async (
identityOrgMembershipDAL, identityOrgMembershipDAL,
licenseService, licenseService,
identityDAL, identityDAL,
identityAuthTemplateDAL identityAuthTemplateDAL,
keyStore
}); });
const dynamicSecretProviders = buildDynamicSecretProviders({ const dynamicSecretProviders = buildDynamicSecretProviders({
@@ -135,19 +135,41 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
}) })
} }
}, },
preValidation: passport.authenticate("ldapauth", { preValidation: [
failWithError: true, async (req, res) => {
session: false await server.services.identityLdapAuth.checkLdapLockout({
}) as any, identityId: req.body.identityId,
username: req.body.username
});
errorHandler: (error) => { try {
if (error.name === "AuthenticationError") { const passportRes = await (
throw new UnauthorizedError({ message: "Invalid credentials" }); 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) => { handler: async (req) => {
if (!req.passportMachineIdentity?.identityId) { if (!req.passportMachineIdentity?.identityId) {
throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." }); throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." });
@@ -241,7 +263,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
.int() .int()
.min(0) .min(0)
.default(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( .refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL, (val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
@@ -291,7 +317,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
.int() .int()
.min(0) .min(0)
.default(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( .refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL, (val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
@@ -331,7 +361,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
accessTokenTTL: identityLdapAuth.accessTokenTTL, accessTokenTTL: identityLdapAuth.accessTokenTTL,
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit,
allowedFields: req.body.allowedFields, 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) .max(315360000)
.min(0) .min(0)
.optional() .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( .refine(
(val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
@@ -434,7 +472,11 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider)
accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit,
accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
allowedFields: req.body.allowedFields, 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 }; 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;
}
});
}; };
@@ -15,9 +15,10 @@ import {
validatePrivilegeChangeOperation validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns"; } from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto"; 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 { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { ActorType, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthTokenType } from "../auth/auth-type";
@@ -32,8 +33,12 @@ import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal";
import { import {
AllowedFieldsSchema, AllowedFieldsSchema,
TAttachLdapAuthDTO, TAttachLdapAuthDTO,
TCheckLdapAuthLockoutDTO,
TClearLdapAuthLockoutsDTO,
TGetLdapAuthDTO, TGetLdapAuthDTO,
TIncrementLdapAuthLockoutDTO,
TLoginLdapAuthDTO, TLoginLdapAuthDTO,
TResetLdapAuthLockoutCounterDTO,
TRevokeLdapAuthDTO, TRevokeLdapAuthDTO,
TUpdateLdapAuthDTO TUpdateLdapAuthDTO
} from "./identity-ldap-auth-types"; } from "./identity-ldap-auth-types";
@@ -50,10 +55,16 @@ type TIdentityLdapAuthServiceFactoryDep = {
kmsService: TKmsServiceFactory; kmsService: TKmsServiceFactory;
identityDAL: TIdentityDALFactory; identityDAL: TIdentityDALFactory;
identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory;
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems">;
}; };
export type TIdentityLdapAuthServiceFactory = ReturnType<typeof identityLdapAuthServiceFactory>; export type TIdentityLdapAuthServiceFactory = ReturnType<typeof identityLdapAuthServiceFactory>;
type LockoutObject = {
lockedOut: boolean;
failedAttempts: number;
};
export const identityLdapAuthServiceFactory = ({ export const identityLdapAuthServiceFactory = ({
identityAccessTokenDAL, identityAccessTokenDAL,
identityDAL, identityDAL,
@@ -62,7 +73,8 @@ export const identityLdapAuthServiceFactory = ({
licenseService, licenseService,
permissionService, permissionService,
kmsService, kmsService,
identityAuthTemplateDAL identityAuthTemplateDAL,
keyStore
}: TIdentityLdapAuthServiceFactoryDep) => { }: TIdentityLdapAuthServiceFactoryDep) => {
const getLdapConfig = async (identityId: string) => { const getLdapConfig = async (identityId: string) => {
const identity = await identityDAL.findOne({ id: identityId }); const identity = await identityDAL.findOne({ id: identityId });
@@ -126,13 +138,17 @@ export const identityLdapAuthServiceFactory = ({
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) { 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 }); const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId });
if (!identityLdapAuth) { 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); const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
@@ -204,7 +220,11 @@ export const identityLdapAuthServiceFactory = ({
actor, actor,
actorOrgId, actorOrgId,
isActorSuperAdmin, isActorSuperAdmin,
allowedFields allowedFields,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}: TAttachLdapAuthDTO) => { }: TAttachLdapAuthDTO) => {
await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
@@ -337,7 +357,11 @@ export const identityLdapAuthServiceFactory = ({
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps),
allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined,
templateId templateId,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}, },
tx tx
); );
@@ -363,7 +387,11 @@ export const identityLdapAuthServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}: TUpdateLdapAuthDTO) => { }: TUpdateLdapAuthDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
@@ -511,7 +539,11 @@ export const identityLdapAuthServiceFactory = ({
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps) ? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined : undefined,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}); });
return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId };
@@ -611,12 +643,104 @@ export const identityLdapAuthServiceFactory = ({
return revokedIdentityLdapAuth; 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 { return {
attachLdapAuth, attachLdapAuth,
getLdapConfig, getLdapConfig,
updateLdapAuth, updateLdapAuth,
login, login,
revokeIdentityLdapAuth, revokeIdentityLdapAuth,
getLdapAuth getLdapAuth,
checkLdapLockout,
incrementLdapLockout,
resetLdapLockoutCounter,
clearLdapAuthLockouts
}; };
}; };
@@ -27,6 +27,10 @@ export type TAttachLdapAuthDTO = {
accessTokenNumUsesLimit: number; accessTokenNumUsesLimit: number;
accessTokenTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[];
isActorSuperAdmin?: boolean; isActorSuperAdmin?: boolean;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDuration: number;
lockoutCounterReset: number;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateLdapAuthDTO = { export type TUpdateLdapAuthDTO = {
@@ -43,6 +47,10 @@ export type TUpdateLdapAuthDTO = {
accessTokenMaxTTL?: number; accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number; accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDuration?: number;
lockoutCounterReset?: number;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetLdapAuthDTO = { export type TGetLdapAuthDTO = {
@@ -56,3 +64,22 @@ export type TLoginLdapAuthDTO = {
export type TRevokeLdapAuthDTO = { export type TRevokeLdapAuthDTO = {
identityId: string; identityId: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TClearLdapAuthLockoutsDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCheckLdapAuthLockoutDTO = {
identityId: string;
username: string;
};
export type TIncrementLdapAuthLockoutDTO = {
identityId: string;
username: string;
};
export type TResetLdapAuthLockoutCounterDTO = {
identityId: string;
username: string;
};
@@ -192,6 +192,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity", [EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity",
[EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved 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.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_LIST]: "List Secret Scanning Data Sources",
[EventType.SECRET_SCANNING_DATA_SOURCE_CREATE]: "Create Secret Scanning Data Source", [EventType.SECRET_SCANNING_DATA_SOURCE_CREATE]: "Create Secret Scanning Data Source",
@@ -54,6 +54,7 @@ export enum EventType {
UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth",
GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth",
REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth", REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth",
CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-ldap-auth-lockouts",
CREATE_ENVIRONMENT = "create-environment", CREATE_ENVIRONMENT = "create-environment",
UPDATE_ENVIRONMENT = "update-environment", UPDATE_ENVIRONMENT = "update-environment",
+9 -1
View File
@@ -874,6 +874,13 @@ interface IntegrationSyncedEvent {
}; };
} }
interface ClearIdentityLdapAuthLockoutsEvent {
type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS;
metadata: {
identityId: string;
};
}
export type Event = export type Event =
| GetSecretsEvent | GetSecretsEvent
| GetSecretEvent | GetSecretEvent
@@ -958,7 +965,8 @@ export type Event =
| GetCertificateTemplateEstConfig | GetCertificateTemplateEstConfig
| UpdateProjectWorkflowIntegrationConfig | UpdateProjectWorkflowIntegrationConfig
| GetProjectWorkflowIntegrationConfig | GetProjectWorkflowIntegrationConfig
| IntegrationSyncedEvent; | IntegrationSyncedEvent
| ClearIdentityLdapAuthLockoutsEvent;
export type AuditLog = { export type AuditLog = {
id: string; id: string;
@@ -18,6 +18,7 @@ import {
AddIdentityTlsCertAuthDTO, AddIdentityTlsCertAuthDTO,
AddIdentityTokenAuthDTO, AddIdentityTokenAuthDTO,
AddIdentityUniversalAuthDTO, AddIdentityUniversalAuthDTO,
ClearIdentityLdapAuthLockoutsDTO,
ClearIdentityUniversalAuthLockoutsDTO, ClearIdentityUniversalAuthLockoutsDTO,
ClientSecretData, ClientSecretData,
CreateIdentityDTO, CreateIdentityDTO,
@@ -1432,7 +1433,11 @@ export const useAddIdentityLdapAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}) => { }) => {
const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>(
`/api/v1/auth/ldap-auth/identities/${identityId}`, `/api/v1/auth/ldap-auth/identities/${identityId}`,
@@ -1448,7 +1453,11 @@ export const useAddIdentityLdapAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
} }
); );
return data.identityLdapAuth; return data.identityLdapAuth;
@@ -1481,7 +1490,11 @@ export const useUpdateIdentityLdapAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}) => { }) => {
const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>(
`/api/v1/auth/ldap-auth/identities/${identityId}`, `/api/v1/auth/ldap-auth/identities/${identityId}`,
@@ -1497,7 +1510,11 @@ export const useUpdateIdentityLdapAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
} }
); );
return data.identityLdapAuth; return data.identityLdapAuth;
@@ -1532,3 +1549,22 @@ export const useDeleteIdentityLdapAuth = () => {
} }
}); });
}; };
export const useClearIdentityLdapAuthLockouts = () => {
const queryClient = useQueryClient();
return useMutation<number, object, ClearIdentityLdapAuthLockoutsDTO>({
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)
});
}
});
};
@@ -603,6 +603,11 @@ export type AddIdentityLdapAuthDTO = {
accessTokenTrustedIps: { accessTokenTrustedIps: {
ipAddress: string; ipAddress: string;
}[]; }[];
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDuration: number;
lockoutCounterReset: number;
}; };
export type UpdateIdentityLdapAuthDTO = { export type UpdateIdentityLdapAuthDTO = {
@@ -625,6 +630,11 @@ export type UpdateIdentityLdapAuthDTO = {
accessTokenTrustedIps?: { accessTokenTrustedIps?: {
ipAddress: string; ipAddress: string;
}[]; }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDuration?: number;
lockoutCounterReset?: number;
}; };
export type DeleteIdentityLdapAuthDTO = { export type DeleteIdentityLdapAuthDTO = {
@@ -650,6 +660,15 @@ export type IdentityLdapAuth = {
accessTokenMaxTTL: number; accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number; accessTokenNumUsesLimit: number;
accessTokenTrustedIps: IdentityTrustedIp[]; accessTokenTrustedIps: IdentityTrustedIp[];
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDuration: number;
lockoutCounterReset: number;
};
export type ClearIdentityLdapAuthLockoutsDTO = {
identityId: string;
}; };
export type AddIdentityTokenAuthDTO = { export type AddIdentityTokenAuthDTO = {
@@ -25,6 +25,7 @@ import {
OrgPermissionMachineIdentityAuthTemplateActions, OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSubjects OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types"; } from "@app/context/OrgPermissionContext/types";
import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime";
import { import {
MachineIdentityAuthMethod, MachineIdentityAuthMethod,
useAddIdentityLdapAuth, useAddIdentityLdapAuth,
@@ -35,6 +36,8 @@ import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { useGetAvailableTemplates } from "@app/hooks/api/identityAuthTemplates/queries"; import { useGetAvailableTemplates } from "@app/hooks/api/identityAuthTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { LockoutTab } from "./lockout/LockoutTab";
import { superRefineLockout } from "./lockout/super-refine";
import { IdentityFormTab } from "./types"; import { IdentityFormTab } from "./types";
const schema = z const schema = z
@@ -74,9 +77,28 @@ const schema = z
ipAddress: z.string().max(50) 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) => { .superRefine((data, ctx) => {
superRefineLockout(data, ctx);
// Validation based on scope // Validation based on scope
if (data.scope === "template") { if (data.scope === "template") {
if (!data.templateId) { if (!data.templateId) {
@@ -178,12 +200,25 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL: "2592000", accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000", accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0", 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 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 { const {
fields: accessTokenTrustedIpsFields, fields: accessTokenTrustedIpsFields,
append: appendAccessTokenTrustedIp, append: appendAccessTokenTrustedIp,
@@ -210,6 +245,9 @@ export const IdentityLdapAuthForm = ({
if (data) { if (data) {
const detectedScope = determineScope(data); const detectedScope = determineScope(data);
const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration);
const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset);
reset({ reset({
scope: detectedScope, scope: detectedScope,
templateId: data.templateId || "", templateId: data.templateId || "",
@@ -229,7 +267,13 @@ export const IdentityLdapAuthForm = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` 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; return;
} }
@@ -247,7 +291,13 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL: "2592000", accessTokenTTL: "2592000",
accessTokenMaxTTL: "2592000", accessTokenMaxTTL: "2592000",
accessTokenNumUsesLimit: "0", 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]); }, [data, reset]);
@@ -275,9 +325,21 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationValue,
lockoutDurationUnit,
lockoutCounterResetValue,
lockoutCounterResetUnit
} = formData; } = formData;
const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit);
const lockoutCounterReset = durationToSeconds(
Number(lockoutCounterResetValue),
lockoutCounterResetUnit
);
const basePayload = { const basePayload = {
organizationId: orgId, organizationId: orgId,
identityId, identityId,
@@ -287,7 +349,11 @@ export const IdentityLdapAuthForm = ({
accessTokenTTL: Number(accessTokenTTL), accessTokenTTL: Number(accessTokenTTL),
accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDuration,
lockoutCounterReset
}; };
// Add scope-specific fields // Add scope-specific fields
@@ -327,7 +393,10 @@ export const IdentityLdapAuthForm = ({
return ( return (
<form <form
onSubmit={handleSubmit(onFormSubmit, (fields) => { onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue( const firstErrorField = Object.keys(fields)[0];
let tab = IdentityFormTab.Advanced;
if (
[ [
"scope", "scope",
"templateId", "templateId",
@@ -340,15 +409,29 @@ export const IdentityLdapAuthForm = ({
"allowedFields", "allowedFields",
"accessTokenMaxTTL", "accessTokenMaxTTL",
"accessTokenNumUsesLimit" "accessTokenNumUsesLimit"
].includes(Object.keys(fields)[0]) ].includes(firstErrorField)
? IdentityFormTab.Configuration ) {
: IdentityFormTab.Advanced 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)}> <Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}>
<TabList> <TabList>
<Tab value={IdentityFormTab.Configuration}>Configuration</Tab> <Tab value={IdentityFormTab.Configuration}>Configuration</Tab>
<Tab value={IdentityFormTab.Lockout}>Lockout</Tab>
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab> <Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList> </TabList>
<TabPanel value={IdentityFormTab.Configuration}> <TabPanel value={IdentityFormTab.Configuration}>
@@ -691,6 +774,15 @@ export const IdentityLdapAuthForm = ({
)} )}
/> />
</TabPanel> </TabPanel>
<LockoutTab
control={control}
lockoutEnabled={lockoutEnabledWatch}
lockoutThreshold={lockoutThresholdWatch}
lockoutDurationValue={lockoutDurationValueWatch}
lockoutDurationUnit={lockoutDurationUnitWatch}
lockoutCounterResetValue={lockoutCounterResetValueWatch}
lockoutCounterResetUnit={lockoutCounterResetUnitWatch}
/>
<TabPanel value={IdentityFormTab.Advanced}> <TabPanel value={IdentityFormTab.Advanced}>
<Controller <Controller
control={control} control={control}
@@ -11,9 +11,6 @@ import {
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
Select,
SelectItem,
Switch,
Tab, Tab,
TabList, TabList,
TabPanel, TabPanel,
@@ -29,6 +26,8 @@ import {
import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { IdentityTrustedIp } from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { LockoutTab } from "./lockout/LockoutTab";
import { superRefineLockout } from "./lockout/super-refine";
import { IdentityFormTab } from "./types"; import { IdentityFormTab } from "./types";
const schema = z const schema = z
@@ -82,63 +81,7 @@ const schema = z
}) })
}) })
.required() .required()
.superRefine((data, ctx) => { .superRefine(superRefineLockout);
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"]
});
}
});
export type FormData = z.infer<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -315,8 +258,8 @@ export const IdentityUniversalAuthForm = ({
accessTokenPeriod: Number(accessTokenPeriod), accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled, lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold), lockoutThreshold: Number(lockoutThreshold),
lockoutDuration: Number(lockoutDuration), lockoutDuration,
lockoutCounterReset: Number(lockoutCounterReset) lockoutCounterReset
}); });
} }
@@ -435,187 +378,15 @@ export const IdentityUniversalAuthForm = ({
)} )}
/> />
</TabPanel> </TabPanel>
<TabPanel value={IdentityFormTab.Lockout}> <LockoutTab
<div className="mb-3 flex flex-col"> control={control}
<Controller lockoutEnabled={lockoutEnabledWatch}
control={control} lockoutThreshold={lockoutThresholdWatch}
name="lockoutEnabled" lockoutDurationValue={lockoutDurationValueWatch}
defaultValue lockoutDurationUnit={lockoutDurationUnitWatch}
render={({ field: { value, onChange }, fieldState: { error } }) => { lockoutCounterResetValue={lockoutCounterResetValueWatch}
return ( lockoutCounterResetUnit={lockoutCounterResetUnitWatch}
<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>
<TabPanel value={IdentityFormTab.Advanced}> <TabPanel value={IdentityFormTab.Advanced}>
{clientSecretTrustedIpsFields.map(({ id }, index) => ( {clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}> <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"]
});
}
}
}
}
@@ -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<number, object, { identityId: string }, unknown>;
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 (
<>
<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 || isPending}
size="xs"
onClick={() => clearLockouts()}
isLoading={isPending}
colorSchema="secondary"
>
Clear All Lockouts
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthFieldDisplay label="Lockout">
{data.lockoutEnabled ? "Enabled" : "Disabled"}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Threshold">
{data.lockoutThreshold}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Duration">
{data.lockoutDuration} seconds
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Counter Reset">
{data.lockoutCounterReset} seconds
</IdentityAuthFieldDisplay>
</>
);
};
@@ -2,11 +2,12 @@ import { faBan, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, EmptyState, Spinner, Tooltip } from "@app/components/v2"; 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 { IdentityLdapAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm";
import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper"; import { ViewIdentityContentWrapper } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper";
import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
import { LockoutFields } from "./IdentityAuthLockoutFields";
import { ViewAuthMethodProps } from "./types"; import { ViewAuthMethodProps } from "./types";
export const ViewIdentityLdapAuthContent = ({ export const ViewIdentityLdapAuthContent = ({
@@ -14,9 +15,11 @@ export const ViewIdentityLdapAuthContent = ({
handlePopUpToggle, handlePopUpToggle,
handlePopUpOpen, handlePopUpOpen,
onDelete, onDelete,
popUp popUp,
lockedOut
}: ViewAuthMethodProps) => { }: ViewAuthMethodProps) => {
const { data, isPending } = useGetIdentityLdapAuth(identityId); const { data, isPending } = useGetIdentityLdapAuth(identityId);
const clearLockoutsResult = useClearIdentityLdapAuthLockouts();
if (isPending) { if (isPending) {
return ( return (
@@ -98,6 +101,12 @@ export const ViewIdentityLdapAuthContent = ({
</Tooltip> </Tooltip>
)} )}
</IdentityAuthFieldDisplay> </IdentityAuthFieldDisplay>
<LockoutFields
identityId={identityId}
lockedOut={lockedOut}
clearLockoutsResult={clearLockoutsResult}
data={data}
/>
</ViewIdentityContentWrapper> </ViewIdentityContentWrapper>
); );
}; };
@@ -1,11 +1,7 @@
import { useState } from "react";
import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
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 { useTimedReset } from "@app/hooks";
import { import {
useClearIdentityUniversalAuthLockouts, useClearIdentityUniversalAuthLockouts,
@@ -15,6 +11,7 @@ import {
import { IdentityUniversalAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm"; import { IdentityUniversalAuthForm } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm";
import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
import { LockoutFields } from "./IdentityAuthLockoutFields";
import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable"; import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable";
import { ViewAuthMethodProps } from "./types"; import { ViewAuthMethodProps } from "./types";
import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper";
@@ -30,32 +27,12 @@ export const ViewIdentityUniversalAuthContent = ({
const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data, isPending } = useGetIdentityUniversalAuth(identityId);
const { data: clientSecrets = [], isPending: clientSecretsPending } = const { data: clientSecrets = [], isPending: clientSecretsPending } =
useGetIdentityUniversalAuthClientSecrets(identityId); useGetIdentityUniversalAuthClientSecrets(identityId);
const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } = const clearLockoutsResult = useClearIdentityUniversalAuthLockouts();
useClearIdentityUniversalAuthLockouts();
const [lockedOutState, setLockedOutState] = useState(lockedOut);
const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({ const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({
initialState: "Copy Client ID to clipboard" 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) { if (isPending || clientSecretsPending) {
return ( return (
<div className="flex w-full items-center justify-center"> <div className="flex w-full items-center justify-center">
@@ -112,34 +89,12 @@ export const ViewIdentityUniversalAuthContent = ({
<IdentityAuthFieldDisplay label="Client Secret Trusted IPs"> <IdentityAuthFieldDisplay label="Client Secret Trusted IPs">
{data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")}
</IdentityAuthFieldDisplay> </IdentityAuthFieldDisplay>
<div className="col-span-2 mt-3 flex justify-between border-b border-mineshaft-500 pb-2"> <LockoutFields
<span className="text-bunker-300">Lockout Options</span> identityId={identityId}
<OrgPermissionCan I={OrgPermissionIdentityActions.Edit} a={OrgPermissionSubjects.Identity}> lockedOut={lockedOut}
{(isAllowed) => ( clearLockoutsResult={clearLockoutsResult}
<Button data={data}
isDisabled={!isAllowed || !lockedOutState || isClearLockoutsPending} />
size="xs"
onClick={() => clearLockouts()}
isLoading={isClearLockoutsPending}
colorSchema="secondary"
>
Clear All Lockouts
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthFieldDisplay label="Lockout">
{data.lockoutEnabled ? "Enabled" : "Disabled"}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Threshold">
{data.lockoutThreshold}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Duration">
{data.lockoutDuration} seconds
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Counter Reset">
{data.lockoutCounterReset} seconds
</IdentityAuthFieldDisplay>
<div className="col-span-2 my-3"> <div className="col-span-2 my-3">
<div className="mb-3 border-b border-mineshaft-500 pb-2"> <div className="mb-3 border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Client ID</span> <span className="text-bunker-300">Client ID</span>