From 6bdbac4750a2bf6357baab2ecbbb4429d11757d8 Mon Sep 17 00:00:00 2001
From: Sheen Capadngan
Date: Thu, 14 Nov 2024 00:07:35 +0800
Subject: [PATCH 01/11] feat: initial implementation for totp authenticator
---
backend/package-lock.json | 61 +++++
backend/package.json | 1 +
backend/src/@types/fastify.d.ts | 2 +
backend/src/@types/knex.d.ts | 4 +
.../20241112082701_add-totp-support.ts | 54 +++++
backend/src/db/schemas/index.ts | 1 +
backend/src/db/schemas/models.ts | 1 +
backend/src/db/schemas/organizations.ts | 3 +-
backend/src/db/schemas/totp-configs.ts | 24 ++
backend/src/db/schemas/users.ts | 3 +-
backend/src/server/routes/index.ts | 14 +-
backend/src/server/routes/v1/user-router.ts | 85 +++++++
backend/src/server/routes/v2/mfa-router.ts | 41 +++-
backend/src/server/routes/v2/user-router.ts | 12 +-
backend/src/server/routes/v3/login-router.ts | 6 +-
.../src/services/auth/auth-login-service.ts | 74 ++++--
backend/src/services/auth/auth-login-type.ts | 3 +-
backend/src/services/auth/auth-type.ts | 6 +
backend/src/services/totp/totp-config-dal.ts | 11 +
backend/src/services/totp/totp-service.ts | 212 ++++++++++++++++++
backend/src/services/totp/totp-types.ts | 26 +++
backend/src/services/user/user-service.ts | 17 +-
backend/src/services/user/user-types.ts | 8 +
frontend/package-lock.json | 199 +++++++++++++++-
frontend/package.json | 2 +
.../src/components/mfa/TotpRegistration.tsx | 70 ++++++
frontend/src/hooks/api/auth/queries.tsx | 34 ++-
frontend/src/hooks/api/auth/types.ts | 6 +
frontend/src/hooks/api/users/index.tsx | 5 +-
frontend/src/hooks/api/users/mutation.tsx | 26 +++
frontend/src/hooks/api/users/queries.tsx | 51 ++++-
frontend/src/hooks/api/users/query-keys.tsx | 2 +
frontend/src/hooks/api/users/types.ts | 2 +
.../src/pages/login/select-organization.tsx | 15 +-
frontend/src/views/Login/Mfa.tsx | 115 +++++++---
.../SecuritySection/MFASection.tsx | 171 +++++++++++---
36 files changed, 1250 insertions(+), 117 deletions(-)
create mode 100644 backend/src/db/migrations/20241112082701_add-totp-support.ts
create mode 100644 backend/src/db/schemas/totp-configs.ts
create mode 100644 backend/src/services/totp/totp-config-dal.ts
create mode 100644 backend/src/services/totp/totp-service.ts
create mode 100644 backend/src/services/totp/totp-types.ts
create mode 100644 frontend/src/components/mfa/TotpRegistration.tsx
diff --git a/backend/package-lock.json b/backend/package-lock.json
index d55521f5bf..baae5fb6af 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -75,6 +75,7 @@
"openid-client": "^5.6.5",
"ora": "^7.0.1",
"oracledb": "^6.4.0",
+ "otplib": "^12.0.1",
"passport-github": "^1.1.0",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
@@ -6815,6 +6816,48 @@
"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": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz",
@@ -16453,6 +16496,16 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -19553,6 +19606,14 @@
"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": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index 240b7db2c2..1aa6caf6a7 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -181,6 +181,7 @@
"openid-client": "^5.6.5",
"ora": "^7.0.1",
"oracledb": "^6.4.0",
+ "otplib": "^12.0.1",
"passport-github": "^1.1.0",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index ebe3f4289d..2843648da7 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -79,6 +79,7 @@ import { TServiceTokenServiceFactory } from "@app/services/service-token/service
import { TSlackServiceFactory } from "@app/services/slack/slack-service";
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-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 { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
@@ -193,6 +194,7 @@ declare module "fastify" {
migration: TExternalMigrationServiceFactory;
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
projectTemplate: TProjectTemplateServiceFactory;
+ totp: TTotpServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index 9cfb78dd71..f5c44ff794 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -314,6 +314,9 @@ import {
TSuperAdmin,
TSuperAdminInsert,
TSuperAdminUpdate,
+ TTotpConfigs,
+ TTotpConfigsInsert,
+ TTotpConfigsUpdate,
TTrustedIps,
TTrustedIpsInsert,
TTrustedIpsUpdate,
@@ -826,5 +829,6 @@ declare module "knex/types/tables" {
TProjectTemplatesInsert,
TProjectTemplatesUpdate
>;
+ [TableName.TotpConfig]: KnexOriginal.CompositeTableType;
}
}
diff --git a/backend/src/db/migrations/20241112082701_add-totp-support.ts b/backend/src/db/migrations/20241112082701_add-totp-support.ts
new file mode 100644
index 0000000000..9aefc444c0
--- /dev/null
+++ b/backend/src/db/migrations/20241112082701_add-totp-support.ts
@@ -0,0 +1,54 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
+
+export async function up(knex: Knex): Promise {
+ 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 {
+ 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");
+ }
+ });
+}
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index 680d8df5b5..74741a8ff8 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -106,6 +106,7 @@ export * from "./secrets-v2";
export * from "./service-tokens";
export * from "./slack-integrations";
export * from "./super-admin";
+export * from "./totp-configs";
export * from "./trusted-ips";
export * from "./user-actions";
export * from "./user-aliases";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index 5bab447fd5..171931f7ea 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -117,6 +117,7 @@ export enum TableName {
ExternalKms = "external_kms",
InternalKms = "internal_kms",
InternalKmsKeyVersion = "internal_kms_key_version",
+ TotpConfig = "totp_configs",
// @depreciated
KmsKeyVersion = "kms_key_versions",
WorkflowIntegrations = "workflow_integrations",
diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts
index 31de98168f..3f40447add 100644
--- a/backend/src/db/schemas/organizations.ts
+++ b/backend/src/db/schemas/organizations.ts
@@ -21,7 +21,8 @@ export const OrganizationsSchema = z.object({
kmsDefaultKeyId: z.string().uuid().nullable().optional(),
kmsEncryptedDataKey: zodBuffer.nullable().optional(),
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;
diff --git a/backend/src/db/schemas/totp-configs.ts b/backend/src/db/schemas/totp-configs.ts
new file mode 100644
index 0000000000..d6ec115925
--- /dev/null
+++ b/backend/src/db/schemas/totp-configs.ts
@@ -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;
+export type TTotpConfigsInsert = Omit, TImmutableDBKeys>;
+export type TTotpConfigsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts
index 5134f3ee60..1c1f579ead 100644
--- a/backend/src/db/schemas/users.ts
+++ b/backend/src/db/schemas/users.ts
@@ -26,7 +26,8 @@ export const UsersSchema = z.object({
consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
isLocked: z.boolean().default(false).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;
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index 88bfaaaae0..f3d3dd7d5c 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -201,6 +201,8 @@ import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admi
import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal";
import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
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 { userServiceFactory } from "@app/services/user/user-service";
import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
@@ -348,6 +350,7 @@ export const registerRoutes = async (
const slackIntegrationDAL = slackIntegrationDALFactory(db);
const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
const workflowIntegrationDAL = workflowIntegrationDALFactory(db);
+ const totpConfigDAL = totpConfigDALFactory(db);
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
@@ -511,7 +514,13 @@ export const registerRoutes = async (
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({
tokenService,
smtpService,
@@ -1369,7 +1378,8 @@ export const registerRoutes = async (
workflowIntegration: workflowIntegrationService,
migration: migrationService,
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
- projectTemplate: projectTemplateService
+ projectTemplate: projectTemplateService,
+ totp: totpService
});
const cronJobs: CronJob[] = [];
diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts
index 4e45831965..706bb9a8b5 100644
--- a/backend/src/server/routes/v1/user-router.ts
+++ b/backend/src/server/routes/v1/user-router.ts
@@ -169,4 +169,89 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
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
+ });
+ }
+ });
};
diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts
index 1c685866d4..738e27824d 100644
--- a/backend/src/server/routes/v2/mfa-router.ts
+++ b/backend/src/server/routes/v2/mfa-router.ts
@@ -2,8 +2,9 @@ import jwt from "jsonwebtoken";
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
+import { NotFoundError } from "@app/lib/errors";
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) => {
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({
url: "/mfa/verify",
method: "POST",
@@ -57,7 +90,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
},
schema: {
body: z.object({
- mfaToken: z.string().trim()
+ mfaToken: z.string().trim(),
+ mfaMethod: z.nativeEnum(MfaMethod).optional().default(MfaMethod.EMAIL)
}),
response: {
200: z.object({
@@ -86,7 +120,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
ip: req.realIp,
userId: req.mfa.userId,
orgId: req.mfa.orgId,
- mfaToken: req.body.mfaToken
+ mfaToken: req.body.mfaToken,
+ mfaMethod: req.body.mfaMethod
});
void res.setCookie("jid", token.refresh, {
diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts
index 01c7eda6d8..a52a45fa9a 100644
--- a/backend/src/server/routes/v2/user-router.ts
+++ b/backend/src/server/routes/v2/user-router.ts
@@ -4,7 +4,7 @@ import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema,
import { ApiKeysSchema } from "@app/db/schemas/api-keys";
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
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) => {
server.route({
@@ -56,7 +56,8 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
},
schema: {
body: z.object({
- isMfaEnabled: z.boolean()
+ isMfaEnabled: z.boolean().optional(),
+ selectedMfaMethod: z.nativeEnum(MfaMethod).optional()
}),
response: {
200: z.object({
@@ -66,7 +67,12 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
},
preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
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 };
}
});
diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts
index 67f8e2c4c0..cddfc1c2b1 100644
--- a/backend/src/server/routes/v3/login-router.ts
+++ b/backend/src/server/routes/v3/login-router.ts
@@ -48,7 +48,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
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) {
return {
token: tokens.mfa as string,
- isMfaEnabled: true
+ isMfaEnabled: true,
+ mfaMethod: tokens.mfaMethod
};
}
diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts
index 83da4724ef..2eb92af068 100644
--- a/backend/src/services/auth/auth-login-service.ts
+++ b/backend/src/services/auth/auth-login-service.ts
@@ -17,6 +17,7 @@ import { TokenType } from "../auth-token/auth-token-types";
import { TOrgDALFactory } from "../org/org-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { LoginMethod } from "../super-admin/super-admin-types";
+import { TTotpServiceFactory } from "../totp/totp-service";
import { TUserDALFactory } from "../user/user-dal";
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
import {
@@ -26,13 +27,14 @@ import {
TOauthTokenExchangeDTO,
TVerifyMfaTokenDTO
} from "./auth-login-type";
-import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type";
+import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "./auth-type";
type TAuthLoginServiceFactoryDep = {
userDAL: TUserDALFactory;
orgDAL: TOrgDALFactory;
tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService;
+ totpService: Pick;
};
export type TAuthLoginFactory = ReturnType;
@@ -40,7 +42,8 @@ export const authLoginServiceFactory = ({
userDAL,
tokenService,
smtpService,
- orgDAL
+ orgDAL,
+ totpService
}: TAuthLoginServiceFactoryDep) => {
/*
* Private
@@ -100,7 +103,8 @@ export const authLoginServiceFactory = ({
userAgent,
organizationId,
authMethod,
- isMfaVerified
+ isMfaVerified,
+ mfaMethod
}: {
user: TUsers;
ip: string;
@@ -108,6 +112,7 @@ export const authLoginServiceFactory = ({
organizationId?: string;
authMethod: AuthMethod;
isMfaVerified?: boolean;
+ mfaMethod?: MfaMethod;
}) => {
const cfg = getConfig();
await updateUserDeviceSession(user, ip, userAgent);
@@ -126,7 +131,8 @@ export const authLoginServiceFactory = ({
tokenVersionId: tokenSession.id,
accessVersion: tokenSession.accessVersion,
organizationId,
- isMfaVerified
+ isMfaVerified,
+ mfaMethod
},
cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
@@ -140,7 +146,8 @@ export const authLoginServiceFactory = ({
tokenVersionId: tokenSession.id,
refreshVersion: tokenSession.refreshVersion,
organizationId,
- isMfaVerified
+ isMfaVerified,
+ mfaMethod
},
cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_REFRESH_LIFETIME }
@@ -353,8 +360,12 @@ export const authLoginServiceFactory = ({
});
}
- // send multi factor auth token if they it enabled
- if ((selectedOrg.enforceMfa || user.isMfaEnabled) && user.email && !decodedToken.isMfaVerified) {
+ const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
+ 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);
const mfaToken = jwt.sign(
@@ -369,12 +380,14 @@ export const authLoginServiceFactory = ({
}
);
- await sendUserMfaCode({
- userId: user.id,
- email: user.email
- });
+ if (mfaMethod === MfaMethod.EMAIL && user.email) {
+ await sendUserMfaCode({
+ 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({
@@ -458,17 +471,39 @@ export const authLoginServiceFactory = ({
* Multi factor authentication verification of code
* 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 user = await userDAL.findById(userId);
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
try {
- await tokenService.validateTokenForUser({
- type: TokenType.TOKEN_EMAIL_MFA,
- userId,
- code: mfaToken
- });
+ if (mfaMethod === MfaMethod.EMAIL) {
+ await tokenService.validateTokenForUser({
+ type: TokenType.TOKEN_EMAIL_MFA,
+ 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) {
const updatedUser = await processFailedMfaAttempt(userId);
if (updatedUser.isLocked) {
@@ -513,7 +548,8 @@ export const authLoginServiceFactory = ({
userAgent,
organizationId: orgId,
authMethod: decodedToken.authMethod,
- isMfaVerified: true
+ isMfaVerified: true,
+ mfaMethod
});
return { token, user: userEnc };
diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts
index db57d730ed..d9d9520a83 100644
--- a/backend/src/services/auth/auth-login-type.ts
+++ b/backend/src/services/auth/auth-login-type.ts
@@ -1,4 +1,4 @@
-import { AuthMethod } from "./auth-type";
+import { AuthMethod, MfaMethod } from "./auth-type";
export type TLoginGenServerPublicKeyDTO = {
email: string;
@@ -19,6 +19,7 @@ export type TLoginClientProofDTO = {
export type TVerifyMfaTokenDTO = {
userId: string;
mfaToken: string;
+ mfaMethod: MfaMethod;
mfaJwtToken: string;
ip: string;
userAgent: string;
diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts
index 44b775945d..10227f595a 100644
--- a/backend/src/services/auth/auth-type.ts
+++ b/backend/src/services/auth/auth-type.ts
@@ -53,6 +53,7 @@ export type AuthModeJwtTokenPayload = {
accessVersion: number;
organizationId?: string;
isMfaVerified?: boolean;
+ mfaMethod?: MfaMethod;
};
export type AuthModeMfaJwtTokenPayload = {
@@ -85,3 +86,8 @@ export type AuthModeProviderSignUpTokenPayload = {
authTokenType: AuthTokenType.SIGNUP_TOKEN;
userId: string;
};
+
+export enum MfaMethod {
+ EMAIL = "email",
+ TOTP = "totp"
+}
diff --git a/backend/src/services/totp/totp-config-dal.ts b/backend/src/services/totp/totp-config-dal.ts
new file mode 100644
index 0000000000..15abb729a2
--- /dev/null
+++ b/backend/src/services/totp/totp-config-dal.ts
@@ -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;
+
+export const totpConfigDALFactory = (db: TDbClient) => {
+ const totpConfigDal = ormify(db, TableName.TotpConfig);
+
+ return totpConfigDal;
+};
diff --git a/backend/src/services/totp/totp-service.ts b/backend/src/services/totp/totp-service.ts
new file mode 100644
index 0000000000..11d3ac7a2c
--- /dev/null
+++ b/backend/src/services/totp/totp-service.ts
@@ -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;
+
+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
+ };
+};
diff --git a/backend/src/services/totp/totp-types.ts b/backend/src/services/totp/totp-types.ts
new file mode 100644
index 0000000000..8564b062cf
--- /dev/null
+++ b/backend/src/services/totp/totp-types.ts
@@ -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;
+};
diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts
index b8cf3c7a88..5da5d493c8 100644
--- a/backend/src/services/user/user-service.ts
+++ b/backend/src/services/user/user-service.ts
@@ -15,7 +15,7 @@ import { AuthMethod } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TUserDALFactory } from "./user-dal";
-import { TListUserGroupsDTO } from "./user-types";
+import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
type TUserServiceFactoryDep = {
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);
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, {
isMfaEnabled,
- mfaMethods: isMfaEnabled ? ["email"] : []
+ mfaMethods,
+ selectedMfaMethod
});
+
return updatedUser;
};
@@ -327,7 +336,7 @@ export const userServiceFactory = ({
return {
sendEmailVerificationCode,
verifyEmailVerificationCode,
- toggleUserMfa,
+ updateUserMfa,
updateUserName,
updateAuthMethods,
deleteUser,
diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts
index 9b482de98b..cef13f27a7 100644
--- a/backend/src/services/user/user-types.ts
+++ b/backend/src/services/user/user-types.ts
@@ -1,5 +1,7 @@
import { TOrgPermission } from "@app/lib/types";
+import { MfaMethod } from "../auth/auth-type";
+
export type TListUserGroupsDTO = {
username: string;
} & Omit;
@@ -8,3 +10,9 @@ export enum UserEncryption {
V1 = 1,
V2 = 2
}
+
+export type TUpdateUserMfaDTO = {
+ userId: string;
+ isMfaEnabled?: boolean;
+ selectedMfaMethod?: MfaMethod;
+};
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1a5abb775b..4c76c1dfb7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -75,6 +75,7 @@
"nprogress": "^0.2.0",
"picomatch": "^2.3.1",
"posthog-js": "^1.105.6",
+ "qrcode": "^1.5.4",
"query-string": "^7.1.3",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1",
@@ -120,6 +121,7 @@
"@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0",
+ "@types/qrcode": "^1.5.5",
"@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0",
"@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",
"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": {
"version": "6.9.11",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz",
@@ -9785,7 +9796,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -9794,7 +9804,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -11076,7 +11085,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -11376,6 +11384,29 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"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": {
"version": "1.0.4",
"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",
"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": {
"version": "1.0.2",
"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==",
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -14943,6 +14987,14 @@
"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": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@@ -16212,7 +16264,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -19339,7 +19390,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -19445,7 +19495,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -19666,6 +19715,14 @@
"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": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz",
@@ -20550,6 +20607,22 @@
"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": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -21846,6 +21919,14 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -21855,6 +21936,11 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
@@ -22314,6 +22400,11 @@
"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": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
@@ -22900,7 +22991,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -22934,8 +23024,7 @@
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/string.prototype.matchall": {
"version": "4.0.10",
@@ -23006,7 +23095,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -24902,6 +24990,11 @@
"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": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz",
@@ -25066,6 +25159,11 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -25079,6 +25177,87 @@
"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": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9558e99e98..b9e85254a5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -88,6 +88,7 @@
"nprogress": "^0.2.0",
"picomatch": "^2.3.1",
"posthog-js": "^1.105.6",
+ "qrcode": "^1.5.4",
"query-string": "^7.1.3",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1",
@@ -133,6 +134,7 @@
"@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0",
+ "@types/qrcode": "^1.5.5",
"@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1",
diff --git a/frontend/src/components/mfa/TotpRegistration.tsx b/frontend/src/components/mfa/TotpRegistration.tsx
new file mode 100644
index 0000000000..37e28069be
--- /dev/null
+++ b/frontend/src/components/mfa/TotpRegistration.tsx
@@ -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;
+};
+
+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 ;
+ }
+ return (
+
+
+ Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR code.
+
+
+
![registration-qr]({qrCodeUrl})
+
+
Enter the resulting verification code
+
+ setTotp(e.target.value)}
+ value={totp}
+ placeholder="Verification code"
+ />
+
+
+
+ );
+};
+
+export default TotpRegistration;
diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx
index dce930418c..5a085e10e7 100644
--- a/frontend/src/hooks/api/auth/queries.tsx
+++ b/frontend/src/hooks/api/auth/queries.tsx
@@ -19,6 +19,7 @@ import {
Login2Res,
LoginLDAPDTO,
LoginLDAPRes,
+ MfaMethod,
ResetPasswordDTO,
SendMfaTokenDTO,
SRP1DTO,
@@ -65,10 +66,11 @@ export const selectOrganization = async (data: {
organizationId: string;
userAgent?: UserAgentType;
}) => {
- const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean }>(
- "/api/v3/auth/select-organization",
- data
- );
+ const { data: res } = await apiRequest.post<{
+ token: string;
+ isMfaEnabled: boolean;
+ mfaMethod?: MfaMethod;
+ }>("/api/v3/auth/select-organization", data);
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", {
email,
- mfaToken: mfaCode
+ mfaToken: mfaCode,
+ mfaMethod
});
return data;
@@ -165,10 +176,11 @@ export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCod
export const useVerifyMfaToken = () => {
return useMutation({
- mutationFn: async ({ email, mfaCode }) => {
+ mutationFn: async ({ email, mfaCode, mfaMethod }) => {
return verifyMfaToken({
email,
- mfaCode
+ mfaCode,
+ mfaMethod
});
}
});
@@ -302,3 +314,9 @@ export const useGetAuthToken = () =>
onSuccess: (data) => setAuthToken(data.token),
retry: 0
});
+
+export const checkUserTotpMfa = async () => {
+ const { data } = await apiRequest.get<{ isVerified: boolean }>("/api/v2/auth/mfa/check/totp");
+
+ return data.isVerified;
+};
diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts
index f51f7b0912..fd0604a88b 100644
--- a/frontend/src/hooks/api/auth/types.ts
+++ b/frontend/src/hooks/api/auth/types.ts
@@ -9,6 +9,7 @@ export type SendMfaTokenDTO = {
export type VerifyMfaTokenDTO = {
email: string;
mfaCode: string;
+ mfaMethod: MfaMethod;
};
export type VerifyMfaTokenRes = {
@@ -149,3 +150,8 @@ export type GetBackupEncryptedPrivateKeyDTO = {
export enum UserAgentType {
CLI = "cli"
}
+
+export enum MfaMethod {
+ EMAIL = "email",
+ TOTP = "totp"
+}
diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx
index ab14892a38..b9d9f159b8 100644
--- a/frontend/src/hooks/api/users/index.tsx
+++ b/frontend/src/hooks/api/users/index.tsx
@@ -21,12 +21,13 @@ export {
useGetOrgUsers,
useGetUser,
useGetUserAction,
+ useGetUserTotpRegistration,
useListUserGroupMemberships,
useLogoutUser,
useRegisterUserAction,
useRevokeMySessions,
- useUpdateMfaEnabled,
useUpdateOrgMembership,
- useUpdateUserAuthMethods
+ useUpdateUserAuthMethods,
+ useUpdateUserMfa
} from "./queries";
export { userKeys } from "./query-keys";
diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx
index d4092baf46..fe48b1f3b5 100644
--- a/frontend/src/hooks/api/users/mutation.tsx
+++ b/frontend/src/hooks/api/users/mutation.tsx
@@ -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);
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx
index 116b98d4f7..262068bc7f 100644
--- a/frontend/src/hooks/api/users/queries.tsx
+++ b/frontend/src/hooks/api/users/queries.tsx
@@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { AxiosError } from "axios";
import { apiRequest } from "@app/config/request";
import { SessionStorageKeys } from "@app/const";
import { setAuthToken } from "@app/reactQuery";
import { APIKeyDataV2 } from "../apiKeys/types";
+import { MfaMethod } from "../auth/types";
import { TGroupWithProjectMemberships } from "../groups/types";
import { workspaceKeys } from "../workspace";
import { userKeys } from "./query-keys";
@@ -390,14 +392,21 @@ export const useRevokeMySessions = () => {
});
};
-export const useUpdateMfaEnabled = () => {
+export const useUpdateUserMfa = () => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: async ({ isMfaEnabled }: { isMfaEnabled: boolean }) => {
+ mutationFn: async ({
+ isMfaEnabled,
+ selectedMfaMethod
+ }: {
+ isMfaEnabled?: boolean;
+ selectedMfaMethod?: MfaMethod;
+ }) => {
const {
data: { user }
} = await apiRequest.patch("/api/v2/users/me/mfa", {
- isMfaEnabled
+ isMfaEnabled,
+ selectedMfaMethod
});
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;
+ }
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/users/query-keys.tsx b/frontend/src/hooks/api/users/query-keys.tsx
index 8603364b2a..34d969b495 100644
--- a/frontend/src/hooks/api/users/query-keys.tsx
+++ b/frontend/src/hooks/api/users/query-keys.tsx
@@ -16,6 +16,8 @@ export const userKeys = {
myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const,
+ totpRegistration: ["totp-registration"],
+ totpConfiguration: ["totp-configuration"],
listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
};
diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts
index b1095b009c..594287c9cd 100644
--- a/frontend/src/hooks/api/users/types.ts
+++ b/frontend/src/hooks/api/users/types.ts
@@ -1,3 +1,4 @@
+import { MfaMethod } from "../auth/types";
import { UserWsKeyPair } from "../keys/types";
import { ProjectUserMembershipTemporaryMode } from "../workspace/types";
@@ -26,6 +27,7 @@ export type User = {
authProvider?: AuthMethod;
authMethods: AuthMethod[];
isMfaEnabled: boolean;
+ selectedMfaMethod?: MfaMethod;
seenIps: string[];
id: string;
};
diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx
index cc19cc5726..c3d939e7b2 100644
--- a/frontend/src/pages/login/select-organization.tsx
+++ b/frontend/src/pages/login/select-organization.tsx
@@ -22,7 +22,7 @@ import {
useLogoutUser,
useSelectOrganization
} 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 { AuthMethod } from "@app/hooks/api/users/types";
import { getAuthToken, isLoggedIn } from "@app/reactQuery";
@@ -46,6 +46,7 @@ export default function LoginPage() {
const selectOrg = useSelectOrganization();
const { data: user, isLoading: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
+ const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
@@ -90,15 +91,17 @@ export default function LoginPage() {
return;
}
- const { token, isMfaEnabled } = await selectOrg.mutateAsync({
+ const { token, isMfaEnabled, mfaMethod } = await selectOrg.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
+ if (mfaMethod) {
+ setRequiredMfaMethod(mfaMethod);
+ }
toggleShowMfa.on();
-
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
@@ -213,7 +216,11 @@ export default function LoginPage() {
{shouldShowMfa ? (
-
+
) : (
diff --git a/frontend/src/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx
index 4b43d28635..bec7bb2e29 100644
--- a/frontend/src/views/Login/Mfa.tsx
+++ b/frontend/src/views/Login/Mfa.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input";
import Image from "next/image";
import Link from "next/link";
@@ -6,10 +6,12 @@ import { useRouter } from "next/router";
import { t } from "i18next";
import Error from "@app/components/basic/Error";
+import TotpRegistration from "@app/components/mfa/TotpRegistration";
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 { 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
const codeInputProps = {
@@ -36,23 +38,37 @@ type Props = {
closeMfa?: () => void;
hideLogo?: boolean;
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 router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState
(undefined);
+ const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const sendMfaToken = useSendMfaToken();
+ useEffect(() => {
+ if (method === MfaMethod.TOTP) {
+ checkUserTotpMfa().then((isVerified) => {
+ if (!isVerified) {
+ SecurityClient.setMfaToken("");
+ setShouldShowTotpRegistration(true);
+ }
+ });
+ }
+ }, []);
+
const verifyMfa = async () => {
setIsLoading(true);
try {
const { token } = await verifyMfaToken({
email,
- mfaCode
+ mfaCode,
+ mfaMethod: method
});
SecurityClient.setMfaToken("");
@@ -92,6 +108,23 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
}
};
+ if (shouldShowTotpRegistration) {
+ return (
+
+ {
+ setShouldShowTotpRegistration(false);
+
+ await successCallback();
+ if (closeMfa) {
+ closeMfa();
+ }
+ }}
+ />
+
+ );
+ }
+
return (
{!hideLogo && (
@@ -101,18 +134,40 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
)}
- {t("mfa.step2-message")}
- {email}
+ {method === MfaMethod.EMAIL && (
+ <>
+ {t("mfa.step2-message")}
+ {email}
+ >
+ )}
+ {method === MfaMethod.TOTP && (
+ <>
+
+ Authenticator MFA Required
+
+
+ Open the authenticator app on your mobile device to get your verification code or enter
+ a recovery code.
+
+ >
+ )}
-
+ {method === MfaMethod.EMAIL && (
+
+ )}
+ {method === MfaMethod.TOTP && (
+
+ setMfaCode(e.target.value)} />
+
+ )}
{typeof triesLeft === "number" && (
@@ -132,21 +187,23 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
-
-
-
{t("signup.step2-resend-alert")}
-
-
+ {method === MfaMethod.EMAIL && (
+
+
+
{t("signup.step2-resend-alert")}
+
+
+
+
{t("signup.step2-spam-alert")}
-
{t("signup.step2-spam-alert")}
-
+ )}
);
};
diff --git a/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/MFASection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/MFASection.tsx
index 96706af8e5..0cdf9cc198 100644
--- a/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/MFASection.tsx
+++ b/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/MFASection.tsx
@@ -1,18 +1,84 @@
+import { useQueryClient } from "@tanstack/react-query";
+
+import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications";
-import { Checkbox, EmailServiceSetupModal } from "@app/components/v2";
-import { useGetUser, useUpdateMfaEnabled } from "@app/hooks/api";
+import {
+ 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 { useDeleteUserTotpConfiguration } from "@app/hooks/api/users/mutation";
+import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => {
const { data: user } = useGetUser();
- const { mutateAsync } = useUpdateMfaEnabled();
-
- const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
+ const { mutateAsync } = useUpdateUserMfa();
+ 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 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) => {
try {
if (!user) return;
@@ -47,31 +113,86 @@ export const MFASection = () => {
return (
<>
-
diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx
index 156854b70e..b704ac46c1 100644
--- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx
+++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx
@@ -72,11 +72,11 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
+ setMfaSuccessCallback(() => finishWithOrgWorkflow);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
- setMfaSuccessCallback(() => finishWithOrgWorkflow);
return;
}
From 6bae3628c0d3ad286f1e79c8c9bbfddaef04ea10 Mon Sep 17 00:00:00 2001
From: Sheen Capadngan
Date: Thu, 14 Nov 2024 19:37:13 +0800
Subject: [PATCH 07/11] misc: readded saml email error
---
backend/src/ee/routes/v1/saml-router.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts
index aaebd9b6f1..b195d32b35 100644
--- a/backend/src/ee/routes/v1/saml-router.ts
+++ b/backend/src/ee/routes/v1/saml-router.ts
@@ -122,6 +122,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
},
`email: ${email} firstName: ${profile.firstName as string}`
);
+
+ throw new Error("Invalid saml request. Missing email or first name");
}
const userMetadata = Object.keys(profile.attributes || {})
From 433971a72dad11b0fb7001a1f4de3bebca3c108f Mon Sep 17 00:00:00 2001
From: Sheen Capadngan
Date: Fri, 15 Nov 2024 23:25:32 +0800
Subject: [PATCH 08/11] misc: addressed comments 1
---
backend/src/server/routes/index.ts | 3 +-
backend/src/server/routes/v1/user-router.ts | 16 ++-
backend/src/server/routes/v2/mfa-router.ts | 4 +-
.../src/services/auth/auth-login-service.ts | 6 +-
.../services/auth/auth-password-service.ts | 11 +-
backend/src/services/totp/totp-service.ts | 81 ++++++++++++--
backend/src/services/totp/totp-types.ts | 4 +
.../src/components/mfa/TotpRegistration.tsx | 28 +++--
frontend/src/hooks/api/users/mutation.tsx | 14 +++
frontend/src/hooks/api/users/queries.tsx | 2 +-
frontend/src/views/Login/Mfa.tsx | 104 ++++++++++--------
.../OrgAuthTab/OrgGenericAuthSection.tsx | 4 +-
.../SecuritySection/MFASection.tsx | 62 ++++++++---
13 files changed, 249 insertions(+), 90 deletions(-)
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index f3d3dd7d5c..b9f46627bb 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -525,7 +525,8 @@ export const registerRoutes = async (
tokenService,
smtpService,
authDAL,
- userDAL
+ userDAL,
+ totpConfigDAL
});
const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL });
diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts
index 706bb9a8b5..a97f11be49 100644
--- a/backend/src/server/routes/v1/user-router.ts
+++ b/backend/src/server/routes/v1/user-router.ts
@@ -196,7 +196,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
method: "DELETE",
url: "/me/totp",
config: {
- rateLimit: readLimit
+ rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
@@ -254,4 +254,18 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
});
}
});
+
+ server.route({
+ method: "POST",
+ url: "/me/totp/recovery-codes",
+ config: {
+ rateLimit: writeLimit
+ },
+ onRequest: verifyAuth([AuthMode.JWT]),
+ handler: async (req) => {
+ return server.services.totp.createUserTotpRecoveryCodes({
+ userId: req.permission.id
+ });
+ }
+ });
};
diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts
index 738e27824d..6f28ec34c2 100644
--- a/backend/src/server/routes/v2/mfa-router.ts
+++ b/backend/src/server/routes/v2/mfa-router.ts
@@ -2,7 +2,7 @@ import jwt from "jsonwebtoken";
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
-import { NotFoundError } from "@app/lib/errors";
+import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { mfaRateLimit } from "@app/server/config/rateLimiter";
import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type";
@@ -73,7 +73,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
isVerified: Boolean(totpConfig)
};
} catch (error) {
- if (error instanceof NotFoundError) {
+ if (error instanceof NotFoundError || error instanceof BadRequestError) {
return { isVerified: false };
}
diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts
index a580af2f5c..dea41e60b1 100644
--- a/backend/src/services/auth/auth-login-service.ts
+++ b/backend/src/services/auth/auth-login-service.ts
@@ -361,9 +361,9 @@ export const authLoginServiceFactory = ({
}
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
- const orgMfaMethod = selectedOrg.enforceMfa ? selectedOrg.selectedMfaMethod : undefined;
- const userMfaMethod = user.isMfaEnabled ? user.selectedMfaMethod : undefined;
- const mfaMethod = orgMfaMethod ?? userMfaMethod ?? MfaMethod.EMAIL;
+ const orgMfaMethod = selectedOrg.enforceMfa ? selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL : undefined;
+ const userMfaMethod = user.isMfaEnabled ? user.selectedMfaMethod ?? MfaMethod.EMAIL : undefined;
+ const mfaMethod = orgMfaMethod ?? userMfaMethod;
if (shouldCheckMfa && (!decodedToken.isMfaVerified || decodedToken.mfaMethod !== mfaMethod)) {
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts
index 0e65589664..9ed9951fe1 100644
--- a/backend/src/services/auth/auth-password-service.ts
+++ b/backend/src/services/auth/auth-password-service.ts
@@ -8,6 +8,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
+import { TTotpConfigDALFactory } from "../totp/totp-config-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TAuthDALFactory } from "./auth-dal";
import { TChangePasswordDTO, TCreateBackupPrivateKeyDTO, TResetPasswordViaBackupKeyDTO } from "./auth-password-type";
@@ -18,6 +19,7 @@ type TAuthPasswordServiceFactoryDep = {
userDAL: TUserDALFactory;
tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService;
+ totpConfigDAL: Pick;
};
export type TAuthPasswordFactory = ReturnType;
@@ -25,7 +27,8 @@ export const authPaswordServiceFactory = ({
authDAL,
userDAL,
tokenService,
- smtpService
+ smtpService,
+ totpConfigDAL
}: TAuthPasswordServiceFactoryDep) => {
/*
* Pre setup for pass change with srp protocol
@@ -185,6 +188,12 @@ export const authPaswordServiceFactory = ({
temporaryLockDateEnd: null,
consecutiveFailedMfaAttempts: 0
});
+
+ /* we reset the mobile authenticator configs of the user
+ because we want this to be one of the recovery modes from account lockout */
+ await totpConfigDAL.delete({
+ userId
+ });
};
/*
diff --git a/backend/src/services/totp/totp-service.ts b/backend/src/services/totp/totp-service.ts
index 11d3ac7a2c..f304f01b8a 100644
--- a/backend/src/services/totp/totp-service.ts
+++ b/backend/src/services/totp/totp-service.ts
@@ -8,6 +8,7 @@ import { TKmsServiceFactory } from "../kms/kms-service";
import { TUserDALFactory } from "../user/user-dal";
import { TTotpConfigDALFactory } from "./totp-config-dal";
import {
+ TCreateUserTotpRecoveryCodesDTO,
TDeleteUserTotpConfigDTO,
TGetUserTotpConfigDTO,
TRegisterUserTotpDTO,
@@ -27,8 +28,7 @@ export type TTotpServiceFactory = ReturnType;
export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotpServiceFactoryDep) => {
const getUserTotpConfig = async ({ userId }: TGetUserTotpConfigDTO) => {
const totpConfig = await totpConfigDAL.findOne({
- userId,
- isVerified: true
+ userId
});
if (!totpConfig) {
@@ -37,6 +37,12 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
});
}
+ if (!totpConfig.isVerified) {
+ throw new BadRequestError({
+ message: "TOTP configuration has not been verified"
+ });
+ }
+
const decryptWithRoot = kmsService.decryptWithRootKey();
const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
@@ -102,8 +108,7 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
const verifyUserTotpConfig = async ({ userId, totp }: TVerifyUserTotpConfigDTO) => {
const totpConfig = await totpConfigDAL.findOne({
- userId,
- isVerified: false
+ userId
});
if (!totpConfig) {
@@ -112,6 +117,12 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
});
}
+ if (totpConfig.isVerified) {
+ throw new BadRequestError({
+ message: "TOTP configuration has already been verified"
+ });
+ }
+
const decryptWithRoot = kmsService.decryptWithRootKey();
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
const isValid = authenticator.verify({
@@ -132,8 +143,7 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => {
const totpConfig = await totpConfigDAL.findOne({
- userId,
- isVerified: true
+ userId
});
if (!totpConfig) {
@@ -142,6 +152,12 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
});
}
+ if (!totpConfig.isVerified) {
+ throw new BadRequestError({
+ message: "TOTP configuration has not been verified"
+ });
+ }
+
const decryptWithRoot = kmsService.decryptWithRootKey();
const secret = decryptWithRoot(totpConfig.encryptedSecret).toString();
const isValid = authenticator.verify({
@@ -158,8 +174,7 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
const verifyWithUserRecoveryCode = async ({ userId, recoveryCode }: TVerifyWithUserRecoveryCodeDTO) => {
const totpConfig = await totpConfigDAL.findOne({
- userId,
- isVerified: true
+ userId
});
if (!totpConfig) {
@@ -168,6 +183,12 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
});
}
+ if (!totpConfig.isVerified) {
+ throw new BadRequestError({
+ message: "TOTP configuration has not been verified"
+ });
+ }
+
const decryptWithRoot = kmsService.decryptWithRootKey();
const encryptWithRoot = kmsService.encryptWithRootKey();
@@ -188,8 +209,7 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
const deleteUserTotpConfig = async ({ userId }: TDeleteUserTotpConfigDTO) => {
const totpConfig = await totpConfigDAL.findOne({
- userId,
- isVerified: true
+ userId
});
if (!totpConfig) {
@@ -201,12 +221,51 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
await totpConfigDAL.deleteById(totpConfig.id);
};
+ const createUserTotpRecoveryCodes = async ({ userId }: TCreateUserTotpRecoveryCodesDTO) => {
+ const decryptWithRoot = kmsService.decryptWithRootKey();
+ const encryptWithRoot = kmsService.encryptWithRootKey();
+
+ return totpConfigDAL.transaction(async (tx) => {
+ const totpConfig = await totpConfigDAL.findOne(
+ {
+ userId,
+ isVerified: true
+ },
+ tx
+ );
+
+ if (!totpConfig) {
+ throw new NotFoundError({
+ message: "Valid TOTP configuration not found"
+ });
+ }
+
+ const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(",");
+ if (recoveryCodes.length >= 10) {
+ throw new BadRequestError({
+ message: "Cannot have more than 10 recovery codes at a time"
+ });
+ }
+
+ const toGenerateCount = 10 - recoveryCodes.length;
+ const newRecoveryCodes = Array.from({ length: toGenerateCount }).map(() =>
+ String(crypto.randomInt(10 ** 7, 10 ** 8 - 1))
+ );
+ const encryptedRecoveryCodes = encryptWithRoot(Buffer.from([...recoveryCodes, ...newRecoveryCodes].join(",")));
+
+ await totpConfigDAL.updateById(totpConfig.id, {
+ encryptedRecoveryCodes
+ });
+ });
+ };
+
return {
registerUserTotp,
verifyUserTotpConfig,
getUserTotpConfig,
verifyUserTotp,
verifyWithUserRecoveryCode,
- deleteUserTotpConfig
+ deleteUserTotpConfig,
+ createUserTotpRecoveryCodes
};
};
diff --git a/backend/src/services/totp/totp-types.ts b/backend/src/services/totp/totp-types.ts
index 8564b062cf..15c0156197 100644
--- a/backend/src/services/totp/totp-types.ts
+++ b/backend/src/services/totp/totp-types.ts
@@ -24,3 +24,7 @@ export type TVerifyWithUserRecoveryCodeDTO = {
export type TDeleteUserTotpConfigDTO = {
userId: string;
};
+
+export type TCreateUserTotpRecoveryCodesDTO = {
+ userId: string;
+};
diff --git a/frontend/src/components/mfa/TotpRegistration.tsx b/frontend/src/components/mfa/TotpRegistration.tsx
index 37e28069be..59d4914f5a 100644
--- a/frontend/src/components/mfa/TotpRegistration.tsx
+++ b/frontend/src/components/mfa/TotpRegistration.tsx
@@ -13,11 +13,13 @@ type Props = {
const TotpRegistration = ({ onComplete }: Props) => {
const { data: registration, isLoading } = useGetUserTotpRegistration();
- const { mutateAsync: verifyUserTotp } = useVerifyUserTotpRegistration();
+ const { mutateAsync: verifyUserTotp, isLoading: isVerifyLoading } =
+ useVerifyUserTotpRegistration();
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [totp, setTotp] = useState("");
- const handleTotpVerify = async () => {
+ const handleTotpVerify = async (event: React.FormEvent) => {
+ event.preventDefault();
await verifyUserTotp({
totp
});
@@ -54,15 +56,19 @@ const TotpRegistration = ({ onComplete }: Props) => {
- Enter the resulting verification code
-
- setTotp(e.target.value)}
- value={totp}
- placeholder="Verification code"
- />
-
-
+
);
};
diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx
index fe48b1f3b5..e8cf41acbd 100644
--- a/frontend/src/hooks/api/users/mutation.tsx
+++ b/frontend/src/hooks/api/users/mutation.tsx
@@ -140,3 +140,17 @@ export const useDeleteUserTotpConfiguration = () => {
}
});
};
+
+export const useCreateNewTotpRecoveryCodes = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ await apiRequest.post("/api/v1/user/me/totp/recovery-codes");
+
+ return {};
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries(userKeys.totpConfiguration);
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx
index 262068bc7f..98d8e4b565 100644
--- a/frontend/src/hooks/api/users/queries.tsx
+++ b/frontend/src/hooks/api/users/queries.tsx
@@ -480,7 +480,7 @@ export const useGetUserTotpConfiguration = () => {
return data;
} catch (error) {
- if (error instanceof AxiosError && error.response?.data?.statusCode === 404) {
+ if (error instanceof AxiosError && [404, 400].includes(error.response?.data?.statusCode)) {
return {
isVerified: false,
recoveryCodes: []
diff --git a/frontend/src/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx
index c9d196b35e..6132d05bfe 100644
--- a/frontend/src/views/Login/Mfa.tsx
+++ b/frontend/src/views/Login/Mfa.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import React, { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input";
import Image from "next/image";
import Link from "next/link";
@@ -62,7 +62,9 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
}
}, []);
- const verifyMfa = async () => {
+ const verifyMfa = async (event: React.FormEvent) => {
+ event.preventDefault();
+
setIsLoading(true);
try {
const { token } = await verifyMfaToken({
@@ -110,14 +112,19 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
if (shouldShowTotpRegistration) {
return (
-
- {
- setShouldShowTotpRegistration(false);
- await successCallback();
- }}
- />
-
+ <>
+
+ Your organization requires mobile authenticator to be configured.
+
+
+ {
+ setShouldShowTotpRegistration(false);
+ await successCallback();
+ }}
+ />
+
+ >
);
}
@@ -147,42 +154,53 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
>
)}
-