Compare commits

...

11 Commits

56 changed files with 1619 additions and 210 deletions

View File

@ -75,6 +75,7 @@
"openid-client": "^5.6.5", "openid-client": "^5.6.5",
"ora": "^7.0.1", "ora": "^7.0.1",
"oracledb": "^6.4.0", "oracledb": "^6.4.0",
"otplib": "^12.0.1",
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"passport-gitlab2": "^5.0.0", "passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
@ -6815,6 +6816,48 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@peculiar/asn1-cms": { "node_modules/@peculiar/asn1-cms": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz",
@ -16453,6 +16496,16 @@
"node": ">=14.6" "node": ">=14.6"
} }
}, },
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/p-finally": { "node_modules/p-finally": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@ -19553,6 +19606,14 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/thread-stream": { "node_modules/thread-stream": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz",

View File

@ -181,6 +181,7 @@
"openid-client": "^5.6.5", "openid-client": "^5.6.5",
"ora": "^7.0.1", "ora": "^7.0.1",
"oracledb": "^6.4.0", "oracledb": "^6.4.0",
"otplib": "^12.0.1",
"passport-github": "^1.1.0", "passport-github": "^1.1.0",
"passport-gitlab2": "^5.0.0", "passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",

View File

@ -79,6 +79,7 @@ import { TServiceTokenServiceFactory } from "@app/services/service-token/service
import { TSlackServiceFactory } from "@app/services/slack/slack-service"; import { TSlackServiceFactory } from "@app/services/slack/slack-service";
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { TTotpServiceFactory } from "@app/services/totp/totp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserServiceFactory } from "@app/services/user/user-service";
import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
@ -193,6 +194,7 @@ declare module "fastify" {
migration: TExternalMigrationServiceFactory; migration: TExternalMigrationServiceFactory;
externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory;
projectTemplate: TProjectTemplateServiceFactory; projectTemplate: TProjectTemplateServiceFactory;
totp: TTotpServiceFactory;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer // everywhere else access using service layer

View File

@ -314,6 +314,9 @@ import {
TSuperAdmin, TSuperAdmin,
TSuperAdminInsert, TSuperAdminInsert,
TSuperAdminUpdate, TSuperAdminUpdate,
TTotpConfigs,
TTotpConfigsInsert,
TTotpConfigsUpdate,
TTrustedIps, TTrustedIps,
TTrustedIpsInsert, TTrustedIpsInsert,
TTrustedIpsUpdate, TTrustedIpsUpdate,
@ -826,5 +829,6 @@ declare module "knex/types/tables" {
TProjectTemplatesInsert, TProjectTemplatesInsert,
TProjectTemplatesUpdate TProjectTemplatesUpdate
>; >;
[TableName.TotpConfig]: KnexOriginal.CompositeTableType<TTotpConfigs, TTotpConfigsInsert, TTotpConfigsUpdate>;
} }
} }

View File

@ -0,0 +1,54 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.TotpConfig))) {
await knex.schema.createTable(TableName.TotpConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.boolean("isVerified").defaultTo(false).notNullable();
t.binary("encryptedRecoveryCodes").notNullable();
t.binary("encryptedSecret").notNullable();
t.timestamps(true, true, true);
t.unique("userId");
});
await createOnUpdateTrigger(knex, TableName.TotpConfig);
}
const doesOrgMfaMethodColExist = await knex.schema.hasColumn(TableName.Organization, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Organization, (t) => {
if (!doesOrgMfaMethodColExist) {
t.string("selectedMfaMethod");
}
});
const doesUserSelectedMfaMethodColExist = await knex.schema.hasColumn(TableName.Users, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Users, (t) => {
if (!doesUserSelectedMfaMethodColExist) {
t.string("selectedMfaMethod");
}
});
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.TotpConfig);
await knex.schema.dropTableIfExists(TableName.TotpConfig);
const doesOrgMfaMethodColExist = await knex.schema.hasColumn(TableName.Organization, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Organization, (t) => {
if (doesOrgMfaMethodColExist) {
t.dropColumn("selectedMfaMethod");
}
});
const doesUserSelectedMfaMethodColExist = await knex.schema.hasColumn(TableName.Users, "selectedMfaMethod");
await knex.schema.alterTable(TableName.Users, (t) => {
if (doesUserSelectedMfaMethodColExist) {
t.dropColumn("selectedMfaMethod");
}
});
}

View File

@ -106,6 +106,7 @@ export * from "./secrets-v2";
export * from "./service-tokens"; export * from "./service-tokens";
export * from "./slack-integrations"; export * from "./slack-integrations";
export * from "./super-admin"; export * from "./super-admin";
export * from "./totp-configs";
export * from "./trusted-ips"; export * from "./trusted-ips";
export * from "./user-actions"; export * from "./user-actions";
export * from "./user-aliases"; export * from "./user-aliases";

View File

@ -117,6 +117,7 @@ export enum TableName {
ExternalKms = "external_kms", ExternalKms = "external_kms",
InternalKms = "internal_kms", InternalKms = "internal_kms",
InternalKmsKeyVersion = "internal_kms_key_version", InternalKmsKeyVersion = "internal_kms_key_version",
TotpConfig = "totp_configs",
// @depreciated // @depreciated
KmsKeyVersion = "kms_key_versions", KmsKeyVersion = "kms_key_versions",
WorkflowIntegrations = "workflow_integrations", WorkflowIntegrations = "workflow_integrations",

View File

@ -21,7 +21,8 @@ export const OrganizationsSchema = z.object({
kmsDefaultKeyId: z.string().uuid().nullable().optional(), kmsDefaultKeyId: z.string().uuid().nullable().optional(),
kmsEncryptedDataKey: zodBuffer.nullable().optional(), kmsEncryptedDataKey: zodBuffer.nullable().optional(),
defaultMembershipRole: z.string().default("member"), defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false) enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional()
}); });
export type TOrganizations = z.infer<typeof OrganizationsSchema>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@ -0,0 +1,24 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const TotpConfigsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
isVerified: z.boolean().default(false),
encryptedRecoveryCodes: zodBuffer,
encryptedSecret: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TTotpConfigs = z.infer<typeof TotpConfigsSchema>;
export type TTotpConfigsInsert = Omit<z.input<typeof TotpConfigsSchema>, TImmutableDBKeys>;
export type TTotpConfigsUpdate = Partial<Omit<z.input<typeof TotpConfigsSchema>, TImmutableDBKeys>>;

View File

@ -26,7 +26,8 @@ export const UsersSchema = z.object({
consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
isLocked: z.boolean().default(false).nullable().optional(), isLocked: z.boolean().default(false).nullable().optional(),
temporaryLockDateEnd: z.date().nullable().optional(), temporaryLockDateEnd: z.date().nullable().optional(),
consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional() consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional(),
selectedMfaMethod: z.string().nullable().optional()
}); });
export type TUsers = z.infer<typeof UsersSchema>; export type TUsers = z.infer<typeof UsersSchema>;

View File

@ -122,6 +122,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}, },
`email: ${email} firstName: ${profile.firstName as string}` `email: ${email} firstName: ${profile.firstName as string}`
); );
throw new Error("Invalid saml request. Missing email or first name");
} }
const userMetadata = Object.keys(profile.attributes || {}) const userMetadata = Object.keys(profile.attributes || {})

View File

