Merge pull request #4394 from Infisical/ENG-3506

feat(identities): Universal Auth Login Lockout
This commit is contained in:
x032205
2025-08-29 15:35:17 -04:00
committed by GitHub
27 changed files with 978 additions and 192 deletions
@@ -0,0 +1,57 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
}
});
}
}
@@ -18,7 +18,11 @@ export const IdentityUniversalAuthsSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
identityId: z.string().uuid(), 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<typeof IdentityUniversalAuthsSchema>; export type TIdentityUniversalAuths = z.infer<typeof IdentityUniversalAuthsSchema>;
@@ -198,6 +198,7 @@ export enum EventType {
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id",
@@ -867,6 +868,10 @@ interface AddIdentityUniversalAuthEvent {
accessTokenMaxTTL: number; accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number; accessTokenNumUsesLimit: number;
accessTokenTrustedIps: Array<TIdentityTrustedIp>; accessTokenTrustedIps: Array<TIdentityTrustedIp>;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
}; };
} }
@@ -879,6 +884,10 @@ interface UpdateIdentityUniversalAuthEvent {
accessTokenMaxTTL?: number; accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number; accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: Array<TIdentityTrustedIp>; accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
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 { interface LoginIdentityGcpAuthEvent {
type: EventType.LOGIN_IDENTITY_GCP_AUTH; type: EventType.LOGIN_IDENTITY_GCP_AUTH;
metadata: { metadata: {
@@ -3500,6 +3516,7 @@ export type Event =
| GetIdentityUniversalAuthClientSecretsEvent | GetIdentityUniversalAuthClientSecretsEvent
| GetIdentityUniversalAuthClientSecretByIdEvent | GetIdentityUniversalAuthClientSecretByIdEvent
| RevokeIdentityUniversalAuthClientSecretEvent | RevokeIdentityUniversalAuthClientSecretEvent
| ClearIdentityUniversalAuthLockoutsEvent
| LoginIdentityGcpAuthEvent | LoginIdentityGcpAuthEvent
| AddIdentityGcpAuthEvent | AddIdentityGcpAuthEvent
| DeleteIdentityGcpAuthEvent | DeleteIdentityGcpAuthEvent
+2 -1
View File
@@ -13,7 +13,8 @@ export const PgSqlLock = {
SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`),
CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`),
CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), 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; } as const;
// all the key prefixes used must be set here to avoid conflict // all the key prefixes used must be set here to avoid conflict
+15 -2
View File
@@ -166,7 +166,12 @@ export const UNIVERSAL_AUTH = {
accessTokenNumUsesLimit: accessTokenNumUsesLimit:
"The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.", "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.",
accessTokenPeriod: 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: { RETRIEVE: {
identityId: "The ID of the identity to retrieve the auth method for." 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.", accessTokenTTL: "The new lifetime for an access token in seconds.",
accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.",
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: { CREATE_CLIENT_SECRET: {
identityId: "The ID of the identity to create a client secret for.", 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.", identityId: "The ID of the identity to revoke the client secret from.",
clientSecretId: "The ID of the client secret to revoke." 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: { RENEW_ACCESS_TOKEN: {
accessToken: "The access token to renew." accessToken: "The access token to renew."
}, },
+4 -2
View File
@@ -1456,7 +1456,8 @@ export const registerRoutes = async (
identityOrgMembershipDAL, identityOrgMembershipDAL,
identityProjectDAL, identityProjectDAL,
licenseService, licenseService,
identityMetadataDAL identityMetadataDAL,
keyStore
}); });
const identityAuthTemplateService = identityAuthTemplateServiceFactory({ const identityAuthTemplateService = identityAuthTemplateServiceFactory({
@@ -1510,7 +1511,8 @@ export const registerRoutes = async (
identityAccessTokenDAL, identityAccessTokenDAL,
identityUaClientSecretDAL, identityUaClientSecretDAL,
identityUaDAL, identityUaDAL,
licenseService licenseService,
keyStore
}); });
const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({
@@ -250,7 +250,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
description: true description: true
}).optional(), }).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ 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())
}) })
}) })
}) })
@@ -137,7 +137,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
.min(0) .min(0)
.default(0) .default(0)
.describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit), .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( .refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL, (val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
@@ -171,7 +185,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps 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) .min(0)
.max(315360000) .max(315360000)
.optional() .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( .refine(
(val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
@@ -276,7 +308,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL,
accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps 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 }; 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;
}
});
}; };
@@ -8,6 +8,7 @@ import {
validatePrivilegeChangeOperation validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns"; } from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; 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 { TIdentityUaDALFactory } from "./identity-ua-dal";
import { import {
TAttachUaDTO, TAttachUaDTO,
TClearUaLockoutsDTO,
TCreateUaClientSecretDTO, TCreateUaClientSecretDTO,
TGetUaClientSecretsDTO, TGetUaClientSecretsDTO,
TGetUaDTO, TGetUaDTO,
@@ -38,30 +40,30 @@ type TIdentityUaServiceFactoryDep = {
identityOrgMembershipDAL: TIdentityOrgDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems">;
}; };
export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>; export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>;
type LockoutObject = {
lockedOut: boolean;
failedAttempts: number;
};
export const identityUaServiceFactory = ({ export const identityUaServiceFactory = ({
identityUaDAL, identityUaDAL,
identityUaClientSecretDAL, identityUaClientSecretDAL,
identityAccessTokenDAL, identityAccessTokenDAL,
identityOrgMembershipDAL, identityOrgMembershipDAL,
permissionService, permissionService,
licenseService licenseService,
keyStore
}: TIdentityUaServiceFactoryDep) => { }: TIdentityUaServiceFactoryDep) => {
const login = async (clientId: string, clientSecret: string, ip: string) => { const login = async (clientId: string, clientSecret: string, ip: string) => {
const identityUa = await identityUaDAL.findOne({ clientId }); const identityUa = await identityUaDAL.findOne({ clientId });
if (!identityUa) { if (!identityUa) {
throw new NotFoundError({ throw new UnauthorizedError({
message: "No identity with specified client ID was found" message: "Invalid credentials"
});
}
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
if (!identityMembershipOrg) {
throw new NotFoundError({
message: "No identity with the org membership was found"
}); });
} }
@@ -69,15 +71,42 @@ export const identityUaServiceFactory = ({
ipAddress: ip, ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[] trustedIps: identityUa.clientSecretTrustedIps as TIp[]
}); });
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
if (!identityMembershipOrg) {
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)]);
// Lockout Check
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
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 clientSecretPrefix = clientSecret.slice(0, 4);
const clientSecrtInfo = await identityUaClientSecretDAL.find({ const clientSecretInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id, identityUAId: identityUa.id,
isClientSecretRevoked: false, isClientSecretRevoked: false,
clientSecretPrefix clientSecretPrefix
}); });
let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null; let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
for await (const info of clientSecrtInfo) { for await (const info of clientSecretInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) { if (isMatch) {
@@ -86,7 +115,31 @@ export const identityUaServiceFactory = ({
} }
} }
if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); 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; const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) { if (Number(clientSecretTTL) > 0) {
@@ -130,8 +183,7 @@ export const identityUaServiceFactory = ({
accessTokenMaxTTL: 1000000000 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( await identityOrgMembershipDAL.updateById(
identityMembershipOrg.id, identityMembershipOrg.id,
{ {
@@ -154,33 +206,33 @@ export const identityUaServiceFactory = ({
tx tx
); );
return newToken; return { newToken, validClientSecretInfo, accessTokenTTLParams };
}); });
const appCfg = getConfig(); const appCfg = getConfig();
const accessToken = crypto.jwt().sign( const accessToken = crypto.jwt().sign(
{ {
identityId: identityUa.identityId, identityId: identityUa.identityId,
clientSecretId: validClientSecretInfo.id, clientSecretId: identityTx.validClientSecretInfo.id,
identityAccessTokenId: identityAccessToken.id, identityAccessTokenId: identityTx.newToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload, } as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET, appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error // 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 ? undefined
: { : {
expiresIn: Number(identityAccessToken.accessTokenTTL) expiresIn: Number(identityTx.newToken.accessTokenTTL)
} }
); );
return { return {
accessToken, accessToken,
identityUa, identityUa,
validClientSecretInfo, validClientSecretInfo: identityTx.validClientSecretInfo,
identityAccessToken, identityAccessToken: identityTx.newToken,
identityMembershipOrg, identityMembershipOrg,
...accessTokenTTLParams ...identityTx.accessTokenTTLParams
}; };
}; };
@@ -196,7 +248,11 @@ export const identityUaServiceFactory = ({
actor, actor,
actorOrgId, actorOrgId,
isActorSuperAdmin, isActorSuperAdmin,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}: TAttachUaDTO) => { }: TAttachUaDTO) => {
await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
@@ -266,7 +322,11 @@ export const identityUaServiceFactory = ({
accessTokenTTL, accessTokenTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps),
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}, },
tx tx
); );
@@ -286,7 +346,11 @@ export const identityUaServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}: TUpdateUaDTO) => { }: TUpdateUaDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
@@ -362,7 +426,11 @@ export const identityUaServiceFactory = ({
accessTokenPeriod, accessTokenPeriod,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps) ? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined : undefined,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}); });
return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId };
}; };
@@ -713,6 +781,38 @@ export const identityUaServiceFactory = ({
return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId }; 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 { return {
login, login,
attachUniversalAuth, attachUniversalAuth,
@@ -722,6 +822,7 @@ export const identityUaServiceFactory = ({
createUniversalAuthClientSecret, createUniversalAuthClientSecret,
getUniversalAuthClientSecrets, getUniversalAuthClientSecrets,
revokeUniversalAuthClientSecret, revokeUniversalAuthClientSecret,
getUniversalAuthClientSecretById getUniversalAuthClientSecretById,
clearUniversalAuthLockouts
}; };
}; };
@@ -9,6 +9,10 @@ export type TAttachUaDTO = {
clientSecretTrustedIps: { ipAddress: string }[]; clientSecretTrustedIps: { ipAddress: string }[];
accessTokenTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[];
isActorSuperAdmin?: boolean; isActorSuperAdmin?: boolean;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateUaDTO = { export type TUpdateUaDTO = {
@@ -19,6 +23,10 @@ export type TUpdateUaDTO = {
accessTokenPeriod?: number; accessTokenPeriod?: number;
clientSecretTrustedIps?: { ipAddress: string }[]; clientSecretTrustedIps?: { ipAddress: string }[];
accessTokenTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetUaDTO = { export type TGetUaDTO = {
@@ -45,6 +53,10 @@ export type TRevokeUaClientSecretDTO = {
clientSecretId: string; clientSecretId: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TClearUaLockoutsDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetUniversalAuthClientSecretByIdDTO = { export type TGetUniversalAuthClientSecretByIdDTO = {
identityId: string; identityId: string;
clientSecretId: string; clientSecretId: string;
@@ -8,6 +8,7 @@ import {
validatePrivilegeChangeOperation validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns"; } from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
@@ -32,6 +33,7 @@ type TIdentityServiceFactoryDep = {
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">; identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">; licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
keyStore: Pick<TKeyStoreFactory, "getKeysByPattern">;
}; };
export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>; export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
@@ -42,7 +44,8 @@ export const identityServiceFactory = ({
identityOrgMembershipDAL, identityOrgMembershipDAL,
identityProjectDAL, identityProjectDAL,
permissionService, permissionService,
licenseService licenseService,
keyStore
}: TIdentityServiceFactoryDep) => { }: TIdentityServiceFactoryDep) => {
const createIdentity = async ({ const createIdentity = async ({
name, name,
@@ -255,7 +258,20 @@ export const identityServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
return identity; const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`);
const activeLockoutAuthMethods = new Set<string>();
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 ({ const deleteIdentity = async ({
+1 -1
View File
@@ -204,7 +204,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{leftIcon && ( {leftIcon && (
<div <div
className={twMerge( className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all", "pointer-events-none inline-flex shrink-0 items-center justify-center transition-all",
loadingToggleClass, loadingToggleClass,
size === "xs" ? "mr-1" : "mr-2" size === "xs" ? "mr-1" : "mr-2"
)} )}
+59
View File
@@ -22,3 +22,62 @@ export const formatDateTime = ({
} }
return format(date, dateFormat); return format(date, dateFormat);
}; };
// 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" };
}
return {
value: totalSeconds,
unit: "s"
};
};
@@ -40,6 +40,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth",
[EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [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.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.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets",
[EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.CREATE_ENVIRONMENT]: "Create environment",
[EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment",
@@ -46,6 +46,7 @@ export enum EventType {
GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth",
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth",
@@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent {
}; };
} }
interface ClearIdentityUniversalAuthLockoutsEvent {
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS;
metadata: {
identityId: string;
clientSecretId: string;
};
}
interface CreateEnvironmentEvent { interface CreateEnvironmentEvent {
type: EventType.CREATE_ENVIRONMENT; type: EventType.CREATE_ENVIRONMENT;
metadata: { metadata: {
@@ -892,6 +900,7 @@ export type Event =
| CreateIdentityUniversalAuthClientSecretEvent | CreateIdentityUniversalAuthClientSecretEvent
| GetIdentityUniversalAuthClientSecretsEvent | GetIdentityUniversalAuthClientSecretsEvent
| RevokeIdentityUniversalAuthClientSecretEvent | RevokeIdentityUniversalAuthClientSecretEvent
| ClearIdentityUniversalAuthLockoutsEvent
| CreateEnvironmentEvent | CreateEnvironmentEvent
| UpdateEnvironmentEvent | UpdateEnvironmentEvent
| DeleteEnvironmentEvent | DeleteEnvironmentEvent
@@ -18,6 +18,7 @@ import {
AddIdentityTlsCertAuthDTO, AddIdentityTlsCertAuthDTO,
AddIdentityTokenAuthDTO, AddIdentityTokenAuthDTO,
AddIdentityUniversalAuthDTO, AddIdentityUniversalAuthDTO,
ClearIdentityUniversalAuthLockoutsDTO,
ClientSecretData, ClientSecretData,
CreateIdentityDTO, CreateIdentityDTO,
CreateIdentityUniversalAuthClientSecretDTO, CreateIdentityUniversalAuthClientSecretDTO,
@@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}) => { }) => {
const { const {
data: { identityUniversalAuth } data: { identityUniversalAuth }
@@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}); });
return identityUniversalAuth; return identityUniversalAuth;
}, },
@@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}) => { }) => {
const { const {
data: { identityUniversalAuth } data: { identityUniversalAuth }
@@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}); });
return identityUniversalAuth; return identityUniversalAuth;
}, },
@@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => {
}); });
}; };
export const useClearIdentityUniversalAuthLockouts = () => {
const queryClient = useQueryClient();
return useMutation<number, object, ClearIdentityUniversalAuthLockoutsDTO>({
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 = () => { export const useAddIdentityGcpAuth = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({ return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({
@@ -16,6 +16,7 @@ export type Identity = {
name: string; name: string;
hasDeleteProtection: boolean; hasDeleteProtection: boolean;
authMethods: IdentityAuthMethod[]; authMethods: IdentityAuthMethod[];
activeLockoutAuthMethods: IdentityAuthMethod[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isInstanceAdmin?: boolean; isInstanceAdmin?: boolean;
@@ -113,6 +114,10 @@ export type IdentityUniversalAuth = {
accessTokenNumUsesLimit: number; accessTokenNumUsesLimit: number;
accessTokenTrustedIps: IdentityTrustedIp[]; accessTokenTrustedIps: IdentityTrustedIp[];
accessTokenPeriod: number; accessTokenPeriod: number;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
}; };
export type AddIdentityUniversalAuthDTO = { export type AddIdentityUniversalAuthDTO = {
@@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = {
accessTokenTrustedIps: { accessTokenTrustedIps: {
ipAddress: string; ipAddress: string;
}[]; }[];
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
}; };
export type UpdateIdentityUniversalAuthDTO = { export type UpdateIdentityUniversalAuthDTO = {
@@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = {
accessTokenTrustedIps?: { accessTokenTrustedIps?: {
ipAddress: string; ipAddress: string;
}[]; }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
}; };
export type DeleteIdentityUniversalAuthDTO = { export type DeleteIdentityUniversalAuthDTO = {
@@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = {
clientSecretId: string; clientSecretId: string;
}; };
export type ClearIdentityUniversalAuthLockoutsDTO = {
identityId: string;
};
export type IdentityTokenAuth = { export type IdentityTokenAuth = {
identityId: string; identityId: string;
accessTokenTTL: number; accessTokenTTL: number;
@@ -148,7 +148,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
accessTokenTTL: 2592000, accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000, accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0, accessTokenNumUsesLimit: 0,
accessTokenPeriod: 0 accessTokenPeriod: 0,
lockoutEnabled: true,
lockoutThreshold: 3,
lockoutDurationSeconds: 300,
lockoutCounterResetSeconds: 30
}); });
handlePopUpToggle("identity", false); handlePopUpToggle("identity", false);
@@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -11,12 +12,16 @@ import {
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
Select,
SelectItem,
Switch,
Tab, Tab,
TabList, TabList,
TabPanel, TabPanel,
Tabs Tabs
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context"; import { useOrganization, useSubscription } from "@app/context";
import { getObjectFromSeconds } from "@app/helpers/datetime";
import { import {
useAddIdentityUniversalAuth, useAddIdentityUniversalAuth,
useGetIdentityUniversalAuth, useGetIdentityUniversalAuth,
@@ -60,9 +65,79 @@ const schema = z
ipAddress: z.string().max(50) ipAddress: z.string().max(50)
}) })
.array() .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<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -107,12 +182,25 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit: "0", accessTokenNumUsesLimit: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/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" }],
accessTokenPeriod: "0" accessTokenPeriod: "0",
lockoutEnabled: true,
lockoutThreshold: "3",
lockoutDurationValue: "5",
lockoutDurationUnit: "m",
lockoutCounterResetValue: "30",
lockoutCounterResetUnit: "s"
} }
}); });
const accessTokenPeriodValue = Number(watch("accessTokenPeriod")); 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 { const {
fields: clientSecretTrustedIpsFields, fields: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp, append: appendClientSecretTrustedIp,
@@ -126,6 +214,9 @@ export const IdentityUniversalAuthForm = ({
useEffect(() => { useEffect(() => {
if (data) { if (data) {
const lockoutDurationObj = getObjectFromSeconds(data.lockoutDurationSeconds);
const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterResetSeconds);
reset({ reset({
accessTokenTTL: String(data.accessTokenTTL), accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL), accessTokenMaxTTL: String(data.accessTokenMaxTTL),
@@ -144,7 +235,13 @@ export const IdentityUniversalAuthForm = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
}; };
} }
) ),
lockoutEnabled: data.lockoutEnabled,
lockoutThreshold: String(data.lockoutThreshold),
lockoutDurationValue: String(lockoutDurationObj.value),
lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d",
lockoutCounterResetValue: String(lockoutCounterResetObj.value),
lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h"
}); });
} else { } else {
reset({ reset({
@@ -153,7 +250,13 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit: "0", accessTokenNumUsesLimit: "0",
accessTokenPeriod: "0", accessTokenPeriod: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/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]); }, [data]);
@@ -164,11 +267,21 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
clientSecretTrustedIps, clientSecretTrustedIps,
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationValue,
lockoutDurationUnit,
lockoutCounterResetValue,
lockoutCounterResetUnit
}: FormData) => { }: FormData) => {
try { try {
if (!identityId) return; if (!identityId) return;
const lockoutDurationSeconds = ms(`${lockoutDurationValue}${lockoutDurationUnit}`) / 1000;
const lockoutCounterResetSeconds =
ms(`${lockoutCounterResetValue}${lockoutCounterResetUnit}`) / 1000;
if (data) { if (data) {
// update universal auth configuration // update universal auth configuration
await updateMutateAsync({ await updateMutateAsync({
@@ -179,7 +292,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod) accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDurationSeconds,
lockoutCounterResetSeconds
}); });
} else { } else {
// create new universal auth configuration // create new universal auth configuration
@@ -192,7 +309,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps, 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 ( return (
<form <form
onSubmit={handleSubmit(onFormSubmit, (fields) => { onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue( const firstErrorField = Object.keys(fields)[0];
["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0]) let tab = IdentityFormTab.Configuration;
? IdentityFormTab.Advanced
: 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);
})} })}
> >
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}> <Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}>
<TabList> <TabList>
<Tab value={IdentityFormTab.Configuration}>Configuration</Tab> <Tab value={IdentityFormTab.Configuration}>Configuration</Tab>
<Tab value={IdentityFormTab.Lockout}>Lockout</Tab>
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab> <Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList> </TabList>
<TabPanel value={IdentityFormTab.Configuration}> <TabPanel value={IdentityFormTab.Configuration}>
@@ -296,6 +432,187 @@ export const IdentityUniversalAuthForm = ({
)} )}
/> />
</TabPanel> </TabPanel>
<TabPanel value={IdentityFormTab.Lockout}>
<div className="mb-3 flex flex-col">
<Controller
control={control}
name="lockoutEnabled"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={`The lockout feature will prevent login attempts for ${lockoutDurationValueWatch}${lockoutDurationUnitWatch} after ${lockoutThresholdWatch} consecutive login failures. If ${lockoutCounterResetValueWatch}${lockoutCounterResetUnitWatch} pass after the most recent failure, the lockout counter resets.`}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="ml-0 mr-3 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="lockout-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
Lockout
</Switch>
</FormControl>
);
}}
/>
<div className="flex flex-col gap-2">
<Controller
control={control}
name="lockoutThreshold"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Threshold"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The amount of times login must fail before locking the identity auth method"
>
<Input
{...field}
placeholder="Enter lockout threshold..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutDurationValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Duration"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long an identity auth method lockout lasts"
>
<Input
{...field}
placeholder="Enter lockout duration..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutDurationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabledWatch ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabledWatch}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
<SelectItem
value="d"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Days</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutCounterResetValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Counter Reset"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long to wait from the most recent failed login until resetting the lockout counter"
>
<Input
{...field}
placeholder="Enter lockout counter reset..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutCounterResetUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabledWatch ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabledWatch}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
</TabPanel>
<TabPanel value={IdentityFormTab.Advanced}> <TabPanel value={IdentityFormTab.Advanced}>
{clientSecretTrustedIpsFields.map(({ id }, index) => ( {clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}> <div className="mb-3 flex items-end space-x-2" key={id}>
@@ -1,4 +1,5 @@
export enum IdentityFormTab { export enum IdentityFormTab {
Advanced = "advanced", Advanced = "advanced",
Lockout = "lockout",
Configuration = "configuration" Configuration = "configuration"
} }
@@ -115,8 +115,10 @@ const Page = () => {
<ViewIdentityAuthModal <ViewIdentityAuthModal
isOpen={popUp.viewAuthMethod.isOpen} isOpen={popUp.viewAuthMethod.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("viewAuthMethod", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("viewAuthMethod", isOpen)}
authMethod={popUp.viewAuthMethod.data} authMethod={popUp.viewAuthMethod.data?.authMethod}
lockedOut={popUp.viewAuthMethod.data?.lockedOut || false}
identityId={identityId} identityId={identityId}
onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity}
/> />
</div> </div>
); );
@@ -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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions"; 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 { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { IdentityAuthMethod, identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api"; import { IdentityAuthMethod, identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -16,7 +16,7 @@ type Props = {
}; };
export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => { export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => {
const { data } = useGetIdentityById(identityId); const { data, refetch } = useGetIdentityById(identityId);
return data ? ( return data ? (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -28,12 +28,25 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P
{data.identity.authMethods.map((authMethod) => ( {data.identity.authMethods.map((authMethod) => (
<button <button
key={authMethod} key={authMethod}
onClick={() => handlePopUpOpen("viewAuthMethod", authMethod)} onClick={() =>
handlePopUpOpen("viewAuthMethod", {
authMethod,
lockedOut: data.identity.activeLockoutAuthMethods.includes(authMethod),
refetchIdentity: refetch
})
}
type="button" type="button"
className="flex w-full items-center justify-between bg-mineshaft-900 px-4 py-2 text-sm hover:bg-mineshaft-700 data-[state=open]:bg-mineshaft-600" className="flex w-full items-center justify-between bg-mineshaft-900 px-4 py-2 text-sm hover:bg-mineshaft-700 data-[state=open]:bg-mineshaft-600"
> >
<span>{identityAuthToNameMap[authMethod]}</span> <span>{identityAuthToNameMap[authMethod]}</span>
<div className="flex gap-2">
{data.identity.activeLockoutAuthMethods.includes(authMethod) && (
<Tooltip content="Auth method has active lockouts">
<FontAwesomeIcon icon={faLock} size="xs" className="text-red-400/50" />
</Tooltip>
)}
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" /> <FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" />
</div>
</button> </button>
))} ))}
</div> </div>
@@ -37,9 +37,11 @@ import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthCon
type Props = { type Props = {
identityId: string; identityId: string;
authMethod?: IdentityAuthMethod; authMethod?: IdentityAuthMethod;
lockedOut: boolean;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
onDeleteAuthMethod: () => void; onDeleteAuthMethod: () => void;
onResetAllLockouts: () => void;
}; };
type TRevokeOptions = { type TRevokeOptions = {
@@ -50,8 +52,13 @@ type TRevokeOptions = {
export const Content = ({ export const Content = ({
identityId, identityId,
authMethod, authMethod,
onDeleteAuthMethod lockedOut,
}: Pick<Props, "authMethod" | "identityId" | "onDeleteAuthMethod">) => { onDeleteAuthMethod,
onResetAllLockouts
}: Pick<
Props,
"authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod" | "onResetAllLockouts"
>) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
@@ -159,9 +166,11 @@ export const Content = ({
<Component <Component
identityId={identityId} identityId={identityId}
onDelete={handleDelete} onDelete={handleDelete}
onResetAllLockouts={onResetAllLockouts}
popUp={popUp} popUp={popUp}
handlePopUpOpen={handlePopUpOpen} handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle} handlePopUpToggle={handlePopUpToggle}
lockedOut={lockedOut}
/> />
<DeleteActionModal <DeleteActionModal
isOpen={popUp?.revokeAuthMethod?.isOpen} isOpen={popUp?.revokeAuthMethod?.isOpen}
@@ -184,7 +193,9 @@ export const ViewIdentityAuthModal = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
authMethod, authMethod,
identityId identityId,
lockedOut,
onResetAllLockouts
}: Omit<Props, "onDeleteAuthMethod">) => { }: Omit<Props, "onDeleteAuthMethod">) => {
if (!identityId || !authMethod) return null; if (!identityId || !authMethod) return null;
@@ -194,7 +205,9 @@ export const ViewIdentityAuthModal = ({
<Content <Content
identityId={identityId} identityId={identityId}
authMethod={authMethod} authMethod={authMethod}
lockedOut={lockedOut}
onDeleteAuthMethod={() => onOpenChange(false)} onDeleteAuthMethod={() => onOpenChange(false)}
onResetAllLockouts={() => onResetAllLockouts()}
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -1,9 +1,15 @@
import { useState } from "react";
import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import 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 { useTimedReset } from "@app/hooks";
import { import {
useClearIdentityUniversalAuthLockouts,
useGetIdentityUniversalAuth, useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -19,16 +25,40 @@ export const ViewIdentityUniversalAuthContent = ({
handlePopUpToggle, handlePopUpToggle,
handlePopUpOpen, handlePopUpOpen,
onDelete, onDelete,
popUp popUp,
lockedOut,
onResetAllLockouts
}: ViewAuthMethodProps) => { }: ViewAuthMethodProps) => {
const { data, isPending } = useGetIdentityUniversalAuth(identityId); const { data, isPending } = useGetIdentityUniversalAuth(identityId);
const { data: clientSecrets = [], isPending: clientSecretsPending } = const { data: clientSecrets = [], isPending: clientSecretsPending } =
useGetIdentityUniversalAuthClientSecrets(identityId); useGetIdentityUniversalAuthClientSecrets(identityId);
const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } =
useClearIdentityUniversalAuthLockouts();
const [lockedOutState, setLockedOutState] = useState(lockedOut);
const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({ const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({
initialState: "Copy Client ID to clipboard" initialState: "Copy Client ID to clipboard"
}); });
async function clearLockouts() {
try {
const deleted = await clearLockoutsFn({ identityId });
createNotification({
text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`,
type: "success"
});
setLockedOutState(false);
onResetAllLockouts();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to clear lockouts. Please try again.",
type: "error"
});
}
}
if (isPending || clientSecretsPending) { if (isPending || clientSecretsPending) {
return ( return (
<div className="flex w-full items-center justify-center"> <div className="flex w-full items-center justify-center">
@@ -85,6 +115,41 @@ export const ViewIdentityUniversalAuthContent = ({
<IdentityAuthFieldDisplay label="Client Secret Trusted IPs"> <IdentityAuthFieldDisplay label="Client Secret Trusted IPs">
{data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")}
</IdentityAuthFieldDisplay> </IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout">
{data.lockoutEnabled ? "Enabled" : "Disabled"}
</IdentityAuthFieldDisplay>
{data.lockoutEnabled && (
<>
<div className="col-span-2 mt-3 flex justify-between border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Lockout Options</span>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed || !lockedOutState || isClearLockoutsPending}
size="xs"
onClick={() => clearLockouts()}
isLoading={isClearLockoutsPending}
colorSchema="secondary"
>
Reset All Lockouts
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthFieldDisplay label="Lockout Threshold">
{data.lockoutThreshold}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Duration">
{ms(data.lockoutDurationSeconds * 1000, { long: true })}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Counter Reset">
{ms(data.lockoutCounterResetSeconds * 1000, { long: true })}
</IdentityAuthFieldDisplay>
</>
)}
<div className="col-span-2 my-3"> <div className="col-span-2 my-3">
<div className="mb-3 border-b border-mineshaft-500 pb-2"> <div className="mb-3 border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Client ID</span> <span className="text-bunker-300">Client ID</span>
@@ -9,4 +9,6 @@ export type ViewAuthMethodProps = {
state?: boolean state?: boolean
) => void; ) => void;
popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>; popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>;
lockedOut: boolean;
onResetAllLockouts: () => void;
}; };
@@ -1,70 +1,19 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { getObjectFromSeconds } from "@app/helpers/datetime";
import { useUpdateOrg } from "@app/hooks/api"; import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds 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 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 const formSchema = z
.object({ .object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"), maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
@@ -77,34 +26,20 @@ const formSchema = z
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data; 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) { 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message, message: "Duration exceeds a maximum of 30 days",
path: ["maxLifetimeValue"] path: ["maxLifetimeValue"]
}); });
} }
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) { 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message, message: "Duration must be at least 5 minutes",
path: ["maxLifetimeValue"] path: ["maxLifetimeValue"]
}); });
} }
@@ -122,10 +57,14 @@ export const OrgSecretShareLimitSection = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const getDefaultFormValues = () => { const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime); const initialLifetime = getObjectFromSeconds(currentOrg?.maxSharedSecretLifetime, [
"m",
"h",
"d"
]);
return { return {
maxLifetimeValue: initialLifetime.maxLifetimeValue, maxLifetimeValue: initialLifetime.value,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit, maxLifetimeUnit: initialLifetime.unit as "m" | "h" | "d",
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1", maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1",
shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit) shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit)
}; };
@@ -152,10 +91,8 @@ export const OrgSecretShareLimitSection = () => {
const handleFormSubmit = async (formData: TForm) => { const handleFormSubmit = async (formData: TForm) => {
try { try {
const maxSharedSecretLifetimeSeconds = durationToSeconds( const maxSharedSecretLifetimeSeconds =
formData.maxLifetimeValue, ms(`${formData.maxLifetimeValue}${formData.maxLifetimeUnit}`) / 1000;
formData.maxLifetimeUnit
);
await mutateAsync({ await mutateAsync({
orgId: currentOrg.id, orgId: currentOrg.id,