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..d25ee4f47 --- /dev/null +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -0,0 +1,57 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +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, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDurationSeconds").notNullable().defaultTo(300); // 5 minutes + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterResetSeconds").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, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDurationSeconds"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterResetSeconds"); + } + }); + } +} diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index da27b4a55..29e8314aa 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), + 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 818c46446..10c84cc4a 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", @@ -867,6 +868,10 @@ interface AddIdentityUniversalAuthEvent { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: Array; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; } @@ -879,6 +884,10 @@ interface UpdateIdentityUniversalAuthEvent { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: Array; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; } @@ -1038,6 +1047,13 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityGcpAuthEvent { type: EventType.LOGIN_IDENTITY_GCP_AUTH; metadata: { @@ -3500,6 +3516,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 47f5ca5cd..985f3b60f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -166,7 +166,12 @@ 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.", + 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." @@ -181,7 +186,12 @@ 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.", + 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.", @@ -201,6 +211,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/index.ts b/backend/src/server/routes/index.ts index 099c04084..3c1038ded 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1456,7 +1456,8 @@ export const registerRoutes = async ( identityOrgMembershipDAL, identityProjectDAL, licenseService, - identityMetadataDAL + identityMetadataDAL, + keyStore }); const identityAuthTemplateService = identityAuthTemplateServiceFactory({ @@ -1510,7 +1511,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 9acd515c4..65b9448c5 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..6d911a88e 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,21 @@ 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), + 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.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -171,7 +185,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, + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -243,7 +261,21 @@ 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), + 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.lockoutCounterResetSeconds) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -276,7 +308,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, + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -594,4 +630,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.CLEAR_CLIENT_LOCKOUTS.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..3aeb5e83c 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.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, + 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, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -266,7 +322,11 @@ export const identityUaServiceFactory = ({ accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }, tx ); @@ -286,7 +346,11 @@ export const identityUaServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: 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, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); 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..8e7644b58 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; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; } & Omit; export type TUpdateUaDTO = { @@ -19,6 +23,10 @@ export type TUpdateUaDTO = { accessTokenPeriod?: number; clientSecretTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: 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 && (
+): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => { + const SECONDS_IN_MINUTE = 60; + const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; + const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; + const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; + const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365; + + const activeUnitsSet = activeUnits ? new Set(activeUnits) : null; + + const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => { + return activeUnitsSet ? activeUnitsSet.has(unit) : true; + }; + + if ( + isUnitActive("y") && + totalSeconds >= SECONDS_IN_YEAR && + totalSeconds % SECONDS_IN_YEAR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" }; + } + + if ( + isUnitActive("w") && + totalSeconds >= SECONDS_IN_WEEK && + totalSeconds % SECONDS_IN_WEEK === 0 + ) { + return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" }; + } + + if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) { + return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" }; + } + + if ( + isUnitActive("h") && + totalSeconds >= SECONDS_IN_HOUR && + totalSeconds % SECONDS_IN_HOUR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" }; + } + + if ( + isUnitActive("m") && + totalSeconds >= SECONDS_IN_MINUTE && + totalSeconds % SECONDS_IN_MINUTE === 0 + ) { + return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" }; + } + + return { + value: totalSeconds, + unit: "s" + }; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 696f5745f..186a8e539 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -40,6 +40,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", + [EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 159a61808..4fe8948dd 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -46,6 +46,7 @@ export enum EventType { GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 0b9e201fe..1ca819e60 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -892,6 +900,7 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | ClearIdentityUniversalAuthLockoutsEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index c01f1aa85..5189f187c 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -18,6 +18,7 @@ import { AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, + ClearIdentityUniversalAuthLockoutsDTO, ClientSecretData, CreateIdentityDTO, CreateIdentityUniversalAuthClientSecretDTO, @@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { }); }; +export const useClearIdentityUniversalAuthLockouts = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { deleted } + } = await apiRequest.post<{ deleted: number }>( + `/api/v1/auth/universal-auth/identities/${identityId}/clear-lockouts` + ); + return deleted; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityUniversalAuth(identityId) + }); + } + }); +}; + export const useAddIdentityGcpAuth = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7098ce244..36f9eae4e 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; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type AddIdentityUniversalAuthDTO = { @@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = { accessTokenTrustedIps: { ipAddress: string; }[]; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type UpdateIdentityUniversalAuthDTO = { @@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = { accessTokenTrustedIps?: { ipAddress: string; }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; export type DeleteIdentityUniversalAuthDTO = { @@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = { clientSecretId: string; }; +export type ClearIdentityUniversalAuthLockoutsDTO = { + identityId: string; +}; + export type IdentityTokenAuth = { identityId: string; accessTokenTTL: number; 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..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 @@ -148,7 +148,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { accessTokenTTL: 2592000, accessTokenMaxTTL: 2592000, accessTokenNumUsesLimit: 0, - accessTokenPeriod: 0 + accessTokenPeriod: 0, + lockoutEnabled: true, + lockoutThreshold: 3, + lockoutDurationSeconds: 300, + lockoutCounterResetSeconds: 30 }); handlePopUpToggle("identity", false); 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..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 @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -11,12 +12,16 @@ import { FormControl, IconButton, Input, + Select, + SelectItem, + Switch, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useOrganization, useSubscription } from "@app/context"; +import { getObjectFromSeconds } from "@app/helpers/datetime"; import { useAddIdentityUniversalAuth, useGetIdentityUniversalAuth, @@ -60,9 +65,79 @@ const schema = z ipAddress: z.string().max(50) }) .array() - .min(1) + .min(1), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z + .string() + .refine( + (value) => Number(value) <= 30 && Number(value) >= 1, + "Lockout threshold must be between 1 and 30" + ), + lockoutDurationValue: z.string(), + lockoutDurationUnit: z.enum(["s", "m", "h", "d"], { + invalid_type_error: "Please select a valid time unit" + }), + lockoutCounterResetValue: z.string(), + lockoutCounterResetUnit: z.enum(["s", "m", "h"], { + invalid_type_error: "Please select a valid time unit" + }) }) - .required(); + .required() + .superRefine((data, ctx) => { + const { + lockoutDurationValue, + lockoutCounterResetValue, + lockoutDurationUnit, + lockoutCounterResetUnit, + lockoutEnabled + } = data; + + if (!lockoutEnabled) return; + + let isAnyParseError = false; + + const parsedLockoutDuration = parseInt(lockoutDurationValue, 10); + if (Number.isNaN(parsedLockoutDuration)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout duration must be a number", + path: ["lockoutDurationValue"] + }); + isAnyParseError = true; + } + + const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10); + if (Number.isNaN(parsedLockoutCounterReset)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout counter reset must be a number", + path: ["lockoutCounterResetValue"] + }); + isAnyParseError = true; + } + + if (isAnyParseError) return; + + const lockoutDurationInSeconds = ms(`${parsedLockoutDuration}${lockoutDurationUnit}`) / 1000; + const lockoutCounterResetInSeconds = + ms(`${parsedLockoutCounterReset}${lockoutCounterResetUnit}`) / 1000; + + if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout duration must be between 30 seconds and 1 day", + path: ["lockoutDurationValue"] + }); + } + + if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Lockout counter reset must be between 5 seconds and 1 hour", + path: ["lockoutCounterResetValue"] + }); + } + }); export type FormData = z.infer; @@ -107,12 +182,25 @@ export const IdentityUniversalAuthForm = ({ accessTokenNumUsesLimit: "0", clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenPeriod: "0" + accessTokenPeriod: "0", + lockoutEnabled: true, + lockoutThreshold: "3", + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" } }); const accessTokenPeriodValue = Number(watch("accessTokenPeriod")); + const lockoutEnabledWatch = watch("lockoutEnabled"); + const lockoutThresholdWatch = watch("lockoutThreshold"); + const lockoutDurationValueWatch = watch("lockoutDurationValue"); + const lockoutDurationUnitWatch = watch("lockoutDurationUnit"); + const lockoutCounterResetValueWatch = watch("lockoutCounterResetValue"); + const lockoutCounterResetUnitWatch = watch("lockoutCounterResetUnit"); + const { fields: clientSecretTrustedIpsFields, append: appendClientSecretTrustedIp, @@ -126,6 +214,9 @@ export const IdentityUniversalAuthForm = ({ useEffect(() => { if (data) { + const lockoutDurationObj = getObjectFromSeconds(data.lockoutDurationSeconds); + const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterResetSeconds); + reset({ accessTokenTTL: String(data.accessTokenTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL), @@ -144,7 +235,13 @@ export const IdentityUniversalAuthForm = ({ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` }; } - ) + ), + lockoutEnabled: data.lockoutEnabled, + lockoutThreshold: String(data.lockoutThreshold), + lockoutDurationValue: String(lockoutDurationObj.value), + lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d", + lockoutCounterResetValue: String(lockoutCounterResetObj.value), + lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h" }); } else { reset({ @@ -153,7 +250,13 @@ export const IdentityUniversalAuthForm = ({ accessTokenNumUsesLimit: "0", accessTokenPeriod: "0", clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + lockoutEnabled: true, + lockoutThreshold: "3", + lockoutDurationValue: "5", + lockoutDurationUnit: "m", + lockoutCounterResetValue: "30", + lockoutCounterResetUnit: "s" }); } }, [data]); @@ -164,11 +267,21 @@ export const IdentityUniversalAuthForm = ({ accessTokenNumUsesLimit, clientSecretTrustedIps, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationValue, + lockoutDurationUnit, + lockoutCounterResetValue, + lockoutCounterResetUnit }: FormData) => { try { if (!identityId) return; + const lockoutDurationSeconds = ms(`${lockoutDurationValue}${lockoutDurationUnit}`) / 1000; + const lockoutCounterResetSeconds = + ms(`${lockoutCounterResetValue}${lockoutCounterResetUnit}`) / 1000; + if (data) { // update universal auth configuration await updateMutateAsync({ @@ -179,7 +292,11 @@ export const IdentityUniversalAuthForm = ({ accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenTrustedIps, - accessTokenPeriod: Number(accessTokenPeriod) + accessTokenPeriod: Number(accessTokenPeriod), + lockoutEnabled, + lockoutThreshold: Number(lockoutThreshold), + lockoutDurationSeconds, + lockoutCounterResetSeconds }); } else { // create new universal auth configuration @@ -192,7 +309,11 @@ export const IdentityUniversalAuthForm = ({ accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenTrustedIps, - accessTokenPeriod: Number(accessTokenPeriod) + accessTokenPeriod: Number(accessTokenPeriod), + lockoutEnabled, + lockoutThreshold: Number(lockoutThreshold), + lockoutDurationSeconds: Number(lockoutDurationSeconds), + lockoutCounterResetSeconds: Number(lockoutCounterResetSeconds) }); } @@ -217,16 +338,31 @@ export const IdentityUniversalAuthForm = ({ return (
{ - setTabValue( - ["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0]) - ? IdentityFormTab.Advanced - : IdentityFormTab.Configuration - ); + const firstErrorField = Object.keys(fields)[0]; + let tab = IdentityFormTab.Configuration; + + if (["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(firstErrorField)) { + tab = IdentityFormTab.Advanced; + } else if ( + [ + "lockoutEnabled", + "lockoutThreshold", + "lockoutDurationValue", + "lockoutDurationUnit", + "lockoutCounterResetValue", + "lockoutCounterResetUnit" + ].includes(firstErrorField) + ) { + tab = IdentityFormTab.Lockout; + } + + setTabValue(tab); })} > setTabValue(value as IdentityFormTab)}> Configuration + Lockout Advanced @@ -296,6 +432,187 @@ export const IdentityUniversalAuthForm = ({ )} /> + +
+ { + return ( + + + Lockout + + + ); + }} + /> +
+ { + 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..446e54885 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -115,8 +115,10 @@ const Page = () => { handlePopUpToggle("viewAuthMethod", isOpen)} - authMethod={popUp.viewAuthMethod.data} + authMethod={popUp.viewAuthMethod.data?.authMethod} + lockedOut={popUp.viewAuthMethod.data?.lockedOut || false} identityId={identityId} + onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity} />
); diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx index 467fc7dcc..a1051afb8 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityAuthenticationSection.tsx @@ -1,8 +1,8 @@ -import { faCog, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button } from "@app/components/v2"; +import { Button, Tooltip } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; import { IdentityAuthMethod, identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -16,7 +16,7 @@ type Props = { }; export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => { - const { data } = useGetIdentityById(identityId); + const { data, refetch } = useGetIdentityById(identityId); return data ? (
@@ -28,12 +28,25 @@ 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..f95a20789 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx @@ -37,9 +37,11 @@ import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthCon type Props = { identityId: string; authMethod?: IdentityAuthMethod; + lockedOut: boolean; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; onDeleteAuthMethod: () => void; + onResetAllLockouts: () => void; }; type TRevokeOptions = { @@ -50,8 +52,13 @@ type TRevokeOptions = { export const Content = ({ identityId, authMethod, - onDeleteAuthMethod -}: Pick) => { + lockedOut, + onDeleteAuthMethod, + onResetAllLockouts +}: Pick< + Props, + "authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod" | "onResetAllLockouts" +>) => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -159,9 +166,11 @@ export const Content = ({ ) => { if (!identityId || !authMethod) return null; @@ -194,7 +205,9 @@ export const ViewIdentityAuthModal = ({ 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 ce6415171..b05a873ce 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -1,9 +1,15 @@ +import { useState } from "react"; import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import ms from "ms"; -import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { + useClearIdentityUniversalAuthLockouts, useGetIdentityUniversalAuth, useGetIdentityUniversalAuthClientSecrets } from "@app/hooks/api"; @@ -19,16 +25,40 @@ export const ViewIdentityUniversalAuthContent = ({ handlePopUpToggle, handlePopUpOpen, onDelete, - popUp + popUp, + lockedOut, + onResetAllLockouts }: ViewAuthMethodProps) => { const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data: clientSecrets = [], isPending: clientSecretsPending } = useGetIdentityUniversalAuthClientSecrets(identityId); + const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } = + useClearIdentityUniversalAuthLockouts(); + + const [lockedOutState, setLockedOutState] = useState(lockedOut); const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset({ initialState: "Copy Client ID to clipboard" }); + async function clearLockouts() { + try { + const deleted = await clearLockoutsFn({ identityId }); + createNotification({ + text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, + type: "success" + }); + setLockedOutState(false); + onResetAllLockouts(); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to clear lockouts. Please try again.", + type: "error" + }); + } + } + if (isPending || clientSecretsPending) { return (
@@ -85,6 +115,41 @@ export const ViewIdentityUniversalAuthContent = ({ {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} + + {data.lockoutEnabled ? "Enabled" : "Disabled"} + + {data.lockoutEnabled && ( + <> +
+ Lockout Options + + {(isAllowed) => ( + + )} + +
+ + {data.lockoutThreshold} + + + {ms(data.lockoutDurationSeconds * 1000, { long: true })} + + + {ms(data.lockoutCounterResetSeconds * 1000, { long: true })} + + + )}
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..da31a233c 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,6 @@ export type ViewAuthMethodProps = { state?: boolean ) => 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 648ad031c..cc90cd43f 100644 --- a/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx +++ b/frontend/src/pages/organization/SecretSharingSettingsPage/components/OrgSecretShareLimitSection/OrgSecretShareLimitSection.tsx @@ -1,70 +1,19 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { getObjectFromSeconds } from "@app/helpers/datetime"; import { useUpdateOrg } from "@app/hooks/api"; const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds -// Helper function to convert duration to seconds -const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => { - switch (unit) { - case "m": - return value * 60; - case "h": - return value * 60 * 60; - case "d": - return value * 60 * 60 * 24; - default: - return 0; - } -}; - -// Helper function to convert seconds to form lifetime value and unit -const getFormLifetimeFromSeconds = ( - totalSeconds: number | null | undefined -): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => { - const DEFAULT_LIFETIME_VALUE = 30; - const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d"; - - if (totalSeconds == null || totalSeconds <= 0) { - return { - maxLifetimeValue: DEFAULT_LIFETIME_VALUE, - maxLifetimeUnit: DEFAULT_LIFETIME_UNIT - }; - } - - const secondsInDay = 24 * 60 * 60; - const secondsInHour = 60 * 60; - const secondsInMinute = 60; - - if (totalSeconds % secondsInDay === 0) { - const value = totalSeconds / secondsInDay; - if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" }; - } - - if (totalSeconds % secondsInHour === 0) { - const value = totalSeconds / secondsInHour; - if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" }; - } - - if (totalSeconds % secondsInMinute === 0) { - const value = totalSeconds / secondsInMinute; - if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" }; - } - - return { - maxLifetimeValue: DEFAULT_LIFETIME_VALUE, - maxLifetimeUnit: DEFAULT_LIFETIME_UNIT - }; -}; - const formSchema = z .object({ maxLifetimeValue: z.number().min(1, "Value must be at least 1"), @@ -77,34 +26,20 @@ const formSchema = z .superRefine((data, ctx) => { const { maxLifetimeValue, maxLifetimeUnit } = data; - const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit); + const durationInSeconds = ms(`${maxLifetimeValue}${maxLifetimeUnit}`) / 1000; - // Check max limit if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) { - let message = "Duration exceeds maximum allowed limit"; - - if (maxLifetimeUnit === "m") { - message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`; - } else if (maxLifetimeUnit === "h") { - message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`; - } else if (maxLifetimeUnit === "d") { - message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`; - } - ctx.addIssue({ code: z.ZodIssueCode.custom, - message, + message: "Duration exceeds a maximum of 30 days", path: ["maxLifetimeValue"] }); } - // Check min limit if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) { - const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes - ctx.addIssue({ code: z.ZodIssueCode.custom, - message, + message: "Duration must be at least 5 minutes", path: ["maxLifetimeValue"] }); } @@ -122,10 +57,14 @@ export const OrgSecretShareLimitSection = () => { const { currentOrg } = useOrganization(); const getDefaultFormValues = () => { - const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime); + const initialLifetime = getObjectFromSeconds(currentOrg?.maxSharedSecretLifetime, [ + "m", + "h", + "d" + ]); return { - maxLifetimeValue: initialLifetime.maxLifetimeValue, - maxLifetimeUnit: initialLifetime.maxLifetimeUnit, + maxLifetimeValue: initialLifetime.value, + maxLifetimeUnit: initialLifetime.unit as "m" | "h" | "d", maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1", shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit) }; @@ -152,10 +91,8 @@ export const OrgSecretShareLimitSection = () => { const handleFormSubmit = async (formData: TForm) => { try { - const maxSharedSecretLifetimeSeconds = durationToSeconds( - formData.maxLifetimeValue, - formData.maxLifetimeUnit - ); + const maxSharedSecretLifetimeSeconds = + ms(`${formData.maxLifetimeValue}${formData.maxLifetimeUnit}`) / 1000; await mutateAsync({ orgId: currentOrg.id,