mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(identities): Universal Auth Login Lockout
This commit is contained in:
@@ -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(),
|
||||
updatedAt: z.date(),
|
||||
identityId: z.string().uuid(),
|
||||
accessTokenPeriod: z.coerce.number().default(0)
|
||||
accessTokenPeriod: z.coerce.number().default(0),
|
||||
lockoutEnabled: z.boolean().default(true),
|
||||
lockoutThreshold: z.number().default(3),
|
||||
lockoutDuration: z.number().default(300),
|
||||
lockoutCounterReset: z.number().default(30)
|
||||
});
|
||||
|
||||
export type TIdentityUniversalAuths = z.infer<typeof IdentityUniversalAuthsSchema>;
|
||||
|
||||
@@ -198,6 +198,7 @@ export enum EventType {
|
||||
|
||||
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
|
||||
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
|
||||
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
|
||||
|
||||
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
|
||||
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id",
|
||||
@@ -866,6 +867,10 @@ interface AddIdentityUniversalAuthEvent {
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: Array<TIdentityTrustedIp>;
|
||||
lockoutEnabled: boolean;
|
||||
lockoutThreshold: number;
|
||||
lockoutDuration: number;
|
||||
lockoutCounterReset: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -878,6 +883,10 @@ interface UpdateIdentityUniversalAuthEvent {
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
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 {
|
||||
type: EventType.LOGIN_IDENTITY_GCP_AUTH;
|
||||
metadata: {
|
||||
@@ -3491,6 +3507,7 @@ export type Event =
|
||||
| GetIdentityUniversalAuthClientSecretsEvent
|
||||
| GetIdentityUniversalAuthClientSecretByIdEvent
|
||||
| RevokeIdentityUniversalAuthClientSecretEvent
|
||||
| ClearIdentityUniversalAuthLockoutsEvent
|
||||
| LoginIdentityGcpAuthEvent
|
||||
| AddIdentityGcpAuthEvent
|
||||
| DeleteIdentityGcpAuthEvent
|
||||
|
||||
@@ -13,7 +13,8 @@ export const PgSqlLock = {
|
||||
SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`),
|
||||
CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`),
|
||||
CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`),
|
||||
SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`)
|
||||
SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`),
|
||||
IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`)
|
||||
} as const;
|
||||
|
||||
// all the key prefixes used must be set here to avoid conflict
|
||||
|
||||
@@ -166,7 +166,11 @@ export const UNIVERSAL_AUTH = {
|
||||
accessTokenNumUsesLimit:
|
||||
"The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.",
|
||||
accessTokenPeriod:
|
||||
"The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0."
|
||||
"The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0.",
|
||||
lockoutEnabled: "Whether the lockout feature is enabled.",
|
||||
lockoutThreshold: "The amount of times login must fail before locking the identity auth method.",
|
||||
lockoutDuration: "How long an identity auth method lockout lasts.",
|
||||
lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter."
|
||||
},
|
||||
RETRIEVE: {
|
||||
identityId: "The ID of the identity to retrieve the auth method for."
|
||||
@@ -181,7 +185,11 @@ export const UNIVERSAL_AUTH = {
|
||||
accessTokenTTL: "The new lifetime for an access token in seconds.",
|
||||
accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.",
|
||||
accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.",
|
||||
accessTokenPeriod: "The new period for an access token in seconds."
|
||||
accessTokenPeriod: "The new period for an access token in seconds.",
|
||||
lockoutEnabled: "Whether the lockout feature is enabled.",
|
||||
lockoutThreshold: "The amount of times login must fail before locking the identity auth method.",
|
||||
lockoutDuration: "How long an identity auth method lockout lasts.",
|
||||
lockoutCounterReset: "How long to wait from the most recent failed login until resetting the lockout counter."
|
||||
},
|
||||
CREATE_CLIENT_SECRET: {
|
||||
identityId: "The ID of the identity to create a client secret for.",
|
||||
|
||||
@@ -1454,7 +1454,8 @@ export const registerRoutes = async (
|
||||
identityOrgMembershipDAL,
|
||||
identityProjectDAL,
|
||||
licenseService,
|
||||
identityMetadataDAL
|
||||
identityMetadataDAL,
|
||||
keyStore
|
||||
});
|
||||
|
||||
const identityAuthTemplateService = identityAuthTemplateServiceFactory({
|
||||
@@ -1508,7 +1509,8 @@ export const registerRoutes = async (
|
||||
identityAccessTokenDAL,
|
||||
identityUaClientSecretDAL,
|
||||
identityUaDAL,
|
||||
licenseService
|
||||
licenseService,
|
||||
keyStore
|
||||
});
|
||||
|
||||
const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({
|
||||
|
||||
@@ -250,7 +250,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
||||
description: true
|
||||
}).optional(),
|
||||
identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({
|
||||
authMethods: z.array(z.string())
|
||||
authMethods: z.array(z.string()),
|
||||
activeLockoutAuthMethods: z.array(z.string())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,7 +137,16 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit),
|
||||
accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod)
|
||||
accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod),
|
||||
lockoutEnabled: z.boolean().default(true).describe(UNIVERSAL_AUTH.ATTACH.lockoutEnabled),
|
||||
lockoutThreshold: z.number().min(1).max(30).default(3).describe(UNIVERSAL_AUTH.ATTACH.lockoutThreshold),
|
||||
lockoutDuration: z.number().min(30).max(86400).default(300).describe(UNIVERSAL_AUTH.ATTACH.lockoutDuration),
|
||||
lockoutCounterReset: z
|
||||
.number()
|
||||
.min(5)
|
||||
.max(3600)
|
||||
.default(30)
|
||||
.describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterReset)
|
||||
})
|
||||
.refine(
|
||||
(val) => val.accessTokenTTL <= val.accessTokenMaxTTL,
|
||||
@@ -171,7 +180,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
|
||||
accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL,
|
||||
accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
|
||||
clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[],
|
||||
accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit
|
||||
accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit,
|
||||
lockoutEnabled: identityUniversalAuth.lockoutEnabled,
|
||||
lockoutThreshold: identityUniversalAuth.lockoutThreshold,
|
||||
lockoutDuration: identityUniversalAuth.lockoutDuration,
|
||||
lockoutCounterReset: identityUniversalAuth.lockoutCounterReset
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -243,7 +256,16 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
|
||||
.min(0)
|
||||
.max(315360000)
|
||||
.optional()
|
||||
.describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod)
|
||||
.describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod),
|
||||
lockoutEnabled: z.boolean().optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutEnabled),
|
||||
lockoutThreshold: z.number().min(1).max(30).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutThreshold),
|
||||
lockoutDuration: z.number().min(30).max(86400).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutDuration),
|
||||
lockoutCounterReset: z
|
||||
.number()
|
||||
.min(5)
|
||||
.max(3600)
|
||||
.optional()
|
||||
.describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterReset)
|
||||
})
|
||||
.refine(
|
||||
(val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true),
|
||||
@@ -276,7 +298,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
|
||||
accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL,
|
||||
accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[],
|
||||
clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[],
|
||||
accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit
|
||||
accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit,
|
||||
lockoutEnabled: identityUniversalAuth.lockoutEnabled,
|
||||
lockoutThreshold: identityUniversalAuth.lockoutThreshold,
|
||||
lockoutDuration: identityUniversalAuth.lockoutDuration,
|
||||
lockoutCounterReset: identityUniversalAuth.lockoutCounterReset
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -594,4 +620,53 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => {
|
||||
return { clientSecretData };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/universal-auth/identities/:identityId/clear-lockouts",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.UniversalAuth],
|
||||
description: "Clear Universal Auth Lockouts for identity",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
identityId: z.string().describe(UNIVERSAL_AUTH.REVOKE_CLIENT_SECRET.identityId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
deleted: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const clearLockoutsData = await server.services.identityUa.clearUniversalAuthLockouts({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
identityId: req.params.identityId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: clearLockoutsData.orgId,
|
||||
event: {
|
||||
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS,
|
||||
metadata: {
|
||||
identityId: clearLockoutsData.identityId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return clearLockoutsData;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validatePrivilegeChangeOperation
|
||||
} from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
|
||||
@@ -22,6 +23,7 @@ import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-d
|
||||
import { TIdentityUaDALFactory } from "./identity-ua-dal";
|
||||
import {
|
||||
TAttachUaDTO,
|
||||
TClearUaLockoutsDTO,
|
||||
TCreateUaClientSecretDTO,
|
||||
TGetUaClientSecretsDTO,
|
||||
TGetUaDTO,
|
||||
@@ -38,30 +40,30 @@ type TIdentityUaServiceFactoryDep = {
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
keyStore: Pick<TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems">;
|
||||
};
|
||||
|
||||
export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>;
|
||||
|
||||
type LockoutObject = {
|
||||
lockedOut: boolean;
|
||||
failedAttempts: number;
|
||||
};
|
||||
|
||||
export const identityUaServiceFactory = ({
|
||||
identityUaDAL,
|
||||
identityUaClientSecretDAL,
|
||||
identityAccessTokenDAL,
|
||||
identityOrgMembershipDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
licenseService,
|
||||
keyStore
|
||||
}: TIdentityUaServiceFactoryDep) => {
|
||||
const login = async (clientId: string, clientSecret: string, ip: string) => {
|
||||
const identityUa = await identityUaDAL.findOne({ clientId });
|
||||
if (!identityUa) {
|
||||
throw new NotFoundError({
|
||||
message: "No identity with specified client ID was found"
|
||||
});
|
||||
}
|
||||
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
|
||||
if (!identityMembershipOrg) {
|
||||
throw new NotFoundError({
|
||||
message: "No identity with the org membership was found"
|
||||
throw new UnauthorizedError({
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,69 +71,119 @@ export const identityUaServiceFactory = ({
|
||||
ipAddress: ip,
|
||||
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
|
||||
});
|
||||
const clientSecretPrefix = clientSecret.slice(0, 4);
|
||||
const clientSecrtInfo = await identityUaClientSecretDAL.find({
|
||||
identityUAId: identityUa.id,
|
||||
isClientSecretRevoked: false,
|
||||
clientSecretPrefix
|
||||
});
|
||||
|
||||
let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null;
|
||||
for await (const info of clientSecrtInfo) {
|
||||
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
|
||||
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
|
||||
|
||||
if (isMatch) {
|
||||
validClientSecretInfo = info;
|
||||
break;
|
||||
}
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
|
||||
if (!identityMembershipOrg) {
|
||||
throw new UnauthorizedError({
|
||||
message: "Invalid credentials"
|
||||
});
|
||||
}
|
||||
|
||||
if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" });
|
||||
const identityTx = await identityUaDAL.transaction(async (tx) => {
|
||||
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.IdentityLogin(identityUa.identityId, clientId)]);
|
||||
|
||||
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
|
||||
if (Number(clientSecretTTL) > 0) {
|
||||
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
|
||||
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
|
||||
const currentDate = new Date();
|
||||
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
|
||||
// Lockout Check
|
||||
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
|
||||
|
||||
if (currentDate > expirationTime) {
|
||||
let lockout: LockoutObject | undefined;
|
||||
if (lockoutRaw) {
|
||||
lockout = JSON.parse(lockoutRaw) as LockoutObject;
|
||||
}
|
||||
|
||||
if (lockout && lockout.lockedOut) {
|
||||
throw new UnauthorizedError({
|
||||
message: "This identity auth method is temporarily locked, please try again later"
|
||||
});
|
||||
}
|
||||
|
||||
const clientSecretPrefix = clientSecret.slice(0, 4);
|
||||
const clientSecretInfo = await identityUaClientSecretDAL.find({
|
||||
identityUAId: identityUa.id,
|
||||
isClientSecretRevoked: false,
|
||||
clientSecretPrefix
|
||||
});
|
||||
|
||||
let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
|
||||
for await (const info of clientSecretInfo) {
|
||||
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
|
||||
|
||||
if (isMatch) {
|
||||
validClientSecretInfo = info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validClientSecretInfo) {
|
||||
if (identityUa.lockoutEnabled) {
|
||||
if (!lockout) {
|
||||
lockout = {
|
||||
lockedOut: false,
|
||||
failedAttempts: 0
|
||||
};
|
||||
}
|
||||
|
||||
lockout.failedAttempts += 1;
|
||||
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
|
||||
lockout.lockedOut = true;
|
||||
}
|
||||
|
||||
await keyStore.setItemWithExpiry(
|
||||
LOCKOUT_KEY,
|
||||
lockout.lockedOut ? identityUa.lockoutDuration : identityUa.lockoutCounterReset,
|
||||
JSON.stringify(lockout)
|
||||
);
|
||||
}
|
||||
|
||||
throw new UnauthorizedError({ message: "Invalid credentials" });
|
||||
} else if (lockout) {
|
||||
await keyStore.deleteItem(LOCKOUT_KEY);
|
||||
}
|
||||
|
||||
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
|
||||
if (Number(clientSecretTTL) > 0) {
|
||||
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
|
||||
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
|
||||
const currentDate = new Date();
|
||||
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationTime) {
|
||||
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
||||
isClientSecretRevoked: true
|
||||
});
|
||||
|
||||
throw new UnauthorizedError({
|
||||
message: "Access denied due to expired client secret"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
|
||||
// number of times client secret can be used for
|
||||
// a login operation reached
|
||||
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
||||
isClientSecretRevoked: true
|
||||
});
|
||||
|
||||
throw new UnauthorizedError({
|
||||
message: "Access denied due to expired client secret"
|
||||
message: "Access denied due to client secret usage limit reached"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
|
||||
// number of times client secret can be used for
|
||||
// a login operation reached
|
||||
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
||||
isClientSecretRevoked: true
|
||||
});
|
||||
throw new UnauthorizedError({
|
||||
message: "Access denied due to client secret usage limit reached"
|
||||
});
|
||||
}
|
||||
const accessTokenTTLParams =
|
||||
Number(identityUa.accessTokenPeriod) === 0
|
||||
? {
|
||||
accessTokenTTL: identityUa.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
|
||||
}
|
||||
: {
|
||||
accessTokenTTL: identityUa.accessTokenPeriod,
|
||||
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
|
||||
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
|
||||
accessTokenMaxTTL: 1000000000
|
||||
};
|
||||
|
||||
const accessTokenTTLParams =
|
||||
Number(identityUa.accessTokenPeriod) === 0
|
||||
? {
|
||||
accessTokenTTL: identityUa.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
|
||||
}
|
||||
: {
|
||||
accessTokenTTL: identityUa.accessTokenPeriod,
|
||||
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
|
||||
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
|
||||
accessTokenMaxTTL: 1000000000
|
||||
};
|
||||
|
||||
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
|
||||
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
|
||||
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx);
|
||||
await identityOrgMembershipDAL.updateById(
|
||||
identityMembershipOrg.id,
|
||||
{
|
||||
@@ -154,33 +206,33 @@ export const identityUaServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
|
||||
return newToken;
|
||||
return { newToken, validClientSecretInfo, accessTokenTTLParams };
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const accessToken = crypto.jwt().sign(
|
||||
{
|
||||
identityId: identityUa.identityId,
|
||||
clientSecretId: validClientSecretInfo.id,
|
||||
identityAccessTokenId: identityAccessToken.id,
|
||||
clientSecretId: identityTx.validClientSecretInfo.id,
|
||||
identityAccessTokenId: identityTx.newToken.id,
|
||||
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
||||
} as TIdentityAccessTokenJwtPayload,
|
||||
appCfg.AUTH_SECRET,
|
||||
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
|
||||
Number(identityAccessToken.accessTokenTTL) === 0
|
||||
Number(identityTx.newToken.accessTokenTTL) === 0
|
||||
? undefined
|
||||
: {
|
||||
expiresIn: Number(identityAccessToken.accessTokenTTL)
|
||||
expiresIn: Number(identityTx.newToken.accessTokenTTL)
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
identityUa,
|
||||
validClientSecretInfo,
|
||||
identityAccessToken,
|
||||
validClientSecretInfo: identityTx.validClientSecretInfo,
|
||||
identityAccessToken: identityTx.newToken,
|
||||
identityMembershipOrg,
|
||||
...accessTokenTTLParams
|
||||
...identityTx.accessTokenTTLParams
|
||||
};
|
||||
};
|
||||
|
||||
@@ -196,7 +248,11 @@ export const identityUaServiceFactory = ({
|
||||
actor,
|
||||
actorOrgId,
|
||||
isActorSuperAdmin,
|
||||
accessTokenPeriod
|
||||
accessTokenPeriod,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
}: TAttachUaDTO) => {
|
||||
await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
|
||||
|
||||
@@ -266,7 +322,11 @@ export const identityUaServiceFactory = ({
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps),
|
||||
accessTokenPeriod
|
||||
accessTokenPeriod,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -286,7 +346,11 @@ export const identityUaServiceFactory = ({
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
actorOrgId,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
}: TUpdateUaDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
|
||||
@@ -362,7 +426,11 @@ export const identityUaServiceFactory = ({
|
||||
accessTokenPeriod,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
|
||||
? JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
: undefined
|
||||
: undefined,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
});
|
||||
return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
@@ -713,6 +781,38 @@ export const identityUaServiceFactory = ({
|
||||
return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const clearUniversalAuthLockouts = async ({
|
||||
identityId,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
}: TClearUaLockoutsDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
|
||||
|
||||
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) {
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have universal auth"
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const deleted = await keyStore.deleteItems({
|
||||
pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*`
|
||||
});
|
||||
|
||||
return { deleted, identityId, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
return {
|
||||
login,
|
||||
attachUniversalAuth,
|
||||
@@ -722,6 +822,7 @@ export const identityUaServiceFactory = ({
|
||||
createUniversalAuthClientSecret,
|
||||
getUniversalAuthClientSecrets,
|
||||
revokeUniversalAuthClientSecret,
|
||||
getUniversalAuthClientSecretById
|
||||
getUniversalAuthClientSecretById,
|
||||
clearUniversalAuthLockouts
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,10 @@ export type TAttachUaDTO = {
|
||||
clientSecretTrustedIps: { ipAddress: string }[];
|
||||
accessTokenTrustedIps: { ipAddress: string }[];
|
||||
isActorSuperAdmin?: boolean;
|
||||
lockoutEnabled: boolean;
|
||||
lockoutThreshold: number;
|
||||
lockoutDuration: number;
|
||||
lockoutCounterReset: number;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateUaDTO = {
|
||||
@@ -19,6 +23,10 @@ export type TUpdateUaDTO = {
|
||||
accessTokenPeriod?: number;
|
||||
clientSecretTrustedIps?: { ipAddress: string }[];
|
||||
accessTokenTrustedIps?: { ipAddress: string }[];
|
||||
lockoutEnabled?: boolean;
|
||||
lockoutThreshold?: number;
|
||||
lockoutDuration?: number;
|
||||
lockoutCounterReset?: number;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetUaDTO = {
|
||||
@@ -45,6 +53,10 @@ export type TRevokeUaClientSecretDTO = {
|
||||
clientSecretId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TClearUaLockoutsDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetUniversalAuthClientSecretByIdDTO = {
|
||||
identityId: string;
|
||||
clientSecretId: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validatePrivilegeChangeOperation
|
||||
} from "@app/ee/services/permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
|
||||
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
|
||||
|
||||
@@ -32,6 +33,7 @@ type TIdentityServiceFactoryDep = {
|
||||
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
|
||||
keyStore: Pick<TKeyStoreFactory, "getKeysByPattern">;
|
||||
};
|
||||
|
||||
export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
|
||||
@@ -42,7 +44,8 @@ export const identityServiceFactory = ({
|
||||
identityOrgMembershipDAL,
|
||||
identityProjectDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
licenseService,
|
||||
keyStore
|
||||
}: TIdentityServiceFactoryDep) => {
|
||||
const createIdentity = async ({
|
||||
name,
|
||||
@@ -255,7 +258,20 @@ export const identityServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
return identity;
|
||||
const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`);
|
||||
|
||||
const activeLockoutAuthMethods = new Set<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 ({
|
||||
|
||||
@@ -204,7 +204,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{leftIcon && (
|
||||
<div
|
||||
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,
|
||||
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.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret",
|
||||
[EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret",
|
||||
[EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts",
|
||||
[EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets",
|
||||
[EventType.CREATE_ENVIRONMENT]: "Create environment",
|
||||
[EventType.UPDATE_ENVIRONMENT]: "Update environment",
|
||||
|
||||
@@ -46,6 +46,7 @@ export enum EventType {
|
||||
GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth",
|
||||
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
|
||||
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
|
||||
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
|
||||
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
|
||||
|
||||
LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth",
|
||||
|
||||
@@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface ClearIdentityUniversalAuthLockoutsEvent {
|
||||
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS;
|
||||
metadata: {
|
||||
identityId: string;
|
||||
clientSecretId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateEnvironmentEvent {
|
||||
type: EventType.CREATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
@@ -892,6 +900,7 @@ export type Event =
|
||||
| CreateIdentityUniversalAuthClientSecretEvent
|
||||
| GetIdentityUniversalAuthClientSecretsEvent
|
||||
| RevokeIdentityUniversalAuthClientSecretEvent
|
||||
| ClearIdentityUniversalAuthLockoutsEvent
|
||||
| CreateEnvironmentEvent
|
||||
| UpdateEnvironmentEvent
|
||||
| DeleteEnvironmentEvent
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
AddIdentityTlsCertAuthDTO,
|
||||
AddIdentityTokenAuthDTO,
|
||||
AddIdentityUniversalAuthDTO,
|
||||
ClearIdentityUniversalAuthLockoutsDTO,
|
||||
ClientSecretData,
|
||||
CreateIdentityDTO,
|
||||
CreateIdentityUniversalAuthClientSecretDTO,
|
||||
@@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => {
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps
|
||||
accessTokenTrustedIps,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
}) => {
|
||||
const {
|
||||
data: { identityUniversalAuth }
|
||||
@@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => {
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps
|
||||
accessTokenTrustedIps,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
});
|
||||
return identityUniversalAuth;
|
||||
},
|
||||
@@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => {
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
accessTokenPeriod
|
||||
accessTokenPeriod,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
}) => {
|
||||
const {
|
||||
data: { identityUniversalAuth }
|
||||
@@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => {
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
accessTokenPeriod
|
||||
accessTokenPeriod,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
});
|
||||
return identityUniversalAuth;
|
||||
},
|
||||
@@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useClearIdentityUniversalAuthLockouts = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<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 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({
|
||||
|
||||
@@ -49,7 +49,9 @@ export const identitiesKeys = {
|
||||
getIdentityTokensTokenAuth: (identityId: string) =>
|
||||
[{ identityId }, "identity-tokens-token-auth"] as const,
|
||||
getIdentityProjectMemberships: (identityId: string) =>
|
||||
[{ identityId }, "identity-project-memberships"] as const
|
||||
[{ identityId }, "identity-project-memberships"] as const,
|
||||
clearIdentityUniversalAuthLockouts: (identityId: string) =>
|
||||
[{ identityId }, "clear-identity-universal-auth-lockouts"] as const
|
||||
};
|
||||
|
||||
export const useGetIdentityById = (identityId: string) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type Identity = {
|
||||
name: string;
|
||||
hasDeleteProtection: boolean;
|
||||
authMethods: IdentityAuthMethod[];
|
||||
activeLockoutAuthMethods: IdentityAuthMethod[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isInstanceAdmin?: boolean;
|
||||
@@ -113,6 +114,10 @@ export type IdentityUniversalAuth = {
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: IdentityTrustedIp[];
|
||||
accessTokenPeriod: number;
|
||||
lockoutEnabled: boolean;
|
||||
lockoutThreshold: number;
|
||||
lockoutDuration: number;
|
||||
lockoutCounterReset: number;
|
||||
};
|
||||
|
||||
export type AddIdentityUniversalAuthDTO = {
|
||||
@@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = {
|
||||
accessTokenTrustedIps: {
|
||||
ipAddress: string;
|
||||
}[];
|
||||
lockoutEnabled: boolean;
|
||||
lockoutThreshold: number;
|
||||
lockoutDuration: number;
|
||||
lockoutCounterReset: number;
|
||||
};
|
||||
|
||||
export type UpdateIdentityUniversalAuthDTO = {
|
||||
@@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = {
|
||||
accessTokenTrustedIps?: {
|
||||
ipAddress: string;
|
||||
}[];
|
||||
lockoutEnabled?: boolean;
|
||||
lockoutThreshold?: number;
|
||||
lockoutDuration?: number;
|
||||
lockoutCounterReset?: number;
|
||||
};
|
||||
|
||||
export type DeleteIdentityUniversalAuthDTO = {
|
||||
@@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = {
|
||||
clientSecretId: string;
|
||||
};
|
||||
|
||||
export type ClearIdentityUniversalAuthLockoutsDTO = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export type IdentityTokenAuth = {
|
||||
identityId: string;
|
||||
accessTokenTTL: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Switch,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
@@ -60,7 +61,26 @@ const schema = z
|
||||
ipAddress: z.string().max(50)
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
.min(1),
|
||||
lockoutEnabled: z.boolean().default(true),
|
||||
lockoutThreshold: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => Number(value) <= 30 && Number(value) >= 1,
|
||||
"Lockout threshold must be between 1 and 30"
|
||||
),
|
||||
lockoutDuration: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => Number(value) <= 86400 && Number(value) >= 30,
|
||||
"Lockout duration must be between 30 seconds and 1 day"
|
||||
),
|
||||
lockoutCounterReset: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => Number(value) <= 3600 && Number(value) >= 5,
|
||||
"Lockout counter reset must be between 5 seconds and 1 hour"
|
||||
)
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -107,12 +127,21 @@ export const IdentityUniversalAuthForm = ({
|
||||
accessTokenNumUsesLimit: "0",
|
||||
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
accessTokenPeriod: "0"
|
||||
accessTokenPeriod: "0",
|
||||
lockoutEnabled: true,
|
||||
lockoutThreshold: "3",
|
||||
lockoutDuration: "300",
|
||||
lockoutCounterReset: "30"
|
||||
}
|
||||
});
|
||||
|
||||
const accessTokenPeriodValue = Number(watch("accessTokenPeriod"));
|
||||
|
||||
const lockoutEnabled = watch("lockoutEnabled");
|
||||
const lockoutThreshold = watch("lockoutThreshold");
|
||||
const lockoutDuration = watch("lockoutDuration");
|
||||
const lockoutCounterReset = watch("lockoutCounterReset");
|
||||
|
||||
const {
|
||||
fields: clientSecretTrustedIpsFields,
|
||||
append: appendClientSecretTrustedIp,
|
||||
@@ -144,7 +173,11 @@ export const IdentityUniversalAuthForm = ({
|
||||
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
|
||||
};
|
||||
}
|
||||
)
|
||||
),
|
||||
lockoutEnabled: data.lockoutEnabled,
|
||||
lockoutThreshold: String(data.lockoutThreshold),
|
||||
lockoutDuration: String(data.lockoutDuration),
|
||||
lockoutCounterReset: String(data.lockoutCounterReset)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
@@ -153,7 +186,11 @@ export const IdentityUniversalAuthForm = ({
|
||||
accessTokenNumUsesLimit: "0",
|
||||
accessTokenPeriod: "0",
|
||||
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
|
||||
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
|
||||
lockoutEnabled: true,
|
||||
lockoutThreshold: "3",
|
||||
lockoutDuration: "300",
|
||||
lockoutCounterReset: "30"
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
@@ -164,7 +201,11 @@ export const IdentityUniversalAuthForm = ({
|
||||
accessTokenNumUsesLimit,
|
||||
clientSecretTrustedIps,
|
||||
accessTokenTrustedIps,
|
||||
accessTokenPeriod
|
||||
accessTokenPeriod,
|
||||
lockoutEnabled,
|
||||
lockoutThreshold,
|
||||
lockoutDuration,
|
||||
lockoutCounterReset
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!identityId) return;
|
||||
@@ -179,7 +220,11 @@ export const IdentityUniversalAuthForm = ({
|
||||
accessTokenMaxTTL: Number(accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
|
||||
accessTokenTrustedIps,
|
||||
accessTokenPeriod: Number(accessTokenPeriod)
|
||||
accessTokenPeriod: Number(accessTokenPeriod),
|
||||
lockoutEnabled,
|
||||
lockoutThreshold: Number(lockoutThreshold),
|
||||
lockoutDuration: Number(lockoutDuration),
|
||||
lockoutCounterReset: Number(lockoutCounterReset)
|
||||
});
|
||||
} else {
|
||||
// create new universal auth configuration
|
||||
@@ -192,7 +237,11 @@ export const IdentityUniversalAuthForm = ({
|
||||
accessTokenMaxTTL: Number(accessTokenMaxTTL),
|
||||
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
|
||||
accessTokenTrustedIps,
|
||||
accessTokenPeriod: Number(accessTokenPeriod)
|
||||
accessTokenPeriod: Number(accessTokenPeriod),
|
||||
lockoutEnabled,
|
||||
lockoutThreshold: Number(lockoutThreshold),
|
||||
lockoutDuration: Number(lockoutDuration),
|
||||
lockoutCounterReset: Number(lockoutCounterReset)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,13 +269,21 @@ export const IdentityUniversalAuthForm = ({
|
||||
setTabValue(
|
||||
["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0])
|
||||
? IdentityFormTab.Advanced
|
||||
: IdentityFormTab.Configuration
|
||||
: [
|
||||
"lockoutEnabled",
|
||||
"lockoutThreshold",
|
||||
"lockoutDuration",
|
||||
"lockoutCounterReset"
|
||||
].includes(Object.keys(fields)[0])
|
||||
? IdentityFormTab.Lockout
|
||||
: IdentityFormTab.Configuration
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}>
|
||||
<TabList>
|
||||
<Tab value={IdentityFormTab.Configuration}>Configuration</Tab>
|
||||
<Tab value={IdentityFormTab.Lockout}>Lockout</Tab>
|
||||
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={IdentityFormTab.Configuration}>
|
||||
@@ -296,6 +353,84 @@ export const IdentityUniversalAuthForm = ({
|
||||
)}
|
||||
/>
|
||||
</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}>
|
||||
{clientSecretTrustedIpsFields.map(({ id }, index) => (
|
||||
<div className="mb-3 flex items-end space-x-2" key={id}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum IdentityFormTab {
|
||||
Advanced = "advanced",
|
||||
Lockout = "lockout",
|
||||
Configuration = "configuration"
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ const Page = () => {
|
||||
<ViewIdentityAuthModal
|
||||
isOpen={popUp.viewAuthMethod.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("viewAuthMethod", isOpen)}
|
||||
authMethod={popUp.viewAuthMethod.data}
|
||||
authMethod={popUp.viewAuthMethod.data?.authMethod}
|
||||
lockedOut={popUp.viewAuthMethod.data?.lockedOut || false}
|
||||
identityId={identityId}
|
||||
/>
|
||||
</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 { OrgPermissionCan } from "@app/components/permissions";
|
||||
@@ -28,12 +28,22 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P
|
||||
{data.identity.authMethods.map((authMethod) => (
|
||||
<button
|
||||
key={authMethod}
|
||||
onClick={() => handlePopUpOpen("viewAuthMethod", authMethod)}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("viewAuthMethod", {
|
||||
authMethod,
|
||||
lockedOut: data.identity.activeLockoutAuthMethods.includes(authMethod)
|
||||
})
|
||||
}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthCon
|
||||
type Props = {
|
||||
identityId: string;
|
||||
authMethod?: IdentityAuthMethod;
|
||||
lockedOut: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onDeleteAuthMethod: () => void;
|
||||
@@ -50,8 +51,9 @@ type TRevokeOptions = {
|
||||
export const Content = ({
|
||||
identityId,
|
||||
authMethod,
|
||||
lockedOut,
|
||||
onDeleteAuthMethod
|
||||
}: Pick<Props, "authMethod" | "identityId" | "onDeleteAuthMethod">) => {
|
||||
}: Pick<Props, "authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod">) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
@@ -162,6 +164,7 @@ export const Content = ({
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
lockedOut={lockedOut}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp?.revokeAuthMethod?.isOpen}
|
||||
@@ -184,7 +187,8 @@ export const ViewIdentityAuthModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
authMethod,
|
||||
identityId
|
||||
identityId,
|
||||
lockedOut
|
||||
}: Omit<Props, "onDeleteAuthMethod">) => {
|
||||
if (!identityId || !authMethod) return null;
|
||||
|
||||
@@ -194,6 +198,7 @@ export const ViewIdentityAuthModal = ({
|
||||
<Content
|
||||
identityId={identityId}
|
||||
authMethod={authMethod}
|
||||
lockedOut={lockedOut}
|
||||
onDeleteAuthMethod={() => onOpenChange(false)}
|
||||
/>
|
||||
</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 { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import {
|
||||
useClearIdentityUniversalAuthLockouts,
|
||||
useGetIdentityUniversalAuth,
|
||||
useGetIdentityUniversalAuthClientSecrets
|
||||
} from "@app/hooks/api";
|
||||
@@ -13,22 +14,42 @@ import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay";
|
||||
import { IdentityUniversalAuthClientSecretsTable } from "./IdentityUniversalAuthClientSecretsTable";
|
||||
import { ViewAuthMethodProps } from "./types";
|
||||
import { ViewIdentityContentWrapper } from "./ViewIdentityContentWrapper";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { useState } from "react";
|
||||
|
||||
export const ViewIdentityUniversalAuthContent = ({
|
||||
identityId,
|
||||
handlePopUpToggle,
|
||||
handlePopUpOpen,
|
||||
onDelete,
|
||||
popUp
|
||||
popUp,
|
||||
lockedOut
|
||||
}: ViewAuthMethodProps) => {
|
||||
const { data, isPending } = useGetIdentityUniversalAuth(identityId);
|
||||
const { data: clientSecrets = [], isPending: clientSecretsPending } =
|
||||
useGetIdentityUniversalAuthClientSecrets(identityId);
|
||||
const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } =
|
||||
useClearIdentityUniversalAuthLockouts();
|
||||
|
||||
const [lockedOutState, setLockedOutState] = useState(lockedOut);
|
||||
|
||||
const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({
|
||||
initialState: "Copy Client ID to clipboard"
|
||||
});
|
||||
|
||||
async function clearLockouts() {
|
||||
const deleted = await clearLockoutsFn({ identityId });
|
||||
|
||||
createNotification({
|
||||
text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
setLockedOutState(false);
|
||||
}
|
||||
|
||||
if (isPending || clientSecretsPending) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
@@ -85,6 +106,40 @@ export const ViewIdentityUniversalAuthContent = ({
|
||||
<IdentityAuthFieldDisplay label="Client Secret Trusted IPs">
|
||||
{data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")}
|
||||
</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="mb-3 border-b border-mineshaft-500 pb-2">
|
||||
<span className="text-bunker-300">Client ID</span>
|
||||
|
||||
@@ -9,4 +9,5 @@ export type ViewAuthMethodProps = {
|
||||
state?: boolean
|
||||
) => void;
|
||||
popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>;
|
||||
lockedOut: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user