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/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 || {}) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 88bfaaaae0..b9f46627bb 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,12 +514,19 @@ 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, authDAL, - userDAL + userDAL, + totpConfigDAL }); const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL }); @@ -1369,7 +1379,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/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index d67e7b5621..6e39d0451d 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -108,7 +108,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { tokenVersionId: tokenVersion.id, accessVersion: tokenVersion.accessVersion, organizationId: decodedToken.organizationId, - isMfaVerified: decodedToken.isMfaVerified + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod }, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_AUTH_LIFETIME } diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 30d032e13f..07f795779f 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -15,7 +15,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -259,7 +259,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { message: "Membership role must be a valid slug" }) .optional(), - enforceMfa: z.boolean().optional() + enforceMfa: z.boolean().optional(), + selectedMfaMethod: z.nativeEnum(MfaMethod).optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 4e45831965..a97f11be49 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -169,4 +169,103 @@ 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: writeLimit + }, + 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 + }); + } + }); + + 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 1c685866d4..6f28ec34c2 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 { BadRequestError, 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 || error instanceof BadRequestError) { + 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..dea41e60b1 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 ?? 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); 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({ @@ -383,7 +396,8 @@ export const authLoginServiceFactory = ({ userAgent, ip: ipAddress, organizationId, - isMfaVerified: decodedToken.isMfaVerified + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod }); return { @@ -458,17 +472,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 +549,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-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/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 44b775945d..c1bf2b6fbd 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 = { @@ -71,6 +72,7 @@ export type AuthModeRefreshJwtTokenPayload = { refreshVersion: number; organizationId?: string; isMfaVerified?: boolean; + mfaMethod?: MfaMethod; }; export type AuthModeProviderJwtTokenPayload = { @@ -85,3 +87,8 @@ export type AuthModeProviderSignUpTokenPayload = { authTokenType: AuthTokenType.SIGNUP_TOKEN; userId: string; }; + +export enum MfaMethod { + EMAIL = "email", + TOTP = "totp" +} diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3d74f80b62..1d656c4d5c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -268,7 +268,7 @@ export const orgServiceFactory = ({ actorOrgId, actorAuthMethod, orgId, - data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa } + data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod } }: TUpdateOrgDTO) => { const appCfg = getConfig(); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); @@ -333,7 +333,8 @@ export const orgServiceFactory = ({ authEnforced, scimEnabled, defaultMembershipRole, - enforceMfa + enforceMfa, + selectedMfaMethod }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 5b44eeea5d..7d4a203de6 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,6 +1,6 @@ import { TOrgPermission } from "@app/lib/types"; -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType, MfaMethod } from "../auth/auth-type"; export type TUpdateOrgMembershipDTO = { userId: string; @@ -65,6 +65,7 @@ export type TUpdateOrgDTO = { scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; + selectedMfaMethod: MfaMethod; }>; } & TOrgPermission; 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-fns.ts b/backend/src/services/totp/totp-fns.ts new file mode 100644 index 0000000000..9e9aae52c5 --- /dev/null +++ b/backend/src/services/totp/totp-fns.ts @@ -0,0 +1,3 @@ +import crypto from "node:crypto"; + +export const generateRecoveryCode = () => String(crypto.randomInt(10 ** 7, 10 ** 8 - 1)); diff --git a/backend/src/services/totp/totp-service.ts b/backend/src/services/totp/totp-service.ts new file mode 100644 index 0000000000..591a66ed6a --- /dev/null +++ b/backend/src/services/totp/totp-service.ts @@ -0,0 +1,270 @@ +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 { generateRecoveryCode } from "./totp-fns"; +import { + TCreateUserTotpRecoveryCodesDTO, + TDeleteUserTotpConfigDTO, + TGetUserTotpConfigDTO, + TRegisterUserTotpDTO, + TVerifyUserTotpConfigDTO, + TVerifyUserTotpDTO, + TVerifyWithUserRecoveryCodeDTO +} from "./totp-types"; + +type TTotpServiceFactoryDep = { + userDAL: TUserDALFactory; + totpConfigDAL: TTotpConfigDALFactory; + kmsService: TKmsServiceFactory; +}; + +export type TTotpServiceFactory = ReturnType; + +const MAX_RECOVERY_CODE_LIMIT = 10; + +export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotpServiceFactoryDep) => { + const getUserTotpConfig = async ({ userId }: TGetUserTotpConfigDTO) => { + const totpConfig = await totpConfigDAL.findOne({ + userId + }); + + if (!totpConfig) { + throw new NotFoundError({ + message: "TOTP configuration not found" + }); + } + + if (!totpConfig.isVerified) { + throw new BadRequestError({ + message: "TOTP configuration has not been verified" + }); + } + + 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: MAX_RECOVERY_CODE_LIMIT }).map(generateRecoveryCode); + 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 + }); + + if (!totpConfig) { + throw new NotFoundError({ + message: "TOTP configuration not found" + }); + } + + 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({ + 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 + }); + + if (!totpConfig) { + throw new NotFoundError({ + message: "TOTP configuration not found" + }); + } + + 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({ + token: totp, + secret + }); + + if (!isValid) { + throw new ForbiddenRequestError({ + message: "Invalid TOTP" + }); + } + }; + + const verifyWithUserRecoveryCode = async ({ userId, recoveryCode }: TVerifyWithUserRecoveryCodeDTO) => { + const totpConfig = await totpConfigDAL.findOne({ + userId + }); + + if (!totpConfig) { + throw new NotFoundError({ + message: "TOTP configuration not found" + }); + } + + if (!totpConfig.isVerified) { + throw new BadRequestError({ + message: "TOTP configuration has not been verified" + }); + } + + 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 + }); + + if (!totpConfig) { + throw new NotFoundError({ + message: "TOTP configuration not found" + }); + } + + 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 >= MAX_RECOVERY_CODE_LIMIT) { + throw new BadRequestError({ + message: `Cannot have more than ${MAX_RECOVERY_CODE_LIMIT} recovery codes at a time` + }); + } + + const toGenerateCount = MAX_RECOVERY_CODE_LIMIT - recoveryCodes.length; + const newRecoveryCodes = Array.from({ length: toGenerateCount }).map(generateRecoveryCode); + const encryptedRecoveryCodes = encryptWithRoot(Buffer.from([...recoveryCodes, ...newRecoveryCodes].join(","))); + + await totpConfigDAL.updateById(totpConfig.id, { + encryptedRecoveryCodes + }); + }); + }; + + return { + registerUserTotp, + verifyUserTotpConfig, + getUserTotpConfig, + verifyUserTotp, + verifyWithUserRecoveryCode, + deleteUserTotpConfig, + createUserTotpRecoveryCodes + }; +}; diff --git a/backend/src/services/totp/totp-types.ts b/backend/src/services/totp/totp-types.ts new file mode 100644 index 0000000000..15c0156197 --- /dev/null +++ b/backend/src/services/totp/totp-types.ts @@ -0,0 +1,30 @@ +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; +}; + +export type TCreateUserTotpRecoveryCodesDTO = { + 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/cli/packages/api/model.go b/cli/packages/api/model.go index adf7a814d3..bc63218729 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -138,6 +138,7 @@ type GetOrganizationsResponse struct { type SelectOrganizationResponse struct { Token string `json:"token"` MfaEnabled bool `json:"isMfaEnabled"` + MfaMethod string `json:"mfaMethod"` } type SelectOrganizationRequest struct { @@ -260,8 +261,9 @@ type GetLoginTwoV2Response struct { } type VerifyMfaTokenRequest struct { - Email string `json:"email"` - MFAToken string `json:"mfaToken"` + Email string `json:"email"` + MFAToken string `json:"mfaToken"` + MFAMethod string `json:"mfaMethod"` } type VerifyMfaTokenResponse struct { diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index f95d904857..05655e97cd 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -79,13 +79,14 @@ var initCmd = &cobra.Command{ if tokenResponse.MfaEnabled { i := 1 for i < 6 { - mfaVerifyCode := askForMFACode() - + mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod) + httpClient := resty.New() httpClient.SetAuthToken(tokenResponse.Token) verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ - Email: userCreds.UserCredentials.Email, - MFAToken: mfaVerifyCode, + Email: userCreds.UserCredentials.Email, + MFAToken: mfaVerifyCode, + MFAMethod: tokenResponse.MfaMethod, }) if requestError != nil { util.HandleError(err) @@ -99,7 +100,7 @@ var initCmd = &cobra.Command{ break } } - + if mfaErrorResponse.Context.Code == "mfa_expired" { util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") break diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 03974ba19e..649b5b01ce 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -343,7 +343,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) { if loginTwoResponse.MfaEnabled { i := 1 for i < 6 { - mfaVerifyCode := askForMFACode() + mfaVerifyCode := askForMFACode("email") httpClient := resty.New() httpClient.SetAuthToken(loginTwoResponse.Token) @@ -756,13 +756,14 @@ func GetJwtTokenWithOrganizationId(oldJwtToken string, email string) string { if selectedOrgRes.MfaEnabled { i := 1 for i < 6 { - mfaVerifyCode := askForMFACode() + mfaVerifyCode := askForMFACode(selectedOrgRes.MfaMethod) httpClient := resty.New() httpClient.SetAuthToken(selectedOrgRes.Token) verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ - Email: email, - MFAToken: mfaVerifyCode, + Email: email, + MFAToken: mfaVerifyCode, + MFAMethod: selectedOrgRes.MfaMethod, }) if requestError != nil { util.HandleError(err) @@ -817,9 +818,15 @@ func generateFromPassword(password string, salt []byte, p *params) (hash []byte, return hash, nil } -func askForMFACode() string { +func askForMFACode(mfaMethod string) string { + var label string + if mfaMethod == "totp" { + label = "Enter the verification code from your mobile authenticator app or use a recovery code" + } else { + label = "Enter the 2FA verification code sent to your email" + } mfaCodePromptUI := promptui.Prompt{ - Label: "Enter the 2FA verification code sent to your email", + Label: label, } mfaVerifyCode, err := mfaCodePromptUI.Run() diff --git a/docs/documentation/platform/mfa.mdx b/docs/documentation/platform/mfa.mdx index 69a84fdd4d..1f61fae5c8 100644 --- a/docs/documentation/platform/mfa.mdx +++ b/docs/documentation/platform/mfa.mdx @@ -4,19 +4,18 @@ sidebarTitle: "MFA" description: "Learn how to secure your Infisical account with MFA." --- -MFA requires users to provide multiple forms of identification to access their account. Currently, this means logging in with your password and a 6-digit code sent to your email. +MFA requires users to provide multiple forms of identification to access their account. ## Email 2FA -Check the box in Personal Settings > Two-factor Authentication to enable email-based 2FA. +If 2-factor authentication is enabled in the Personal settings page, email will be used for MFA by default. -![Email-based MFA](../../images/mfa-email.png) +![Email-based MFA](/images/mfa-email.png) - - Infisical currently supports email-based 2FA. We're actively working on - building support for other forms of identification via SMS and Authenticator - App. - +## Mobile Authenticator 2FA + +You can use any mobile authenticator app (Authy, Google Authenticator, Duo, etc.) to secure your account. After registration with an authenticator, select **Mobile Authenticator** as your 2FA method. +![Authenticator-based MFA](/images/mfa-authenticator.png) ## Entra ID / Azure AD MFA @@ -25,32 +24,39 @@ Check the box in Personal Settings > Two-factor Authentication to enable email-b We also encourage you to have your team download and setup the [Microsoft Authenticator App](https://www.microsoft.com/en-us/security/mobile-authenticator-app) prior to enabling MFA. + - - ![Entra Infisical app](../../images/platform/mfa/entra/mfa_entra_infisical_app.png) - - - ![conditional access](../../images/platform/mfa/entra/mfa_entra_conditional_access.png) - - - ![create policy](../../images/platform/mfa/entra/mfa_entra_create_policy.png) - - - ![require MFA and review policy](../../images/platform/mfa/entra/mfa_entra_review_policy.png) - - By default all users except the configuring admin will be setup to require MFA. - Microsoft encourages keeping at least one admin excluded from MFA to prevent accidental lockout. - - - - ![enable policy and confirm](../../images/platform/mfa/entra/mfa_entra_confirm_policy.png) - - - ![mfa login](../../images/platform/mfa/entra/mfa_entra_login.png) - - If users have not setup MFA for Entra / Azure they will be prompted to do so at this time. - - - \ No newline at end of file + + ![Entra Infisical + app](/images/platform/mfa/entra/mfa_entra_infisical_app.png) + + + ![conditional + access](/images/platform/mfa/entra/mfa_entra_conditional_access.png) + + + ![create policy](/images/platform/mfa/entra/mfa_entra_create_policy.png) + + + ![require MFA and review + policy](/images/platform/mfa/entra/mfa_entra_review_policy.png) + + By default all users except the configuring admin will be setup to require + MFA. Microsoft encourages keeping at least one admin excluded from MFA to + prevent accidental lockout. + + + + ![enable policy and + confirm](/images/platform/mfa/entra/mfa_entra_confirm_policy.png) + + + ![mfa login](/images/platform/mfa/entra/mfa_entra_login.png) + + If users have not setup MFA for Entra / Azure they will be prompted to do + so at this time. + + + diff --git a/docs/images/mfa-authenticator.png b/docs/images/mfa-authenticator.png new file mode 100644 index 0000000000..2a72042edf Binary files /dev/null and b/docs/images/mfa-authenticator.png differ diff --git a/docs/images/mfa-email.png b/docs/images/mfa-email.png index f592ee239e..01f3a23f8a 100644 Binary files a/docs/images/mfa-email.png and b/docs/images/mfa-email.png differ 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..59d4914f5a --- /dev/null +++ b/frontend/src/components/mfa/TotpRegistration.tsx @@ -0,0 +1,76 @@ +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, isLoading: isVerifyLoading } = + useVerifyUserTotpRegistration(); + const [qrCodeUrl, setQrCodeUrl] = useState(""); + const [totp, setTotp] = useState(""); + + const handleTotpVerify = async (event: React.FormEvent) => { + event.preventDefault(); + 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 dce930418c..de3c60d461 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/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index ad44d280ac..4923177ba6 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -91,7 +91,8 @@ export const useUpdateOrg = () => { slug, orgId, defaultMembershipRoleSlug, - enforceMfa + enforceMfa, + selectedMfaMethod }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -99,7 +100,8 @@ export const useUpdateOrg = () => { scimEnabled, slug, defaultMembershipRoleSlug, - enforceMfa + enforceMfa, + selectedMfaMethod }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index b644cb3259..5fbbd18634 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -1,6 +1,8 @@ import { OrderByDirection } from "@app/hooks/api/generic/types"; import { IdentityMembershipOrg } from "@app/hooks/api/identities/types"; +import { MfaMethod } from "../auth/types"; + export type Organization = { id: string; name: string; @@ -12,6 +14,7 @@ export type Organization = { slug: string; defaultMembershipRole: string; enforceMfa: boolean; + selectedMfaMethod?: MfaMethod; }; export type UpdateOrgDTO = { @@ -22,6 +25,7 @@ export type UpdateOrgDTO = { slug?: string; defaultMembershipRoleSlug?: string; enforceMfa?: boolean; + selectedMfaMethod?: MfaMethod; }; export type BillingDetails = { 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..e8cf41acbd 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -114,3 +114,43 @@ 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); + } + }); +}; + +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 116b98d4f7..98d8e4b565 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 && [404, 400].includes(error.response?.data?.statusCode)) { + 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/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index d4d60d2741..d6164cba68 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -78,6 +78,7 @@ import { useLogoutUser, useSelectOrganization } from "@app/hooks/api"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; import { Workspace } from "@app/hooks/api/types"; @@ -143,6 +144,7 @@ export const AppLayout = ({ children }: LayoutProps) => { const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const workspacesWithFaveProp = useMemo( @@ -214,12 +216,15 @@ export const AppLayout = ({ children }: LayoutProps) => { }; const changeOrg = async (orgId: string) => { - const { token, isMfaEnabled } = await selectOrganization({ + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId: orgId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => () => changeOrg(orgId)); return; @@ -365,6 +370,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
toggleShowMfa.off()} /> diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index cc19cc5726..e2ebe37ae6 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,19 @@ export default function LoginPage() { return; } - const { token, isMfaEnabled } = await selectOrg.mutateAsync({ - organizationId: organization.id, - userAgent: callbackPort ? UserAgentType.CLI : undefined - }); + const { token, isMfaEnabled, mfaMethod } = await selectOrg + .mutateAsync({ + organizationId: organization.id, + userAgent: callbackPort ? UserAgentType.CLI : undefined + }) + .finally(() => setIsInitialOrgCheckLoading(false)); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); - setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); return; } @@ -213,7 +218,11 @@ export default function LoginPage() { {shouldShowMfa ? ( - + ) : (
diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 7ab132a996..e64f4d7c08 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -29,8 +29,10 @@ import { useSelectOrganization, verifySignupInvite } from "@app/hooks/api/auth/queries"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; +import { Mfa } from "@app/views/Login/Mfa"; // eslint-disable-next-line new-cap const client = new jsrp.client(); @@ -59,6 +61,7 @@ export default function SignupInvite() { const [errors, setErrors] = useState({}); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split("?")[1]); @@ -184,12 +187,19 @@ export default function SignupInvite() { if (!orgId) throw new Error("You are not part of any organization"); const completeSignupFlow = async () => { - const { token: mfaToken, isMfaEnabled } = await selectOrganization({ + const { + token: mfaToken, + isMfaEnabled, + mfaMethod + } = await selectOrganization({ organizationId: orgId }); if (isMfaEnabled) { SecurityClient.setMfaToken(mfaToken); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => completeSignupFlow); return; @@ -390,12 +400,23 @@ export default function SignupInvite() { Sign Up - -
- Infisical Logo -
- - {step === 1 ? stepConfirmEmail : step === 2 ? main : step4} + {shouldShowMfa ? ( + toggleShowMfa.off()} + /> + ) : ( + <> + +
+ Infisical Logo +
+ + {step === 1 ? stepConfirmEmail : step === 2 ? main : step4} + + )}
); } diff --git a/frontend/src/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx index 4b43d28635..d494551900 100644 --- a/frontend/src/views/Login/Mfa.tsx +++ b/frontend/src/views/Login/Mfa.tsx @@ -1,4 +1,4 @@ -import { 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"; @@ -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,39 @@ 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(); - const verifyMfa = async () => { + useEffect(() => { + if (method === MfaMethod.TOTP) { + checkUserTotpMfa().then((isVerified) => { + if (!isVerified) { + SecurityClient.setMfaToken(""); + setShouldShowTotpRegistration(true); + } + }); + } + }, []); + + const verifyMfa = async (event: React.FormEvent) => { + event.preventDefault(); + setIsLoading(true); try { const { token } = await verifyMfaToken({ email, - mfaCode + mfaCode, + mfaMethod: method }); SecurityClient.setMfaToken(""); @@ -92,6 +110,24 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => { } }; + if (shouldShowTotpRegistration) { + return ( + <> +
+ Your organization requires mobile authentication to be configured. +
+
+ { + setShouldShowTotpRegistration(false); + await successCallback(); + }} + /> +
+ + ); + } + return (
{!hideLogo && ( @@ -101,52 +137,87 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
)} -

{t("mfa.step2-message")}

-

{email}

-
- -
- {typeof triesLeft === "number" && ( - + {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)} /> +
+ )}
-
-
-
- {t("signup.step2-resend-alert")} -
- + {typeof triesLeft === "number" && ( + + )} +
+
+
-

