From 6d5bed756aa83bf1ff65821332df7b08652779c0 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 18 Aug 2025 23:57:31 +0800 Subject: [PATCH 01/11] feat(identities): Universal Auth Login Lockout --- .../20250815022242_identity-lockouts.ts | 25 ++ .../db/schemas/identity-universal-auths.ts | 6 +- .../ee/services/audit-log/audit-log-types.ts | 17 ++ backend/src/keystore/keystore.ts | 3 +- backend/src/lib/api-docs/constants.ts | 12 +- backend/src/server/routes/index.ts | 6 +- .../src/server/routes/v1/identity-router.ts | 3 +- .../v1/identity-universal-auth-router.ts | 83 +++++- .../identity-ua/identity-ua-service.ts | 245 +++++++++++++----- .../services/identity-ua/identity-ua-types.ts | 12 + .../src/services/identity/identity-service.ts | 20 +- frontend/src/components/v2/Button/Button.tsx | 2 +- .../src/hooks/api/auditLogs/constants.tsx | 1 + frontend/src/hooks/api/auditLogs/enums.tsx | 1 + frontend/src/hooks/api/auditLogs/types.tsx | 9 + .../src/hooks/api/identities/mutations.tsx | 44 +++- frontend/src/hooks/api/identities/queries.tsx | 4 +- frontend/src/hooks/api/identities/types.ts | 17 ++ .../IdentityUniversalAuthForm.tsx | 151 ++++++++++- .../components/IdentitySection/types/index.ts | 1 + .../IdentityDetailsByIDPage.tsx | 3 +- .../IdentityAuthenticationSection.tsx | 16 +- .../ViewIdentityAuthModal.tsx | 9 +- .../ViewIdentityUniversalAuthContent.tsx | 61 ++++- .../ViewIdentityAuthModal/types/index.ts | 1 + 25 files changed, 644 insertions(+), 108 deletions(-) create mode 100644 backend/src/db/migrations/20250815022242_identity-lockouts.ts 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; }; From bceddab89f81b7acf1ac94f872a39bfe99967a4c Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 19 Aug 2025 14:01:39 +0800 Subject: [PATCH 02/11] Greptile review fixes --- backend/src/lib/api-docs/constants.ts | 3 +++ .../v1/identity-universal-auth-router.ts | 2 +- .../src/hooks/api/identities/mutations.tsx | 2 +- frontend/src/hooks/api/identities/queries.tsx | 4 +--- .../ViewIdentityUniversalAuthContent.tsx | 22 ++++++++++++------- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 80a717753..f520d87da 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -209,6 +209,9 @@ export const UNIVERSAL_AUTH = { identityId: "The ID of the identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, + CLEAR_CLIENT_LOCKOUTS: { + identityId: "The ID of the identity to clear the client lockouts from." + }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." }, 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 f0fefb4d8..a3d332e0c 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -638,7 +638,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - identityId: z.string().describe(UNIVERSAL_AUTH.REVOKE_CLIENT_SECRET.identityId) + identityId: z.string().describe(UNIVERSAL_AUTH.CLEAR_CLIENT_LOCKOUTS.identityId) }), response: { 200: z.object({ diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index f3194a744..ca903bb48 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -305,7 +305,7 @@ export const useClearIdentityUniversalAuthLockouts = () => { }, onSuccess: (_, { identityId }) => { queryClient.invalidateQueries({ - queryKey: identitiesKeys.clearIdentityUniversalAuthLockouts(identityId) + queryKey: identitiesKeys.getIdentityUniversalAuth(identityId) }); } }); diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index ba81a97f1..cf7f4be87 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -49,9 +49,7 @@ export const identitiesKeys = { getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, getIdentityProjectMemberships: (identityId: string) => - [{ identityId }, "identity-project-memberships"] as const, - clearIdentityUniversalAuthLockouts: (identityId: string) => - [{ identityId }, "clear-identity-universal-auth-lockouts"] as const + [{ identityId }, "identity-project-memberships"] as const }; export const useGetIdentityById = (identityId: string) => { diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index 7ea5858a7..c2a1df6b9 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -40,14 +40,20 @@ export const ViewIdentityUniversalAuthContent = ({ }); async function clearLockouts() { - const deleted = await clearLockoutsFn({ identityId }); - - createNotification({ - text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, - type: "success" - }); - - setLockedOutState(false); + 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) { From 5136dbc5431c19bf1365667ab29680e7f3d72d17 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 19 Aug 2025 14:05:56 +0800 Subject: [PATCH 03/11] Tooltips for inputs --- .../components/IdentitySection/IdentityUniversalAuthForm.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 cdd65e581..05bf47c73 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 @@ -390,6 +390,7 @@ export const IdentityUniversalAuthForm = ({ label="Lockout Threshold" isError={Boolean(error)} errorText={error?.message} + tooltipText="The amount of times login must fail before locking the identity auth method" > @@ -406,6 +407,7 @@ export const IdentityUniversalAuthForm = ({ label="Lockout Duration (seconds)" isError={Boolean(error)} errorText={error?.message} + tooltipText="How long an identity auth method lockout lasts" > @@ -422,6 +424,7 @@ export const IdentityUniversalAuthForm = ({ label="Lockout Counter Reset (seconds)" isError={Boolean(error)} errorText={error?.message} + tooltipText="How long to wait from the most recent failed login until resetting the lockout counter" > From ebd3b5c9d1ea1c12b425a52dab4e740126ec7e33 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 19 Aug 2025 15:24:20 +0800 Subject: [PATCH 04/11] UI polish: Add better time inputs and tooltips --- frontend/src/helpers/datetime.ts | 86 +++++ .../IdentityUniversalAuthForm.tsx | 335 +++++++++++++----- .../ViewIdentityUniversalAuthContent.tsx | 8 +- .../OrgSecretShareLimitSection.tsx | 82 +---- 4 files changed, 353 insertions(+), 158 deletions(-) diff --git a/frontend/src/helpers/datetime.ts b/frontend/src/helpers/datetime.ts index 6e0fb8e48..bb258878d 100644 --- a/frontend/src/helpers/datetime.ts +++ b/frontend/src/helpers/datetime.ts @@ -22,3 +22,89 @@ export const formatDateTime = ({ } return format(date, dateFormat); }; + +// Helper function to convert duration to seconds +export const durationToSeconds = ( + value: number, + unit: "s" | "m" | "h" | "d" | "w" | "y" +): number => { + switch (unit) { + case "s": + return value; + case "m": + return value * 60; + case "h": + return value * 60 * 60; + case "d": + return value * 60 * 60 * 24; + case "w": + return value * 60 * 60 * 24 * 7; + case "y": + return value * 60 * 60 * 24 * 365; + default: + return 0; + } +}; + +// Helper function to convert seconds to value and unit +export const getObjectFromSeconds = ( + totalSeconds: number, + activeUnits?: Array<"s" | "m" | "h" | "d" | "w" | "y"> +): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => { + const SECONDS_IN_MINUTE = 60; + const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; + const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; + const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; + const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365; + + const activeUnitsSet = activeUnits ? new Set(activeUnits) : null; + + const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => { + return activeUnitsSet ? activeUnitsSet.has(unit) : true; + }; + + if ( + isUnitActive("y") && + totalSeconds >= SECONDS_IN_YEAR && + totalSeconds % SECONDS_IN_YEAR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" }; + } + + if ( + isUnitActive("w") && + totalSeconds >= SECONDS_IN_WEEK && + totalSeconds % SECONDS_IN_WEEK === 0 + ) { + return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" }; + } + + if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) { + return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" }; + } + + if ( + isUnitActive("h") && + totalSeconds >= SECONDS_IN_HOUR && + totalSeconds % SECONDS_IN_HOUR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" }; + } + + if ( + isUnitActive("m") && + totalSeconds >= SECONDS_IN_MINUTE && + totalSeconds % SECONDS_IN_MINUTE === 0 + ) { + return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" }; + } + + if (isUnitActive("s") && totalSeconds >= 1) { + return { value: totalSeconds, unit: "s" }; + } + + return { + value: 0, + unit: "s" + }; +}; 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 05bf47c73..c8d424e47 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,8 @@ import { FormControl, IconButton, Input, + Select, + SelectItem, Switch, Tab, TabList, @@ -27,6 +29,7 @@ import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityFormTab } from "./types"; +import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; const schema = z .object({ @@ -69,20 +72,73 @@ const schema = z (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" - ) + lockoutDurationValue: z.string(), + lockoutDurationUnit: z.enum(["s", "m", "h", "d"], { + invalid_type_error: "Please select a valid time unit" + }), + lockoutCounterResetValue: z.string(), + lockoutCounterResetUnit: z.enum(["s", "m", "h"], { + invalid_type_error: "Please select a valid time unit" + }) }) - .required(); + .required() + .superRefine((data, ctx) => { + const { + lockoutDurationValue, + lockoutCounterResetValue, + lockoutDurationUnit, + lockoutCounterResetUnit, + lockoutEnabled + } = data; + + if (!lockoutEnabled) return; + + let isAnyParseError = false; + + const parsedLockoutDuration = parseInt(lockoutDurationValue, 10); + if (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 (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; @@ -130,8 +186,10 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: "0", lockoutEnabled: true, lockoutThreshold: "3", - lockoutDuration: "300", - lockoutCounterReset: "30" + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" } }); @@ -139,8 +197,10 @@ export const IdentityUniversalAuthForm = ({ const lockoutEnabled = watch("lockoutEnabled"); const lockoutThreshold = watch("lockoutThreshold"); - const lockoutDuration = watch("lockoutDuration"); - const lockoutCounterReset = watch("lockoutCounterReset"); + const lockoutDurationValue = watch("lockoutDurationValue"); + const lockoutDurationUnit = watch("lockoutDurationUnit"); + const lockoutCounterResetValue = watch("lockoutCounterResetValue"); + const lockoutCounterResetUnit = watch("lockoutCounterResetUnit"); const { fields: clientSecretTrustedIpsFields, @@ -155,6 +215,9 @@ export const IdentityUniversalAuthForm = ({ useEffect(() => { if (data) { + const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration); + const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset); + reset({ accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), @@ -176,8 +239,10 @@ export const IdentityUniversalAuthForm = ({ ), lockoutEnabled: data.lockoutEnabled, lockoutThreshold: String(data.lockoutThreshold), - lockoutDuration: String(data.lockoutDuration), - lockoutCounterReset: String(data.lockoutCounterReset) + lockoutDurationValue: String(lockoutDurationObj.value), + lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d", + lockoutCounterResetValue: String(lockoutCounterResetObj.value), + lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h" }); } else { reset({ @@ -189,8 +254,10 @@ export const IdentityUniversalAuthForm = ({ accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], lockoutEnabled: true, lockoutThreshold: "3", - lockoutDuration: "300", - lockoutCounterReset: "30" + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" }); } }, [data]); @@ -204,12 +271,20 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationValue, + lockoutDurationUnit, + lockoutCounterResetValue, + lockoutCounterResetUnit }: FormData) => { try { if (!identityId) return; + const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit); + const lockoutCounterReset = durationToSeconds( + Number(lockoutCounterResetValue), + lockoutCounterResetUnit + ); + if (data) { // update universal auth configuration await updateMutateAsync({ @@ -223,8 +298,8 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: Number(accessTokenPeriod), lockoutEnabled, lockoutThreshold: Number(lockoutThreshold), - lockoutDuration: Number(lockoutDuration), - lockoutCounterReset: Number(lockoutCounterReset) + lockoutDuration, + lockoutCounterReset }); } else { // create new universal auth configuration @@ -272,8 +347,10 @@ export const IdentityUniversalAuthForm = ({ : [ "lockoutEnabled", "lockoutThreshold", - "lockoutDuration", - "lockoutCounterReset" + "lockoutDurationValue", + "lockoutDurationUnit", + "lockoutCounterResetValue", + "lockoutCounterResetUnit" ].includes(Object.keys(fields)[0]) ? IdentityFormTab.Lockout : IdentityFormTab.Configuration @@ -362,7 +439,7 @@ export const IdentityUniversalAuthForm = ({ render={({ field: { value, onChange }, fieldState: { error } }) => { return ( @@ -380,57 +457,157 @@ export const IdentityUniversalAuthForm = ({ ); }} /> - { - return ( - - - - ); - }} - /> - { - return ( - - - - ); - }} - /> - { - return ( - - - - ); - }} - /> +
+ { + return ( + + + + ); + }} + /> +
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
+ { + return ( + + + + ); + }} + /> + ( + + + + )} + /> +
+
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index c2a1df6b9..aac1ee52c 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -120,13 +120,7 @@ export const ViewIdentityUniversalAuthContent = ({ isDisabled={!isAllowed || !lockedOutState || isClearLockoutsPending} size="xs" onClick={clearLockouts} - leftIcon={ - isClearLockoutsPending ? ( - - ) : ( - - ) - } + isLoading={isClearLockoutsPending} colorSchema="secondary" > Clear All Lockouts diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx index 648ad031c..71d133d2e 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -8,63 +8,11 @@ import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useUpdateOrg } from "@app/hooks/api"; +import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds -// Helper function to convert duration to seconds -const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => { - switch (unit) { - case "m": - return value * 60; - case "h": - return value * 60 * 60; - case "d": - return value * 60 * 60 * 24; - default: - return 0; - } -}; - -// Helper function to convert seconds to form lifetime value and unit -const getFormLifetimeFromSeconds = ( - totalSeconds: number | null | undefined -): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => { - const DEFAULT_LIFETIME_VALUE = 30; - const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d"; - - if (totalSeconds == null || totalSeconds <= 0) { - return { - maxLifetimeValue: DEFAULT_LIFETIME_VALUE, - maxLifetimeUnit: DEFAULT_LIFETIME_UNIT - }; - } - - const secondsInDay = 24 * 60 * 60; - const secondsInHour = 60 * 60; - const secondsInMinute = 60; - - if (totalSeconds % secondsInDay === 0) { - const value = totalSeconds / secondsInDay; - if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" }; - } - - if (totalSeconds % secondsInHour === 0) { - const value = totalSeconds / secondsInHour; - if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" }; - } - - if (totalSeconds % secondsInMinute === 0) { - const value = totalSeconds / secondsInMinute; - if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" }; - } - - return { - maxLifetimeValue: DEFAULT_LIFETIME_VALUE, - maxLifetimeUnit: DEFAULT_LIFETIME_UNIT - }; -}; - const formSchema = z .object({ maxLifetimeValue: z.number().min(1, "Value must be at least 1"), @@ -79,32 +27,18 @@ const formSchema = z const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit); - // Check max limit if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) { - let message = "Duration exceeds maximum allowed limit"; - - if (maxLifetimeUnit === "m") { - message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`; - } else if (maxLifetimeUnit === "h") { - message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`; - } else if (maxLifetimeUnit === "d") { - message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`; - } - ctx.addIssue({ code: z.ZodIssueCode.custom, - message, + message: "Duration exceeds a maximum of 30 days", path: ["maxLifetimeValue"] }); } - // Check min limit if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) { - const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes - ctx.addIssue({ code: z.ZodIssueCode.custom, - message, + message: "Duration must be at least 5 minutes", path: ["maxLifetimeValue"] }); } @@ -122,10 +56,14 @@ export const OrgSecretShareLimitSection = () => { const { currentOrg } = useOrganization(); const getDefaultFormValues = () => { - const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime); + const initialLifetime = getObjectFromSeconds(currentOrg?.maxSharedSecretLifetime, [ + "m", + "h", + "d" + ]); return { - maxLifetimeValue: initialLifetime.maxLifetimeValue, - maxLifetimeUnit: initialLifetime.maxLifetimeUnit, + maxLifetimeValue: initialLifetime.value, + maxLifetimeUnit: initialLifetime.unit as "m" | "h" | "d", maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1", shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit) }; From 15d36386126b465ef2f2920234c249acb0ce355e Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 19 Aug 2025 15:38:07 +0800 Subject: [PATCH 05/11] Type check fixes --- .../IdentitySection/IdentityModal.tsx | 6 +- .../IdentityUniversalAuthForm.tsx | 75 ++++++++++--------- .../ViewIdentityUniversalAuthContent.tsx | 12 +-- .../OrgSecretShareLimitSection.tsx | 2 +- 4 files changed, 52 insertions(+), 43 deletions(-) diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 829b55674..1d080be7d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -148,7 +148,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { accessTokenTTL: 2592000, accessTokenMaxTTL: 2592000, accessTokenNumUsesLimit: 0, - accessTokenPeriod: 0 + accessTokenPeriod: 0, + lockoutEnabled: true, + lockoutThreshold: 3, + lockoutDuration: 300, + lockoutCounterReset: 30 }); handlePopUpToggle("identity", false); 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 c8d424e47..4a2802e08 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 @@ -20,6 +20,7 @@ import { Tabs } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; +import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; import { useAddIdentityUniversalAuth, useGetIdentityUniversalAuth, @@ -29,7 +30,6 @@ import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityFormTab } from "./types"; -import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; const schema = z .object({ @@ -96,7 +96,7 @@ const schema = z let isAnyParseError = false; const parsedLockoutDuration = parseInt(lockoutDurationValue, 10); - if (isNaN(parsedLockoutDuration)) { + if (Number.isNaN(parsedLockoutDuration)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Lockout duration must be a number", @@ -106,7 +106,7 @@ const schema = z } const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10); - if (isNaN(parsedLockoutCounterReset)) { + if (Number.isNaN(parsedLockoutCounterReset)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Lockout counter reset must be a number", @@ -195,12 +195,12 @@ export const IdentityUniversalAuthForm = ({ const accessTokenPeriodValue = Number(watch("accessTokenPeriod")); - const lockoutEnabled = watch("lockoutEnabled"); - const lockoutThreshold = watch("lockoutThreshold"); - const lockoutDurationValue = watch("lockoutDurationValue"); - const lockoutDurationUnit = watch("lockoutDurationUnit"); - const lockoutCounterResetValue = watch("lockoutCounterResetValue"); - const lockoutCounterResetUnit = watch("lockoutCounterResetUnit"); + const lockoutEnabledWatch = watch("lockoutEnabled"); + const lockoutThresholdWatch = watch("lockoutThreshold"); + const lockoutDurationValueWatch = watch("lockoutDurationValue"); + const lockoutDurationUnitWatch = watch("lockoutDurationUnit"); + const lockoutCounterResetValueWatch = watch("lockoutCounterResetValue"); + const lockoutCounterResetUnitWatch = watch("lockoutCounterResetUnit"); const { fields: clientSecretTrustedIpsFields, @@ -341,20 +341,25 @@ export const IdentityUniversalAuthForm = ({ return (
{ - setTabValue( - ["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0]) - ? IdentityFormTab.Advanced - : [ - "lockoutEnabled", - "lockoutThreshold", - "lockoutDurationValue", - "lockoutDurationUnit", - "lockoutCounterResetValue", - "lockoutCounterResetUnit" - ].includes(Object.keys(fields)[0]) - ? IdentityFormTab.Lockout - : IdentityFormTab.Configuration - ); + const firstErrorField = Object.keys(fields)[0]; + let tab = IdentityFormTab.Configuration; + + if (["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(firstErrorField)) { + tab = IdentityFormTab.Advanced; + } else if ( + [ + "lockoutEnabled", + "lockoutThreshold", + "lockoutDurationValue", + "lockoutDurationUnit", + "lockoutCounterResetValue", + "lockoutCounterResetUnit" + ].includes(firstErrorField) + ) { + tab = IdentityFormTab.Lockout; + } + + setTabValue(tab); })} > setTabValue(value as IdentityFormTab)}> @@ -435,11 +440,11 @@ export const IdentityUniversalAuthForm = ({ { return ( @@ -464,7 +469,7 @@ export const IdentityUniversalAuthForm = ({ render={({ field, fieldState: { error } }) => { return ( ); @@ -486,7 +491,7 @@ export const IdentityUniversalAuthForm = ({ render={({ field, fieldState: { error } }) => { return ( ); @@ -506,12 +511,12 @@ export const IdentityUniversalAuthForm = ({ name="lockoutDurationUnit" render={({ field, fieldState: { error } }) => ( clearLockouts()} isLoading={isClearLockoutsPending} colorSchema="secondary" > diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx index 71d133d2e..b3e4976f3 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -7,8 +7,8 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { useUpdateOrg } from "@app/hooks/api"; import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; +import { useUpdateOrg } from "@app/hooks/api"; const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds From 57c667f0b15d35dfae489641da6bee3897091e54 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 19 Aug 2025 15:40:01 +0800 Subject: [PATCH 06/11] Improve getObjectFromSeconds func --- frontend/src/helpers/datetime.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/helpers/datetime.ts b/frontend/src/helpers/datetime.ts index bb258878d..3dd57cf52 100644 --- a/frontend/src/helpers/datetime.ts +++ b/frontend/src/helpers/datetime.ts @@ -99,12 +99,8 @@ export const getObjectFromSeconds = ( return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" }; } - if (isUnitActive("s") && totalSeconds >= 1) { - return { value: totalSeconds, unit: "s" }; - } - return { - value: 0, + value: totalSeconds, unit: "s" }; }; From 1b22438c4649cdbed6ee8a7ac9f17813854c1121 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 26 Aug 2025 03:11:10 -0400 Subject: [PATCH 07/11] Fix migration --- .../20250815022242_identity-lockouts.ts | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts index 7b27296dd..a0e661d2d 100644 --- a/backend/src/db/migrations/20250815022242_identity-lockouts.ts +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -4,22 +4,48 @@ import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { - await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { - t.boolean("lockoutEnabled").notNullable().defaultTo(true); - t.integer("lockoutThreshold").notNullable().defaultTo(3); - t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) - t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, async (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + } }); } } export async function down(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); + const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { - t.dropColumn("lockoutEnabled"); - t.dropColumn("lockoutThreshold"); - t.dropColumn("lockoutDuration"); - t.dropColumn("lockoutCounterReset"); + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDuration"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterReset"); + } }); } } From 8945bc0dc10060411efbe638bd8ad33cae8de529 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 26 Aug 2025 20:40:16 -0400 Subject: [PATCH 08/11] Review fixes --- .../20250815022242_identity-lockouts.ts | 22 +++++++++----- .../db/schemas/identity-universal-auths.ts | 4 +-- .../ee/services/audit-log/audit-log-types.ts | 8 ++--- backend/src/lib/api-docs/constants.ts | 10 ++++--- .../v1/identity-universal-auth-router.ts | 30 ++++++++++++------- .../identity-ua/identity-ua-service.ts | 18 +++++------ .../services/identity-ua/identity-ua-types.ts | 8 ++--- .../src/hooks/api/identities/mutations.tsx | 16 +++++----- frontend/src/hooks/api/identities/types.ts | 12 ++++---- .../IdentitySection/IdentityModal.tsx | 4 +-- .../IdentityUniversalAuthForm.tsx | 19 +++++++----- .../ViewIdentityUniversalAuthContent.tsx | 5 ++-- 12 files changed, 89 insertions(+), 67 deletions(-) diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts index a0e661d2d..e6a20f02c 100644 --- a/backend/src/db/migrations/20250815022242_identity-lockouts.ts +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -6,8 +6,11 @@ export async function up(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); - const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); - const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); await knex.schema.alterTable(TableName.IdentityUniversalAuth, async (t) => { if (!hasLockoutEnabled) { @@ -17,10 +20,10 @@ export async function up(knex: Knex): Promise { t.integer("lockoutThreshold").notNullable().defaultTo(3); } if (!hasLockoutDuration) { - t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds) + t.integer("lockoutDurationSeconds").notNullable().defaultTo(300); // 5 minutes } if (!hasLockoutCounterReset) { - t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds + t.integer("lockoutCounterResetSeconds").notNullable().defaultTo(30); // 30 seconds } }); } @@ -30,8 +33,11 @@ export async function down(knex: Knex): Promise { if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); - const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDuration"); - const hasLockoutCounterReset = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutCounterReset"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { if (hasLockoutEnabled) { @@ -41,10 +47,10 @@ export async function down(knex: Knex): Promise { t.dropColumn("lockoutThreshold"); } if (hasLockoutDuration) { - t.dropColumn("lockoutDuration"); + t.dropColumn("lockoutDurationSeconds"); } if (hasLockoutCounterReset) { - t.dropColumn("lockoutCounterReset"); + t.dropColumn("lockoutCounterResetSeconds"); } }); } diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index e42c886c6..29e8314aa 100644 --- a/backend/src/db/schemas/identity-universal-auths.ts +++ b/backend/src/db/schemas/identity-universal-auths.ts @@ -21,8 +21,8 @@ export const IdentityUniversalAuthsSchema = z.object({ 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) + lockoutDurationSeconds: z.number().default(300), + lockoutCounterResetSeconds: 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 622bac031..f96468bc7 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -869,8 +869,8 @@ interface AddIdentityUniversalAuthEvent { accessTokenTrustedIps: Array; lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; } @@ -885,8 +885,8 @@ interface UpdateIdentityUniversalAuthEvent { accessTokenTrustedIps?: Array; lockoutEnabled?: boolean; lockoutThreshold?: number; - lockoutDuration?: number; - lockoutCounterReset?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; } diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f520d87da..7ecaeff77 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -169,8 +169,9 @@ export const UNIVERSAL_AUTH = { "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." + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "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." @@ -188,8 +189,9 @@ export const UNIVERSAL_AUTH = { 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." + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "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/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index a3d332e0c..6d911a88e 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -140,13 +140,18 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { 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 + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z .number() .min(5) .max(3600) .default(30) - .describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterReset) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -183,8 +188,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, lockoutEnabled: identityUniversalAuth.lockoutEnabled, lockoutThreshold: identityUniversalAuth.lockoutThreshold, - lockoutDuration: identityUniversalAuth.lockoutDuration, - lockoutCounterReset: identityUniversalAuth.lockoutCounterReset + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -259,13 +264,18 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .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 + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.lockoutDurationSeconds), + lockoutCounterResetSeconds: z .number() .min(5) .max(3600) .optional() - .describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterReset) + .describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterResetSeconds) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -301,8 +311,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, lockoutEnabled: identityUniversalAuth.lockoutEnabled, lockoutThreshold: identityUniversalAuth.lockoutThreshold, - lockoutDuration: identityUniversalAuth.lockoutDuration, - lockoutCounterReset: identityUniversalAuth.lockoutCounterReset + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 02594c22f..3aeb5e83c 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -131,7 +131,7 @@ export const identityUaServiceFactory = ({ await keyStore.setItemWithExpiry( LOCKOUT_KEY, - lockout.lockedOut ? identityUa.lockoutDuration : identityUa.lockoutCounterReset, + lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, JSON.stringify(lockout) ); } @@ -251,8 +251,8 @@ export const identityUaServiceFactory = ({ accessTokenPeriod, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -325,8 +325,8 @@ export const identityUaServiceFactory = ({ accessTokenPeriod, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }, tx ); @@ -349,8 +349,8 @@ export const identityUaServiceFactory = ({ actorOrgId, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TUpdateUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -429,8 +429,8 @@ export const identityUaServiceFactory = ({ : undefined, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; }; diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index 9c3cc09c2..8e7644b58 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -11,8 +11,8 @@ export type TAttachUaDTO = { isActorSuperAdmin?: boolean; lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; } & Omit; export type TUpdateUaDTO = { @@ -25,8 +25,8 @@ export type TUpdateUaDTO = { accessTokenTrustedIps?: { ipAddress: string }[]; lockoutEnabled?: boolean; lockoutThreshold?: number; - lockoutDuration?: number; - lockoutCounterReset?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; } & Omit; export type TGetUaDTO = { diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index ca903bb48..5189f187c 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -152,8 +152,8 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTrustedIps, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -165,8 +165,8 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTrustedIps, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -195,8 +195,8 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenPeriod, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -209,8 +209,8 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenPeriod, lockoutEnabled, lockoutThreshold, - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 8f5ec2b8e..36f9eae4e 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -116,8 +116,8 @@ export type IdentityUniversalAuth = { accessTokenPeriod: number; lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type AddIdentityUniversalAuthDTO = { @@ -135,8 +135,8 @@ export type AddIdentityUniversalAuthDTO = { }[]; lockoutEnabled: boolean; lockoutThreshold: number; - lockoutDuration: number; - lockoutCounterReset: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type UpdateIdentityUniversalAuthDTO = { @@ -154,8 +154,8 @@ export type UpdateIdentityUniversalAuthDTO = { }[]; lockoutEnabled?: boolean; lockoutThreshold?: number; - lockoutDuration?: number; - lockoutCounterReset?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; export type DeleteIdentityUniversalAuthDTO = { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 1d080be7d..4c7fd69cc 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -151,8 +151,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { accessTokenPeriod: 0, lockoutEnabled: true, lockoutThreshold: 3, - lockoutDuration: 300, - lockoutCounterReset: 30 + lockoutDurationSeconds: 300, + lockoutCounterResetSeconds: 30 }); handlePopUpToggle("identity", false); 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 4a2802e08..9681461e6 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 @@ -215,8 +215,8 @@ export const IdentityUniversalAuthForm = ({ useEffect(() => { if (data) { - const lockoutDurationObj = getObjectFromSeconds(data.lockoutDuration); - const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterReset); + const lockoutDurationObj = getObjectFromSeconds(data.lockoutDurationSeconds); + const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterResetSeconds); reset({ accessTokenTTL: String(data.accessTokenTTL), @@ -279,8 +279,11 @@ export const IdentityUniversalAuthForm = ({ try { if (!identityId) return; - const lockoutDuration = durationToSeconds(Number(lockoutDurationValue), lockoutDurationUnit); - const lockoutCounterReset = durationToSeconds( + const lockoutDurationSeconds = durationToSeconds( + Number(lockoutDurationValue), + lockoutDurationUnit + ); + const lockoutCounterResetSeconds = durationToSeconds( Number(lockoutCounterResetValue), lockoutCounterResetUnit ); @@ -298,8 +301,8 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: Number(accessTokenPeriod), lockoutEnabled, lockoutThreshold: Number(lockoutThreshold), - lockoutDuration, - lockoutCounterReset + lockoutDurationSeconds, + lockoutCounterResetSeconds }); } else { // create new universal auth configuration @@ -315,8 +318,8 @@ export const IdentityUniversalAuthForm = ({ accessTokenPeriod: Number(accessTokenPeriod), lockoutEnabled, lockoutThreshold: Number(lockoutThreshold), - lockoutDuration: Number(lockoutDuration), - lockoutCounterReset: Number(lockoutCounterReset) + lockoutDurationSeconds: Number(lockoutDurationSeconds), + lockoutCounterResetSeconds: Number(lockoutCounterResetSeconds) }); } diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index f31e59cd8..70f7e6948 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import ms from "ms"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; @@ -135,10 +136,10 @@ export const ViewIdentityUniversalAuthContent = ({ {data.lockoutThreshold} - {data.lockoutDuration} seconds + {ms(data.lockoutDurationSeconds * 1000, { long: true })} - {data.lockoutCounterReset} seconds + {ms(data.lockoutCounterResetSeconds * 1000, { long: true })}
From 8d5b6a17b1cfd16f4a7afb192f0d94717a2a2d77 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 26 Aug 2025 20:44:23 -0400 Subject: [PATCH 09/11] Remove async from migration --- backend/src/db/migrations/20250815022242_identity-lockouts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts index e6a20f02c..d25ee4f47 100644 --- a/backend/src/db/migrations/20250815022242_identity-lockouts.ts +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -12,7 +12,7 @@ export async function up(knex: Knex): Promise { "lockoutCounterResetSeconds" ); - await knex.schema.alterTable(TableName.IdentityUniversalAuth, async (t) => { + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { if (!hasLockoutEnabled) { t.boolean("lockoutEnabled").notNullable().defaultTo(true); } From 8d6461b01d4afbbd39ba9adadbcd0f532381fe1f Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 27 Aug 2025 04:47:21 -0400 Subject: [PATCH 10/11] - Swap to using ms in some frontend areas - Rename button from "Clear All Lockouts" to "Reset All Lockouts" - Add a tooltip to the red lock icon on auth row - Make the red lock icon go away after resetting all lockouts --- frontend/src/helpers/datetime.ts | 23 ------------------- .../IdentityUniversalAuthForm.tsx | 22 +++++++----------- .../IdentityDetailsByIDPage.tsx | 1 + .../IdentityAuthenticationSection.tsx | 11 +++++---- .../ViewIdentityAuthModal.tsx | 14 ++++++++--- .../ViewIdentityUniversalAuthContent.tsx | 6 +++-- .../ViewIdentityAuthModal/types/index.ts | 1 + .../OrgSecretShareLimitSection.tsx | 11 ++++----- 8 files changed, 37 insertions(+), 52 deletions(-) diff --git a/frontend/src/helpers/datetime.ts b/frontend/src/helpers/datetime.ts index 3dd57cf52..d8a5e90c6 100644 --- a/frontend/src/helpers/datetime.ts +++ b/frontend/src/helpers/datetime.ts @@ -23,29 +23,6 @@ export const formatDateTime = ({ return format(date, dateFormat); }; -// Helper function to convert duration to seconds -export const durationToSeconds = ( - value: number, - unit: "s" | "m" | "h" | "d" | "w" | "y" -): number => { - switch (unit) { - case "s": - return value; - case "m": - return value * 60; - case "h": - return value * 60 * 60; - case "d": - return value * 60 * 60 * 24; - case "w": - return value * 60 * 60 * 24 * 7; - case "y": - return value * 60 * 60 * 24 * 365; - default: - return 0; - } -}; - // Helper function to convert seconds to value and unit export const getObjectFromSeconds = ( totalSeconds: 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 9681461e6..3fb7a3acc 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 @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -20,7 +21,7 @@ import { Tabs } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; -import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; +import { getObjectFromSeconds } from "@app/helpers/datetime"; import { useAddIdentityUniversalAuth, useGetIdentityUniversalAuth, @@ -117,11 +118,9 @@ const schema = z if (isAnyParseError) return; - const lockoutDurationInSeconds = durationToSeconds(parsedLockoutDuration, lockoutDurationUnit); - const lockoutCounterResetInSeconds = durationToSeconds( - parsedLockoutCounterReset, - lockoutCounterResetUnit - ); + const lockoutDurationInSeconds = ms(`${parsedLockoutDuration}${lockoutDurationUnit}`) / 1000; + const lockoutCounterResetInSeconds = + ms(`${parsedLockoutCounterReset}${lockoutCounterResetUnit}`) / 1000; if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) { ctx.addIssue({ @@ -279,14 +278,9 @@ export const IdentityUniversalAuthForm = ({ try { if (!identityId) return; - const lockoutDurationSeconds = durationToSeconds( - Number(lockoutDurationValue), - lockoutDurationUnit - ); - const lockoutCounterResetSeconds = durationToSeconds( - Number(lockoutCounterResetValue), - lockoutCounterResetUnit - ); + const lockoutDurationSeconds = ms(`${lockoutDurationValue}${lockoutDurationUnit}`) / 1000; + const lockoutCounterResetSeconds = + ms(`${lockoutCounterResetValue}${lockoutCounterResetUnit}`) / 1000; if (data) { // update universal auth configuration diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 1b7cb956b..446e54885 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -118,6 +118,7 @@ const Page = () => { authMethod={popUp.viewAuthMethod.data?.authMethod} lockedOut={popUp.viewAuthMethod.data?.lockedOut || false} identityId={identityId} + onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity} />
); diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx index 61268070b..a1051afb8 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx @@ -2,7 +2,7 @@ import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button } from "@app/components/v2"; +import { Button, Tooltip } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; import { IdentityAuthMethod, identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -16,7 +16,7 @@ type Props = { }; export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => { - const { data } = useGetIdentityById(identityId); + const { data, refetch } = useGetIdentityById(identityId); return data ? (
@@ -31,7 +31,8 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P onClick={() => handlePopUpOpen("viewAuthMethod", { authMethod, - lockedOut: data.identity.activeLockoutAuthMethods.includes(authMethod) + lockedOut: data.identity.activeLockoutAuthMethods.includes(authMethod), + refetchIdentity: refetch }) } type="button" @@ -40,7 +41,9 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P {identityAuthToNameMap[authMethod]}
{data.identity.activeLockoutAuthMethods.includes(authMethod) && ( - + + + )}
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx index 7236d4c27..f95a20789 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx @@ -41,6 +41,7 @@ type Props = { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; onDeleteAuthMethod: () => void; + onResetAllLockouts: () => void; }; type TRevokeOptions = { @@ -52,8 +53,12 @@ export const Content = ({ identityId, authMethod, lockedOut, - onDeleteAuthMethod -}: Pick) => { + onDeleteAuthMethod, + onResetAllLockouts +}: Pick< + Props, + "authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod" | "onResetAllLockouts" +>) => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -161,6 +166,7 @@ export const Content = ({ ) => { if (!identityId || !authMethod) return null; @@ -200,6 +207,7 @@ export const ViewIdentityAuthModal = ({ authMethod={authMethod} lockedOut={lockedOut} onDeleteAuthMethod={() => onOpenChange(false)} + onResetAllLockouts={() => onResetAllLockouts()} /> diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index 70f7e6948..a9cb5a1d1 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -26,7 +26,8 @@ export const ViewIdentityUniversalAuthContent = ({ handlePopUpOpen, onDelete, popUp, - lockedOut + lockedOut, + onResetAllLockouts }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data: clientSecrets = [], isPending: clientSecretsPending } = @@ -48,6 +49,7 @@ export const ViewIdentityUniversalAuthContent = ({ type: "success" }); setLockedOutState(false); + onResetAllLockouts(); } catch (error) { console.error(error); createNotification({ @@ -124,7 +126,7 @@ export const ViewIdentityUniversalAuthContent = ({ isLoading={isClearLockoutsPending} colorSchema="secondary" > - Clear All Lockouts + Reset All Lockouts )} 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 c566040b4..da31a233c 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/types/index.ts @@ -10,4 +10,5 @@ export type ViewAuthMethodProps = { ) => void; popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>; lockedOut: boolean; + onResetAllLockouts: () => void; }; diff --git a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx index b3e4976f3..cc90cd43f 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -1,13 +1,14 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { durationToSeconds, getObjectFromSeconds } from "@app/helpers/datetime"; +import { getObjectFromSeconds } from "@app/helpers/datetime"; import { useUpdateOrg } from "@app/hooks/api"; const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds @@ -25,7 +26,7 @@ const formSchema = z .superRefine((data, ctx) => { const { maxLifetimeValue, maxLifetimeUnit } = data; - const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit); + const durationInSeconds = ms(`${maxLifetimeValue}${maxLifetimeUnit}`) / 1000; if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) { ctx.addIssue({ @@ -90,10 +91,8 @@ export const OrgSecretShareLimitSection = () => { const handleFormSubmit = async (formData: TForm) => { try { - const maxSharedSecretLifetimeSeconds = durationToSeconds( - formData.maxLifetimeValue, - formData.maxLifetimeUnit - ); + const maxSharedSecretLifetimeSeconds = + ms(`${formData.maxLifetimeValue}${formData.maxLifetimeUnit}`) / 1000; await mutateAsync({ orgId: currentOrg.id, From 0f76003f77444358956d6f4f403328f838209ad1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 29 Aug 2025 15:23:41 -0400 Subject: [PATCH 11/11] UX Tweaks --- .../IdentityUniversalAuthForm.tsx | 2 +- .../ViewIdentityUniversalAuthContent.tsx | 57 +++++++++++-------- 2 files changed, 33 insertions(+), 26 deletions(-) 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 3fb7a3acc..2489be7be 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 @@ -453,7 +453,7 @@ export const IdentityUniversalAuthForm = ({ onCheckedChange={onChange} isChecked={value} > - Lockout {value ? "Enabled" : "Disabled"} + Lockout ); diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index a9cb5a1d1..b05a873ce 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -115,34 +115,41 @@ export const ViewIdentityUniversalAuthContent = ({ {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} -
- Lockout Options - - {(isAllowed) => ( - - )} - -
{data.lockoutEnabled ? "Enabled" : "Disabled"} - - {data.lockoutThreshold} - - - {ms(data.lockoutDurationSeconds * 1000, { long: true })} - - - {ms(data.lockoutCounterResetSeconds * 1000, { long: true })} - + {data.lockoutEnabled && ( + <> +
+ Lockout Options + + {(isAllowed) => ( + + )} + +
+ + {data.lockoutThreshold} + + + {ms(data.lockoutDurationSeconds * 1000, { long: true })} + + + {ms(data.lockoutCounterResetSeconds * 1000, { long: true })} + + + )}
Client ID