feat(identities): Universal Auth Login Lockout

This commit is contained in:
x032205
2025-08-18 23:57:31 +08:00
parent ca2825ba95
commit 6d5bed756a
25 changed files with 644 additions and 108 deletions
@@ -0,0 +1,25 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) {
await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => {
t.boolean("lockoutEnabled").notNullable().defaultTo(true);
t.integer("lockoutThreshold").notNullable().defaultTo(3);
t.integer("lockoutDuration").notNullable().defaultTo(300); // 5 minutes (in seconds)
t.integer("lockoutCounterReset").notNullable().defaultTo(30); // 30 seconds
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) {
await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => {
t.dropColumn("lockoutEnabled");
t.dropColumn("lockoutThreshold");
t.dropColumn("lockoutDuration");
t.dropColumn("lockoutCounterReset");
});
}
}
@@ -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),
lockoutDuration: z.number().default(300),
lockoutCounterReset: 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",
@@ -866,6 +867,10 @@ interface AddIdentityUniversalAuthEvent {
accessTokenMaxTTL: number; accessTokenMaxTTL: number;
accessTokenNumUsesLimit: number; accessTokenNumUsesLimit: number;
accessTokenTrustedIps: Array<TIdentityTrustedIp>; accessTokenTrustedIps: Array<TIdentityTrustedIp>;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDuration: number;
lockoutCounterReset: number;
}; };
} }
@@ -878,6 +883,10 @@ interface UpdateIdentityUniversalAuthEvent {
accessTokenMaxTTL?: number; accessTokenMaxTTL?: number;
accessTokenNumUsesLimit?: number; accessTokenNumUsesLimit?: number;
accessTokenTrustedIps?: Array<TIdentityTrustedIp>; accessTokenTrustedIps?: Array<TIdentityTrustedIp>;
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDuration?: number;
lockoutCounterReset?: number;
}; };
} }
@@ -1037,6 +1046,13 @@ interface RevokeIdentityUniversalAuthClientSecretEvent {
}; };
} }
interface ClearIdentityUniversalAuthLockoutsEvent {
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS;
metadata: {
identityId: string;
};
}
interface LoginIdentityGcpAuthEvent { interface LoginIdentityGcpAuthEvent {
type: EventType.LOGIN_IDENTITY_GCP_AUTH; type: EventType.LOGIN_IDENTITY_GCP_AUTH;
metadata: { metadata: {
@@ -3491,6 +3507,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
+10 -2
View File
@@ -166,7 +166,11 @@ 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.",
lockoutDuration: "How long an identity auth method lockout lasts.",
lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter."
}, },
RETRIEVE: { RETRIEVE: {
identityId: "The ID of the identity to retrieve the auth method for." identityId: "The ID of the identity to retrieve the auth method for."
@@ -181,7 +185,11 @@ export const UNIVERSAL_AUTH = {
accessTokenTTL: "The new lifetime for an access token in seconds.", 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.",
lockoutDuration: "How long an identity auth method lockout lasts.",
lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter."
}, },
CREATE_CLIENT_SECRET: { 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.",
+4 -2
View File
@@ -1454,7 +1454,8 @@ export const registerRoutes = async (
identityOrgMembershipDAL, identityOrgMembershipDAL,
identityProjectDAL, identityProjectDAL,
licenseService, licenseService,
identityMetadataDAL identityMetadataDAL,
keyStore
}); });
const identityAuthTemplateService = identityAuthTemplateServiceFactory({ const identityAuthTemplateService = identityAuthTemplateServiceFactory({
@@ -1508,7 +1509,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,16 @@ 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),
lockoutDuration: z.number().min(30).max(86400).default(300).describe(UNIVERSAL_AUTH.ATTACH.lockoutDuration),
lockoutCounterReset: z
.number()
.min(5)
.max(3600)
.default(30)
.describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterReset)
}) })
.refine( .refine(
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL, (val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
@@ -171,7 +180,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,
lockoutDuration: identityUniversalAuth.lockoutDuration,
lockoutCounterReset: identityUniversalAuth.lockoutCounterReset
} }
} }
}); });
@@ -243,7 +256,16 @@ 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),
lockoutDuration: z.number().min(30).max(86400).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutDuration),
lockoutCounterReset: z
.number()
.min(5)
.max(3600)
.optional()
.describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterReset)
}) })
.refine( .refine(
(val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
@@ -276,7 +298,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,
lockoutDuration: identityUniversalAuth.lockoutDuration,
lockoutCounterReset: identityUniversalAuth.lockoutCounterReset
} }
} }
}); });
@@ -594,4 +620,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.REVOKE_CLIENT_SECRET.identityId)
}),
response: {
200: z.object({
deleted: z.number()
})
}
},
handler: async (req) => {
const clearLockoutsData = await server.services.identityUa.clearUniversalAuthLockouts({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
identityId: req.params.identityId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: clearLockoutsData.orgId,
event: {
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS,
metadata: {
identityId: clearLockoutsData.identityId
}
}
});
return clearLockoutsData;
}
});
}; };
@@ -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,69 +71,119 @@ export const identityUaServiceFactory = ({
ipAddress: ip, ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[] 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; const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
for await (const info of clientSecrtInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
validClientSecretInfo = info; if (!identityMembershipOrg) {
break; 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; // Lockout Check
if (Number(clientSecretTTL) > 0) { const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
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) { let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
if (lockout && lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
const clientSecretPrefix = clientSecret.slice(0, 4);
const clientSecretInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
for await (const info of clientSecretInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) {
validClientSecretInfo = info;
break;
}
}
if (!validClientSecretInfo) {
if (identityUa.lockoutEnabled) {
if (!lockout) {
lockout = {
lockedOut: false,
failedAttempts: 0
};
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
lockout.lockedOut = true;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDuration : identityUa.lockoutCounterReset,
JSON.stringify(lockout)
);
}
throw new UnauthorizedError({ message: "Invalid credentials" });
} else if (lockout) {
await keyStore.deleteItem(LOCKOUT_KEY);
}
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationTime) {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true isClientSecretRevoked: true
}); });
throw new UnauthorizedError({ 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) { const accessTokenTTLParams =
// number of times client secret can be used for Number(identityUa.accessTokenPeriod) === 0
// a login operation reached ? {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { accessTokenTTL: identityUa.accessTokenTTL,
isClientSecretRevoked: true accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}); }
throw new UnauthorizedError({ : {
message: "Access denied due to client secret usage limit reached" 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 = const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx);
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);
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,
lockoutDuration,
lockoutCounterReset
}: 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,
lockoutDuration,
lockoutCounterReset
}, },
tx tx
); );
@@ -286,7 +346,11 @@ export const identityUaServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}: 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,
lockoutDuration,
lockoutCounterReset
}); });
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;
lockoutDuration: number;
lockoutCounterReset: 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;
lockoutDuration?: number;
lockoutCounterReset?: 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"
)} )}
@@ -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,
lockoutDuration,
lockoutCounterReset
}) => { }) => {
const { const {
data: { identityUniversalAuth } data: { identityUniversalAuth }
@@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL, accessTokenTTL,
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}); });
return identityUniversalAuth; return identityUniversalAuth;
}, },
@@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}) => { }) => {
const { const {
data: { identityUniversalAuth } data: { identityUniversalAuth }
@@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL, accessTokenMaxTTL,
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}); });
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.clearIdentityUniversalAuthLockouts(identityId)
});
}
});
};
export const useAddIdentityGcpAuth = () => { export const useAddIdentityGcpAuth = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({ return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({
@@ -49,7 +49,9 @@ export const identitiesKeys = {
getIdentityTokensTokenAuth: (identityId: string) => getIdentityTokensTokenAuth: (identityId: string) =>
[{ identityId }, "identity-tokens-token-auth"] as const, [{ identityId }, "identity-tokens-token-auth"] as const,
getIdentityProjectMemberships: (identityId: string) => getIdentityProjectMemberships: (identityId: string) =>
[{ identityId }, "identity-project-memberships"] as const [{ identityId }, "identity-project-memberships"] as const,
clearIdentityUniversalAuthLockouts: (identityId: string) =>
[{ identityId }, "clear-identity-universal-auth-lockouts"] as const
}; };
export const useGetIdentityById = (identityId: string) => { export const useGetIdentityById = (identityId: string) => {
@@ -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;
lockoutDuration: number;
lockoutCounterReset: number;
}; };
export type AddIdentityUniversalAuthDTO = { export type AddIdentityUniversalAuthDTO = {
@@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = {
accessTokenTrustedIps: { accessTokenTrustedIps: {
ipAddress: string; ipAddress: string;
}[]; }[];
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDuration: number;
lockoutCounterReset: number;
}; };
export type UpdateIdentityUniversalAuthDTO = { export type UpdateIdentityUniversalAuthDTO = {
@@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = {
accessTokenTrustedIps?: { accessTokenTrustedIps?: {
ipAddress: string; ipAddress: string;
}[]; }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDuration?: number;
lockoutCounterReset?: 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;
@@ -11,6 +11,7 @@ import {
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
Switch,
Tab, Tab,
TabList, TabList,
TabPanel, TabPanel,
@@ -60,7 +61,26 @@ 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"
),
lockoutDuration: z
.string()
.refine(
(value) => Number(value) <= 86400 && Number(value) >= 30,
"Lockout duration must be between 30 seconds and 1 day"
),
lockoutCounterReset: z
.string()
.refine(
(value) => Number(value) <= 3600 && Number(value) >= 5,
"Lockout counter reset must be between 5 seconds and 1 hour"
)
}) })
.required(); .required();
@@ -107,12 +127,21 @@ 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",
lockoutDuration: "300",
lockoutCounterReset: "30"
} }
}); });
const accessTokenPeriodValue = Number(watch("accessTokenPeriod")); const accessTokenPeriodValue = Number(watch("accessTokenPeriod"));
const lockoutEnabled = watch("lockoutEnabled");
const lockoutThreshold = watch("lockoutThreshold");
const lockoutDuration = watch("lockoutDuration");
const lockoutCounterReset = watch("lockoutCounterReset");
const { const {
fields: clientSecretTrustedIpsFields, fields: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp, append: appendClientSecretTrustedIp,
@@ -144,7 +173,11 @@ export const IdentityUniversalAuthForm = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
}; };
} }
) ),
lockoutEnabled: data.lockoutEnabled,
lockoutThreshold: String(data.lockoutThreshold),
lockoutDuration: String(data.lockoutDuration),
lockoutCounterReset: String(data.lockoutCounterReset)
}); });
} else { } else {
reset({ reset({
@@ -153,7 +186,11 @@ 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",
lockoutDuration: "300",
lockoutCounterReset: "30"
}); });
} }
}, [data]); }, [data]);
@@ -164,7 +201,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit, accessTokenNumUsesLimit,
clientSecretTrustedIps, clientSecretTrustedIps,
accessTokenTrustedIps, accessTokenTrustedIps,
accessTokenPeriod accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDuration,
lockoutCounterReset
}: FormData) => { }: FormData) => {
try { try {
if (!identityId) return; if (!identityId) return;
@@ -179,7 +220,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),
lockoutDuration: Number(lockoutDuration),
lockoutCounterReset: Number(lockoutCounterReset)
}); });
} else { } else {
// create new universal auth configuration // create new universal auth configuration
@@ -192,7 +237,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),
lockoutDuration: Number(lockoutDuration),
lockoutCounterReset: Number(lockoutCounterReset)
}); });
} }
@@ -220,13 +269,21 @@ export const IdentityUniversalAuthForm = ({
setTabValue( setTabValue(
["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0]) ["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0])
? IdentityFormTab.Advanced ? IdentityFormTab.Advanced
: IdentityFormTab.Configuration : [
"lockoutEnabled",
"lockoutThreshold",
"lockoutDuration",
"lockoutCounterReset"
].includes(Object.keys(fields)[0])
? IdentityFormTab.Lockout
: IdentityFormTab.Configuration
); );
})} })}
> >
<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 +353,84 @@ export const IdentityUniversalAuthForm = ({
)} )}
/> />
</TabPanel> </TabPanel>
<TabPanel value={IdentityFormTab.Lockout}>
<div className="mb-3 flex flex-col">
<Controller
control={control}
name="lockoutEnabled"
defaultValue={true}
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={`The lockout feature will prevent login attempts for ${lockoutDuration || 300} seconds after ${lockoutThreshold || 3} consecutive login failures. If ${lockoutCounterReset || 30} seconds pass after the most recent failure, the lockout counter resets.`}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="ml-0 mr-3 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="lockout-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
Lockout {value ? "Enabled" : "Disabled"}
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutThreshold"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabled ? "" : "opacity-70"}`}
label="Lockout Threshold"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="3" isDisabled={!lockoutEnabled} />
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutDuration"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabled ? "" : "opacity-70"}`}
label="Lockout Duration (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="300" isDisabled={!lockoutEnabled} />
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutCounterReset"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabled ? "" : "opacity-70"}`}
label="Lockout Counter Reset (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="30" isDisabled={!lockoutEnabled} />
</FormControl>
);
}}
/>
</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,7 +115,8 @@ 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}
/> />
</div> </div>
@@ -1,4 +1,4 @@
import { faCog, faPlus } from "@fortawesome/free-solid-svg-icons"; import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
@@ -28,12 +28,22 @@ 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)
})
}
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>
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" /> <div className="flex gap-2">
{data.identity.activeLockoutAuthMethods.includes(authMethod) && (
<FontAwesomeIcon icon={faLock} size="xs" className="text-red-400/50" />
)}
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" />
</div>
</button> </button>
))} ))}
</div> </div>
@@ -37,6 +37,7 @@ 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;
@@ -50,8 +51,9 @@ type TRevokeOptions = {
export const Content = ({ export const Content = ({
identityId, identityId,
authMethod, authMethod,
lockedOut,
onDeleteAuthMethod onDeleteAuthMethod
}: Pick<Props, "authMethod" | "identityId" | "onDeleteAuthMethod">) => { }: Pick<Props, "authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod">) => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
@@ -162,6 +164,7 @@ export const Content = ({
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 +187,8 @@ export const ViewIdentityAuthModal = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
authMethod, authMethod,
identityId identityId,
lockedOut
}: Omit<Props, "onDeleteAuthMethod">) => { }: Omit<Props, "onDeleteAuthMethod">) => {
if (!identityId || !authMethod) return null; if (!identityId || !authMethod) return null;
@@ -194,6 +198,7 @@ export const ViewIdentityAuthModal = ({
<Content <Content
identityId={identityId} identityId={identityId}
authMethod={authMethod} authMethod={authMethod}
lockedOut={lockedOut}
onDeleteAuthMethod={() => onOpenChange(false)} onDeleteAuthMethod={() => onOpenChange(false)}
/> />
</ModalContent> </ModalContent>
@@ -1,9 +1,10 @@
import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { faArrowsRotate, faBan, faCheck, faCopy, faFire } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks"; import { useTimedReset } from "@app/hooks";
import { import {
useClearIdentityUniversalAuthLockouts,
useGetIdentityUniversalAuth, useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -13,22 +14,42 @@ import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable"; import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable";
import { ViewAuthMethodProps } from "./types"; import { ViewAuthMethodProps } from "./types";
import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper"; import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { OrgPermissionCan } from "@app/components/permissions";
import { createNotification } from "@app/components/notifications";
import { useState } from "react";
export const ViewIdentityUniversalAuthContent = ({ export const ViewIdentityUniversalAuthContent = ({
identityId, identityId,
handlePopUpToggle, handlePopUpToggle,
handlePopUpOpen, handlePopUpOpen,
onDelete, onDelete,
popUp popUp,
lockedOut
}: 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() {
const deleted = await clearLockoutsFn({ identityId });
createNotification({
text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`,
type: "success"
});
setLockedOutState(false);
}
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 +106,40 @@ export const ViewIdentityUniversalAuthContent = ({
<IdentityAuthFieldDisplay label="Client Secret Trusted IPs"> <IdentityAuthFieldDisplay label="Client Secret Trusted IPs">
{data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")} {data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")}
</IdentityAuthFieldDisplay> </IdentityAuthFieldDisplay>
<div className="col-span-2 mt-3 flex justify-between border-b border-mineshaft-500 pb-2">
<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}
leftIcon={
isClearLockoutsPending ? (
<FontAwesomeIcon icon={faArrowsRotate} className="animate-spin" />
) : (
<FontAwesomeIcon icon={faFire} />
)
}
colorSchema="secondary"
>
Clear All Lockouts
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthFieldDisplay label="Lockout">
{data.lockoutEnabled ? "Enabled" : "Disabled"}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Threshold">
{data.lockoutThreshold}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Duration">
{data.lockoutDuration} seconds
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Counter Reset">
{data.lockoutCounterReset} seconds
</IdentityAuthFieldDisplay>
<div className="col-span-2 my-3"> <div className="col-span-2 my-3">
<div className="mb-3 border-b border-mineshaft-500 pb-2"> <div className="mb-3 border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Client ID</span> <span className="text-bunker-300">Client ID</span>
@@ -9,4 +9,5 @@ export type ViewAuthMethodProps = {
state?: boolean state?: boolean
) => void; ) => void;
popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>; popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>;
lockedOut: boolean;
}; };