{t("signup.step2-spam-alert")}

-
+ + {method === MfaMethod.TOTP && ( +
+ + + Lost your recovery codes? Reset your account + + +
+ )} + {method === MfaMethod.EMAIL && ( +
+
+ {t("signup.step2-resend-alert")} +
+ +
+
+

{t("signup.step2-spam-alert")}

+
+ )}
); }; diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index 8276030675..b704ac46c1 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -16,6 +16,7 @@ import { Button, Input, Spinner } from "@app/components/v2"; import { SessionStorageKeys } from "@app/const"; import { useToggle } from "@app/hooks"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; @@ -36,6 +37,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); @@ -66,12 +68,15 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { - const { token, isMfaEnabled } = await selectOrganization({ organizationId }); + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); - toggleShowMfa.on(); setMfaSuccessCallback(() => finishWithOrgWorkflow); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } + toggleShowMfa.on(); return; } @@ -167,10 +172,15 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { - const { token, isMfaEnabled } = await selectOrganization({ organizationId }); + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ + organizationId + }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => finishWithOrgWorkflow); return; @@ -283,6 +293,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } toggleShowMfa.off()} />
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx index 3a6663ddec..9b9aa65532 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx @@ -1,6 +1,6 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Switch, UpgradePlanModal } from "@app/components/v2"; +import { FormControl, Select, SelectItem, Switch, UpgradePlanModal } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -8,6 +8,7 @@ import { useSubscription } from "@app/context"; import { useUpdateOrg } from "@app/hooks/api"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { usePopUp } from "@app/hooks/usePopUp"; export const OrgGenericAuthSection = () => { @@ -43,6 +44,32 @@ export const OrgGenericAuthSection = () => { } }; + const handleUpdateSelectedMfa = async (selectedMfaMethod: MfaMethod) => { + try { + if (!currentOrg?.id) return; + if (!subscription?.enforceMfa) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + selectedMfaMethod + }); + + createNotification({ + text: "Successfully updated selected MFA method", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: (err as { response: { data: { message: string } } }).response.data.message, + type: "error" + }); + } + }; + return (
@@ -62,6 +89,22 @@ export const OrgGenericAuthSection = () => {

Enforce members to authenticate with MFA in order to access the organization

+ {currentOrg?.enforceMfa && ( + + + + )}
{ 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 { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes(); + 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 handleGenerateMoreRecoveryCodes = async () => { + try { + await createTotpRecoveryCodes(); + + createNotification({ + text: "Successfully generated new recovery codes", + type: "success" + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to generate new recovery codes"; + + createNotification({ + text, + type: "error" + }); + } + }; + + const updateSelectedMfa = async (mfaMethod: MfaMethod) => { + try { + if (!user) return; + + await mutateAsync({ + selectedMfaMethod: mfaMethod + }); + + createNotification({ + text: "Successfully updated selected 2FA method", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Something went wrong while updating selected 2FA method.", + type: "error" + }); + console.error(err); + } + }; + const toggleMfa = async (state: boolean) => { try { if (!user) return; @@ -47,31 +137,96 @@ 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 && ( +
+ {totpConfiguration.recoveryCodes.map((code) => ( +
{code}
+ ))} +
+ )} +
+ ) : ( + <> +
+ For added security, you can configure a mobile authenticator and set it as your + selected 2FA method. +
+
+ { + await queryClient.invalidateQueries(userKeys.totpConfiguration); + }} + /> +
+ + )} +
+ )} +
handlePopUpToggle("setUpEmail", isOpen)} /> + handlePopUpToggle("deleteTotpConfig", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleTotpDeletion} + /> ); }; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 5f63c49526..4156b93d49 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -13,6 +13,7 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Input } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import ProjectService from "@app/services/ProjectService"; import { Mfa } from "@app/views/Login/Mfa"; @@ -57,6 +58,7 @@ export const UserInfoSSOStep = ({ const [organizationNameError, setOrganizationNameError] = useState(false); const [attributionSource, setAttributionSource] = useState(""); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const { mutateAsync: selectOrganization } = useSelectOrganization(); @@ -178,12 +180,15 @@ export const UserInfoSSOStep = ({ const completeSignupFlow = async () => { try { - const { isMfaEnabled, token } = await selectOrganization({ + const { isMfaEnabled, token, mfaMethod } = await selectOrganization({ organizationId: orgId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => completeSignupFlow); return; @@ -231,6 +236,7 @@ export const UserInfoSSOStep = ({ hideLogo email={username} successCallback={mfaSuccessCallback} + method={requiredMfaMethod} closeMfa={() => toggleShowMfa.off()} /> );