feat: initial implementation for totp authenticator

This commit is contained in:
Sheen Capadngan
2024-11-14 00:07:35 +08:00
parent 2d2ad0724f
commit 6bdbac4750
36 changed files with 1250 additions and 117 deletions
+61
View File
@@ -75,6 +75,7 @@
"openid-client": "^5.6.5", "openid-client": "^5.6.5",
"ora": "^7.0.1", "ora": "^7.0.1",
"oracledb": "^6.4.0", "oracledb": "^6.4.0",
"otplib": "^12.0.1",
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"passport-gitlab2": "^5.0.0", "passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
@@ -6815,6 +6816,48 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@peculiar/asn1-cms": { "node_modules/@peculiar/asn1-cms": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz",
@@ -16453,6 +16496,16 @@
"node": ">=14.6" "node": ">=14.6"
} }
}, },
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/p-finally": { "node_modules/p-finally": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -19553,6 +19606,14 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/thread-stream": { "node_modules/thread-stream": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz",
+1
View File
@@ -181,6 +181,7 @@
"openid-client": "^5.6.5", "openid-client": "^5.6.5",
"ora": "^7.0.1", "ora": "^7.0.1",
"oracledb": "^6.4.0", "oracledb": "^6.4.0",
"otplib": "^12.0.1",
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"passport-gitlab2": "^5.0.0", "passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
+2
View File
@@ -79,6 +79,7 @@ import { TServiceTokenServiceFactory } from "@app/services/service-token/service
import { TSlackServiceFactory } from "@app/services/slack/slack-service"; import { TSlackServiceFactory } from "@app/services/slack/slack-service";
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { TTotpServiceFactory } from "@app/services/totp/totp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
@@ -193,6 +194,7 @@ declare module "fastify" {
migration: TExternalMigrationServiceFactory; migration: TExternalMigrationServiceFactory;
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
projectTemplate: TProjectTemplateServiceFactory; projectTemplate: TProjectTemplateServiceFactory;
totp: TTotpServiceFactory;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer // everywhere else access using service layer
+4
View File
@@ -314,6 +314,9 @@ import {
TSuperAdmin, TSuperAdmin,
TSuperAdminInsert, TSuperAdminInsert,
TSuperAdminUpdate, TSuperAdminUpdate,
TTotpConfigs,
TTotpConfigsInsert,
TTotpConfigsUpdate,
TTrustedIps, TTrustedIps,
TTrustedIpsInsert, TTrustedIpsInsert,
TTrustedIpsUpdate, TTrustedIpsUpdate,
@@ -826,5 +829,6 @@ declare module "knex/types/tables" {
TProjectTemplatesInsert, TProjectTemplatesInsert,
TProjectTemplatesUpdate TProjectTemplatesUpdate
>; >;
[TableName.TotpConfig]: KnexOriginal.CompositeTableType<TTotpConfigs, TTotpConfigsInsert, TTotpConfigsUpdate>;
} }
} }
@@ -0,0 +1,54 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.TotpConfig))) {
await knex.schema.createTable(TableName.TotpConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.boolean("isVerified").defaultTo(false).notNullable();
t.binary("encryptedRecoveryCodes").notNullable();
t.binary("encryptedSecret").notNullable();
t.timestamps(true, true, true);
t.unique("userId");
});
await createOnUpdateTrigger(knex, TableName.TotpConfig);
}
const doesOrgMfaMethodColExist = await knex.schema.hasColumn(TableName.Organization, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Organization, (t) => {
if (!doesOrgMfaMethodColExist) {
t.string("selectedMfaMethod");
}
});
const doesUserSelectedMfaMethodColExist = await knex.schema.hasColumn(TableName.Users, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Users, (t) => {
if (!doesUserSelectedMfaMethodColExist) {
t.string("selectedMfaMethod");
}
});
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.TotpConfig);
await knex.schema.dropTableIfExists(TableName.TotpConfig);
const doesOrgMfaMethodColExist = await knex.schema.hasColumn(TableName.Organization, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Organization, (t) => {
if (doesOrgMfaMethodColExist) {
t.dropColumn("selectedMfaMethod");
}
});
const doesUserSelectedMfaMethodColExist = await knex.schema.hasColumn(TableName.Users, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Users, (t) => {
if (doesUserSelectedMfaMethodColExist) {
t.dropColumn("selectedMfaMethod");
}
});
}
+1
View File
@@ -106,6 +106,7 @@ export * from "./secrets-v2";
export * from "./service-tokens"; export * from "./service-tokens";
export * from "./slack-integrations"; export * from "./slack-integrations";
export * from "./super-admin"; export * from "./super-admin";
export * from "./totp-configs";
export * from "./trusted-ips"; export * from "./trusted-ips";
export * from "./user-actions"; export * from "./user-actions";
export * from "./user-aliases"; export * from "./user-aliases";
+1
View File
@@ -117,6 +117,7 @@ export enum TableName {
ExternalKms = "external_kms", ExternalKms = "external_kms",
InternalKms = "internal_kms", InternalKms = "internal_kms",
InternalKmsKeyVersion = "internal_kms_key_version", InternalKmsKeyVersion = "internal_kms_key_version",
TotpConfig = "totp_configs",
// @depreciated // @depreciated
KmsKeyVersion = "kms_key_versions", KmsKeyVersion = "kms_key_versions",
WorkflowIntegrations = "workflow_integrations", WorkflowIntegrations = "workflow_integrations",
+2 -1
View File
@@ -21,7 +21,8 @@ export const OrganizationsSchema = z.object({
kmsDefaultKeyId: z.string().uuid().nullable().optional(), kmsDefaultKeyId: z.string().uuid().nullable().optional(),
kmsEncryptedDataKey: zodBuffer.nullable().optional(), kmsEncryptedDataKey: zodBuffer.nullable().optional(),
defaultMembershipRole: z.string().default("member"), defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false) enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional()
}); });
export type TOrganizations = z.infer<typeof OrganizationsSchema>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;
+24
View File
@@ -0,0 +1,24 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const TotpConfigsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
isVerified: z.boolean().default(false),
encryptedRecoveryCodes: zodBuffer,
encryptedSecret: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TTotpConfigs = z.infer<typeof TotpConfigsSchema>;
export type TTotpConfigsInsert = Omit<z.input<typeof TotpConfigsSchema>, TImmutableDBKeys>;
export type TTotpConfigsUpdate = Partial<Omit<z.input<typeof TotpConfigsSchema>, TImmutableDBKeys>>;
+2 -1
View File
@@ -26,7 +26,8 @@ export const UsersSchema = z.object({
consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
isLocked: z.boolean().default(false).nullable().optional(), isLocked: z.boolean().default(false).nullable().optional(),
temporaryLockDateEnd: z.date().nullable().optional(), temporaryLockDateEnd: z.date().nullable().optional(),
consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional() consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional(),
selectedMfaMethod: z.string().nullable().optional()
}); });
export type TUsers = z.infer<typeof UsersSchema>; export type TUsers = z.infer<typeof UsersSchema>;
+12 -2
View File
@@ -201,6 +201,8 @@ import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admi
import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal"; import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal";
import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue"; import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal";
import { totpServiceFactory } from "@app/services/totp/totp-service";
import { userDALFactory } from "@app/services/user/user-dal"; import { userDALFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service"; import { userServiceFactory } from "@app/services/user/user-service";
import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
@@ -348,6 +350,7 @@ export const registerRoutes = async (
const slackIntegrationDAL = slackIntegrationDALFactory(db); const slackIntegrationDAL = slackIntegrationDALFactory(db);
const projectSlackConfigDAL = projectSlackConfigDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
const workflowIntegrationDAL = workflowIntegrationDALFactory(db); const workflowIntegrationDAL = workflowIntegrationDALFactory(db);
const totpConfigDAL = totpConfigDALFactory(db);
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db); const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
@@ -511,7 +514,13 @@ export const registerRoutes = async (
projectMembershipDAL projectMembershipDAL
}); });
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL }); const totpService = totpServiceFactory({
totpConfigDAL,
userDAL,
kmsService
});
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, totpService });
const passwordService = authPaswordServiceFactory({ const passwordService = authPaswordServiceFactory({
tokenService, tokenService,
smtpService, smtpService,
@@ -1369,7 +1378,8 @@ export const registerRoutes = async (
workflowIntegration: workflowIntegrationService, workflowIntegration: workflowIntegrationService,
migration: migrationService, migration: migrationService,
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
projectTemplate: projectTemplateService projectTemplate: projectTemplateService,
totp: totpService
}); });
const cronJobs: CronJob[] = []; const cronJobs: CronJob[] = [];
@@ -169,4 +169,89 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
return groupMemberships; return groupMemberships;
} }
}); });
server.route({
method: "GET",
url: "/me/totp",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
isVerified: z.boolean(),
recoveryCodes: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.totp.getUserTotpConfig({
userId: req.permission.id
});
}
});
server.route({
method: "DELETE",
url: "/me/totp",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.totp.deleteUserTotpConfig({
userId: req.permission.id
});
}
});
server.route({
method: "POST",
url: "/me/totp/register",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
otpUrl: z.string(),
recoveryCodes: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT], {
requireOrg: false
}),
handler: async (req) => {
return server.services.totp.registerUserTotp({
userId: req.permission.id
});
}
});
server.route({
method: "POST",
url: "/me/totp/verify",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
totp: z.string()
}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.JWT], {
requireOrg: false
}),
handler: async (req) => {
return server.services.totp.verifyUserTotpConfig({
userId: req.permission.id,
totp: req.body.totp
});
}
});
}; };
+38 -3
View File
@@ -2,8 +2,9 @@ import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { NotFoundError } from "@app/lib/errors";
import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { mfaRateLimit } from "@app/server/config/rateLimiter";
import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type";
export const registerMfaRouter = async (server: FastifyZodProvider) => { export const registerMfaRouter = async (server: FastifyZodProvider) => {
const cfg = getConfig(); const cfg = getConfig();
@@ -49,6 +50,38 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/mfa/check/totp",
config: {
rateLimit: mfaRateLimit
},
schema: {
response: {
200: z.object({
isVerified: z.boolean()
})
}
},
handler: async (req) => {
try {
const totpConfig = await server.services.totp.getUserTotpConfig({
userId: req.mfa.userId
});
return {
isVerified: Boolean(totpConfig)
};
} catch (error) {
if (error instanceof NotFoundError) {
return { isVerified: false };
}
throw error;
}
}
});
server.route({ server.route({
url: "/mfa/verify", url: "/mfa/verify",
method: "POST", method: "POST",
@@ -57,7 +90,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
mfaToken: z.string().trim() mfaToken: z.string().trim(),
mfaMethod: z.nativeEnum(MfaMethod).optional().default(MfaMethod.EMAIL)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -86,7 +120,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
ip: req.realIp, ip: req.realIp,
userId: req.mfa.userId, userId: req.mfa.userId,
orgId: req.mfa.orgId, orgId: req.mfa.orgId,
mfaToken: req.body.mfaToken mfaToken: req.body.mfaToken,
mfaMethod: req.body.mfaMethod
}); });
void res.setCookie("jid", token.refresh, { void res.setCookie("jid", token.refresh, {
+9 -3
View File
@@ -4,7 +4,7 @@ import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema,
import { ApiKeysSchema } from "@app/db/schemas/api-keys"; import { ApiKeysSchema } from "@app/db/schemas/api-keys";
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
export const registerUserRouter = async (server: FastifyZodProvider) => { export const registerUserRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -56,7 +56,8 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
isMfaEnabled: z.boolean() isMfaEnabled: z.boolean().optional(),
selectedMfaMethod: z.nativeEnum(MfaMethod).optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -66,7 +67,12 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => { handler: async (req) => {
const user = await server.services.user.toggleUserMfa(req.permission.id, req.body.isMfaEnabled); const user = await server.services.user.updateUserMfa({
userId: req.permission.id,
isMfaEnabled: req.body.isMfaEnabled,
selectedMfaMethod: req.body.selectedMfaMethod
});
return { user }; return { user };
} }
}); });
+4 -2
View File
@@ -48,7 +48,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
token: z.string(), token: z.string(),
isMfaEnabled: z.boolean() isMfaEnabled: z.boolean(),
mfaMethod: z.string().optional()
}) })
} }
}, },
@@ -64,7 +65,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
if (tokens.isMfaEnabled) { if (tokens.isMfaEnabled) {
return { return {
token: tokens.mfa as string, token: tokens.mfa as string,
isMfaEnabled: true isMfaEnabled: true,
mfaMethod: tokens.mfaMethod
}; };
} }
+55 -19
View File
@@ -17,6 +17,7 @@ import { TokenType } from "../auth-token/auth-token-types";
import { TOrgDALFactory } from "../org/org-dal"; import { TOrgDALFactory } from "../org/org-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { LoginMethod } from "../super-admin/super-admin-types"; import { LoginMethod } from "../super-admin/super-admin-types";
import { TTotpServiceFactory } from "../totp/totp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns"; import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
import { import {
@@ -26,13 +27,14 @@ import {
TOauthTokenExchangeDTO, TOauthTokenExchangeDTO,
TVerifyMfaTokenDTO TVerifyMfaTokenDTO
} from "./auth-login-type"; } from "./auth-login-type";
import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type"; import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "./auth-type";
type TAuthLoginServiceFactoryDep = { type TAuthLoginServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
orgDAL: TOrgDALFactory; orgDAL: TOrgDALFactory;
tokenService: TAuthTokenServiceFactory; tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService; smtpService: TSmtpService;
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
}; };
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>; export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
@@ -40,7 +42,8 @@ export const authLoginServiceFactory = ({
userDAL, userDAL,
tokenService, tokenService,
smtpService, smtpService,
orgDAL orgDAL,
totpService
}: TAuthLoginServiceFactoryDep) => { }: TAuthLoginServiceFactoryDep) => {
/* /*
* Private * Private
@@ -100,7 +103,8 @@ export const authLoginServiceFactory = ({
userAgent, userAgent,
organizationId, organizationId,
authMethod, authMethod,
isMfaVerified isMfaVerified,
mfaMethod
}: { }: {
user: TUsers; user: TUsers;
ip: string; ip: string;
@@ -108,6 +112,7 @@ export const authLoginServiceFactory = ({
organizationId?: string; organizationId?: string;
authMethod: AuthMethod; authMethod: AuthMethod;
isMfaVerified?: boolean; isMfaVerified?: boolean;
mfaMethod?: MfaMethod;
}) => { }) => {
const cfg = getConfig(); const cfg = getConfig();
await updateUserDeviceSession(user, ip, userAgent); await updateUserDeviceSession(user, ip, userAgent);
@@ -126,7 +131,8 @@ export const authLoginServiceFactory = ({
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
accessVersion: tokenSession.accessVersion, accessVersion: tokenSession.accessVersion,
organizationId, organizationId,
isMfaVerified isMfaVerified,
mfaMethod
}, },
cfg.AUTH_SECRET, cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_AUTH_LIFETIME } { expiresIn: cfg.JWT_AUTH_LIFETIME }
@@ -140,7 +146,8 @@ export const authLoginServiceFactory = ({
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
refreshVersion: tokenSession.refreshVersion, refreshVersion: tokenSession.refreshVersion,
organizationId, organizationId,
isMfaVerified isMfaVerified,
mfaMethod
}, },
cfg.AUTH_SECRET, cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_REFRESH_LIFETIME } { expiresIn: cfg.JWT_REFRESH_LIFETIME }
@@ -353,8 +360,12 @@ export const authLoginServiceFactory = ({
}); });
} }
// send multi factor auth token if they it enabled const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
if ((selectedOrg.enforceMfa || user.isMfaEnabled) && user.email && !decodedToken.isMfaVerified) { const orgMfaMethod = selectedOrg.enforceMfa ? selectedOrg.selectedMfaMethod : undefined;
const userMfaMethod = user.isMfaEnabled ? user.selectedMfaMethod : undefined;
const mfaMethod = orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL;
if (shouldCheckMfa && (!decodedToken.isMfaVerified || decodedToken.mfaMethod !== mfaMethod)) {
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
const mfaToken = jwt.sign( const mfaToken = jwt.sign(
@@ -369,12 +380,14 @@ export const authLoginServiceFactory = ({
} }
); );
await sendUserMfaCode({ if (mfaMethod === MfaMethod.EMAIL && user.email) {
userId: user.id, await sendUserMfaCode({
email: user.email userId: user.id,
}); email: user.email
});
}
return { isMfaEnabled: true, mfa: mfaToken } as const; return { isMfaEnabled: true, mfa: mfaToken, mfaMethod } as const;
} }
const tokens = await generateUserTokens({ const tokens = await generateUserTokens({
@@ -458,17 +471,39 @@ export const authLoginServiceFactory = ({
* Multi factor authentication verification of code * Multi factor authentication verification of code
* Third step of login in which user completes with mfa * Third step of login in which user completes with mfa
* */ * */
const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => { const verifyMfaToken = async ({
userId,
mfaToken,
mfaMethod,
mfaJwtToken,
ip,
userAgent,
orgId
}: TVerifyMfaTokenDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const user = await userDAL.findById(userId); const user = await userDAL.findById(userId);
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
try { try {
await tokenService.validateTokenForUser({ if (mfaMethod === MfaMethod.EMAIL) {
type: TokenType.TOKEN_EMAIL_MFA, await tokenService.validateTokenForUser({
userId, type: TokenType.TOKEN_EMAIL_MFA,
code: mfaToken userId,
}); code: mfaToken
});
} else if (mfaMethod === MfaMethod.TOTP) {
if (mfaToken.length === 6) {
await totpService.verifyUserTotp({
userId,
totp: mfaToken
});
} else {
await totpService.verifyWithUserRecoveryCode({
userId,
recoveryCode: mfaToken
});
}
}
} catch (err) { } catch (err) {
const updatedUser = await processFailedMfaAttempt(userId); const updatedUser = await processFailedMfaAttempt(userId);
if (updatedUser.isLocked) { if (updatedUser.isLocked) {
@@ -513,7 +548,8 @@ export const authLoginServiceFactory = ({
userAgent, userAgent,
organizationId: orgId, organizationId: orgId,
authMethod: decodedToken.authMethod, authMethod: decodedToken.authMethod,
isMfaVerified: true isMfaVerified: true,
mfaMethod
}); });
return { token, user: userEnc }; return { token, user: userEnc };
+2 -1
View File
@@ -1,4 +1,4 @@
import { AuthMethod } from "./auth-type"; import { AuthMethod, MfaMethod } from "./auth-type";
export type TLoginGenServerPublicKeyDTO = { export type TLoginGenServerPublicKeyDTO = {
email: string; email: string;
@@ -19,6 +19,7 @@ export type TLoginClientProofDTO = {
export type TVerifyMfaTokenDTO = { export type TVerifyMfaTokenDTO = {
userId: string; userId: string;
mfaToken: string; mfaToken: string;
mfaMethod: MfaMethod;
mfaJwtToken: string; mfaJwtToken: string;
ip: string; ip: string;
userAgent: string; userAgent: string;
+6
View File
@@ -53,6 +53,7 @@ export type AuthModeJwtTokenPayload = {
accessVersion: number; accessVersion: number;
organizationId?: string; organizationId?: string;
isMfaVerified?: boolean; isMfaVerified?: boolean;
mfaMethod?: MfaMethod;
}; };
export type AuthModeMfaJwtTokenPayload = { export type AuthModeMfaJwtTokenPayload = {
@@ -85,3 +86,8 @@ export type AuthModeProviderSignUpTokenPayload = {
authTokenType: AuthTokenType.SIGNUP_TOKEN; authTokenType: AuthTokenType.SIGNUP_TOKEN;
userId: string; userId: string;
}; };
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
}
@@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TTotpConfigDALFactory = ReturnType<typeof totpConfigDALFactory>;
export const totpConfigDALFactory = (db: TDbClient) => {
const totpConfigDal = ormify(db, TableName.TotpConfig);
return totpConfigDal;
};
+212
View File
@@ -0,0 +1,212 @@
import crypto from "node:crypto";
import { authenticator } from "otplib";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TUserDALFactory } from "../user/user-dal";
import { TTotpConfigDALFactory } from "./totp-config-dal";
import {
TDeleteUserTotpConfigDTO,
TGetUserTotpConfigDTO,
TRegisterUserTotpDTO,
TVerifyUserTotpConfigDTO,
TVerifyUserTotpDTO,
TVerifyWithUserRecoveryCodeDTO
} from "./totp-types";
type TTotpServiceFactoryDep = {
userDAL: TUserDALFactory;
totpConfigDAL: TTotpConfigDALFactory;
kmsService: TKmsServiceFactory;
};
export type TTotpServiceFactory = ReturnType<typeof totpServiceFactory>;
export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotpServiceFactoryDep) => {
const getUserTotpConfig = async ({ userId }: TGetUserTotpConfigDTO) => {
const totpConfig = await totpConfigDAL.findOne({
userId,
isVerified: true
});
if (!totpConfig) {
throw new NotFoundError({
message: "TOTP configuration not found"
});
}
const decryptWithRoot = kmsService.decryptWithRootKey();
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
return {
isVerified: totpConfig.isVerified,
recoveryCodes
};
};
const registerUserTotp = async ({ userId }: TRegisterUserTotpDTO) => {
const totpConfig = await totpConfigDAL.transaction(async (tx) => {
const verifiedTotpConfig = await totpConfigDAL.findOne(
{
userId,
isVerified: true
},
tx
);
if (verifiedTotpConfig) {
throw new BadRequestError({
message: "TOTP configuration for user already exists"
});
}
const unverifiedTotpConfig = await totpConfigDAL.findOne({
userId,
isVerified: false
});
if (unverifiedTotpConfig) {
return unverifiedTotpConfig;
}
const encryptWithRoot = kmsService.encryptWithRootKey();
// create new TOTP configuration
const secret = authenticator.generateSecret();
const encryptedSecret = encryptWithRoot(Buffer.from(secret));
const recoveryCodes = Array.from({ length: 10 }).map(() => String(crypto.randomInt(10 ** 7, 10 ** 8 - 1)));
const encryptedRecoveryCodes = encryptWithRoot(Buffer.from(recoveryCodes.join(",")));
const newTotpConfig = await totpConfigDAL.create({
userId,
encryptedRecoveryCodes,
encryptedSecret
});
return newTotpConfig;
});
const user = await userDAL.findById(userId);
const decryptWithRoot = kmsService.decryptWithRootKey();
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
const otpUrl = authenticator.keyuri(user.username, "Infisical", secret);
return {
otpUrl,
recoveryCodes
};
};
const verifyUserTotpConfig = async ({ userId, totp }: TVerifyUserTotpConfigDTO) => {
const totpConfig = await totpConfigDAL.findOne({
userId,
isVerified: false
});
if (!totpConfig) {
throw new NotFoundError({
message: "TOTP configuration not found"
});
}
const decryptWithRoot = kmsService.decryptWithRootKey();
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
const isValid = authenticator.verify({
token: totp,
secret
});
if (isValid) {
await totpConfigDAL.updateById(totpConfig.id, {
isVerified: true
});
} else {
throw new BadRequestError({
message: "Invalid TOTP token"
});
}
};
const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => {
const totpConfig = await totpConfigDAL.findOne({
userId,
isVerified: true
});
if (!totpConfig) {
throw new NotFoundError({
message: "TOTP configuration not found"
});
}
const decryptWithRoot = kmsService.decryptWithRootKey();
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
const isValid = authenticator.verify({
token: totp,
secret
});
if (!isValid) {
throw new ForbiddenRequestError({
message: "Invalid TOTP"
});
}
};
const verifyWithUserRecoveryCode = async ({ userId, recoveryCode }: TVerifyWithUserRecoveryCodeDTO) => {
const totpConfig = await totpConfigDAL.findOne({
userId,
isVerified: true
});
if (!totpConfig) {
throw new NotFoundError({
message: "TOTP configuration not found"
});
}
const decryptWithRoot = kmsService.decryptWithRootKey();
const encryptWithRoot = kmsService.encryptWithRootKey();
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
const matchingCode = recoveryCodes.find((code) => recoveryCode === code);
if (!matchingCode) {
throw new ForbiddenRequestError({
message: "Invalid TOTP recovery code"
});
}
const updatedRecoveryCodes = recoveryCodes.filter((code) => code !== matchingCode);
const encryptedRecoveryCodes = encryptWithRoot(Buffer.from(updatedRecoveryCodes.join(",")));
await totpConfigDAL.updateById(totpConfig.id, {
encryptedRecoveryCodes
});
};
const deleteUserTotpConfig = async ({ userId }: TDeleteUserTotpConfigDTO) => {
const totpConfig = await totpConfigDAL.findOne({
userId,
isVerified: true
});
if (!totpConfig) {
throw new NotFoundError({
message: "TOTP configuration not found"
});
}
await totpConfigDAL.deleteById(totpConfig.id);
};
return {
registerUserTotp,
verifyUserTotpConfig,
getUserTotpConfig,
verifyUserTotp,
verifyWithUserRecoveryCode,
deleteUserTotpConfig
};
};
+26
View File
@@ -0,0 +1,26 @@
export type TRegisterUserTotpDTO = {
userId: string;
};
export type TVerifyUserTotpConfigDTO = {
userId: string;
totp: string;
};
export type TGetUserTotpConfigDTO = {
userId: string;
};
export type TVerifyUserTotpDTO = {
userId: string;
totp: string;
};
export type TVerifyWithUserRecoveryCodeDTO = {
userId: string;
recoveryCode: string;
};
export type TDeleteUserTotpConfigDTO = {
userId: string;
};
+13 -4
View File
@@ -15,7 +15,7 @@ import { AuthMethod } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TUserDALFactory } from "./user-dal"; import { TUserDALFactory } from "./user-dal";
import { TListUserGroupsDTO } from "./user-types"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
type TUserServiceFactoryDep = { type TUserServiceFactoryDep = {
userDAL: Pick< userDAL: Pick<
@@ -171,15 +171,24 @@ export const userServiceFactory = ({
}); });
}; };
const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const updateUserMfa = async ({ userId, isMfaEnabled, selectedMfaMethod }: TUpdateUserMfaDTO) => {
const user = await userDAL.findById(userId); const user = await userDAL.findById(userId);
if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" }); if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" });
let mfaMethods;
if (isMfaEnabled === undefined) {
mfaMethods = undefined;
} else {
mfaMethods = isMfaEnabled ? ["email"] : [];
}
const updatedUser = await userDAL.updateById(userId, { const updatedUser = await userDAL.updateById(userId, {
isMfaEnabled, isMfaEnabled,
mfaMethods: isMfaEnabled ? ["email"] : [] mfaMethods,
selectedMfaMethod
}); });
return updatedUser; return updatedUser;
}; };
@@ -327,7 +336,7 @@ export const userServiceFactory = ({
return { return {
sendEmailVerificationCode, sendEmailVerificationCode,
verifyEmailVerificationCode, verifyEmailVerificationCode,
toggleUserMfa, updateUserMfa,
updateUserName, updateUserName,
updateAuthMethods, updateAuthMethods,
deleteUser, deleteUser,
+8
View File
@@ -1,5 +1,7 @@
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { MfaMethod } from "../auth/auth-type";
export type TListUserGroupsDTO = { export type TListUserGroupsDTO = {
username: string; username: string;
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
@@ -8,3 +10,9 @@ export enum UserEncryption {
V1 = 1, V1 = 1,
V2 = 2 V2 = 2
} }
export type TUpdateUserMfaDTO = {
userId: string;
isMfaEnabled?: boolean;
selectedMfaMethod?: MfaMethod;
};
+189 -10
View File
@@ -75,6 +75,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"picomatch": "^2.3.1", "picomatch": "^2.3.1",
"posthog-js": "^1.105.6", "posthog-js": "^1.105.6",
"qrcode": "^1.5.4",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@@ -120,6 +121,7 @@
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0", "@types/picomatch": "^2.3.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
@@ -8857,6 +8859,15 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
}, },
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.11", "version": "6.9.11",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz",
@@ -9785,7 +9796,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -9794,7 +9804,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@@ -11076,7 +11085,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -11376,6 +11384,29 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone": { "node_modules/clone": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
@@ -12281,6 +12312,14 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": { "node_modules/decode-named-character-reference": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
@@ -12677,6 +12716,11 @@
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
"dev": true "dev": true
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dir-glob": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -14943,6 +14987,14 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@@ -16212,7 +16264,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -19339,7 +19390,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -19445,7 +19495,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -19666,6 +19715,14 @@
"pathe": "^1.1.2" "pathe": "^1.1.2"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/pnp-webpack-plugin": { "node_modules/pnp-webpack-plugin": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz",
@@ -20550,6 +20607,22 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -21846,6 +21919,14 @@
"throttleit": "^1.0.0" "throttleit": "^1.0.0"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -21855,6 +21936,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/requireindex": { "node_modules/requireindex": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
@@ -22314,6 +22400,11 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
@@ -22900,7 +22991,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0", "is-fullwidth-code-point": "^3.0.0",
@@ -22934,8 +23024,7 @@
"node_modules/string-width/node_modules/emoji-regex": { "node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
"dev": true
}, },
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.0.10", "version": "4.0.10",
@@ -23006,7 +23095,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@@ -24902,6 +24990,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz",
@@ -25066,6 +25159,11 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -25079,6 +25177,87 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+2
View File
@@ -88,6 +88,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"picomatch": "^2.3.1", "picomatch": "^2.3.1",
"posthog-js": "^1.105.6", "posthog-js": "^1.105.6",
"qrcode": "^1.5.4",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@@ -133,6 +134,7 @@
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0", "@types/picomatch": "^2.3.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { useGetUserTotpRegistration } from "@app/hooks/api";
import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation";
import { createNotification } from "../notifications";
import { Button, ContentLoader, Input } from "../v2";
type Props = {
onComplete?: () => Promise<void>;
};
const TotpRegistration = ({ onComplete }: Props) => {
const { data: registration, isLoading } = useGetUserTotpRegistration();
const { mutateAsync: verifyUserTotp } = useVerifyUserTotpRegistration();
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [totp, setTotp] = useState("");
const handleTotpVerify = async () => {
await verifyUserTotp({
totp
});
createNotification({
text: "Successfully configured mobile authenticator",
type: "success"
});
if (onComplete) {
onComplete();
}
};
useEffect(() => {
const generateQRCode = async () => {
if (registration?.otpUrl) {
const url = await QRCode.toDataURL(registration.otpUrl);
setQrCodeUrl(url);
}
};
generateQRCode();
}, [registration]);
if (isLoading) {
return <ContentLoader />;
}
return (
<div className="flex max-w-sm flex-col text-bunker-200">
<div className="mb-4 text-center">
Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR code.
</div>
<div className="mb-10 flex items-center justify-center">
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<div className="mb-4 text-center">Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button onClick={handleTotpVerify}>Enable MFA</Button>
</div>
</div>
);
};
export default TotpRegistration;
+26 -8
View File
@@ -19,6 +19,7 @@ import {
Login2Res, Login2Res,
LoginLDAPDTO, LoginLDAPDTO,
LoginLDAPRes, LoginLDAPRes,
MfaMethod,
ResetPasswordDTO, ResetPasswordDTO,
SendMfaTokenDTO, SendMfaTokenDTO,
SRP1DTO, SRP1DTO,
@@ -65,10 +66,11 @@ export const selectOrganization = async (data: {
organizationId: string; organizationId: string;
userAgent?: UserAgentType; userAgent?: UserAgentType;
}) => { }) => {
const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean }>( const { data: res } = await apiRequest.post<{
"/api/v3/auth/select-organization", token: string;
data isMfaEnabled: boolean;
); mfaMethod?: MfaMethod;
}>("/api/v3/auth/select-organization", data);
return res; return res;
}; };
@@ -154,10 +156,19 @@ export const useSendMfaToken = () => {
}); });
}; };
export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCode: string }) => { export const verifyMfaToken = async ({
email,
mfaCode,
mfaMethod
}: {
email: string;
mfaCode: string;
mfaMethod: string;
}) => {
const { data } = await apiRequest.post("/api/v2/auth/mfa/verify", { const { data } = await apiRequest.post("/api/v2/auth/mfa/verify", {
email, email,
mfaToken: mfaCode mfaToken: mfaCode,
mfaMethod
}); });
return data; return data;
@@ -165,10 +176,11 @@ export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCod
export const useVerifyMfaToken = () => { export const useVerifyMfaToken = () => {
return useMutation<VerifyMfaTokenRes, {}, VerifyMfaTokenDTO>({ return useMutation<VerifyMfaTokenRes, {}, VerifyMfaTokenDTO>({
mutationFn: async ({ email, mfaCode }) => { mutationFn: async ({ email, mfaCode, mfaMethod }) => {
return verifyMfaToken({ return verifyMfaToken({
email, email,
mfaCode mfaCode,
mfaMethod
}); });
} }
}); });
@@ -302,3 +314,9 @@ export const useGetAuthToken = () =>
onSuccess: (data) => setAuthToken(data.token), onSuccess: (data) => setAuthToken(data.token),
retry: 0 retry: 0
}); });
export const checkUserTotpMfa = async () => {
const { data } = await apiRequest.get<{ isVerified: boolean }>("/api/v2/auth/mfa/check/totp");
return data.isVerified;
};
+6
View File
@@ -9,6 +9,7 @@ export type SendMfaTokenDTO = {
export type VerifyMfaTokenDTO = { export type VerifyMfaTokenDTO = {
email: string; email: string;
mfaCode: string; mfaCode: string;
mfaMethod: MfaMethod;
}; };
export type VerifyMfaTokenRes = { export type VerifyMfaTokenRes = {
@@ -149,3 +150,8 @@ export type GetBackupEncryptedPrivateKeyDTO = {
export enum UserAgentType { export enum UserAgentType {
CLI = "cli" CLI = "cli"
} }
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
}
+3 -2
View File
@@ -21,12 +21,13 @@ export {
useGetOrgUsers, useGetOrgUsers,
useGetUser, useGetUser,
useGetUserAction, useGetUserAction,
useGetUserTotpRegistration,
useListUserGroupMemberships, useListUserGroupMemberships,
useLogoutUser, useLogoutUser,
useRegisterUserAction, useRegisterUserAction,
useRevokeMySessions, useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgMembership, useUpdateOrgMembership,
useUpdateUserAuthMethods useUpdateUserAuthMethods,
useUpdateUserMfa
} from "./queries"; } from "./queries";
export { userKeys } from "./query-keys"; export { userKeys } from "./query-keys";
+26
View File
@@ -114,3 +114,29 @@ export const useUpdateUserProjectFavorites = () => {
} }
}); });
}; };
export const useVerifyUserTotpRegistration = () => {
return useMutation({
mutationFn: async ({ totp }: { totp: string }) => {
await apiRequest.post("/api/v1/user/me/totp/verify", {
totp
});
return {};
}
});
};
export const useDeleteUserTotpConfiguration = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await apiRequest.delete("/api/v1/user/me/totp");
return {};
},
onSuccess: () => {
queryClient.invalidateQueries(userKeys.totpConfiguration);
}
});
};
+48 -3
View File
@@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { SessionStorageKeys } from "@app/const"; import { SessionStorageKeys } from "@app/const";
import { setAuthToken } from "@app/reactQuery"; import { setAuthToken } from "@app/reactQuery";
import { APIKeyDataV2 } from "../apiKeys/types"; import { APIKeyDataV2 } from "../apiKeys/types";
import { MfaMethod } from "../auth/types";
import { TGroupWithProjectMemberships } from "../groups/types"; import { TGroupWithProjectMemberships } from "../groups/types";
import { workspaceKeys } from "../workspace"; import { workspaceKeys } from "../workspace";
import { userKeys } from "./query-keys"; import { userKeys } from "./query-keys";
@@ -390,14 +392,21 @@ export const useRevokeMySessions = () => {
}); });
}; };
export const useUpdateMfaEnabled = () => { export const useUpdateUserMfa = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ isMfaEnabled }: { isMfaEnabled: boolean }) => { mutationFn: async ({
isMfaEnabled,
selectedMfaMethod
}: {
isMfaEnabled?: boolean;
selectedMfaMethod?: MfaMethod;
}) => {
const { const {
data: { user } data: { user }
} = await apiRequest.patch("/api/v2/users/me/mfa", { } = await apiRequest.patch("/api/v2/users/me/mfa", {
isMfaEnabled isMfaEnabled,
selectedMfaMethod
}); });
return user; return user;
@@ -446,3 +455,39 @@ export const useListUserGroupMemberships = (username: string) => {
} }
}); });
}; };
export const useGetUserTotpRegistration = () => {
return useQuery({
queryKey: userKeys.totpRegistration,
queryFn: async () => {
const { data } = await apiRequest.post<{ otpUrl: string; recoveryCodes: string[] }>(
"/api/v1/user/me/totp/register"
);
return data;
}
});
};
export const useGetUserTotpConfiguration = () => {
return useQuery({
queryKey: userKeys.totpConfiguration,
queryFn: async () => {
try {
const { data } = await apiRequest.get<{ isVerified: boolean; recoveryCodes: string[] }>(
"/api/v1/user/me/totp"
);
return data;
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.statusCode === 404) {
return {
isVerified: false,
recoveryCodes: []
};
}
throw error;
}
}
});
};
@@ -16,6 +16,8 @@ export const userKeys = {
myAPIKeysV2: ["api-keys-v2"] as const, myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const, mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const, listUsers: ["user-list"] as const,
totpRegistration: ["totp-registration"],
totpConfiguration: ["totp-configuration"],
listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const, listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
}; };
+2
View File
@@ -1,3 +1,4 @@
import { MfaMethod } from "../auth/types";
import { UserWsKeyPair } from "../keys/types"; import { UserWsKeyPair } from "../keys/types";
import { ProjectUserMembershipTemporaryMode } from "../workspace/types"; import { ProjectUserMembershipTemporaryMode } from "../workspace/types";
@@ -26,6 +27,7 @@ export type User = {
authProvider?: AuthMethod; authProvider?: AuthMethod;
authMethods: AuthMethod[]; authMethods: AuthMethod[];
isMfaEnabled: boolean; isMfaEnabled: boolean;
selectedMfaMethod?: MfaMethod;
seenIps: string[]; seenIps: string[];
id: string; id: string;
}; };
@@ -22,7 +22,7 @@ import {
useLogoutUser, useLogoutUser,
useSelectOrganization useSelectOrganization
} from "@app/hooks/api"; } from "@app/hooks/api";
import { UserAgentType } from "@app/hooks/api/auth/types"; import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { Organization } from "@app/hooks/api/types"; import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethod } from "@app/hooks/api/users/types";
import { getAuthToken, isLoggedIn } from "@app/reactQuery"; import { getAuthToken, isLoggedIn } from "@app/reactQuery";
@@ -46,6 +46,7 @@ export default function LoginPage() {
const selectOrg = useSelectOrganization(); const selectOrg = useSelectOrganization();
const { data: user, isLoading: userLoading } = useGetUser(); const { data: user, isLoading: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
@@ -90,15 +91,17 @@ export default function LoginPage() {
return; return;
} }
const { token, isMfaEnabled } = await selectOrg.mutateAsync({ const { token, isMfaEnabled, mfaMethod } = await selectOrg.mutateAsync({
organizationId: organization.id, organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined userAgent: callbackPort ? UserAgentType.CLI : undefined
}); });
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(token); SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on(); toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return; return;
} }
@@ -213,7 +216,11 @@ export default function LoginPage() {
<meta name="og:description" content={t("login.og-description") ?? ""} /> <meta name="og:description" content={t("login.og-description") ?? ""} />
</Head> </Head>
{shouldShowMfa ? ( {shouldShowMfa ? (
<Mfa email={user.email as string} successCallback={mfaSuccessCallback} /> <Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : ( ) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg"> <div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link href="/"> <Link href="/">
+86 -29
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input"; import ReactCodeInput from "react-code-input";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -6,10 +6,12 @@ import { useRouter } from "next/router";
import { t } from "i18next"; import { t } from "i18next";
import Error from "@app/components/basic/Error"; import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button } from "@app/components/v2"; import { Button, Input } from "@app/components/v2";
import { useSendMfaToken } from "@app/hooks/api"; import { useSendMfaToken } from "@app/hooks/api";
import { verifyMfaToken } from "@app/hooks/api/auth/queries"; import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
// The style for the verification code input // The style for the verification code input
const codeInputProps = { const codeInputProps = {
@@ -36,23 +38,37 @@ type Props = {
closeMfa?: () => void; closeMfa?: () => void;
hideLogo?: boolean; hideLogo?: boolean;
email: string; email: string;
method: MfaMethod;
}; };
export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => { export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
const [mfaCode, setMfaCode] = useState(""); const [mfaCode, setMfaCode] = useState("");
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined); const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const sendMfaToken = useSendMfaToken(); const sendMfaToken = useSendMfaToken();
useEffect(() => {
if (method === MfaMethod.TOTP) {
checkUserTotpMfa().then((isVerified) => {
if (!isVerified) {
SecurityClient.setMfaToken("");
setShouldShowTotpRegistration(true);
}
});
}
}, []);
const verifyMfa = async () => { const verifyMfa = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const { token } = await verifyMfaToken({ const { token } = await verifyMfaToken({
email, email,
mfaCode mfaCode,
mfaMethod: method
}); });
SecurityClient.setMfaToken(""); SecurityClient.setMfaToken("");
@@ -92,6 +108,23 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
} }
}; };
if (shouldShowTotpRegistration) {
return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
<TotpRegistration
onComplete={async () => {
setShouldShowTotpRegistration(false);
await successCallback();
if (closeMfa) {
closeMfa();
}
}}
/>
</div>
);
}
return ( return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8"> <div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
{!hideLogo && ( {!hideLogo && (
@@ -101,18 +134,40 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
</div> </div>
</Link> </Link>
)} )}
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p> {method === MfaMethod.EMAIL && (
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p> <>
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
</>
)}
{method === MfaMethod.TOTP && (
<>
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
Authenticator MFA Required
</p>
<p className="text-l flex max-w-xs justify-center text-center text-bunker-300">
Open the authenticator app on your mobile device to get your verification code or enter
a recovery code.
</p>
</>
)}
<div className="mx-auto hidden w-max min-w-[20rem] md:block"> <div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput {method === MfaMethod.EMAIL && (
name="" <ReactCodeInput
inputMode="tel" name=""
type="text" inputMode="tel"
fields={6} type="text"
onChange={setMfaCode} fields={6}
className="mt-6 mb-2" onChange={setMfaCode}
{...codeInputProps} className="mt-6 mb-2"
/> {...codeInputProps}
/>
)}
{method === MfaMethod.TOTP && (
<div className="mt-6 mb-4">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
</div>
)}
</div> </div>
{typeof triesLeft === "number" && ( {typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} /> <Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
@@ -132,21 +187,23 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
</Button> </Button>
</div> </div>
</div> </div>
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2"> {method === MfaMethod.EMAIL && (
<div className="flex flex-row items-baseline gap-1 text-sm"> <div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span> <div className="flex flex-row items-baseline gap-1 text-sm">
<div className="text-md mt-2 flex flex-row text-bunker-400"> <span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button"> <div className="text-md mt-2 flex flex-row text-bunker-400">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"> <button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
{isLoadingResend <span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
? t("signup.step2-resend-progress") {isLoadingResend
: t("signup.step2-resend-submit")} ? t("signup.step2-resend-progress")
</span> : t("signup.step2-resend-submit")}
</button> </span>
</button>
</div>
</div> </div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
</div> </div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p> )}
</div>
</div> </div>
); );
}; };
@@ -1,18 +1,84 @@
import { useQueryClient } from "@tanstack/react-query";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Checkbox, EmailServiceSetupModal } from "@app/components/v2"; import {
import { useGetUser, useUpdateMfaEnabled } from "@app/hooks/api"; Button,
Checkbox,
ContentLoader,
DeleteActionModal,
EmailServiceSetupModal,
FormControl,
Select,
SelectItem
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { useDeleteUserTotpConfiguration } from "@app/hooks/api/users/mutation";
import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethod } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => { export const MFASection = () => {
const { data: user } = useGetUser(); const { data: user } = useGetUser();
const { mutateAsync } = useUpdateMfaEnabled(); const { mutateAsync } = useUpdateUserMfa();
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail",
"deleteTotpConfig"
] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration, isLoading: isTotpConfigurationLoading } =
useGetUserTotpConfiguration();
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
const handleTotpDeletion = async () => {
try {
await deleteTotpConfiguration();
createNotification({
text: "Successfully deleted mobile authenticator",
type: "success"
});
handlePopUpClose("deleteTotpConfig");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete mobile authenticator";
createNotification({
text,
type: "error"
});
}
};
const updateSelectedMfa = async (mfaMethod: MfaMethod) => {
try {
if (!user) return;
await mutateAsync({
selectedMfaMethod: mfaMethod
});
createNotification({
text: "Successfully updated preferred 2FA method",
type: "success"
});
} catch (err) {
createNotification({
text: "Something went wrong while updating preferred 2FA method.",
type: "error"
});
console.error(err);
}
};
const toggleMfa = async (state: boolean) => { const toggleMfa = async (state: boolean) => {
try { try {
if (!user) return; if (!user) return;
@@ -47,31 +113,86 @@ export const MFASection = () => {
return ( return (
<> <>
<form> <div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <p className="mb-8 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
<p className="mb-8 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p> {user && (
{user && ( <Checkbox
<Checkbox className="data-[state=checked]:bg-primary"
className="data-[state=checked]:bg-primary" id="isTwoFAEnabled"
id="isTwoFAEnabled" isChecked={user?.isMfaEnabled}
isChecked={user?.isMfaEnabled} onCheckedChange={(state) => {
onCheckedChange={(state) => { if (serverDetails?.emailConfigured) {
if (serverDetails?.emailConfigured) { toggleMfa(state as boolean);
toggleMfa(state as boolean); } else {
} else { handlePopUpOpen("setUpEmail");
handlePopUpOpen("setUpEmail"); }
} }}
}} >
Enable 2-factor authentication
</Checkbox>
)}
{user?.isMfaEnabled && (
<FormControl label="Preferred 2FA method" className="mt-3">
<Select
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={updateSelectedMfa}
defaultValue={user.selectedMfaMethod ?? MfaMethod.EMAIL}
> >
Enable 2-factor authentication via your personal email. <SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
</Checkbox> Email
)} </SelectItem>
</div> <SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
</form> Mobile Authenticator
</SelectItem>
</Select>
</FormControl>
)}
<div className="mt-12 text-lg font-semibold text-mineshaft-100">Mobile Authenticator</div>
{isTotpConfigurationLoading ? (
<ContentLoader />
) : (
<div>
{totpConfiguration?.isVerified ? (
<div className="mt-2">
<div className="flex flex-row gap-2">
<Button colorSchema="secondary" onClick={setShouldShowRecoveryCodes.toggle}>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button colorSchema="danger" onClick={() => handlePopUpOpen("deleteTotpConfig")}>
Delete
</Button>
</div>
{shouldShowRecoveryCodes && totpConfiguration.recoveryCodes.length && (
<div className="mt-4 bg-mineshaft-600 p-4">
{totpConfiguration.recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
)}
</div>
) : (
<div className="mt-6 flex min-w-full justify-center">
<TotpRegistration
onComplete={async () => {
await queryClient.invalidateQueries(userKeys.totpConfiguration);
}}
/>
</div>
)}
</div>
)}
</div>
<EmailServiceSetupModal <EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen} isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/> />
<DeleteActionModal
isOpen={popUp.deleteTotpConfig.isOpen}
title="Are you sure want to delete the mobile authenticator? You’ll have to go through the setup process to enable it again."
onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleTotpDeletion}
/>
</> </>
); );
}; };