From 6bdbac4750a2bf6357baab2ecbbb4429d11757d8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 14 Nov 2024 00:07:35 +0800 Subject: [PATCH] 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 d55521f5b..baae5fb6a 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 240b7db2c..1aa6caf6a 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 ebe3f4289..2843648da 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 9cfb78dd7..f5c44ff79 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 000000000..9aefc444c --- /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 680d8df5b..74741a8ff 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 5bab447fd..171931f7e 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 31de98168..3f40447ad 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 000000000..d6ec11592 --- /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 5134f3ee6..1c1f579ea 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 88bfaaaae..f3d3dd7d5 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 4e4583196..706bb9a8b 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 1c685866d..738e27824 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 01c7eda6d..a52a45fa9 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 67f8e2c4c..cddfc1c2b 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 83da4724e..2eb92af06 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 db57d730e..d9d9520a8 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 44b775945..10227f595 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 000000000..15abb729a --- /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 000000000..11d3ac7a2 --- /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 000000000..8564b062c --- /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 b8cf3c7a8..5da5d493c 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 9b482de98..cef13f27a 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 1a5abb775..4c76c1dfb 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 9558e99e9..b9e85254a 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 000000000..37e28069b --- /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 +
+
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 dce930418..5a085e10e 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 f51f7b091..fd0604a88 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 ab14892a3..b9d9f159b 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 d4092baf4..fe48b1f3b 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 116b98d4f..262068bc7 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 8603364b2..34d969b49 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 b1095b009..594287c9c 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 cc19cc572..c3d939e7b 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 4b43d2863..bec7bb2e2 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 96706af8e..0cdf9cc19 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 ( <> -
-
-

Two-factor Authentication

- {user && ( - { - if (serverDetails?.emailConfigured) { - toggleMfa(state as boolean); - } else { - handlePopUpOpen("setUpEmail"); - } - }} +
+

Two-factor Authentication

+ {user && ( + { + if (serverDetails?.emailConfigured) { + toggleMfa(state as boolean); + } else { + handlePopUpOpen("setUpEmail"); + } + }} + > + Enable 2-factor authentication + + )} + {user?.isMfaEnabled && ( + + + + )} +
Mobile Authenticator
+ {isTotpConfigurationLoading ? ( + + ) : ( +
+ {totpConfiguration?.isVerified ? ( +
+
+ + +
+ {shouldShowRecoveryCodes && totpConfiguration.recoveryCodes.length && ( +
+ {totpConfiguration.recoveryCodes.map((code) => ( +
{code}
+ ))} +
+ )} +
+ ) : ( +
+ { + await queryClient.invalidateQueries(userKeys.totpConfiguration); + }} + /> +
+ )} +
+ )} +
handlePopUpToggle("setUpEmail", isOpen)} /> + handlePopUpToggle("deleteTotpConfig", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleTotpDeletion} + /> ); };