diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts new file mode 100644 index 000000000..7b27296dd --- /dev/null +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +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 + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + t.dropColumn("lockoutEnabled"); + t.dropColumn("lockoutThreshold"); + t.dropColumn("lockoutDuration"); + t.dropColumn("lockoutCounterReset"); + }); + } +} diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index da27b4a55..e42c886c6 100644 --- a/backend/src/db/schemas/identity-universal-auths.ts +++ b/backend/src/db/schemas/identity-universal-auths.ts @@ -18,7 +18,11 @@ export const IdentityUniversalAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), identityId: z.string().uuid(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z.number().default(3), + lockoutDuration: z.number().default(300), + lockoutCounterReset: z.number().default(30) }); export type TIdentityUniversalAuths = 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 11d045eb2..622bac031 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -198,6 +198,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", 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", @@ -866,6 +867,10 @@ interface AddIdentityUniversalAuthEvent { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: Array; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; } @@ -878,6 +883,10 @@ interface UpdateIdentityUniversalAuthEvent { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: Array; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; }; } @@ -1037,6 +1046,13 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityGcpAuthEvent { type: EventType.LOGIN_IDENTITY_GCP_AUTH; metadata: { @@ -3491,6 +3507,7 @@ export type Event = | GetIdentityUniversalAuthClientSecretsEvent | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent + | ClearIdentityUniversalAuthLockoutsEvent | LoginIdentityGcpAuthEvent | AddIdentityGcpAuthEvent | DeleteIdentityGcpAuthEvent diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 26aff767e..3f7b8dc9f 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -13,7 +13,8 @@ export const PgSqlLock = { SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), - SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`) + SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`), + IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`) } as const; // all the key prefixes used must be set here to avoid conflict diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cc272f15..80a717753 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -166,7 +166,11 @@ export const UNIVERSAL_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.", accessTokenPeriod: - "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0." + "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0.", + 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 auth method for." @@ -181,7 +185,11 @@ export const UNIVERSAL_AUTH = { accessTokenTTL: "The new 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.", - accessTokenPeriod: "The new period for an access token in seconds." + accessTokenPeriod: "The new period for an access token in seconds.", + 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." }, CREATE_CLIENT_SECRET: { identityId: "The ID of the identity to create a client secret for.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8be9f3034..0455a4da0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1454,7 +1454,8 @@ export const registerRoutes = async ( identityOrgMembershipDAL, identityProjectDAL, licenseService, - identityMetadataDAL + identityMetadataDAL, + keyStore }); const identityAuthTemplateService = identityAuthTemplateServiceFactory({ @@ -1508,7 +1509,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, identityUaClientSecretDAL, identityUaDAL, - licenseService + licenseService, + keyStore }); const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index c0578fc0a..ad0411b8c 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -250,7 +250,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { description: true }).optional(), identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ - authMethods: z.array(z.string()) + authMethods: z.array(z.string()), + activeLockoutAuthMethods: z.array(z.string()) }) }) }) diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 09fffbff8..f0fefb4d8 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -137,7 +137,16 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .default(0) .describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit), - accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod) + accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod), + lockoutEnabled: z.boolean().default(true).describe(UNIVERSAL_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(UNIVERSAL_AUTH.ATTACH.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).default(300).describe(UNIVERSAL_AUTH.ATTACH.lockoutDuration), + lockoutCounterReset: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterReset) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -171,7 +180,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + lockoutEnabled: identityUniversalAuth.lockoutEnabled, + lockoutThreshold: identityUniversalAuth.lockoutThreshold, + lockoutDuration: identityUniversalAuth.lockoutDuration, + lockoutCounterReset: identityUniversalAuth.lockoutCounterReset } } }); @@ -243,7 +256,16 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .max(315360000) .optional() - .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod) + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod), + lockoutEnabled: z.boolean().optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutThreshold), + lockoutDuration: z.number().min(30).max(86400).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutDuration), + lockoutCounterReset: z + .number() + .min(5) + .max(3600) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterReset) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -276,7 +298,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + lockoutEnabled: identityUniversalAuth.lockoutEnabled, + lockoutThreshold: identityUniversalAuth.lockoutThreshold, + lockoutDuration: identityUniversalAuth.lockoutDuration, + lockoutCounterReset: identityUniversalAuth.lockoutCounterReset } } }); @@ -594,4 +620,53 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { return { clientSecretData }; } }); + + server.route({ + method: "POST", + url: "/universal-auth/identities/:identityId/clear-lockouts", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.UniversalAuth], + description: "Clear Universal Auth Lockouts for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(UNIVERSAL_AUTH.REVOKE_CLIENT_SECRET.identityId) + }), + response: { + 200: z.object({ + deleted: z.number() + }) + } + }, + handler: async (req) => { + const clearLockoutsData = await server.services.identityUa.clearUniversalAuthLockouts({ + 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_UNIVERSAL_AUTH_LOCKOUTS, + metadata: { + identityId: clearLockoutsData.identityId + } + } + }); + + return clearLockoutsData; + } + }); }; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index cd7211b5c..02594c22f 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -8,6 +8,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; @@ -22,6 +23,7 @@ import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-d import { TIdentityUaDALFactory } from "./identity-ua-dal"; import { TAttachUaDTO, + TClearUaLockoutsDTO, TCreateUaClientSecretDTO, TGetUaClientSecretsDTO, TGetUaDTO, @@ -38,30 +40,30 @@ type TIdentityUaServiceFactoryDep = { identityOrgMembershipDAL: TIdentityOrgDALFactory; permissionService: Pick; licenseService: Pick; + keyStore: Pick; }; export type TIdentityUaServiceFactory = ReturnType; +type LockoutObject = { + lockedOut: boolean; + failedAttempts: number; +}; + export const identityUaServiceFactory = ({ identityUaDAL, identityUaClientSecretDAL, identityAccessTokenDAL, identityOrgMembershipDAL, permissionService, - licenseService + licenseService, + keyStore }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) { - throw new NotFoundError({ - message: "No identity with specified client ID was found" - }); - } - - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: "No identity with the org membership was found" + throw new UnauthorizedError({ + message: "Invalid credentials" }); } @@ -69,69 +71,119 @@ export const identityUaServiceFactory = ({ ipAddress: ip, trustedIps: identityUa.clientSecretTrustedIps as TIp[] }); - const clientSecretPrefix = clientSecret.slice(0, 4); - const clientSecrtInfo = await identityUaClientSecretDAL.find({ - identityUAId: identityUa.id, - isClientSecretRevoked: false, - clientSecretPrefix - }); - let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null; - for await (const info of clientSecrtInfo) { - const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - if (isMatch) { - validClientSecretInfo = info; - break; - } + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + if (!identityMembershipOrg) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); } - if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); + const identityTx = await identityUaDAL.transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.IdentityLogin(identityUa.identityId, clientId)]); - const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (Number(clientSecretTTL) > 0) { - const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = Number(clientSecretTTL) * 1000; - const currentDate = new Date(); - const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + // Lockout Check + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - if (currentDate > expirationTime) { + let lockout: LockoutObject | undefined; + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } + + if (lockout && lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + + const clientSecretPrefix = clientSecret.slice(0, 4); + const clientSecretInfo = await identityUaClientSecretDAL.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false, + clientSecretPrefix + }); + + let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; + for await (const info of clientSecretInfo) { + const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + + if (isMatch) { + validClientSecretInfo = info; + break; + } + } + + if (!validClientSecretInfo) { + if (identityUa.lockoutEnabled) { + if (!lockout) { + lockout = { + lockedOut: false, + failedAttempts: 0 + }; + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityUa.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityUa.lockoutDuration : identityUa.lockoutCounterReset, + JSON.stringify(lockout) + ); + } + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } else if (lockout) { + await keyStore.deleteItem(LOCKOUT_KEY); + } + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; + if (Number(clientSecretTTL) > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true + }); + + throw new UnauthorizedError({ + message: "Access denied due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); - throw new UnauthorizedError({ - message: "Access denied due to expired client secret" + message: "Access denied due to client secret usage limit reached" }); } - } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { - // number of times client secret can be used for - // a login operation reached - await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { - isClientSecretRevoked: true - }); - throw new UnauthorizedError({ - message: "Access denied due to client secret usage limit reached" - }); - } + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 + }; - const accessTokenTTLParams = - Number(identityUa.accessTokenPeriod) === 0 - ? { - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL - } - : { - accessTokenTTL: identityUa.accessTokenPeriod, - // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token - // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" - accessTokenMaxTTL: 1000000000 - }; - - const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx); await identityOrgMembershipDAL.updateById( identityMembershipOrg.id, { @@ -154,33 +206,33 @@ export const identityUaServiceFactory = ({ tx ); - return newToken; + return { newToken, validClientSecretInfo, accessTokenTTLParams }; }); const appCfg = getConfig(); const accessToken = crypto.jwt().sign( { identityId: identityUa.identityId, - clientSecretId: validClientSecretInfo.id, - identityAccessTokenId: identityAccessToken.id, + clientSecretId: identityTx.validClientSecretInfo.id, + identityAccessTokenId: identityTx.newToken.id, authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN } as TIdentityAccessTokenJwtPayload, appCfg.AUTH_SECRET, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 + Number(identityTx.newToken.accessTokenTTL) === 0 ? undefined : { - expiresIn: Number(identityAccessToken.accessTokenTTL) + expiresIn: Number(identityTx.newToken.accessTokenTTL) } ); return { accessToken, identityUa, - validClientSecretInfo, - identityAccessToken, + validClientSecretInfo: identityTx.validClientSecretInfo, + identityAccessToken: identityTx.newToken, identityMembershipOrg, - ...accessTokenTTLParams + ...identityTx.accessTokenTTLParams }; }; @@ -196,7 +248,11 @@ export const identityUaServiceFactory = ({ actor, actorOrgId, isActorSuperAdmin, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -266,7 +322,11 @@ export const identityUaServiceFactory = ({ accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }, tx ); @@ -286,7 +346,11 @@ export const identityUaServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: TUpdateUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -362,7 +426,11 @@ export const identityUaServiceFactory = ({ accessTokenPeriod, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) - : undefined + : undefined, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }); return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; }; @@ -713,6 +781,38 @@ export const identityUaServiceFactory = ({ return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId }; }; + const clearUniversalAuthLockouts = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TClearUaLockoutsDTO) => { + 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.UNIVERSAL_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have universal 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.UNIVERSAL_AUTH}:*` + }); + + return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + }; + return { login, attachUniversalAuth, @@ -722,6 +822,7 @@ export const identityUaServiceFactory = ({ createUniversalAuthClientSecret, getUniversalAuthClientSecrets, revokeUniversalAuthClientSecret, - getUniversalAuthClientSecretById + getUniversalAuthClientSecretById, + clearUniversalAuthLockouts }; }; diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index f7938e0f7..9c3cc09c2 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -9,6 +9,10 @@ export type TAttachUaDTO = { clientSecretTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; } & Omit; export type TUpdateUaDTO = { @@ -19,6 +23,10 @@ export type TUpdateUaDTO = { accessTokenPeriod?: number; clientSecretTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; } & Omit; export type TGetUaDTO = { @@ -45,6 +53,10 @@ export type TRevokeUaClientSecretDTO = { clientSecretId: string; } & Omit; +export type TClearUaLockoutsDTO = { + identityId: string; +} & Omit; + export type TGetUniversalAuthClientSecretByIdDTO = { identityId: string; clientSecretId: string; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 7c76520b3..969d00331 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -8,6 +8,7 @@ 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 { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -32,6 +33,7 @@ type TIdentityServiceFactoryDep = { identityProjectDAL: Pick; permissionService: Pick; licenseService: Pick; + keyStore: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -42,7 +44,8 @@ export const identityServiceFactory = ({ identityOrgMembershipDAL, identityProjectDAL, permissionService, - licenseService + licenseService, + keyStore }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -255,7 +258,20 @@ export const identityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return identity; + const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); + + const activeLockoutAuthMethods = new Set(); + activeLockouts.forEach((key) => { + const parts = key.split(":"); + if (parts.length > 3) { + activeLockoutAuthMethods.add(parts[3]); + } + }); + + return { + ...identity, + identity: { ...identity.identity, activeLockoutAuthMethods: Array.from(activeLockoutAuthMethods) } + }; }; const deleteIdentity = async ({ diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 2daa76930..179a55714 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -204,7 +204,7 @@ export const Button = forwardRef( {leftIcon && (
{ accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }) => { const { data: { identityUniversalAuth } @@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }); return identityUniversalAuth; }, @@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }) => { const { data: { identityUniversalAuth } @@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }); return identityUniversalAuth; }, @@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { }); }; +export const useClearIdentityUniversalAuthLockouts = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { deleted } + } = await apiRequest.post<{ deleted: number }>( + `/api/v1/auth/universal-auth/identities/${identityId}/clear-lockouts` + ); + return deleted; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries({ + queryKey: identitiesKeys.clearIdentityUniversalAuthLockouts(identityId) + }); + } + }); +}; + export const useAddIdentityGcpAuth = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index cf7f4be87..ba81a97f1 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -49,7 +49,9 @@ export const identitiesKeys = { getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, getIdentityProjectMemberships: (identityId: string) => - [{ identityId }, "identity-project-memberships"] as const + [{ identityId }, "identity-project-memberships"] as const, + clearIdentityUniversalAuthLockouts: (identityId: string) => + [{ identityId }, "clear-identity-universal-auth-lockouts"] as const }; export const useGetIdentityById = (identityId: string) => { diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7098ce244..8f5ec2b8e 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -16,6 +16,7 @@ export type Identity = { name: string; hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; + activeLockoutAuthMethods: IdentityAuthMethod[]; createdAt: string; updatedAt: string; isInstanceAdmin?: boolean; @@ -113,6 +114,10 @@ export type IdentityUniversalAuth = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; accessTokenPeriod: number; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; export type AddIdentityUniversalAuthDTO = { @@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = { accessTokenTrustedIps: { ipAddress: string; }[]; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDuration: number; + lockoutCounterReset: number; }; export type UpdateIdentityUniversalAuthDTO = { @@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = { accessTokenTrustedIps?: { ipAddress: string; }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDuration?: number; + lockoutCounterReset?: number; }; export type DeleteIdentityUniversalAuthDTO = { @@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = { clientSecretId: string; }; +export type ClearIdentityUniversalAuthLockoutsDTO = { + identityId: string; +}; + export type IdentityTokenAuth = { identityId: string; accessTokenTTL: number; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx index e14acd869..cdd65e581 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -11,6 +11,7 @@ import { FormControl, IconButton, Input, + Switch, Tab, TabList, TabPanel, @@ -60,7 +61,26 @@ const schema = z ipAddress: z.string().max(50) }) .array() - .min(1) + .min(1), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z + .string() + .refine( + (value) => Number(value) <= 30 && Number(value) >= 1, + "Lockout threshold must be between 1 and 30" + ), + lockoutDuration: z + .string() + .refine( + (value) => Number(value) <= 86400 && Number(value) >= 30, + "Lockout duration must be between 30 seconds and 1 day" + ), + lockoutCounterReset: z + .string() + .refine( + (value) => Number(value) <= 3600 && Number(value) >= 5, + "Lockout counter reset must be between 5 seconds and 1 hour" + ) }) .required(); @@ -107,12 +127,21 @@ export const IdentityUniversalAuthForm = ({ accessTokenNumUsesLimit: "0", clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenPeriod: "0" + accessTokenPeriod: "0", + lockoutEnabled: true, + lockoutThreshold: "3", + lockoutDuration: "300", + lockoutCounterReset: "30" } }); const accessTokenPeriodValue = Number(watch("accessTokenPeriod")); + const lockoutEnabled = watch("lockoutEnabled"); + const lockoutThreshold = watch("lockoutThreshold"); + const lockoutDuration = watch("lockoutDuration"); + const lockoutCounterReset = watch("lockoutCounterReset"); + const { fields: clientSecretTrustedIpsFields, append: appendClientSecretTrustedIp, @@ -144,7 +173,11 @@ export const IdentityUniversalAuthForm = ({ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` }; } - ) + ), + lockoutEnabled: data.lockoutEnabled, + lockoutThreshold: String(data.lockoutThreshold), + lockoutDuration: String(data.lockoutDuration), + lockoutCounterReset: String(data.lockoutCounterReset) }); } else { reset({ @@ -153,7 +186,11 @@ export const IdentityUniversalAuthForm = ({ accessTokenNumUsesLimit: "0", accessTokenPeriod: "0", clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + lockoutEnabled: true, + lockoutThreshold: "3", + lockoutDuration: "300", + lockoutCounterReset: "30" }); } }, [data]); @@ -164,7 +201,11 @@ export const IdentityUniversalAuthForm = ({ accessTokenNumUsesLimit, clientSecretTrustedIps, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDuration, + lockoutCounterReset }: FormData) => { try { if (!identityId) return; @@ -179,7 +220,11 @@ export const IdentityUniversalAuthForm = ({ accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenTrustedIps, - accessTokenPeriod: Number(accessTokenPeriod) + accessTokenPeriod: Number(accessTokenPeriod), + lockoutEnabled, + lockoutThreshold: Number(lockoutThreshold), + lockoutDuration: Number(lockoutDuration), + lockoutCounterReset: Number(lockoutCounterReset) }); } else { // create new universal auth configuration @@ -192,7 +237,11 @@ export const IdentityUniversalAuthForm = ({ accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenTrustedIps, - accessTokenPeriod: Number(accessTokenPeriod) + accessTokenPeriod: Number(accessTokenPeriod), + lockoutEnabled, + lockoutThreshold: Number(lockoutThreshold), + lockoutDuration: Number(lockoutDuration), + lockoutCounterReset: Number(lockoutCounterReset) }); } @@ -220,13 +269,21 @@ export const IdentityUniversalAuthForm = ({ setTabValue( ["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0]) ? IdentityFormTab.Advanced - : IdentityFormTab.Configuration + : [ + "lockoutEnabled", + "lockoutThreshold", + "lockoutDuration", + "lockoutCounterReset" + ].includes(Object.keys(fields)[0]) + ? IdentityFormTab.Lockout + : IdentityFormTab.Configuration ); })} > setTabValue(value as IdentityFormTab)}> Configuration + Lockout Advanced @@ -296,6 +353,84 @@ 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/types/index.ts b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/types/index.ts index 56e82f7af..f1c7015c4 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/types/index.ts +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/types/index.ts @@ -1,4 +1,5 @@ export enum IdentityFormTab { Advanced = "advanced", + Lockout = "lockout", Configuration = "configuration" } diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index d4de5f1c4..1b7cb956b 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -115,7 +115,8 @@ const Page = () => { handlePopUpToggle("viewAuthMethod", isOpen)} - authMethod={popUp.viewAuthMethod.data} + authMethod={popUp.viewAuthMethod.data?.authMethod} + lockedOut={popUp.viewAuthMethod.data?.lockedOut || false} identityId={identityId} />
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx index 467fc7dcc..61268070b 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx @@ -1,4 +1,4 @@ -import { faCog, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; @@ -28,12 +28,22 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P {data.identity.authMethods.map((authMethod) => ( ))}
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx index ca0a5e6f3..7236d4c27 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx @@ -37,6 +37,7 @@ import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthCon type Props = { identityId: string; authMethod?: IdentityAuthMethod; + lockedOut: boolean; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; onDeleteAuthMethod: () => void; @@ -50,8 +51,9 @@ type TRevokeOptions = { export const Content = ({ identityId, authMethod, + lockedOut, onDeleteAuthMethod -}: Pick) => { +}: Pick) => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -162,6 +164,7 @@ export const Content = ({ popUp={popUp} handlePopUpOpen={handlePopUpOpen} handlePopUpToggle={handlePopUpToggle} + lockedOut={lockedOut} /> ) => { if (!identityId || !authMethod) return null; @@ -194,6 +198,7 @@ export const ViewIdentityAuthModal = ({ onOpenChange(false)} /> diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index ce6415171..7ea5858a7 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -1,9 +1,10 @@ -import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { faArrowsRotate, faBan, faCheck, faCopy, faFire } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { + useClearIdentityUniversalAuthLockouts, useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "@app/hooks/api"; @@ -13,22 +14,42 @@ import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable"; import { ViewAuthMethodProps } from "./types"; import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { createNotification } from "@app/components/notifications"; +import { useState } from "react"; export const ViewIdentityUniversalAuthContent = ({ identityId, handlePopUpToggle, handlePopUpOpen, onDelete, - popUp + popUp, + lockedOut }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data: clientSecrets = [], isPending: clientSecretsPending } = useGetIdentityUniversalAuthClientSecrets(identityId); + const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } = + useClearIdentityUniversalAuthLockouts(); + + const [lockedOutState, setLockedOutState] = useState(lockedOut); const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset({ initialState: "Copy Client ID to clipboard" }); + async function clearLockouts() { + const deleted = await clearLockoutsFn({ identityId }); + + createNotification({ + text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, + type: "success" + }); + + setLockedOutState(false); + } + if (isPending || clientSecretsPending) { return (
@@ -85,6 +106,40 @@ 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 diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts index 7673f5d6b..c566040b4 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts @@ -9,4 +9,5 @@ export type ViewAuthMethodProps = { state?: boolean ) => void; popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>; + lockedOut: boolean; };