@ -201,6 +201,8 @@ import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admi
import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal"; import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal";
import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue"; import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal";
import { totpServiceFactory } from "@app/services/totp/totp-service";
import { userDALFactory } from "@app/services/user/user-dal"; import { userDALFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service"; import { userServiceFactory } from "@app/services/user/user-service";
import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
@ -348,6 +350,7 @@ export const registerRoutes = async (
const slackIntegrationDAL = slackIntegrationDALFactory(db); const slackIntegrationDAL = slackIntegrationDALFactory(db);
const projectSlackConfigDAL = projectSlackConfigDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db);
const workflowIntegrationDAL = workflowIntegrationDALFactory(db); const workflowIntegrationDAL = workflowIntegrationDALFactory(db);
const totpConfigDAL = totpConfigDALFactory(db);
const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db); const externalGroupOrgRoleMappingDAL = externalGroupOrgRoleMappingDALFactory(db);
@ -511,12 +514,19 @@ export const registerRoutes = async (
projectMembershipDAL projectMembershipDAL
}); });
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL }); const totpService = totpServiceFactory({
totpConfigDAL,
userDAL,
kmsService
});
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, totpService });
const passwordService = authPaswordServiceFactory({ const passwordService = authPaswordServiceFactory({
tokenService, tokenService,
smtpService, smtpService,
authDAL, authDAL,
userDAL userDAL,
totpConfigDAL
}); });
const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL }); const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL });
@ -1369,7 +1379,8 @@ export const registerRoutes = async (
workflowIntegration: workflowIntegrationService, workflowIntegration: workflowIntegrationService,
migration: migrationService, migration: migrationService,
externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService,
projectTemplate: projectTemplateService projectTemplate: projectTemplateService,
totp: totpService
}); });
const cronJobs: CronJob[] = []; const cronJobs: CronJob[] = [];

View File

@ -108,7 +108,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
tokenVersionId: tokenVersion.id, tokenVersionId: tokenVersion.id,
accessVersion: tokenVersion.accessVersion, accessVersion: tokenVersion.accessVersion,
organizationId: decodedToken.organizationId, organizationId: decodedToken.organizationId,
isMfaVerified: decodedToken.isMfaVerified isMfaVerified: decodedToken.isMfaVerified,
mfaMethod: decodedToken.mfaMethod
}, },
appCfg.AUTH_SECRET, appCfg.AUTH_SECRET,
{ expiresIn: appCfg.JWT_AUTH_LIFETIME } { expiresIn: appCfg.JWT_AUTH_LIFETIME }

View File

@ -15,7 +15,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs";
import { getLastMidnightDateISO } from "@app/lib/fn"; import { getLastMidnightDateISO } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { integrationAuthPubSchema } from "../sanitizedSchemas"; import { integrationAuthPubSchema } from "../sanitizedSchemas";
@ -259,7 +259,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
message: "Membership role must be a valid slug" message: "Membership role must be a valid slug"
}) })
.optional(), .optional(),
enforceMfa: z.boolean().optional() enforceMfa: z.boolean().optional(),
selectedMfaMethod: z.nativeEnum(MfaMethod).optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({

View File

@ -169,4 +169,103 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
return groupMemberships; return groupMemberships;
} }
}); });
server.route({
method: "GET",
url: "/me/totp",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
isVerified: z.boolean(),
recoveryCodes: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.totp.getUserTotpConfig({
userId: req.permission.id
});
}
});
server.route({
method: "DELETE",
url: "/me/totp",
config: {
rateLimit: 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
});
}
});
}; };

View File

@ -2,8 +2,9 @@ import jwt from "jsonwebtoken";
import { z } from "zod"; import { z } from "zod";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { mfaRateLimit } from "@app/server/config/rateLimiter";
import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type";
export const registerMfaRouter = async (server: FastifyZodProvider) => { export const registerMfaRouter = async (server: FastifyZodProvider) => {
const cfg = getConfig(); const cfg = getConfig();
@ -49,6 +50,38 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/mfa/check/totp",
config: {
rateLimit: mfaRateLimit
},
schema: {
response: {
200: z.object({
isVerified: z.boolean()
})
}
},
handler: async (req) => {
try {
const totpConfig = await server.services.totp.getUserTotpConfig({
userId: req.mfa.userId
});
return {
isVerified: Boolean(totpConfig)
};
} catch (error) {
if (error instanceof NotFoundError || error instanceof BadRequestError) {
return { isVerified: false };
}
throw error;
}
}
});
server.route({ server.route({
url: "/mfa/verify", url: "/mfa/verify",
method: "POST", method: "POST",
@ -57,7 +90,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
mfaToken: z.string().trim() mfaToken: z.string().trim(),
mfaMethod: z.nativeEnum(MfaMethod).optional().default(MfaMethod.EMAIL)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@ -86,7 +120,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
ip: req.realIp, ip: req.realIp,
userId: req.mfa.userId, userId: req.mfa.userId,
orgId: req.mfa.orgId, orgId: req.mfa.orgId,
mfaToken: req.body.mfaToken mfaToken: req.body.mfaToken,
mfaMethod: req.body.mfaMethod
}); });
void res.setCookie("jid", token.refresh, { void res.setCookie("jid", token.refresh, {

View File

@ -4,7 +4,7 @@ import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema,
import { ApiKeysSchema } from "@app/db/schemas/api-keys"; import { ApiKeysSchema } from "@app/db/schemas/api-keys";
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
export const registerUserRouter = async (server: FastifyZodProvider) => { export const registerUserRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@ -56,7 +56,8 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
body: z.object({ body: z.object({
isMfaEnabled: z.boolean() isMfaEnabled: z.boolean().optional(),
selectedMfaMethod: z.nativeEnum(MfaMethod).optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@ -66,7 +67,12 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
}, },
preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
handler: async (req) => { handler: async (req) => {
const user = await server.services.user.toggleUserMfa(req.permission.id, req.body.isMfaEnabled); const user = await server.services.user.updateUserMfa({
userId: req.permission.id,
isMfaEnabled: req.body.isMfaEnabled,
selectedMfaMethod: req.body.selectedMfaMethod
});
return { user }; return { user };
} }
}); });

View File

@ -48,7 +48,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
token: z.string(), token: z.string(),
isMfaEnabled: z.boolean() isMfaEnabled: z.boolean(),
mfaMethod: z.string().optional()
}) })
} }
}, },
@ -64,7 +65,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
if (tokens.isMfaEnabled) { if (tokens.isMfaEnabled) {
return { return {
token: tokens.mfa as string, token: tokens.mfa as string,
isMfaEnabled: true isMfaEnabled: true,
mfaMethod: tokens.mfaMethod
}; };
} }

View File

