Compare commits

..

11 Commits

Author SHA1 Message Date
Sheen Capadngan
682b552fdc misc: addressed remaining comments 2024-11-16 03:15:39 +08:00
Sheen Capadngan
774371a218 misc: added mention of authenticator in the docs 2024-11-16 00:10:56 +08:00
Sheen Capadngan
c4b54de303 misc: migrated to switch component 2024-11-15 23:49:20 +08:00
Sheen Capadngan
433971a72d misc: addressed comments 1 2024-11-15 23:25:32 +08:00
Sheen Capadngan
6bae3628c0 misc: readded saml email error 2024-11-14 19:37:13 +08:00
Sheen Capadngan
4cb935dae7 misc: addressed signupinvite issue 2024-11-14 19:10:21 +08:00
Sheen Capadngan
5b0dbf04b2 misc: minor ui 2024-11-14 03:22:02 +08:00
Sheen Capadngan
b050db84ab feat: added totp support for cli 2024-11-14 02:27:33 +08:00
Sheen Capadngan
8fef6911f1 misc: addressed lint 2024-11-14 01:25:23 +08:00
Sheen Capadngan
44ba31a743 misc: added org mfa settings update and other fixes 2024-11-14 01:16:15 +08:00
Sheen Capadngan
6bdbac4750 feat: initial implementation for totp authenticator 2024-11-14 00:07:35 +08:00
58 changed files with 1620 additions and 357 deletions

View File

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

View File

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

View File

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

View File