@ -17,6 +17,7 @@ import { TokenType } from "../auth-token/auth-token-types";
import { TOrgDALFactory } from "../org/org-dal"; import { TOrgDALFactory } from "../org/org-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { LoginMethod } from "../super-admin/super-admin-types"; import { LoginMethod } from "../super-admin/super-admin-types";
import { TTotpServiceFactory } from "../totp/totp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns"; import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
import { import {
@ -26,13 +27,14 @@ import {
TOauthTokenExchangeDTO, TOauthTokenExchangeDTO,
TVerifyMfaTokenDTO TVerifyMfaTokenDTO
} from "./auth-login-type"; } from "./auth-login-type";
import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type"; import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "./auth-type";
type TAuthLoginServiceFactoryDep = { type TAuthLoginServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
orgDAL: TOrgDALFactory; orgDAL: TOrgDALFactory;
tokenService: TAuthTokenServiceFactory; tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService; smtpService: TSmtpService;
totpService: Pick<TTotpServiceFactory, "verifyUserTotp" | "verifyWithUserRecoveryCode">;
}; };
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>; export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
@ -40,7 +42,8 @@ export const authLoginServiceFactory = ({
userDAL, userDAL,
tokenService, tokenService,
smtpService, smtpService,
orgDAL orgDAL,
totpService
}: TAuthLoginServiceFactoryDep) => { }: TAuthLoginServiceFactoryDep) => {
/* /*
* Private * Private
@ -100,7 +103,8 @@ export const authLoginServiceFactory = ({
userAgent, userAgent,
organizationId, organizationId,
authMethod, authMethod,
isMfaVerified isMfaVerified,
mfaMethod
}: { }: {
user: TUsers; user: TUsers;
ip: string; ip: string;
@ -108,6 +112,7 @@ export const authLoginServiceFactory = ({
organizationId?: string; organizationId?: string;
authMethod: AuthMethod; authMethod: AuthMethod;
isMfaVerified?: boolean; isMfaVerified?: boolean;
mfaMethod?: MfaMethod;
}) => { }) => {
const cfg = getConfig(); const cfg = getConfig();
await updateUserDeviceSession(user, ip, userAgent); await updateUserDeviceSession(user, ip, userAgent);
@ -126,7 +131,8 @@ export const authLoginServiceFactory = ({
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
accessVersion: tokenSession.accessVersion, accessVersion: tokenSession.accessVersion,
organizationId, organizationId,
isMfaVerified isMfaVerified,
mfaMethod
}, },
cfg.AUTH_SECRET, cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_AUTH_LIFETIME } { expiresIn: cfg.JWT_AUTH_LIFETIME }
@ -140,7 +146,8 @@ export const authLoginServiceFactory = ({
tokenVersionId: tokenSession.id, tokenVersionId: tokenSession.id,
refreshVersion: tokenSession.refreshVersion, refreshVersion: tokenSession.refreshVersion,
organizationId, organizationId,
isMfaVerified isMfaVerified,
mfaMethod
}, },
cfg.AUTH_SECRET, cfg.AUTH_SECRET,
{ expiresIn: cfg.JWT_REFRESH_LIFETIME } { expiresIn: cfg.JWT_REFRESH_LIFETIME }
@ -353,8 +360,12 @@ export const authLoginServiceFactory = ({
}); });
} }
// send multi factor auth token if they it enabled const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
if ((selectedOrg.enforceMfa || user.isMfaEnabled) && user.email && !decodedToken.isMfaVerified) { const orgMfaMethod = selectedOrg.enforceMfa ? selectedOrg.selectedMfaMethod ?? 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); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
const mfaToken = jwt.sign( const mfaToken = jwt.sign(
@ -369,12 +380,14 @@ export const authLoginServiceFactory = ({
} }
); );
await sendUserMfaCode({ if (mfaMethod === MfaMethod.EMAIL && user.email) {
userId: user.id, await sendUserMfaCode({
email: user.email userId: user.id,
}); email: user.email
});
}
return { isMfaEnabled: true, mfa: mfaToken } as const; return { isMfaEnabled: true, mfa: mfaToken, mfaMethod } as const;
} }
const tokens = await generateUserTokens({ const tokens = await generateUserTokens({
@ -383,7 +396,8 @@ export const authLoginServiceFactory = ({
userAgent, userAgent,
ip: ipAddress, ip: ipAddress,
organizationId, organizationId,
isMfaVerified: decodedToken.isMfaVerified isMfaVerified: decodedToken.isMfaVerified,
mfaMethod: decodedToken.mfaMethod
}); });
return { return {
@ -458,17 +472,39 @@ export const authLoginServiceFactory = ({
* Multi factor authentication verification of code * Multi factor authentication verification of code
* Third step of login in which user completes with mfa * Third step of login in which user completes with mfa
* */ * */
const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => { const verifyMfaToken = async ({
userId,
mfaToken,
mfaMethod,
mfaJwtToken,
ip,
userAgent,
orgId
}: TVerifyMfaTokenDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const user = await userDAL.findById(userId); const user = await userDAL.findById(userId);
enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd);
try { try {
await tokenService.validateTokenForUser({ if (mfaMethod === MfaMethod.EMAIL) {
type: TokenType.TOKEN_EMAIL_MFA, await tokenService.validateTokenForUser({
userId, type: TokenType.TOKEN_EMAIL_MFA,
code: mfaToken userId,
}); code: mfaToken
});
} else if (mfaMethod === MfaMethod.TOTP) {
if (mfaToken.length === 6) {
await totpService.verifyUserTotp({
userId,
totp: mfaToken
});
} else {
await totpService.verifyWithUserRecoveryCode({
userId,
recoveryCode: mfaToken
});
}
}
} catch (err) { } catch (err) {
const updatedUser = await processFailedMfaAttempt(userId); const updatedUser = await processFailedMfaAttempt(userId);
if (updatedUser.isLocked) { if (updatedUser.isLocked) {
@ -513,7 +549,8 @@ export const authLoginServiceFactory = ({
userAgent, userAgent,
organizationId: orgId, organizationId: orgId,
authMethod: decodedToken.authMethod, authMethod: decodedToken.authMethod,
isMfaVerified: true isMfaVerified: true,
mfaMethod
}); });
return { token, user: userEnc }; return { token, user: userEnc };

View File

@ -1,4 +1,4 @@
import { AuthMethod } from "./auth-type"; import { AuthMethod, MfaMethod } from "./auth-type";
export type TLoginGenServerPublicKeyDTO = { export type TLoginGenServerPublicKeyDTO = {
email: string; email: string;
@ -19,6 +19,7 @@ export type TLoginClientProofDTO = {
export type TVerifyMfaTokenDTO = { export type TVerifyMfaTokenDTO = {
userId: string; userId: string;
mfaToken: string; mfaToken: string;
mfaMethod: MfaMethod;
mfaJwtToken: string; mfaJwtToken: string;
ip: string; ip: string;
userAgent: string; userAgent: string;

View File

@ -8,6 +8,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types"; import { TokenType } from "../auth-token/auth-token-types";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TTotpConfigDALFactory } from "../totp/totp-config-dal";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TAuthDALFactory } from "./auth-dal"; import { TAuthDALFactory } from "./auth-dal";
import { TChangePasswordDTO, TCreateBackupPrivateKeyDTO, TResetPasswordViaBackupKeyDTO } from "./auth-password-type"; import { TChangePasswordDTO, TCreateBackupPrivateKeyDTO, TResetPasswordViaBackupKeyDTO } from "./auth-password-type";
@ -18,6 +19,7 @@ type TAuthPasswordServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
tokenService: TAuthTokenServiceFactory; tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService; smtpService: TSmtpService;
totpConfigDAL: Pick<TTotpConfigDALFactory, "delete">;
}; };
export type TAuthPasswordFactory = ReturnType<typeof authPaswordServiceFactory>; export type TAuthPasswordFactory = ReturnType<typeof authPaswordServiceFactory>;
@ -25,7 +27,8 @@ export const authPaswordServiceFactory = ({
authDAL, authDAL,
userDAL, userDAL,
tokenService, tokenService,
smtpService smtpService,
totpConfigDAL
}: TAuthPasswordServiceFactoryDep) => { }: TAuthPasswordServiceFactoryDep) => {
/* /*
* Pre setup for pass change with srp protocol * Pre setup for pass change with srp protocol
@ -185,6 +188,12 @@ export const authPaswordServiceFactory = ({
temporaryLockDateEnd: null, temporaryLockDateEnd: null,
consecutiveFailedMfaAttempts: 0 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
});
}; };
/* /*

View File

@ -53,6 +53,7 @@ export type AuthModeJwtTokenPayload = {
accessVersion: number; accessVersion: number;
organizationId?: string; organizationId?: string;
isMfaVerified?: boolean; isMfaVerified?: boolean;
mfaMethod?: MfaMethod;
}; };
export type AuthModeMfaJwtTokenPayload = { export type AuthModeMfaJwtTokenPayload = {
@ -71,6 +72,7 @@ export type AuthModeRefreshJwtTokenPayload = {
refreshVersion: number; refreshVersion: number;
organizationId?: string; organizationId?: string;
isMfaVerified?: boolean; isMfaVerified?: boolean;
mfaMethod?: MfaMethod;
}; };
export type AuthModeProviderJwtTokenPayload = { export type AuthModeProviderJwtTokenPayload = {
@ -85,3 +87,8 @@ export type AuthModeProviderSignUpTokenPayload = {
authTokenType: AuthTokenType.SIGNUP_TOKEN; authTokenType: AuthTokenType.SIGNUP_TOKEN;
userId: string; userId: string;
}; };
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
}

View File

@ -268,7 +268,7 @@ export const orgServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
orgId, orgId,
data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa } data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod }
}: TUpdateOrgDTO) => { }: TUpdateOrgDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
@ -333,7 +333,8 @@ export const orgServiceFactory = ({
authEnforced, authEnforced,
scimEnabled, scimEnabled,
defaultMembershipRole, defaultMembershipRole,
enforceMfa enforceMfa,
selectedMfaMethod
}); });
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org; return org;

View File

@ -1,6 +1,6 @@
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType, MfaMethod } from "../auth/auth-type";
export type TUpdateOrgMembershipDTO = { export type TUpdateOrgMembershipDTO = {
userId: string; userId: string;
@ -65,6 +65,7 @@ export type TUpdateOrgDTO = {
scimEnabled: boolean; scimEnabled: boolean;
defaultMembershipRoleSlug: string; defaultMembershipRoleSlug: string;
enforceMfa: boolean; enforceMfa: boolean;
selectedMfaMethod: MfaMethod;
}>; }>;
} & TOrgPermission; } & TOrgPermission;

View File

@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TTotpConfigDALFactory = ReturnType<typeof totpConfigDALFactory>;
export const totpConfigDALFactory = (db: TDbClient) => {
const totpConfigDal = ormify(db, TableName.TotpConfig);
return totpConfigDal;
};

View File

@ -0,0 +1,3 @@
import crypto from "node:crypto";
export const generateRecoveryCode = () => String(crypto.randomInt(10 ** 7, 10 ** 8 - 1));

View File

@ -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<typeof totpServiceFactory>;
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
};
};

View File

@ -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;
};

View File

@ -15,7 +15,7 @@ import { AuthMethod } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TUserDALFactory } from "./user-dal"; import { TUserDALFactory } from "./user-dal";
import { TListUserGroupsDTO } from "./user-types"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
type TUserServiceFactoryDep = { type TUserServiceFactoryDep = {
userDAL: Pick< userDAL: Pick<
@ -171,15 +171,24 @@ export const userServiceFactory = ({
}); });
}; };
const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const updateUserMfa = async ({ userId, isMfaEnabled, selectedMfaMethod }: TUpdateUserMfaDTO) => {
const user = await userDAL.findById(userId); const user = await userDAL.findById(userId);
if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" }); if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" });
let mfaMethods;
if (isMfaEnabled === undefined) {
mfaMethods = undefined;
} else {
mfaMethods = isMfaEnabled ? ["email"] : [];
}
const updatedUser = await userDAL.updateById(userId, { const updatedUser = await userDAL.updateById(userId, {
isMfaEnabled, isMfaEnabled,
mfaMethods: isMfaEnabled ? ["email"] : [] mfaMethods,
selectedMfaMethod
}); });
return updatedUser; return updatedUser;
}; };
@ -327,7 +336,7 @@ export const userServiceFactory = ({
return { return {
sendEmailVerificationCode, sendEmailVerificationCode,
verifyEmailVerificationCode, verifyEmailVerificationCode,
toggleUserMfa, updateUserMfa,
updateUserName, updateUserName,
updateAuthMethods, updateAuthMethods,
deleteUser, deleteUser,

View File

@ -1,5 +1,7 @@
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { MfaMethod } from "../auth/auth-type";
export type TListUserGroupsDTO = { export type TListUserGroupsDTO = {
username: string; username: string;
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
@ -8,3 +10,9 @@ export enum UserEncryption {
V1 = 1, V1 = 1,
V2 = 2 V2 = 2
} }
export type TUpdateUserMfaDTO = {
userId: string;
isMfaEnabled?: boolean;
selectedMfaMethod?: MfaMethod;
};

View File

@ -138,6 +138,7 @@ type GetOrganizationsResponse struct {
type SelectOrganizationResponse struct { type SelectOrganizationResponse struct {
Token string `json:"token"` Token string `json:"token"`
MfaEnabled bool `json:"isMfaEnabled"` MfaEnabled bool `json:"isMfaEnabled"`
MfaMethod string `json:"mfaMethod"`
} }
type SelectOrganizationRequest struct { type SelectOrganizationRequest struct {
@ -260,8 +261,9 @@ type GetLoginTwoV2Response struct {
} }
type VerifyMfaTokenRequest struct { type VerifyMfaTokenRequest struct {
Email string `json:"email"` Email string `json:"email"`
MFAToken string `json:"mfaToken"` MFAToken string `json:"mfaToken"`
MFAMethod string `json:"mfaMethod"`
} }
type VerifyMfaTokenResponse struct { type VerifyMfaTokenResponse struct {

View File

@ -79,13 +79,14 @@ var initCmd = &cobra.Command{
if tokenResponse.MfaEnabled { if tokenResponse.MfaEnabled {
i := 1 i := 1
for i < 6 { for i < 6 {
mfaVerifyCode := askForMFACode() mfaVerifyCode := askForMFACode(tokenResponse.MfaMethod)
httpClient := resty.New() httpClient := resty.New()
httpClient.SetAuthToken(tokenResponse.Token) httpClient.SetAuthToken(tokenResponse.Token)
verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{
Email: userCreds.UserCredentials.Email, Email: userCreds.UserCredentials.Email,
MFAToken: mfaVerifyCode, MFAToken: mfaVerifyCode,
MFAMethod: tokenResponse.MfaMethod,
}) })
if requestError != nil { if requestError != nil {
util.HandleError(err) util.HandleError(err)
@ -99,7 +100,7 @@ var initCmd = &cobra.Command{
break break
} }
} }
if mfaErrorResponse.Context.Code == "mfa_expired" { if mfaErrorResponse.Context.Code == "mfa_expired" {
util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again")
break break

View File

@ -343,7 +343,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) {
if loginTwoResponse.MfaEnabled { if loginTwoResponse.MfaEnabled {
i := 1 i := 1
for i < 6 { for i < 6 {
mfaVerifyCode := askForMFACode() mfaVerifyCode := askForMFACode("email")
httpClient := resty.New() httpClient := resty.New()
httpClient.SetAuthToken(loginTwoResponse.Token) httpClient.SetAuthToken(loginTwoResponse.Token)
@ -756,13 +756,14 @@ func GetJwtTokenWithOrganizationId(oldJwtToken string, email string) string {
if selectedOrgRes.MfaEnabled { if selectedOrgRes.MfaEnabled {
i := 1 i := 1
for i < 6 { for i < 6 {
mfaVerifyCode := askForMFACode() mfaVerifyCode := askForMFACode(selectedOrgRes.MfaMethod)
httpClient := resty.New() httpClient := resty.New()
httpClient.SetAuthToken(selectedOrgRes.Token) httpClient.SetAuthToken(selectedOrgRes.Token)
verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{
Email: email, Email: email,
MFAToken: mfaVerifyCode, MFAToken: mfaVerifyCode,
MFAMethod: selectedOrgRes.MfaMethod,
}) })
if requestError != nil { if requestError != nil {
util.HandleError(err) util.HandleError(err)
@ -817,9 +818,15 @@ func generateFromPassword(password string, salt []byte, p *params) (hash []byte,
return hash, nil 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{ mfaCodePromptUI := promptui.Prompt{
Label: "Enter the 2FA verification code sent to your email", Label: label,
} }
mfaVerifyCode, err := mfaCodePromptUI.Run() mfaVerifyCode, err := mfaCodePromptUI.Run()

View File

@ -4,19 +4,18 @@ sidebarTitle: "MFA"
description: "Learn how to secure your Infisical account with 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 ## 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)
<Note> ## Mobile Authenticator 2FA
Infisical currently supports email-based 2FA. We're actively working on
building support for other forms of identification via SMS and Authenticator 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.
App. ![Authenticator-based MFA](/images/mfa-authenticator.png)
</Note>
## Entra ID / Azure AD MFA ## 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 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. [Microsoft Authenticator App](https://www.microsoft.com/en-us/security/mobile-authenticator-app) prior to enabling MFA.
</Note> </Note>
<Steps> <Steps>
<Step title="Open your Infisical Application in the Microsoft Entra Admin Center"> <Step title="Open your Infisical Application in the Microsoft Entra Admin Center">
![Entra Infisical app](../../images/platform/mfa/entra/mfa_entra_infisical_app.png) ![Entra Infisical
</Step> app](/images/platform/mfa/entra/mfa_entra_infisical_app.png)
<Step title="Tap on Conditional Access under the Security Tab"> </Step>
![conditional access](../../images/platform/mfa/entra/mfa_entra_conditional_access.png) <Step title="Tap on Conditional Access under the Security Tab">
</Step> ![conditional
<Step title="Tap on Create New Policy from Templates"> access](/images/platform/mfa/entra/mfa_entra_conditional_access.png)
![create policy](../../images/platform/mfa/entra/mfa_entra_create_policy.png) </Step>
</Step> <Step title="Tap on Create New Policy from Templates">
<Step title="Select Require MFA for All Users and Tap on Review + Create"> ![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) </Step>
<Note> <Step title="Select Require MFA for All Users and Tap on Review + Create">
By default all users except the configuring admin will be setup to require MFA. ![require MFA and review
Microsoft encourages keeping at least one admin excluded from MFA to prevent accidental lockout. policy](/images/platform/mfa/entra/mfa_entra_review_policy.png)
</Note> <Note>
</Step> By default all users except the configuring admin will be setup to require
<Step title="Set Policy State to Enabled and Tap on Create"> MFA. Microsoft encourages keeping at least one admin excluded from MFA to
![enable policy and confirm](../../images/platform/mfa/entra/mfa_entra_confirm_policy.png) prevent accidental lockout.
</Step> </Note>
<Step title="MFA is now Required When Accessing Infisical"> </Step>
![mfa login](../../images/platform/mfa/entra/mfa_entra_login.png) <Step title="Set Policy State to Enabled and Tap on Create">
<Note> ![enable policy and
If users have not setup MFA for Entra / Azure they will be prompted to do so at this time. confirm](/images/platform/mfa/entra/mfa_entra_confirm_policy.png)
</Note> </Step>
</Step> <Step title="MFA is now Required When Accessing Infisical">
</Steps> ![mfa login](/images/platform/mfa/entra/mfa_entra_login.png)
<Note>
If users have not setup MFA for Entra / Azure they will be prompted to do
so at this time.
</Note>
</Step>
</Steps>

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 555 KiB

View File

@ -75,6 +75,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"picomatch": "^2.3.1", "picomatch": "^2.3.1",
"posthog-js": "^1.105.6", "posthog-js": "^1.105.6",
"qrcode": "^1.5.4",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -120,6 +121,7 @@
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0", "@types/picomatch": "^2.3.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
@ -8857,6 +8859,15 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
}, },
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.11", "version": "6.9.11",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz",
@ -9785,7 +9796,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -9794,7 +9804,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@ -11076,7 +11085,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -11376,6 +11384,29 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone": { "node_modules/clone": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
@ -12281,6 +12312,14 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": { "node_modules/decode-named-character-reference": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
@ -12677,6 +12716,11 @@
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
"dev": true "dev": true
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dir-glob": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -14943,6 +14987,14 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@ -16212,7 +16264,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -19339,7 +19390,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -19445,7 +19495,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -19666,6 +19715,14 @@
"pathe": "^1.1.2" "pathe": "^1.1.2"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/pnp-webpack-plugin": { "node_modules/pnp-webpack-plugin": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.7.0.tgz",
@ -20550,6 +20607,22 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@ -21846,6 +21919,14 @@
"throttleit": "^1.0.0" "throttleit": "^1.0.0"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -21855,6 +21936,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/requireindex": { "node_modules/requireindex": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
@ -22314,6 +22400,11 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
@ -22900,7 +22991,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0", "is-fullwidth-code-point": "^3.0.0",
@ -22934,8 +23024,7 @@
"node_modules/string-width/node_modules/emoji-regex": { "node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
"dev": true
}, },
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.0.10", "version": "4.0.10",
@ -23006,7 +23095,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@ -24902,6 +24990,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz",
@ -25066,6 +25159,11 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -25079,6 +25177,87 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",

View File

@ -88,6 +88,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"picomatch": "^2.3.1", "picomatch": "^2.3.1",
"posthog-js": "^1.105.6", "posthog-js": "^1.105.6",
"qrcode": "^1.5.4",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -133,6 +134,7 @@
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0", "@types/picomatch": "^2.3.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",

View File

@ -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<void>;
};
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<HTMLFormElement>) => {
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 <ContentLoader />;
}
return (
<div className="flex max-w-sm flex-col text-bunker-200">
<div className="mb-4 text-center">
Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR code.
</div>
<div className="mb-10 flex items-center justify-center">
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<form onSubmit={handleTotpVerify}>
<div className="mb-4 text-center">Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button isLoading={isVerifyLoading} type="submit">
Enable MFA
</Button>
</div>
</form>
</div>
);
};
export default TotpRegistration;

View File

@ -19,6 +19,7 @@ import {
Login2Res, Login2Res,
LoginLDAPDTO, LoginLDAPDTO,
LoginLDAPRes, LoginLDAPRes,
MfaMethod,
ResetPasswordDTO, ResetPasswordDTO,
SendMfaTokenDTO, SendMfaTokenDTO,
SRP1DTO, SRP1DTO,
@ -65,10 +66,11 @@ export const selectOrganization = async (data: {
organizationId: string; organizationId: string;
userAgent?: UserAgentType; userAgent?: UserAgentType;
}) => { }) => {
const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean }>( const { data: res } = await apiRequest.post<{
"/api/v3/auth/select-organization", token: string;
data isMfaEnabled: boolean;
); mfaMethod?: MfaMethod;
}>("/api/v3/auth/select-organization", data);
return res; return res;
}; };
@ -154,10 +156,19 @@ export const useSendMfaToken = () => {
}); });
}; };
export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCode: string }) => { export const verifyMfaToken = async ({
email,
mfaCode,
mfaMethod
}: {
email: string;
mfaCode: string;
mfaMethod?: string;
}) => {
const { data } = await apiRequest.post("/api/v2/auth/mfa/verify", { const { data } = await apiRequest.post("/api/v2/auth/mfa/verify", {
email, email,
mfaToken: mfaCode mfaToken: mfaCode,
mfaMethod
}); });
return data; return data;
@ -165,10 +176,11 @@ export const verifyMfaToken = async ({ email, mfaCode }: { email: string; mfaCod
export const useVerifyMfaToken = () => { export const useVerifyMfaToken = () => {
return useMutation<VerifyMfaTokenRes, {}, VerifyMfaTokenDTO>({ return useMutation<VerifyMfaTokenRes, {}, VerifyMfaTokenDTO>({
mutationFn: async ({ email, mfaCode }) => { mutationFn: async ({ email, mfaCode, mfaMethod }) => {
return verifyMfaToken({ return verifyMfaToken({
email, email,
mfaCode mfaCode,
mfaMethod
}); });
} }
}); });
@ -302,3 +314,9 @@ export const useGetAuthToken = () =>
onSuccess: (data) => setAuthToken(data.token), onSuccess: (data) => setAuthToken(data.token),
retry: 0 retry: 0
}); });
export const checkUserTotpMfa = async () => {
const { data } = await apiRequest.get<{ isVerified: boolean }>("/api/v2/auth/mfa/check/totp");
return data.isVerified;
};

View File

@ -9,6 +9,7 @@ export type SendMfaTokenDTO = {
export type VerifyMfaTokenDTO = { export type VerifyMfaTokenDTO = {
email: string; email: string;
mfaCode: string; mfaCode: string;
mfaMethod: MfaMethod;
}; };
export type VerifyMfaTokenRes = { export type VerifyMfaTokenRes = {
@ -149,3 +150,8 @@ export type GetBackupEncryptedPrivateKeyDTO = {
export enum UserAgentType { export enum UserAgentType {
CLI = "cli" CLI = "cli"
} }
export enum MfaMethod {
EMAIL = "email",
TOTP = "totp"
}

View File

@ -91,7 +91,8 @@ export const useUpdateOrg = () => {
slug, slug,
orgId, orgId,
defaultMembershipRoleSlug, defaultMembershipRoleSlug,
enforceMfa enforceMfa,
selectedMfaMethod
}) => { }) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, { return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name, name,
@ -99,7 +100,8 @@ export const useUpdateOrg = () => {
scimEnabled, scimEnabled,
slug, slug,
defaultMembershipRoleSlug, defaultMembershipRoleSlug,
enforceMfa enforceMfa,
selectedMfaMethod
}); });
}, },
onSuccess: () => { onSuccess: () => {

View File

@ -1,6 +1,8 @@
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { IdentityMembershipOrg } from "@app/hooks/api/identities/types"; import { IdentityMembershipOrg } from "@app/hooks/api/identities/types";
import { MfaMethod } from "../auth/types";
export type Organization = { export type Organization = {
id: string; id: string;
name: string; name: string;
@ -12,6 +14,7 @@ export type Organization = {
slug: string; slug: string;
defaultMembershipRole: string; defaultMembershipRole: string;
enforceMfa: boolean; enforceMfa: boolean;
selectedMfaMethod?: MfaMethod;
}; };
export type UpdateOrgDTO = { export type UpdateOrgDTO = {
@ -22,6 +25,7 @@ export type UpdateOrgDTO = {
slug?: string; slug?: string;
defaultMembershipRoleSlug?: string; defaultMembershipRoleSlug?: string;
enforceMfa?: boolean; enforceMfa?: boolean;
selectedMfaMethod?: MfaMethod;
}; };
export type BillingDetails = { export type BillingDetails = {

View File

@ -21,12 +21,13 @@ export {
useGetOrgUsers, useGetOrgUsers,
useGetUser, useGetUser,
useGetUserAction, useGetUserAction,
useGetUserTotpRegistration,
useListUserGroupMemberships, useListUserGroupMemberships,
useLogoutUser, useLogoutUser,
useRegisterUserAction, useRegisterUserAction,
useRevokeMySessions, useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgMembership, useUpdateOrgMembership,
useUpdateUserAuthMethods useUpdateUserAuthMethods,
useUpdateUserMfa
} from "./queries"; } from "./queries";
export { userKeys } from "./query-keys"; export { userKeys } from "./query-keys";

View File

@ -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);
}
});
};

View File

@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { SessionStorageKeys } from "@app/const"; import { SessionStorageKeys } from "@app/const";
import { setAuthToken } from "@app/reactQuery"; import { setAuthToken } from "@app/reactQuery";
import { APIKeyDataV2 } from "../apiKeys/types"; import { APIKeyDataV2 } from "../apiKeys/types";
import { MfaMethod } from "../auth/types";
import { TGroupWithProjectMemberships } from "../groups/types"; import { TGroupWithProjectMemberships } from "../groups/types";
import { workspaceKeys } from "../workspace"; import { workspaceKeys } from "../workspace";
import { userKeys } from "./query-keys"; import { userKeys } from "./query-keys";
@ -390,14 +392,21 @@ export const useRevokeMySessions = () => {
}); });
}; };
export const useUpdateMfaEnabled = () => { export const useUpdateUserMfa = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ isMfaEnabled }: { isMfaEnabled: boolean }) => { mutationFn: async ({
isMfaEnabled,
selectedMfaMethod
}: {
isMfaEnabled?: boolean;
selectedMfaMethod?: MfaMethod;
}) => {
const { const {
data: { user } data: { user }
} = await apiRequest.patch("/api/v2/users/me/mfa", { } = await apiRequest.patch("/api/v2/users/me/mfa", {
isMfaEnabled isMfaEnabled,
selectedMfaMethod
}); });
return user; return user;
@ -446,3 +455,39 @@ export const useListUserGroupMemberships = (username: string) => {
} }
}); });
}; };
export const useGetUserTotpRegistration = () => {
return useQuery({
queryKey: userKeys.totpRegistration,
queryFn: async () => {
const { data } = await apiRequest.post<{ otpUrl: string; recoveryCodes: string[] }>(
"/api/v1/user/me/totp/register"
);
return data;
}
});
};
export const useGetUserTotpConfiguration = () => {
return useQuery({
queryKey: userKeys.totpConfiguration,
queryFn: async () => {
try {
const { data } = await apiRequest.get<{ isVerified: boolean; recoveryCodes: string[] }>(
"/api/v1/user/me/totp"
);
return data;
} catch (error) {
if (error instanceof AxiosError && [404, 400].includes(error.response?.data?.statusCode)) {
return {
isVerified: false,
recoveryCodes: []
};
}
throw error;
}
}
});
};

View File

@ -16,6 +16,8 @@ export const userKeys = {
myAPIKeysV2: ["api-keys-v2"] as const, myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const, mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const, listUsers: ["user-list"] as const,
totpRegistration: ["totp-registration"],
totpConfiguration: ["totp-configuration"],
listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const, listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
}; };

View File

@ -1,3 +1,4 @@
import { MfaMethod } from "../auth/types";
import { UserWsKeyPair } from "../keys/types"; import { UserWsKeyPair } from "../keys/types";
import { ProjectUserMembershipTemporaryMode } from "../workspace/types"; import { ProjectUserMembershipTemporaryMode } from "../workspace/types";
@ -26,6 +27,7 @@ export type User = {
authProvider?: AuthMethod; authProvider?: AuthMethod;
authMethods: AuthMethod[]; authMethods: AuthMethod[];
isMfaEnabled: boolean; isMfaEnabled: boolean;
selectedMfaMethod?: MfaMethod;
seenIps: string[]; seenIps: string[];
id: string; id: string;
}; };

View File

@ -78,6 +78,7 @@ import {
useLogoutUser, useLogoutUser,
useSelectOrganization useSelectOrganization
} from "@app/hooks/api"; } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
import { Workspace } from "@app/hooks/api/types"; import { Workspace } from "@app/hooks/api/types";
@ -143,6 +144,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!);
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const workspacesWithFaveProp = useMemo( const workspacesWithFaveProp = useMemo(
@ -214,12 +216,15 @@ export const AppLayout = ({ children }: LayoutProps) => {
}; };
const changeOrg = async (orgId: string) => { const changeOrg = async (orgId: string) => {
const { token, isMfaEnabled } = await selectOrganization({ const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId: orgId organizationId: orgId
}); });
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(token); SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on(); toggleShowMfa.on();
setMfaSuccessCallback(() => () => changeOrg(orgId)); setMfaSuccessCallback(() => () => changeOrg(orgId));
return; return;
@ -365,6 +370,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700"> <div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Mfa <Mfa
email={user.email as string} email={user.email as string}
method={requiredMfaMethod}
successCallback={mfaSuccessCallback} successCallback={mfaSuccessCallback}
closeMfa={() => toggleShowMfa.off()} closeMfa={() => toggleShowMfa.off()}
/> />

View File

@ -22,7 +22,7 @@ import {
useLogoutUser, useLogoutUser,
useSelectOrganization useSelectOrganization
} from "@app/hooks/api"; } from "@app/hooks/api";
import { UserAgentType } from "@app/hooks/api/auth/types"; import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { Organization } from "@app/hooks/api/types"; import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethod } from "@app/hooks/api/users/types";
import { getAuthToken, isLoggedIn } from "@app/reactQuery"; import { getAuthToken, isLoggedIn } from "@app/reactQuery";
@ -46,6 +46,7 @@ export default function LoginPage() {
const selectOrg = useSelectOrganization(); const selectOrg = useSelectOrganization();
const { data: user, isLoading: userLoading } = useGetUser(); const { data: user, isLoading: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
@ -90,15 +91,19 @@ export default function LoginPage() {
return; return;
} }
const { token, isMfaEnabled } = await selectOrg.mutateAsync({ const { token, isMfaEnabled, mfaMethod } = await selectOrg
organizationId: organization.id, .mutateAsync({
userAgent: callbackPort ? UserAgentType.CLI : undefined organizationId: organization.id,
}); userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(token); SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on(); toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return; return;
} }
@ -213,7 +218,11 @@ export default function LoginPage() {
<meta name="og:description" content={t("login.og-description") ?? ""} /> <meta name="og:description" content={t("login.og-description") ?? ""} />
</Head> </Head>
{shouldShowMfa ? ( {shouldShowMfa ? (
<Mfa email={user.email as string} successCallback={mfaSuccessCallback} /> <Mfa
email={user.email as string}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
/>
) : ( ) : (
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg"> <div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
<Link href="/"> <Link href="/">

View File

@ -29,8 +29,10 @@ import {
useSelectOrganization, useSelectOrganization,
verifySignupInvite verifySignupInvite
} from "@app/hooks/api/auth/queries"; } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { navigateUserToOrg } from "@app/views/Login/Login.utils";
import { Mfa } from "@app/views/Login/Mfa";
// eslint-disable-next-line new-cap // eslint-disable-next-line new-cap
const client = new jsrp.client(); const client = new jsrp.client();
@ -59,6 +61,7 @@ export default function SignupInvite() {
const [errors, setErrors] = useState<Errors>({}); const [errors, setErrors] = useState<Errors>({});
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const router = useRouter(); const router = useRouter();
const parsedUrl = queryString.parse(router.asPath.split("?")[1]); 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"); if (!orgId) throw new Error("You are not part of any organization");
const completeSignupFlow = async () => { const completeSignupFlow = async () => {
const { token: mfaToken, isMfaEnabled } = await selectOrganization({ const {
token: mfaToken,
isMfaEnabled,
mfaMethod
} = await selectOrganization({
organizationId: orgId organizationId: orgId
}); });
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(mfaToken); SecurityClient.setMfaToken(mfaToken);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on(); toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow); setMfaSuccessCallback(() => completeSignupFlow);
return; return;
@ -390,12 +400,23 @@ export default function SignupInvite() {
<title>Sign Up</title> <title>Sign Up</title>
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
</Head> </Head>
<Link href="/"> {shouldShowMfa ? (
<div className="mb-4 mt-20 flex justify-center"> <Mfa
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" /> email={email}
</div> successCallback={mfaSuccessCallback}
</Link> method={requiredMfaMethod}
{step === 1 ? stepConfirmEmail : step === 2 ? main : step4} closeMfa={() => toggleShowMfa.off()}
/>
) : (
<>
<Link href="/">
<div className="mb-4 mt-20 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
</div>
</Link>
{step === 1 ? stepConfirmEmail : step === 2 ? main : step4}
</>
)}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import React, { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input"; import ReactCodeInput from "react-code-input";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@ -6,10 +6,12 @@ import { useRouter } from "next/router";
import { t } from "i18next"; import { t } from "i18next";
import Error from "@app/components/basic/Error"; import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button } from "@app/components/v2"; import { Button, Input } from "@app/components/v2";
import { useSendMfaToken } from "@app/hooks/api"; import { useSendMfaToken } from "@app/hooks/api";
import { verifyMfaToken } from "@app/hooks/api/auth/queries"; import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
// The style for the verification code input // The style for the verification code input
const codeInputProps = { const codeInputProps = {
@ -36,23 +38,39 @@ type Props = {
closeMfa?: () => void; closeMfa?: () => void;
hideLogo?: boolean; hideLogo?: boolean;
email: string; email: string;
method: MfaMethod;
}; };
export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => { export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
const [mfaCode, setMfaCode] = useState(""); const [mfaCode, setMfaCode] = useState("");
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined); const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const sendMfaToken = useSendMfaToken(); const sendMfaToken = useSendMfaToken();
const verifyMfa = async () => { useEffect(() => {
if (method === MfaMethod.TOTP) {
checkUserTotpMfa().then((isVerified) => {
if (!isVerified) {
SecurityClient.setMfaToken("");
setShouldShowTotpRegistration(true);
}
});
}
}, []);
const verifyMfa = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true); setIsLoading(true);
try { try {
const { token } = await verifyMfaToken({ const { token } = await verifyMfaToken({
email, email,
mfaCode mfaCode,
mfaMethod: method
}); });
SecurityClient.setMfaToken(""); SecurityClient.setMfaToken("");
@ -92,6 +110,24 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
} }
}; };
if (shouldShowTotpRegistration) {
return (
<>
<div className="mb-6 text-center text-lg font-bold text-white">
Your organization requires mobile authentication to be configured.
</div>
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
<TotpRegistration
onComplete={async () => {
setShouldShowTotpRegistration(false);
await successCallback();
}}
/>
</div>
</>
);
}
return ( return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8"> <div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
{!hideLogo && ( {!hideLogo && (
@ -101,52 +137,87 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
</div> </div>
</Link> </Link>
)} )}
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p> {method === MfaMethod.EMAIL && (
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p> <>
<div className="mx-auto hidden w-max min-w-[20rem] md:block"> <p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
<ReactCodeInput <p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
name="" </>
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
</div>
{typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
)} )}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]"> {method === MfaMethod.TOTP && (
<div className="text-l w-full py-1 text-lg"> <>
<Button <p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
onClick={() => verifyMfa()} Authenticator MFA Required
size="sm" </p>
isFullWidth <p className="text-l flex max-w-xs justify-center text-center text-bunker-300">
className="h-14" Open the authenticator app on your mobile device to get your verification code or enter
colorSchema="primary" a recovery code.
variant="outline_bg" </p>
isLoading={isLoading} </>
> )}
{String(t("mfa.verify"))} <form onSubmit={verifyMfa}>
</Button> <div className="mx-auto hidden w-max min-w-[20rem] md:block">
{method === MfaMethod.EMAIL && (
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mt-6 mb-2"
{...codeInputProps}
/>
)}
{method === MfaMethod.TOTP && (
<div className="mt-6 mb-4">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
</div>
)}
</div> </div>
</div> {typeof triesLeft === "number" && (
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2"> <Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
<div className="flex flex-row items-baseline gap-1 text-sm"> )}
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span> <div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-md mt-2 flex flex-row text-bunker-400"> <div className="text-l w-full py-1 text-lg">
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button"> <Button
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"> size="sm"
{isLoadingResend type="submit"
? t("signup.step2-resend-progress") isFullWidth
: t("signup.step2-resend-submit")} className="h-14"
</span> colorSchema="primary"
</button> variant="outline_bg"
isLoading={isLoading}
>
{String(t("mfa.verify"))}
</Button>
</div> </div>
</div> </div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p> </form>
</div> {method === MfaMethod.TOTP && (
<div className="mt-2 flex flex-row justify-center text-sm text-bunker-400 ">
<Link href="/verify-email">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
Lost your recovery codes? Reset your account
</span>
</Link>
</div>
)}
{method === MfaMethod.EMAIL && (
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
<div className="flex flex-row items-baseline gap-1 text-sm">
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
<div className="text-md mt-2 flex flex-row text-bunker-400">
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
{isLoadingResend
? t("signup.step2-resend-progress")
: t("signup.step2-resend-submit")}
</span>
</button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
</div>
)}
</div> </div>
); );
}; };

View File

@ -16,6 +16,7 @@ import { Button, Input, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const"; import { SessionStorageKeys } from "@app/const";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/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: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); 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 // 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) { if (organizationId) {
const finishWithOrgWorkflow = async () => { const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled } = await selectOrganization({ organizationId }); const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(token); SecurityClient.setMfaToken(token);
toggleShowMfa.on();
setMfaSuccessCallback(() => finishWithOrgWorkflow); setMfaSuccessCallback(() => finishWithOrgWorkflow);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
return; 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 // 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) { if (organizationId) {
const finishWithOrgWorkflow = async () => { const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled } = await selectOrganization({ organizationId }); const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId
});
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(token); SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on(); toggleShowMfa.on();
setMfaSuccessCallback(() => finishWithOrgWorkflow); setMfaSuccessCallback(() => finishWithOrgWorkflow);
return; return;
@ -283,6 +293,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
<Mfa <Mfa
email={email} email={email}
successCallback={mfaSuccessCallback} successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()} closeMfa={() => toggleShowMfa.off()}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Switch, UpgradePlanModal } from "@app/components/v2"; import { FormControl, Select, SelectItem, Switch, UpgradePlanModal } from "@app/components/v2";
import { import {
OrgPermissionActions, OrgPermissionActions,
OrgPermissionSubjects, OrgPermissionSubjects,
@ -8,6 +8,7 @@ import {
useSubscription useSubscription
} from "@app/context"; } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api"; import { useUpdateOrg } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
export const OrgGenericAuthSection = () => { 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 ( return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6"> <div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="py-4"> <div className="py-4">
@ -62,6 +89,22 @@ export const OrgGenericAuthSection = () => {
<p className="text-sm text-mineshaft-300"> <p className="text-sm text-mineshaft-300">
Enforce members to authenticate with MFA in order to access the organization Enforce members to authenticate with MFA in order to access the organization
</p> </p>
{currentOrg?.enforceMfa && (
<FormControl label="Selected 2FA method" className="mt-3">
<Select
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={handleUpdateSelectedMfa}
defaultValue={currentOrg.selectedMfaMethod ?? MfaMethod.EMAIL}
>
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
Email
</SelectItem>
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
Mobile Authenticator
</SelectItem>
</Select>
</FormControl>
)}
</div> </div>
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}

View File

@ -1,18 +1,108 @@
import { useQueryClient } from "@tanstack/react-query";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Checkbox, EmailServiceSetupModal } from "@app/components/v2"; import {
import { useGetUser, useUpdateMfaEnabled } from "@app/hooks/api"; Button,
ContentLoader,
DeleteActionModal,
EmailServiceSetupModal,
FormControl,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetUser, userKeys, useUpdateUserMfa } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import {
useCreateNewTotpRecoveryCodes,
useDeleteUserTotpConfiguration
} from "@app/hooks/api/users/mutation";
import { useGetUserTotpConfiguration } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types"; import { AuthMethod } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => { export const MFASection = () => {
const { data: user } = useGetUser(); const { data: user } = useGetUser();
const { mutateAsync } = useUpdateMfaEnabled(); const { mutateAsync } = useUpdateUserMfa();
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail",
"deleteTotpConfig"
] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration, isLoading: isTotpConfigurationLoading } =
useGetUserTotpConfiguration();
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
const { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes();
const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
const handleTotpDeletion = async () => {
try {
await deleteTotpConfiguration();
createNotification({
text: "Successfully deleted mobile authenticator",
type: "success"
});
handlePopUpClose("deleteTotpConfig");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete mobile authenticator";
createNotification({
text,
type: "error"
});
}
};
const 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) => { const toggleMfa = async (state: boolean) => {
try { try {
if (!user) return; if (!user) return;
@ -47,31 +137,96 @@ export const MFASection = () => {
return ( return (
<> <>
<form> <div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <p className="mb-4 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
<p className="mb-8 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p> {user && (
{user && ( <Switch
<Checkbox className="data-[state=checked]:bg-primary"
className="data-[state=checked]:bg-primary" id="isTwoFAEnabled"
id="isTwoFAEnabled" isChecked={user?.isMfaEnabled}
isChecked={user?.isMfaEnabled} onCheckedChange={(state) => {
onCheckedChange={(state) => { if (serverDetails?.emailConfigured) {
if (serverDetails?.emailConfigured) { toggleMfa(state as boolean);
toggleMfa(state as boolean); } else {
} else { handlePopUpOpen("setUpEmail");
handlePopUpOpen("setUpEmail"); }
} }}
}} >
Enable 2-factor authentication
</Switch>
)}
{user?.isMfaEnabled && (
<FormControl label="Selected 2FA method" className="mt-3">
<Select
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={updateSelectedMfa}
defaultValue={user.selectedMfaMethod ?? MfaMethod.EMAIL}
> >
Enable 2-factor authentication via your personal email. <SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
</Checkbox> Email
)} </SelectItem>
</div> <SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
</form> Mobile Authenticator
</SelectItem>
</Select>
</FormControl>
)}
<div className="mt-8 text-lg font-semibold text-mineshaft-100">Mobile Authenticator</div>
{isTotpConfigurationLoading ? (
<ContentLoader />
) : (
<div>
{totpConfiguration?.isVerified ? (
<div className="mt-2">
<div className="flex flex-row gap-2">
<Button colorSchema="secondary" onClick={setShouldShowRecoveryCodes.toggle}>
{shouldShowRecoveryCodes ? "Hide recovery codes" : "Show recovery codes"}
</Button>
<Button colorSchema="secondary" onClick={handleGenerateMoreRecoveryCodes}>
Generate more codes
</Button>
<Button colorSchema="danger" onClick={() => handlePopUpOpen("deleteTotpConfig")}>
Delete
</Button>
</div>
{shouldShowRecoveryCodes && totpConfiguration.recoveryCodes && (
<div className="mt-4 bg-mineshaft-600 p-4">
{totpConfiguration.recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
)}
</div>
) : (
<>
<div className="text-sm text-gray-400">
For added security, you can configure a mobile authenticator and set it as your
selected 2FA method.
</div>
<div className="mt-6 flex min-w-full justify-center">
<TotpRegistration
onComplete={async () => {
await queryClient.invalidateQueries(userKeys.totpConfiguration);
}}
/>
</div>
</>
)}
</div>
)}
</div>
<EmailServiceSetupModal <EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen} isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/> />
<DeleteActionModal
isOpen={popUp.deleteTotpConfig.isOpen}
title="Are you sure want to delete the configured authenticator?"
subTitle="This action is irreversible. Youll have to go through the setup process to enable it again."
onChange={(isOpen) => handlePopUpToggle("deleteTotpConfig", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleTotpDeletion}
/>
</> </>
); );
}; };

View File

@ -13,6 +13,7 @@ import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input } from "@app/components/v2"; import { Button, Input } from "@app/components/v2";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; 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 { fetchOrganizations } from "@app/hooks/api/organization/queries";
import ProjectService from "@app/services/ProjectService"; import ProjectService from "@app/services/ProjectService";
import { Mfa } from "@app/views/Login/Mfa"; import { Mfa } from "@app/views/Login/Mfa";
@ -57,6 +58,7 @@ export const UserInfoSSOStep = ({
const [organizationNameError, setOrganizationNameError] = useState(false); const [organizationNameError, setOrganizationNameError] = useState(false);
const [attributionSource, setAttributionSource] = useState(""); const [attributionSource, setAttributionSource] = useState("");
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: selectOrganization } = useSelectOrganization();
@ -178,12 +180,15 @@ export const UserInfoSSOStep = ({
const completeSignupFlow = async () => { const completeSignupFlow = async () => {
try { try {
const { isMfaEnabled, token } = await selectOrganization({ const { isMfaEnabled, token, mfaMethod } = await selectOrganization({
organizationId: orgId organizationId: orgId
}); });
if (isMfaEnabled) { if (isMfaEnabled) {
SecurityClient.setMfaToken(token); SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on(); toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow); setMfaSuccessCallback(() => completeSignupFlow);
return; return;
@ -231,6 +236,7 @@ export const UserInfoSSOStep = ({
hideLogo hideLogo
email={username} email={username}
successCallback={mfaSuccessCallback} successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()} closeMfa={() => toggleShowMfa.off()}
/> />
); );