@@ -314,6 +314,9 @@ import {
TSuperAdmin,
TSuperAdminInsert,
TSuperAdminUpdate,
TTotpConfigs,
TTotpConfigsInsert,
TTotpConfigsUpdate,
TTrustedIps,
TTrustedIpsInsert,
TTrustedIpsUpdate,
@@ -826,5 +829,6 @@ declare module "knex/types/tables" {
TProjectTemplatesInsert,
TProjectTemplatesUpdate
>;
[TableName.TotpConfig]: KnexOriginal.CompositeTableType<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 "./slack-integrations";
export * from "./super-admin";
export * from "./totp-configs";
export * from "./trusted-ips";
export * from "./user-actions";
export * from "./user-aliases";

View File

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

View File

@@ -21,7 +21,8 @@ export const OrganizationsSchema = z.object({
kmsDefaultKeyId: z.string().uuid().nullable().optional(),
kmsEncryptedDataKey: zodBuffer.nullable().optional(),
defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false)
enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional()
});
export type TOrganizations = z.infer<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(),
isLocked: z.boolean().default(false).nullable().optional(),
temporaryLockDateEnd: z.date().nullable().optional(),
consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional()
consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional(),
selectedMfaMethod: z.string().nullable().optional()
});
export type TUsers = z.infer<typeof UsersSchema>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -169,4 +169,103 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
return groupMemberships;
}
});
server.route({
method: "GET",
url: "/me/totp",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
isVerified: z.boolean(),
recoveryCodes: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.totp.getUserTotpConfig({
userId: req.permission.id
});
}
});
server.route({
method: "DELETE",
url: "/me/totp",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.totp.deleteUserTotpConfig({
userId: req.permission.id
});
}
});
server.route({
method: "POST",
url: "/me/totp/register",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
otpUrl: z.string(),
recoveryCodes: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT], {
requireOrg: false
}),
handler: async (req) => {
return server.services.totp.registerUserTotp({
userId: req.permission.id
});
}
});
server.route({
method: "POST",
url: "/me/totp/verify",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
totp: z.string()
}),
response: {
200: z.object({})
}
},
onRequest: verifyAuth([AuthMode.JWT], {
requireOrg: false
}),
handler: async (req) => {
return server.services.totp.verifyUserTotpConfig({
userId: req.permission.id,
totp: req.body.totp
});
}
});
server.route({
method: "POST",
url: "/me/totp/recovery-codes",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
return server.services.totp.createUserTotpRecoveryCodes({
userId: req.permission.id
});
}
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TTotpConfigDALFactory } from "../totp/totp-config-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TAuthDALFactory } from "./auth-dal";
import { TChangePasswordDTO, TCreateBackupPrivateKeyDTO, TResetPasswordViaBackupKeyDTO } from "./auth-password-type";
@@ -18,6 +19,7 @@ type TAuthPasswordServiceFactoryDep = {
userDAL: TUserDALFactory;
tokenService: TAuthTokenServiceFactory;
smtpService: TSmtpService;
totpConfigDAL: Pick<TTotpConfigDALFactory, "delete">;
};
export type TAuthPasswordFactory = ReturnType<typeof authPaswordServiceFactory>;
@@ -25,7 +27,8 @@ export const authPaswordServiceFactory = ({
authDAL,
userDAL,
tokenService,
smtpService
smtpService,
totpConfigDAL
}: TAuthPasswordServiceFactoryDep) => {
/*
* Pre setup for pass change with srp protocol
@@ -185,6 +188,12 @@ export const authPaswordServiceFactory = ({
temporaryLockDateEnd: null,
consecutiveFailedMfaAttempts: 0
});
/* we reset the mobile authenticator configs of the user
because we want this to be one of the recovery modes from account lockout */
await totpConfigDAL.delete({
userId
});
};
/*

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,145 +0,0 @@
---
title: GitLab
description: "Learn how to authenticate GitLab pipelines with Infisical using OpenID Connect (OIDC)."
---
**OIDC Auth** is a platform-agnostic JWT-based authentication method that can be used to authenticate from any platform or environment using an identity provider with OpenID Connect.
## Diagram
The following sequence diagram illustrates the OIDC Auth workflow for authenticating GitLab pipelines with Infisical.
```mermaid
sequenceDiagram
participant Client as GitLab Pipeline
participant Idp as GitLab Identity Provider
participant Infis as Infisical
Client->>Idp: Step 1: Request identity token
Idp-->>Client: Return JWT with verifiable claims
Note over Client,Infis: Step 2: Login Operation
Client->>Infis: Send signed JWT to /api/v1/auth/oidc-auth/login
Note over Infis,Idp: Step 3: Query verification
Infis->>Idp: Request JWT public key using OIDC Discovery
Idp-->>Infis: Return public key
Note over Infis: Step 4: JWT validation
Infis->>Client: Return short-lived access token
Note over Client,Infis: Step 5: Access Infisical API with Token
Client->>Infis: Make authenticated requests using the short-lived access token
```
## Concept
At a high-level, Infisical authenticates a client by verifying the JWT and checking that it meets specific requirements (e.g. it is issued by a trusted identity provider) at the `/api/v1/auth/oidc-auth/login` endpoint. If successful,
then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API.
To be more specific:
1. The GitLab pipeline requests an identity token from GitLab's identity provider.
2. The fetched identity token is sent to Infisical at the `/api/v1/auth/oidc-auth/login` endpoint.
3. Infisical fetches the public key that was used to sign the identity token from GitLab's identity provider using OIDC Discovery.
4. Infisical validates the JWT using the public key provided by the identity provider and checks that the subject, audience, and claims of the token matches with the set criteria.
5. If all is well, Infisical returns a short-lived access token that the GitLab pipeline can use to make authenticated requests to the Infisical API.
<Note>
Infisical needs network-level access to GitLab's identity provider endpoints.
</Note>
## Guide
In the following steps, we explore how to create and use identities to access the Infisical API using the OIDC Auth authentication method.
<Steps>
<Step title="Creating an identity">
To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**.
![identities organization](/images/platform/identities/identities-org.png)
When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles.
![identities organization create](/images/platform/identities/identities-org-create.png)
Now input a few details for your new identity. Here's some guidance for each field:
- Name (required): A friendly name for the identity.
- Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to.
Once you've created an identity, you'll be redirected to a page where you can manage the identity.
![identities page](/images/platform/identities/identities-page.png)
Since the identity has been configured with Universal Auth by default, you should re-configure it to use OIDC Auth instead. To do this, press to edit the **Authentication** section,
remove the existing Universal Auth configuration, and add a new OIDC Auth configuration onto the identity.
![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png)
![identities create oidc auth method](/images/platform/identities/identities-org-create-oidc-auth-method.png)
<Warning>Restrict access by configuring the Subject, Audiences, and Claims fields</Warning>
Here's some more guidance on each field:
- OIDC Discovery URL: The URL used to retrieve the OpenID Connect configuration from the identity provider. This will be used to fetch the public key needed for verifying the provided JWT. For GitLab SaaS (GitLab.com), this should be set to `https://gitlab.com`. For self-hosted GitLab instances, use the domain of your GitLab instance.
- Issuer: The unique identifier of the identity provider issuing the JWT. This value is used to verify the iss (issuer) claim in the JWT to ensure the token is issued by a trusted provider. This should also be set to the domain of the Gitlab instance.
- CA Certificate: The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints. For GitLab.com, this can be left blank.
- Subject: The expected principal that is the subject of the JWT. For GitLab pipelines, this should be set to a string that uniquely identifies the pipeline and its context, in the format `project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}` (e.g., `project_path:example-group/example-project:ref_type:branch:ref:main`).
- Claims: Additional information or attributes that should be present in the JWT for it to be valid. You can refer to GitLab's [documentation](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html#token-payload) for the list of supported claims.
- Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time.
- Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time.
- Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses.
- Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address.
<Tip>For more details on the appropriate values for the OIDC fields, refer to GitLab's [documentation](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html#token-payload). </Tip>
<Info>The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible.</Info>
</Step>
<Step title="Adding an identity to a project">
To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project.
To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**.
Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to.
![identities project](/images/platform/identities/identities-project.png)
![identities project create](/images/platform/identities/identities-project-create.png)
</Step>
<Step title="Accessing the Infisical API with the identity">
As demonstration, we will be using the Infisical CLI to fetch Infisical secrets and utilize them within a GitLab pipeline.
To access Infisical secrets as the identity, you need to use an identity token from GitLab which matches the OIDC configuration defined for the machine identity.
This can be done by defining the `id_tokens` property. The resulting token would then be used to login with OIDC like the following: `infisical login --method=oidc-auth --oidc-jwt=$GITLAB_TOKEN`
Below is a complete example of how a GitLab pipeline can be configured to work with secrets from Infisical using the Infisical CLI with OIDC Auth:
```yaml
image: ubuntu
stages:
- build
build-job:
stage: build
id_tokens:
INFISICAL_ID_TOKEN:
aud: infisical-aud-test
script:
- apt update && apt install -y curl
- curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash
- apt-get update && apt-get install -y infisical
- export INFISICAL_TOKEN=$(infisical login --method=oidc-auth --machine-identity-id=4e807a78-1b1c-4bd6-9609-ef2b0cf4fd54 --oidc-jwt=$INFISICAL_ID_TOKEN --silent --plain)
- infisical run --projectId=1d0443c1-cd43-4b3a-91a3-9d5f81254a89 --env=dev -- npm run build
```
The `id_tokens` keyword is used to request an ID token for the job. In this example, an ID token named `INFISICAL_ID_TOKEN` is requested with the audience (`aud`) claim set to "infisical-aud-test". This ID token will be used to authenticate with Infisical.
<Note>
Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds, which can be adjusted.
If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation.
</Note>
</Step>
</Steps>

View File

@@ -4,19 +4,18 @@ sidebarTitle: "MFA"
description: "Learn how to secure your Infisical account with MFA."
---
MFA requires users to provide multiple forms of identification to access their account. Currently, this means logging in with your password and a 6-digit code sent to your email.
MFA requires users to provide multiple forms of identification to access their account.
## Email 2FA
Check the box in Personal Settings > Two-factor Authentication to enable email-based 2FA.
If 2-factor authentication is enabled in the Personal settings page, email will be used for MFA by default.
![Email-based MFA](../../images/mfa-email.png)
![Email-based MFA](/images/mfa-email.png)
<Note>
Infisical currently supports email-based 2FA. We're actively working on
building support for other forms of identification via SMS and Authenticator
App.
</Note>
## Mobile Authenticator 2FA
You can use any mobile authenticator app (Authy, Google Authenticator, Duo, etc.) to secure your account. After registration with an authenticator, select **Mobile Authenticator** as your 2FA method.
![Authenticator-based MFA](/images/mfa-authenticator.png)
## Entra ID / Azure AD MFA
@@ -25,32 +24,39 @@ Check the box in Personal Settings > Two-factor Authentication to enable email-b
We also encourage you to have your team download and setup the
[Microsoft Authenticator App](https://www.microsoft.com/en-us/security/mobile-authenticator-app) prior to enabling MFA.
</Note>
<Steps>
<Step title="Open your Infisical Application in the Microsoft Entra Admin Center">
![Entra Infisical app](../../images/platform/mfa/entra/mfa_entra_infisical_app.png)
</Step>
<Step title="Tap on Conditional Access under the Security Tab">
![conditional access](../../images/platform/mfa/entra/mfa_entra_conditional_access.png)
</Step>
<Step title="Tap on Create New Policy from Templates">
![create policy](../../images/platform/mfa/entra/mfa_entra_create_policy.png)
</Step>
<Step title="Select Require MFA for All Users and Tap on Review + Create">
![require MFA and review policy](../../images/platform/mfa/entra/mfa_entra_review_policy.png)
<Note>
By default all users except the configuring admin will be setup to require MFA.
Microsoft encourages keeping at least one admin excluded from MFA to prevent accidental lockout.
</Note>
</Step>
<Step title="Set Policy State to Enabled and Tap on Create">
![enable policy and confirm](../../images/platform/mfa/entra/mfa_entra_confirm_policy.png)
</Step>
<Step title="MFA is now Required When Accessing Infisical">
![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>
<Step title="Open your Infisical Application in the Microsoft Entra Admin Center">
![Entra Infisical
app](/images/platform/mfa/entra/mfa_entra_infisical_app.png)
</Step>
<Step title="Tap on Conditional Access under the Security Tab">
![conditional
access](/images/platform/mfa/entra/mfa_entra_conditional_access.png)
</Step>
<Step title="Tap on Create New Policy from Templates">
![create policy](/images/platform/mfa/entra/mfa_entra_create_policy.png)
</Step>
<Step title="Select Require MFA for All Users and Tap on Review + Create">
![require MFA and review
policy](/images/platform/mfa/entra/mfa_entra_review_policy.png)
<Note>
By default all users except the configuring admin will be setup to require
MFA. Microsoft encourages keeping at least one admin excluded from MFA to
prevent accidental lockout.
</Note>
</Step>
<Step title="Set Policy State to Enabled and Tap on Create">
![enable policy and
confirm](/images/platform/mfa/entra/mfa_entra_confirm_policy.png)
</Step>
<Step title="MFA is now Required When Accessing Infisical">
![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

@@ -226,8 +226,7 @@
"pages": [
"documentation/platform/identities/oidc-auth/general",
"documentation/platform/identities/oidc-auth/github",
"documentation/platform/identities/oidc-auth/circleci",
"documentation/platform/identities/oidc-auth/gitlab"
"documentation/platform/identities/oidc-auth/circleci"
]
},
"documentation/platform/mfa",

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -78,6 +78,7 @@ import {
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
import { Workspace } from "@app/hooks/api/types";
@@ -143,6 +144,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!);
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const workspacesWithFaveProp = useMemo(
@@ -214,12 +216,15 @@ export const AppLayout = ({ children }: LayoutProps) => {
};
const changeOrg = async (orgId: string) => {
const { token, isMfaEnabled } = await selectOrganization({
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => changeOrg(orgId));
return;
@@ -365,6 +370,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
<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
email={user.email as string}
method={requiredMfaMethod}
successCallback={mfaSuccessCallback}
closeMfa={() => toggleShowMfa.off()}
/>

View File

@@ -22,7 +22,7 @@ import {
useLogoutUser,
useSelectOrganization
} from "@app/hooks/api";
import { UserAgentType } from "@app/hooks/api/auth/types";
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
import { Organization } from "@app/hooks/api/types";
import { AuthMethod } from "@app/hooks/api/users/types";
import { getAuthToken, isLoggedIn } from "@app/reactQuery";
@@ -46,6 +46,7 @@ export default function LoginPage() {
const selectOrg = useSelectOrganization();
const { data: user, isLoading: userLoading } = useGetUser();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
@@ -90,15 +91,19 @@ export default function LoginPage() {
return;
}
const { token, isMfaEnabled } = await selectOrg.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
});
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
})
.finally(() => setIsInitialOrgCheckLoading(false));
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
return;
}
@@ -213,7 +218,11 @@ export default function LoginPage() {
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Head>
{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">
<Link href="/">

View File

@@ -29,8 +29,10 @@ import {
useSelectOrganization,
verifySignupInvite
} from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { navigateUserToOrg } from "@app/views/Login/Login.utils";
import { Mfa } from "@app/views/Login/Mfa";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
@@ -59,6 +61,7 @@ export default function SignupInvite() {
const [errors, setErrors] = useState<Errors>({});
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const router = useRouter();
const parsedUrl = queryString.parse(router.asPath.split("?")[1]);
@@ -184,12 +187,19 @@ export default function SignupInvite() {
if (!orgId) throw new Error("You are not part of any organization");
const completeSignupFlow = async () => {
const { token: mfaToken, isMfaEnabled } = await selectOrganization({
const {
token: mfaToken,
isMfaEnabled,
mfaMethod
} = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(mfaToken);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow);
return;
@@ -390,12 +400,23 @@ export default function SignupInvite() {
<title>Sign Up</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<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}
{shouldShowMfa ? (
<Mfa
email={email}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import React, { useEffect, useState } from "react";
import ReactCodeInput from "react-code-input";
import Image from "next/image";
import Link from "next/link";
@@ -6,10 +6,12 @@ import { useRouter } from "next/router";
import { t } from "i18next";
import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button } from "@app/components/v2";
import { Button, Input } from "@app/components/v2";
import { useSendMfaToken } from "@app/hooks/api";
import { verifyMfaToken } from "@app/hooks/api/auth/queries";
import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
// The style for the verification code input
const codeInputProps = {
@@ -36,23 +38,39 @@ type Props = {
closeMfa?: () => void;
hideLogo?: boolean;
email: string;
method: MfaMethod;
};
export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
const [mfaCode, setMfaCode] = useState("");
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = useState(false);
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
const sendMfaToken = useSendMfaToken();
const verifyMfa = async () => {
useEffect(() => {
if (method === MfaMethod.TOTP) {
checkUserTotpMfa().then((isVerified) => {
if (!isVerified) {
SecurityClient.setMfaToken("");
setShouldShowTotpRegistration(true);
}
});
}
}, []);
const verifyMfa = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);
try {
const { token } = await verifyMfaToken({
email,
mfaCode
mfaCode,
mfaMethod: method
});
SecurityClient.setMfaToken("");
@@ -92,6 +110,24 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
}
};
if (shouldShowTotpRegistration) {
return (
<>
<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 (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
{!hideLogo && (
@@ -101,52 +137,87 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => {
</div>
</Link>
)}
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput
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.`} />
{method === MfaMethod.EMAIL && (
<>
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
</>
)}
<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-l w-full py-1 text-lg">
<Button
onClick={() => verifyMfa()}
size="sm"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{String(t("mfa.verify"))}
</Button>
{method === MfaMethod.TOTP && (
<>
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
Authenticator MFA Required
</p>
<p className="text-l flex max-w-xs justify-center text-center text-bunker-300">
Open the authenticator app on your mobile device to get your verification code or enter
a recovery code.
</p>
</>
)}
<form onSubmit={verifyMfa}>
<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 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>
{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%]">
<div className="text-l w-full py-1 text-lg">
<Button
size="sm"
type="submit"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{String(t("mfa.verify"))}
</Button>
</div>
</div>
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
</div>
</form>
{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>
);
};

View File

@@ -16,6 +16,7 @@ import { Button, Input, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useToggle } from "@app/hooks";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
@@ -36,6 +37,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
@@ -66,12 +68,15 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled } = await selectOrganization({ organizationId });
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
toggleShowMfa.on();
setMfaSuccessCallback(() => finishWithOrgWorkflow);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
return;
}
@@ -167,10 +172,15 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled } = await selectOrganization({ organizationId });
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => finishWithOrgWorkflow);
return;
@@ -283,6 +293,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
<Mfa
email={email}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
</div>

View File

@@ -1,6 +1,6 @@
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Switch, UpgradePlanModal } from "@app/components/v2";
import { FormControl, Select, SelectItem, Switch, UpgradePlanModal } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -8,6 +8,7 @@ import {
useSubscription
} from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const OrgGenericAuthSection = () => {
@@ -43,6 +44,32 @@ export const OrgGenericAuthSection = () => {
}
};
const handleUpdateSelectedMfa = async (selectedMfaMethod: MfaMethod) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.enforceMfa) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
selectedMfaMethod
});
createNotification({
text: "Successfully updated selected MFA method",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: (err as { response: { data: { message: string } } }).response.data.message,
type: "error"
});
}
};
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="py-4">
@@ -62,6 +89,22 @@ export const OrgGenericAuthSection = () => {
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate with MFA in order to access the organization
</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>
<UpgradePlanModal
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 { Checkbox, EmailServiceSetupModal } from "@app/components/v2";
import { useGetUser, useUpdateMfaEnabled } from "@app/hooks/api";
import {
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 {
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 { usePopUp } from "@app/hooks/usePopUp";
export const MFASection = () => {
const { data: user } = useGetUser();
const { mutateAsync } = useUpdateMfaEnabled();
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
const { mutateAsync } = useUpdateUserMfa();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"setUpEmail",
"deleteTotpConfig"
] as const);
const [shouldShowRecoveryCodes, setShouldShowRecoveryCodes] = useToggle();
const { data: totpConfiguration, isLoading: isTotpConfigurationLoading } =
useGetUserTotpConfiguration();
const { mutateAsync: deleteTotpConfiguration } = useDeleteUserTotpConfiguration();
const { mutateAsync: createTotpRecoveryCodes } = useCreateNewTotpRecoveryCodes();
const queryClient = useQueryClient();
const { data: serverDetails } = useFetchServerStatus();
const handleTotpDeletion = async () => {
try {
await deleteTotpConfiguration();
createNotification({
text: "Successfully deleted mobile authenticator",
type: "success"
});
handlePopUpClose("deleteTotpConfig");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete mobile authenticator";
createNotification({
text,
type: "error"
});
}
};
const handleGenerateMoreRecoveryCodes = async () => {
try {
await createTotpRecoveryCodes();
createNotification({
text: "Successfully generated new recovery codes",
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to generate new recovery codes";
createNotification({
text,
type: "error"
});
}
};
const updateSelectedMfa = async (mfaMethod: MfaMethod) => {
try {
if (!user) return;
await mutateAsync({
selectedMfaMethod: mfaMethod
});
createNotification({
text: "Successfully updated selected 2FA method",
type: "success"
});
} catch (err) {
createNotification({
text: "Something went wrong while updating selected 2FA method.",
type: "error"
});
console.error(err);
}
};
const toggleMfa = async (state: boolean) => {
try {
if (!user) return;
@@ -47,31 +137,96 @@ export const MFASection = () => {
return (
<>
<form>
<div className="mb-6 max-w-6xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold text-mineshaft-100">Two-factor Authentication</p>
{user && (
<Checkbox
className="data-[state=checked]:bg-primary"
id="isTwoFAEnabled"
isChecked={user?.isMfaEnabled}
onCheckedChange={(state) => {
if (serverDetails?.emailConfigured) {
toggleMfa(state as boolean);
} else {
handlePopUpOpen("setUpEmail");
}
}}
<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>
{user && (
<Switch
className="data-[state=checked]:bg-primary"
id="isTwoFAEnabled"
isChecked={user?.isMfaEnabled}
onCheckedChange={(state) => {
if (serverDetails?.emailConfigured) {
toggleMfa(state as boolean);
} else {
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.
</Checkbox>
)}
</div>
</form>
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
Email
</SelectItem>
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
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
isOpen={popUp.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 { useToggle } from "@app/hooks";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import ProjectService from "@app/services/ProjectService";
import { Mfa } from "@app/views/Login/Mfa";
@@ -57,6 +58,7 @@ export const UserInfoSSOStep = ({
const [organizationNameError, setOrganizationNameError] = useState(false);
const [attributionSource, setAttributionSource] = useState("");
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const { mutateAsync: selectOrganization } = useSelectOrganization();
@@ -178,12 +180,15 @@ export const UserInfoSSOStep = ({
const completeSignupFlow = async () => {
try {
const { isMfaEnabled, token } = await selectOrganization({
const { isMfaEnabled, token, mfaMethod } = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => completeSignupFlow);
return;
@@ -231,6 +236,7 @@ export const UserInfoSSOStep = ({
hideLogo
email={username}
successCallback={mfaSuccessCallback}
method={requiredMfaMethod}
closeMfa={() => toggleShowMfa.off()}
/>
);