diff --git a/backend/e2e-test/routes/v1/login.spec.ts b/backend/e2e-test/routes/v1/login.spec.ts index cd6ec3194..0b2123f4e 100644 --- a/backend/e2e-test/routes/v1/login.spec.ts +++ b/backend/e2e-test/routes/v1/login.spec.ts @@ -39,8 +39,6 @@ describe("Login V1 Router", async () => { }); expect(res.statusCode).toBe(200); const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("mfaEnabled"); expect(payload).toHaveProperty("token"); - expect(payload.mfaEnabled).toBeFalsy(); }); }); diff --git a/backend/src/db/migrations/20241016183616_add-org-enforce-mfa.ts b/backend/src/db/migrations/20241016183616_add-org-enforce-mfa.ts new file mode 100644 index 000000000..d01f1698e --- /dev/null +++ b/backend/src/db/migrations/20241016183616_add-org-enforce-mfa.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Organization, "enforceMfa"))) { + await knex.schema.alterTable(TableName.Organization, (tb) => { + tb.boolean("enforceMfa").defaultTo(false).notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Organization, "enforceMfa")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("enforceMfa"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 7bd20d94d..31de98168 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -20,7 +20,8 @@ export const OrganizationsSchema = z.object({ scimEnabled: z.boolean().default(false).nullable().optional(), kmsDefaultKeyId: z.string().uuid().nullable().optional(), kmsEncryptedDataKey: zodBuffer.nullable().optional(), - defaultMembershipRole: z.string().default("member") + defaultMembershipRole: z.string().default("member"), + enforceMfa: z.boolean().default(false) }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index fa67b72d1..031c9f1a2 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -46,7 +46,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ writeLimit: 200, secretsLimit: 40 }, - pkiEst: false + pkiEst: false, + enforceMfa: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 6e671c26f..c331679b5 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -64,6 +64,7 @@ export type TFeatureSet = { secretsLimit: number; }; pkiEst: boolean; + enforceMfa: boolean; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 1cc863168..9d239a405 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -18,6 +18,7 @@ export type TAuthMode = user: TUsers; orgId: string; authMethod: AuthMethod; + isMfaVerified?: boolean; } | { authMode: AuthMode.API_KEY; @@ -121,7 +122,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { tokenVersionId, actor, orgId: orgId as string, - authMethod: token.authMethod + authMethod: token.authMethod, + isMfaVerified: token.isMfaVerified }; break; } diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 61bc910a6..d67e7b562 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -107,7 +107,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { userId: decodedToken.userId, tokenVersionId: tokenVersion.id, accessVersion: tokenVersion.accessVersion, - organizationId: decodedToken.organizationId + organizationId: decodedToken.organizationId, + isMfaVerified: decodedToken.isMfaVerified }, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_AUTH_LIFETIME } diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index e7e5fb532..30d032e13 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -258,7 +258,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .refine((v) => slugify(v) === v, { message: "Membership role must be a valid slug" }) - .optional() + .optional(), + enforceMfa: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 18c8595af..9007ca828 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -280,10 +280,6 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { providerAuthToken: req.body.providerAuthToken }); - if (data.isMfaEnabled) { - return { mfaEnabled: true, token: data.token } as const; // for discriminated union - } - void res.setCookie("jid", data.token.refresh, { httpOnly: true, path: "/", @@ -292,7 +288,6 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }); return { - mfaEnabled: false, encryptionVersion: data.user.encryptionVersion, token: data.token.access, publicKey: data.user.publicKey, diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index b5f523a54..67f8e2c4c 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -47,7 +47,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - token: z.string() + token: z.string(), + isMfaEnabled: z.boolean() }) } }, @@ -60,6 +61,13 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { ipAddress: req.realIp }); + if (tokens.isMfaEnabled) { + return { + token: tokens.mfa as string, + isMfaEnabled: true + }; + } + void res.setCookie("jid", tokens.refresh, { httpOnly: true, path: "/", @@ -67,7 +75,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { secure: cfg.HTTPS_ENABLED }); - return { token: tokens.access }; + return { token: tokens.access, isMfaEnabled: false }; } }); @@ -86,21 +94,17 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { password: z.string().optional() }), response: { - 200: z.discriminatedUnion("mfaEnabled", [ - z.object({ mfaEnabled: z.literal(true), token: z.string() }), - z.object({ - mfaEnabled: z.literal(false), - encryptionVersion: z.number().default(1).nullable().optional(), - protectedKey: z.string().nullable(), - protectedKeyIV: z.string().nullable(), - protectedKeyTag: z.string().nullable(), - publicKey: z.string(), - encryptedPrivateKey: z.string(), - iv: z.string(), - tag: z.string(), - token: z.string() - }) - ]) + 200: z.object({ + encryptionVersion: z.number().default(1).nullable().optional(), + protectedKey: z.string().nullable(), + protectedKeyIV: z.string().nullable(), + protectedKeyTag: z.string().nullable(), + publicKey: z.string(), + encryptedPrivateKey: z.string(), + iv: z.string(), + tag: z.string(), + token: z.string() + }) } }, handler: async (req, res) => { @@ -118,10 +122,6 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { password: req.body.password }); - if (data.isMfaEnabled) { - return { mfaEnabled: true, token: data.token } as const; // for discriminated union - } - void res.setCookie("jid", data.token.refresh, { httpOnly: true, path: "/", @@ -130,7 +130,6 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }); return { - mfaEnabled: false, encryptionVersion: data.user.encryptionVersion, token: data.token.access, publicKey: data.user.publicKey, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index a80f0bc85..83da4724e 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -99,13 +99,15 @@ export const authLoginServiceFactory = ({ ip, userAgent, organizationId, - authMethod + authMethod, + isMfaVerified }: { user: TUsers; ip: string; userAgent: string; organizationId?: string; authMethod: AuthMethod; + isMfaVerified?: boolean; }) => { const cfg = getConfig(); await updateUserDeviceSession(user, ip, userAgent); @@ -123,7 +125,8 @@ export const authLoginServiceFactory = ({ userId: user.id, tokenVersionId: tokenSession.id, accessVersion: tokenSession.accessVersion, - organizationId + organizationId, + isMfaVerified }, cfg.AUTH_SECRET, { expiresIn: cfg.JWT_AUTH_LIFETIME } @@ -136,7 +139,8 @@ export const authLoginServiceFactory = ({ userId: user.id, tokenVersionId: tokenSession.id, refreshVersion: tokenSession.refreshVersion, - organizationId + organizationId, + isMfaVerified }, cfg.AUTH_SECRET, { expiresIn: cfg.JWT_REFRESH_LIFETIME } @@ -298,30 +302,6 @@ export const authLoginServiceFactory = ({ }); } - // send multi factor auth token if they it enabled - if (userEnc.isMfaEnabled && userEnc.email) { - enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); - - const mfaToken = jwt.sign( - { - authMethod, - authTokenType: AuthTokenType.MFA_TOKEN, - userId: userEnc.userId - }, - cfg.AUTH_SECRET, - { - expiresIn: cfg.JWT_MFA_LIFETIME - } - ); - - await sendUserMfaCode({ - userId: userEnc.userId, - email: userEnc.email - }); - - return { isMfaEnabled: true, token: mfaToken } as const; - } - const token = await generateUserTokens({ user: { ...userEnc, @@ -333,7 +313,7 @@ export const authLoginServiceFactory = ({ organizationId }); - return { token, isMfaEnabled: false, user: userEnc } as const; + return { token, user: userEnc } as const; }; const selectOrganization = async ({ @@ -373,15 +353,43 @@ export const authLoginServiceFactory = ({ }); } + // send multi factor auth token if they it enabled + if ((selectedOrg.enforceMfa || user.isMfaEnabled) && user.email && !decodedToken.isMfaVerified) { + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + + const mfaToken = jwt.sign( + { + authMethod: decodedToken.authMethod, + authTokenType: AuthTokenType.MFA_TOKEN, + userId: user.id + }, + cfg.AUTH_SECRET, + { + expiresIn: cfg.JWT_MFA_LIFETIME + } + ); + + await sendUserMfaCode({ + userId: user.id, + email: user.email + }); + + return { isMfaEnabled: true, mfa: mfaToken } as const; + } + const tokens = await generateUserTokens({ authMethod: decodedToken.authMethod, user, userAgent, ip: ipAddress, - organizationId + organizationId, + isMfaVerified: decodedToken.isMfaVerified }); - return tokens; + return { + ...tokens, + isMfaEnabled: false + }; }; /* @@ -504,7 +512,8 @@ export const authLoginServiceFactory = ({ ip, userAgent, organizationId: orgId, - authMethod: decodedToken.authMethod + authMethod: decodedToken.authMethod, + isMfaVerified: true }); return { token, user: userEnc }; @@ -629,7 +638,6 @@ export const authLoginServiceFactory = ({ const oauth2TokenExchange = async ({ userAgent, ip, providerAuthToken, email }: TOauthTokenExchangeDTO) => { const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); - const appCfg = getConfig(); const { authMethod, userName } = decodedProviderToken; if (!userName) throw new BadRequestError({ message: "Missing user name" }); const organizationId = @@ -644,29 +652,6 @@ export const authLoginServiceFactory = ({ if (!userEnc) throw new BadRequestError({ message: "Invalid token" }); if (!userEnc.serverEncryptedPrivateKey) throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); - // send multi factor auth token if they it enabled - if (userEnc.isMfaEnabled && userEnc.email) { - enforceUserLockStatus(Boolean(userEnc.isLocked), userEnc.temporaryLockDateEnd); - - const mfaToken = jwt.sign( - { - authMethod, - authTokenType: AuthTokenType.MFA_TOKEN, - userId: userEnc.userId - }, - appCfg.AUTH_SECRET, - { - expiresIn: appCfg.JWT_MFA_LIFETIME - } - ); - - await sendUserMfaCode({ - userId: userEnc.userId, - email: userEnc.email - }); - - return { isMfaEnabled: true, token: mfaToken } as const; - } const token = await generateUserTokens({ user: { ...userEnc, id: userEnc.userId }, diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 87522a803..44b775945 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -52,6 +52,7 @@ export type AuthModeJwtTokenPayload = { tokenVersionId: string; accessVersion: number; organizationId?: string; + isMfaVerified?: boolean; }; export type AuthModeMfaJwtTokenPayload = { @@ -69,6 +70,7 @@ export type AuthModeRefreshJwtTokenPayload = { tokenVersionId: string; refreshVersion: number; organizationId?: string; + isMfaVerified?: boolean; }; export type AuthModeProviderJwtTokenPayload = { diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index e71ad6983..a751efe39 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -268,13 +268,28 @@ export const orgServiceFactory = ({ actorOrgId, actorAuthMethod, orgId, - data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug } + data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa } }: TUpdateOrgDTO) => { + const appCfg = getConfig(); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); const plan = await licenseService.getPlan(orgId); + if (enforceMfa !== undefined) { + if (!plan.enforceMfa) { + throw new BadRequestError({ + message: "Failed to enforce user MFA due to plan restriction. Upgrade plan to enforce/un-enforce MFA." + }); + } + + if (!appCfg.isSmtpConfigured) { + throw new BadRequestError({ + message: "Failed to enforce user MFA due to missing instance SMTP configuration." + }); + } + } + if (authEnforced !== undefined) { if (!plan?.samlSSO || !plan.oidcSSO) throw new BadRequestError({ @@ -317,7 +332,8 @@ export const orgServiceFactory = ({ slug: slug ? slugify(slug) : undefined, authEnforced, scimEnabled, - defaultMembershipRole + defaultMembershipRole, + enforceMfa }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index d62a2c25b..5b44eeea5 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -64,6 +64,7 @@ export type TUpdateOrgDTO = { authEnforced: boolean; scimEnabled: boolean; defaultMembershipRoleSlug: string; + enforceMfa: boolean; }>; } & TOrgPermission; diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 4b6c5a761..f96c93709 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -137,6 +137,7 @@ type GetOrganizationsResponse struct { type SelectOrganizationResponse struct { Token string `json:"token"` + MfaEnabled bool `json:"isMfaEnabled"` } type SelectOrganizationRequest struct { diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index 99d2ef502..f95d90485 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -5,6 +5,7 @@ package cmd import ( "encoding/json" + "fmt" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" @@ -75,6 +76,42 @@ var initCmd = &cobra.Command{ selectedOrganization := organizations[index] tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + if tokenResponse.MfaEnabled { + i := 1 + for i < 6 { + mfaVerifyCode := askForMFACode() + + httpClient := resty.New() + httpClient.SetAuthToken(tokenResponse.Token) + verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ + Email: userCreds.UserCredentials.Email, + MFAToken: mfaVerifyCode, + }) + if requestError != nil { + util.HandleError(err) + break + } else if mfaErrorResponse != nil { + if mfaErrorResponse.Context.Code == "mfa_invalid" { + msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) + fmt.Println(msg) + if i == 5 { + util.PrintErrorMessageAndExit("No tries left, please try again in a bit") + break + } + } + + if mfaErrorResponse.Context.Code == "mfa_expired" { + util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") + break + } + i++ + } else { + httpClient.SetAuthToken(verifyMFAresponse.Token) + tokenResponse, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + break + } + } + } if err != nil { util.HandleError(err, "Unable to select organization") diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 6cba897f3..a7d3661b3 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -479,7 +479,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) { util.PrintErrorMessageAndExit("We were unable to fetch required details to complete your login. Run with -d to see more info") } // Login is successful so ask user to choose organization - newJwtToken := GetJwtTokenWithOrganizationId(loginTwoResponse.Token) + newJwtToken := GetJwtTokenWithOrganizationId(loginTwoResponse.Token, email) //updating usercredentials userCredentialsToBeStored.Email = email @@ -718,7 +718,7 @@ func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2R return &loginOneResponseResult, &loginTwoResponseResult, nil } -func GetJwtTokenWithOrganizationId(oldJwtToken string) string { +func GetJwtTokenWithOrganizationId(oldJwtToken string, email string) string { log.Debug().Msg(fmt.Sprint("GetJwtTokenWithOrganizationId: ", "oldJwtToken", oldJwtToken)) httpClient := resty.New() @@ -747,11 +747,51 @@ func GetJwtTokenWithOrganizationId(oldJwtToken string) string { selectedOrganization := organizations[index] selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) - if err != nil { util.HandleError(err) } + if selectedOrgRes.MfaEnabled { + i := 1 + for i < 6 { + mfaVerifyCode := askForMFACode() + + httpClient := resty.New() + httpClient.SetAuthToken(selectedOrgRes.Token) + verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ + Email: email, + MFAToken: mfaVerifyCode, + }) + if requestError != nil { + util.HandleError(err) + break + } else if mfaErrorResponse != nil { + if mfaErrorResponse.Context.Code == "mfa_invalid" { + msg := fmt.Sprintf("Incorrect, verification code. You have %v attempts left", 5-i) + fmt.Println(msg) + if i == 5 { + util.PrintErrorMessageAndExit("No tries left, please try again in a bit") + break + } + } + + if mfaErrorResponse.Context.Code == "mfa_expired" { + util.PrintErrorMessageAndExit("Your 2FA verification code has expired, please try logging in again") + break + } + i++ + } else { + httpClient.SetAuthToken(verifyMFAresponse.Token) + selectedOrgRes, err = api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + break + } + } + } + + if err != nil { + util.HandleError(err, "Unable to select organization") + } + return selectedOrgRes.Token } diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 28d5f3941..dce930418 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -65,7 +65,7 @@ export const selectOrganization = async (data: { organizationId: string; userAgent?: UserAgentType; }) => { - const { data: res } = await apiRequest.post<{ token: string }>( + const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean }>( "/api/v3/auth/select-organization", data ); @@ -79,7 +79,7 @@ export const useSelectOrganization = () => { const data = await selectOrganization(details); // If a custom user agent is set, then this session is meant for another consuming application, not the web application. - if (!details.userAgent) { + if (!details.userAgent && !data.isMfaEnabled) { SecurityClient.setToken(data.token); SecurityClient.setProviderAuthToken(""); } diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index b52fbeb4f..ad44d280a 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -84,13 +84,22 @@ export const useCreateOrg = (options: { invalidate: boolean } = { invalidate: tr export const useUpdateOrg = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, UpdateOrgDTO>({ - mutationFn: ({ name, authEnforced, scimEnabled, slug, orgId, defaultMembershipRoleSlug }) => { + mutationFn: ({ + name, + authEnforced, + scimEnabled, + slug, + orgId, + defaultMembershipRoleSlug, + enforceMfa + }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, scimEnabled, slug, - defaultMembershipRoleSlug + defaultMembershipRoleSlug, + enforceMfa }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 3d8d5474b..b644cb325 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -11,6 +11,7 @@ export type Organization = { scimEnabled: boolean; slug: string; defaultMembershipRole: string; + enforceMfa: boolean; }; export type UpdateOrgDTO = { @@ -20,6 +21,7 @@ export type UpdateOrgDTO = { scimEnabled?: boolean; slug?: string; defaultMembershipRoleSlug?: string; + enforceMfa?: boolean; }; export type BillingDetails = { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 6ee571d93..3506295fa 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -42,4 +42,5 @@ export type SubscriptionPlan = { instanceUserManagement: boolean; externalKms: boolean; pkiEst: boolean; + enforceMfa: boolean; }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 312e6af17..6a91bce67 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,7 +5,7 @@ /* eslint-disable no-var */ /* eslint-disable func-names */ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; @@ -35,6 +35,7 @@ import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage"; +import SecurityClient from "@app/components/utilities/SecurityClient"; import { Accordion, AccordionContent, @@ -64,7 +65,7 @@ import { useUser, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; +import { usePopUp, useToggle } from "@app/hooks"; import { fetchOrgUsers, useAddUserToWsNonE2EE, @@ -82,6 +83,7 @@ import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { AuthMethod } from "@app/hooks/api/users/types"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; +import { Mfa } from "@app/views/Login/Mfa"; import { CreateOrgModal } from "@app/views/Org/components"; import { WishForm } from "./components/WishForm/WishForm"; @@ -136,6 +138,8 @@ export const AppLayout = ({ children }: LayoutProps) => { const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const workspacesWithFaveProp = useMemo( () => @@ -206,10 +210,17 @@ export const AppLayout = ({ children }: LayoutProps) => { }; const changeOrg = async (orgId: string) => { - await selectOrganization({ + const { token, isMfaEnabled } = await selectOrganization({ organizationId: orgId }); + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + toggleShowMfa.on(); + setMfaSuccessCallback(() => () => changeOrg(orgId)); + return; + } + await navigateUserToOrg(router, orgId); }; @@ -335,6 +346,18 @@ export const AppLayout = ({ children }: LayoutProps) => { } }; + if (shouldShowMfa) { + return ( +
+ toggleShowMfa.off()} + /> +
+ ); + } + return ( <>
diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 5b9c0f72c..cc19cc572 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Head from "next/head"; import Image from "next/image"; @@ -12,15 +12,22 @@ import jwt_decode from "jwt-decode"; import { createNotification } from "@app/components/notifications"; import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; +import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Spinner } from "@app/components/v2"; import { SessionStorageKeys } from "@app/const"; -import { useUser } from "@app/context"; -import { useGetOrganizations, useLogoutUser, useSelectOrganization } from "@app/hooks/api"; +import { useToggle } from "@app/hooks"; +import { + useGetOrganizations, + useGetUser, + useLogoutUser, + useSelectOrganization +} from "@app/hooks/api"; import { 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"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; +import { Mfa } from "@app/views/Login/Mfa"; const LoadingScreen = () => { return ( @@ -37,10 +44,16 @@ export default function LoginPage() { const organizations = useGetOrganizations(); const selectOrg = useSelectOrganization(); - const { user, isLoading: userLoading } = useUser(); + const { data: user, isLoading: userLoading } = useGetUser(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); + + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const queryParams = new URLSearchParams(window.location.search); + const orgId = queryParams.get("org_id"); const callbackPort = queryParams.get("callback_port"); + const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); const logout = useLogoutUser(true); const handleLogout = useCallback(async () => { @@ -77,18 +90,26 @@ export default function LoginPage() { return; } - const { token } = await selectOrg.mutateAsync({ + const { token, isMfaEnabled } = await selectOrg.mutateAsync({ organizationId: organization.id, userAgent: callbackPort ? UserAgentType.CLI : undefined }); + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + toggleShowMfa.on(); + + setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); + return; + } + if (callbackPort) { const privateKey = localStorage.getItem("PRIVATE_KEY"); let error: string | null = null; if (!privateKey) error = "Private key not found"; - if (!user.email) error = "User email not found"; + if (!user?.email) error = "User email not found"; if (!token) error = "No token found"; if (error) { @@ -101,7 +122,7 @@ export default function LoginPage() { const payload = { JTWToken: token, - email: user.email, + email: user?.email, privateKey } as IsCliLoginSuccessful["loginResponse"]; @@ -149,23 +170,36 @@ export default function LoginPage() { } }, [router]); - // Case: User has no organizations. - // This can happen if the user was previously a member, but the organization was deleted or the user was removed. useEffect(() => { if (organizations.isLoading || !organizations.data) return; + // Case: User has no organizations. + // This can happen if the user was previously a member, but the organization was deleted or the user was removed. if (organizations.data.length === 0) { router.push("/org/none"); } else if (organizations.data.length === 1) { if (callbackPort) { handleCliRedirect(); + setIsInitialOrgCheckLoading(false); } else { handleSelectOrganization(organizations.data[0]); } + } else { + setIsInitialOrgCheckLoading(false); } }, [organizations.isLoading, organizations.data]); - if (userLoading || !user) { + useEffect(() => { + if (defaultSelectedOrg) { + handleSelectOrganization(defaultSelectedOrg); + } + }, [defaultSelectedOrg]); + + if ( + userLoading || + !user || + ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) + ) { return ; } @@ -178,56 +212,57 @@ export default function LoginPage() { -
- -
- Infisical logo -
- -
console.log("submit")} - className="mx-auto flex w-full flex-col items-center justify-center" - > -
-

- Choose your organization -

- -
-

- You‘re currently logged in as {user.username} -

-

- Not you?{" "} - -

+ {shouldShowMfa ? ( + + ) : ( +
+ +
+ Infisical logo
-
-
- {organizations.isLoading ? ( - - ) : ( - organizations.data?.map((org) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
handleSelectOrganization(org)} - key={org.id} - className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" - > -

{org.name}

+ + +
+

+ Choose your organization +

- -
- )) - )} -
- -
+
+

+ You‘re currently logged in as {user.username} +

+

+ Not you?{" "} + +

+
+
+
+ {organizations.isLoading ? ( + + ) : ( + organizations.data?.map((org) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
handleSelectOrganization(org)} + key={org.id} + className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" + > +

{org.name}

+ + +
+ )) + )} +
+ +
+ )}
diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index c05a2ef18..7ab132a99 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -23,6 +23,7 @@ import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKe import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { useServerConfig } from "@app/context"; +import { useToggle } from "@app/hooks"; import { completeAccountSignupInvite, useSelectOrganization, @@ -57,6 +58,8 @@ export default function SignupInvite() { const [backupKeyIssued, setBackupKeyIssued] = useState(false); const [errors, setErrors] = useState({}); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const router = useRouter(); const parsedUrl = queryString.parse(router.asPath.split("?")[1]); const token = parsedUrl.token as string; @@ -180,11 +183,24 @@ export default function SignupInvite() { if (!orgId) throw new Error("You are not part of any organization"); - await selectOrganization({ organizationId: orgId }); + const completeSignupFlow = async () => { + const { token: mfaToken, isMfaEnabled } = await selectOrganization({ + organizationId: orgId + }); - localStorage.setItem("orgData.id", orgId); + if (isMfaEnabled) { + SecurityClient.setMfaToken(mfaToken); + toggleShowMfa.on(); + setMfaSuccessCallback(() => completeSignupFlow); + return; + } - setStep(3); + localStorage.setItem("orgData.id", orgId); + + setStep(3); + }; + + await completeSignupFlow(); } catch (error) { setIsLoading(false); console.error(error); @@ -222,11 +238,24 @@ export default function SignupInvite() { SecurityClient.setSignupToken(response.token); setStep(2); } else { - await selectOrganization({ organizationId }); + const redirectExistingUser = async () => { + const { token: mfaToken, isMfaEnabled } = await selectOrganization({ + organizationId + }); - // user will be redirected to dashboard - // if not logged in gets kicked out to login - await navigateUserToOrg(router, organizationId); + if (isMfaEnabled) { + SecurityClient.setMfaToken(mfaToken); + toggleShowMfa.on(); + setMfaSuccessCallback(() => redirectExistingUser); + return; + } + + // user will be redirected to dashboard + // if not logged in gets kicked out to login + await navigateUserToOrg(router, organizationId); + }; + + await redirectExistingUser(); } } } catch (err) { diff --git a/frontend/src/views/Login/Login.tsx b/frontend/src/views/Login/Login.tsx index 36cd355f5..05b81b4f2 100644 --- a/frontend/src/views/Login/Login.tsx +++ b/frontend/src/views/Login/Login.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { isLoggedIn } from "@app/reactQuery"; -import { InitialStep, MFAStep, SSOStep } from "./components"; +import { InitialStep, SSOStep } from "./components"; import { useNavigateToSelectOrganization } from "./Login.utils"; export const Login = () => { @@ -46,15 +46,6 @@ export const Login = () => { setPassword={setPassword} /> ); - case 1: - return ( - - ); case 2: return ; case 3: diff --git a/frontend/src/views/Login/Login.utils.tsx b/frontend/src/views/Login/Login.utils.tsx index 9aa726d6b..b0cd3d031 100644 --- a/frontend/src/views/Login/Login.utils.tsx +++ b/frontend/src/views/Login/Login.utils.tsx @@ -1,7 +1,6 @@ import { NextRouter, useRouter } from "next/router"; import { useServerConfig } from "@app/context"; -import { useSelectOrganization } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { userKeys } from "@app/hooks/api/users"; import { queryClient } from "@app/reactQuery"; @@ -31,23 +30,18 @@ export const navigateUserToOrg = async (router: NextRouter, organizationId?: str export const useNavigateToSelectOrganization = () => { const { config } = useServerConfig(); - const selectOrganization = useSelectOrganization(); const router = useRouter(); const navigate = async (cliCallbackPort?: string) => { + let redirectTo = "/login/select-organization?"; if (config.defaultAuthOrgId) { - await selectOrganization.mutateAsync({ - organizationId: config.defaultAuthOrgId - }); - - await navigateUserToOrg(router, config.defaultAuthOrgId); + redirectTo += `org_id=${config.defaultAuthOrgId}&`; + } else { + queryClient.invalidateQueries(userKeys.getUser); } - queryClient.invalidateQueries(userKeys.getUser); - let redirectTo = "/login/select-organization"; - if (cliCallbackPort) { - redirectTo += `?callback_port=${cliCallbackPort}`; + redirectTo += `callback_port=${cliCallbackPort}`; } router.push(redirectTo, undefined, { shallow: true }); diff --git a/frontend/src/views/Login/LoginSSO.tsx b/frontend/src/views/Login/LoginSSO.tsx index a182a2713..7741602d8 100644 --- a/frontend/src/views/Login/LoginSSO.tsx +++ b/frontend/src/views/Login/LoginSSO.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import jwt_decode from "jwt-decode"; -import { MFAStep, PasswordStep } from "./components"; +import { PasswordStep } from "./components"; type Props = { providerAuthToken: string; @@ -30,13 +30,8 @@ export const LoginSSO = ({ providerAuthToken }: Props) => { email={username} password={password} setPassword={setPassword} - setStep={setStep} /> ); - case 2: - return ( - - ); default: return
; } diff --git a/frontend/src/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx new file mode 100644 index 000000000..4b43d2863 --- /dev/null +++ b/frontend/src/views/Login/Mfa.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import ReactCodeInput from "react-code-input"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { t } from "i18next"; + +import Error from "@app/components/basic/Error"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { Button } from "@app/components/v2"; +import { useSendMfaToken } from "@app/hooks/api"; +import { verifyMfaToken } from "@app/hooks/api/auth/queries"; + +// The style for the verification code input +const codeInputProps = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "48px", + borderRadius: "5px", + fontSize: "24px", + height: "48px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +type Props = { + successCallback: () => void | Promise; + closeMfa?: () => void; + hideLogo?: boolean; + email: string; +}; + +export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => { + const [mfaCode, setMfaCode] = useState(""); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingResend, setIsLoadingResend] = useState(false); + const [triesLeft, setTriesLeft] = useState(undefined); + + const sendMfaToken = useSendMfaToken(); + + const verifyMfa = async () => { + setIsLoading(true); + try { + const { token } = await verifyMfaToken({ + email, + mfaCode + }); + + SecurityClient.setMfaToken(""); + SecurityClient.setToken(token); + + await successCallback(); + if (closeMfa) { + closeMfa(); + } + } catch (error) { + if (triesLeft) { + setTriesLeft((left) => { + if (triesLeft === 1) { + router.push("/"); + + SecurityClient.setMfaToken(""); + SecurityClient.setToken(""); + } + return (left as number) - 1; + }); + } else { + setTriesLeft(2); + } + } finally { + setIsLoading(false); + } + }; + + const handleResendMfaCode = async () => { + try { + setIsLoadingResend(true); + await sendMfaToken.mutateAsync({ email }); + setIsLoadingResend(false); + } catch (err) { + console.error(err); + setIsLoadingResend(false); + } + }; + + return ( +
+ {!hideLogo && ( + +
+ Infisical logo +
+ + )} +

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

+

{email}

+
+ +
+ {typeof triesLeft === "number" && ( + + )} +
+
+ +
+
+
+
+ {t("signup.step2-resend-alert")} +
+ +
+
+

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

+
+
+ ); +}; diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index 1c4271e09..e2f8f99b9 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -85,13 +85,6 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); if (isCliLoginSuccessful && isCliLoginSuccessful.success) { - if (isCliLoginSuccessful.mfaEnabled) { - // case: login requires MFA step - setStep(1); - setIsLoading(false); - return; - } - navigateToSelectOrganization(callbackPort!); } else { setLoginError(true); @@ -109,17 +102,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: if (isLoginSuccessful && isLoginSuccessful.success) { // case: login was successful - - if (isLoginSuccessful.mfaEnabled) { - // case: login requires MFA step - setStep(1); - setIsLoading(false); - return; - } - navigateToSelectOrganization(); - - // case: login does not require MFA step createNotification({ text: "Successfully logged in", type: "success" diff --git a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx b/frontend/src/views/Login/components/MFAStep/MFAStep.tsx deleted file mode 100644 index a6235e9d5..000000000 --- a/frontend/src/views/Login/components/MFAStep/MFAStep.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import React, { useState } from "react"; -import ReactCodeInput from "react-code-input"; -import { useTranslation } from "react-i18next"; -import { useRouter } from "next/router"; -import axios from "axios"; -import { addSeconds, formatISO } from "date-fns"; -import jwt_decode from "jwt-decode"; - -import Error from "@app/components/basic/Error"; -import { createNotification } from "@app/components/notifications"; -import attemptCliLoginMfa from "@app/components/utilities/attemptCliLoginMfa"; -import attemptLoginMfa from "@app/components/utilities/attemptLoginMfa"; -import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button } from "@app/components/v2"; -import { SessionStorageKeys } from "@app/const"; -import { useSendMfaToken } from "@app/hooks/api/auth"; -import { useSelectOrganization, verifyMfaToken } from "@app/hooks/api/auth/queries"; -import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; - -import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; - -// The style for the verification code input -const props = { - inputStyle: { - fontFamily: "monospace", - margin: "4px", - MozAppearance: "textfield", - width: "48px", - borderRadius: "5px", - fontSize: "24px", - height: "48px", - paddingLeft: "7", - backgroundColor: "#0d1117", - color: "white", - border: "1px solid #2d2f33", - textAlign: "center", - outlineColor: "#8ca542", - borderColor: "#2d2f33" - } -} as const; - -type Props = { - email: string; - password: string; - providerAuthToken?: string; - callbackPort?: string | null; -}; - -export const MFAStep = ({ email, password, providerAuthToken }: Props) => { - const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingResend, setIsLoadingResend] = useState(false); - const [mfaCode, setMfaCode] = useState(""); - const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); - const [triesLeft, setTriesLeft] = useState(undefined); - - const { t } = useTranslation(); - - const sendMfaToken = useSendMfaToken(); - const { mutateAsync: selectOrganization } = useSelectOrganization(); - - // They don't have password - const handleLoginMfaOauth = async (callbackPort: string, organizationId?: string) => { - setIsLoading(true); - const { token } = await verifyMfaToken({ - email, - mfaCode - }); - // - // unset temporary (MFA) JWT token and set JWT token - SecurityClient.setMfaToken(""); - SecurityClient.setToken(token); - SecurityClient.setProviderAuthToken(""); - const privateKey = await fetchMyPrivateKey(); - localStorage.setItem("PRIVATE_KEY", privateKey); - - // 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 { token: newJwtToken } = await selectOrganization({ organizationId }); - if (callbackPort) { - const cliUrl = `http://127.0.0.1:${callbackPort}/`; - const instance = axios.create(); - const payload = { - email, - privateKey, - JTWToken: newJwtToken - }; - await instance.post(cliUrl, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - router.push("/cli-redirect"); - return; - } - await navigateUserToOrg(router, organizationId); - } - // case: no organization ID is present -- navigate to the select org page IF the user has any orgs - // if the user has no orgs, navigate to the create org page - else { - const userOrgs = await fetchOrganizations(); - - // case: user has orgs, so we navigate the user to select an org - if (userOrgs.length > 0) { - navigateToSelectOrganization(callbackPort); - } - // case: no orgs found, so we navigate the user to create an org - // cli login will fail in this case - else { - await navigateUserToOrg(router); - } - } - }; - - const handleLoginMfa = async () => { - try { - let callbackPort: undefined | string; - let organizationId: undefined | string; - let hasExchangedPrivateKey: undefined | boolean; - - const queryParams = new URLSearchParams(window.location.search); - - callbackPort = queryParams.get("callback_port") || undefined; - - if (providerAuthToken) { - const decodedToken = jwt_decode(providerAuthToken) as any; - - callbackPort = decodedToken.callbackPort; - organizationId = decodedToken?.organizationId; - hasExchangedPrivateKey = decodedToken?.hasExchangedPrivateKey; - } - - if (mfaCode.length !== 6) { - createNotification({ - text: "Please enter a 6-digit MFA code and try again", - type: "error" - }); - return; - } - - if (hasExchangedPrivateKey) { - await handleLoginMfaOauth(callbackPort as string, organizationId); - return; - } - - setIsLoading(true); - if (callbackPort) { - // attemptCliLogin - const isCliLoginSuccessful = await attemptCliLoginMfa({ - email, - password, - providerAuthToken, - mfaToken: mfaCode - }); - - if (isCliLoginSuccessful && isCliLoginSuccessful.success) { - const cliUrl = `http://127.0.0.1:${callbackPort}/`; - - // 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 { token: newJwtToken } = await selectOrganization({ organizationId }); - - const instance = axios.create(); - const payload = { - ...isCliLoginSuccessful.loginResponse, - JTWToken: newJwtToken - }; - await instance.post(cliUrl, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - router.push("/cli-redirect"); - return; - } - // case: no organization ID is present -- navigate to the select org page IF the user has any orgs - // if the user has no orgs, navigate to the create org page - - const userOrgs = await fetchOrganizations(); - - // case: user has orgs, so we navigate the user to select an org - if (userOrgs.length > 0) { - navigateToSelectOrganization(callbackPort); - } - // case: no orgs found, so we navigate the user to create an org - // cli login will fail in this case - else { - await navigateUserToOrg(router); - } - - } - } else { - const isLoginSuccessful = await attemptLoginMfa({ - email, - password, - providerAuthToken, - mfaToken: mfaCode - }); - - if (isLoginSuccessful) { - setIsLoading(false); - - // case: login does not require MFA step - createNotification({ - text: "Successfully logged in", - type: "success" - }); - - if (organizationId) { - await navigateUserToOrg(router, organizationId); - } else { - navigateToSelectOrganization(); - } - } else { - createNotification({ - text: "Failed to log in", - type: "error" - }); - } - } - } catch (err: any) { - if (err.response.data.error === "User Locked") { - createNotification({ - title: err.response.data.error, - text: err.response.data.message, - type: "error" - }); - setIsLoading(false); - return; - } - - createNotification({ - text: "Failed to log in", - type: "error" - }); - - if (triesLeft) { - setTriesLeft((left) => { - if (triesLeft === 1) { - router.push("/"); - } - return (left as number) - 1; - }); - } else { - setTriesLeft(2); - } - - setIsLoading(false); - } - }; - - const handleResendMfaCode = async () => { - try { - setIsLoadingResend(true); - await sendMfaToken.mutateAsync({ email }); - setIsLoadingResend(false); - } catch (err) { - console.error(err); - setIsLoadingResend(false); - } - }; - - return ( -
-

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

-

{email}

-
- -
-
- -
- {typeof triesLeft === "number" && ( - - )} -
-
- -
-
-
-
- {t("signup.step2-resend-alert")} -
- -
-
-

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

-
- - ); -}; diff --git a/frontend/src/views/Login/components/MFAStep/index.tsx b/frontend/src/views/Login/components/MFAStep/index.tsx deleted file mode 100644 index 4062f25d1..000000000 --- a/frontend/src/views/Login/components/MFAStep/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MFAStep } from "./MFAStep"; diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index 16a06f9b7..827603067 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -14,32 +14,29 @@ import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config"; import SecurityClient from "@app/components/utilities/SecurityClient"; 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 { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils"; +import { Mfa } from "../../Mfa"; type Props = { providerAuthToken: string; email: string; password: string; setPassword: (password: string) => void; - setStep: (step: number) => void; }; -export const PasswordStep = ({ - providerAuthToken, - email, - password, - setPassword, - setStep -}: Props) => { +export const PasswordStep = ({ providerAuthToken, email, password, setPassword }: Props) => { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const router = useRouter(); const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); @@ -56,17 +53,8 @@ export const PasswordStep = ({ }); // attemptCliLogin - if (oauthLogin.mfaEnabled) { - SecurityClient.setMfaToken(oauthLogin.token); - // case: login requires MFA step - setStep(2); - setIsLoading(false); - return; - } const cliUrl = `http://127.0.0.1:${callbackPort}/`; - // case: MFA is not enabled - // unset provider auth token in case it was used SecurityClient.setProviderAuthToken(""); // set JWT token @@ -77,31 +65,43 @@ export const PasswordStep = ({ // 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 { token: newJwtToken } = await selectOrganization({ organizationId }); - if (callbackPort) { - console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken); - const instance = axios.create(); - const payload = { - privateKey, - email, - JTWToken: newJwtToken - }; - await instance.post(cliUrl, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - router.push("/cli-redirect"); - return; - } + const finishWithOrgWorkflow = async () => { + const { token, isMfaEnabled } = await selectOrganization({ organizationId }); - await navigateUserToOrg(router, organizationId); + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + toggleShowMfa.on(); + setMfaSuccessCallback(() => finishWithOrgWorkflow); + return; + } + + if (callbackPort) { + console.log("organization id was present. new JWT token to be used in CLI:", token); + const instance = axios.create(); + const payload = { + privateKey, + email, + JTWToken: token + }; + await instance.post(cliUrl, payload).catch(() => { + // if error happens to communicate we set the token with an expiry in sessino storage + // the cli-redirect page has logic to show this to user and ask them to paste it in terminal + sessionStorage.setItem( + SessionStorageKeys.CLI_TERMINAL_TOKEN, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 30)), + data: window.btoa(JSON.stringify(payload)) + }) + ); + }); + router.push("/cli-redirect"); + return; + } + + await navigateUserToOrg(router, organizationId); + }; + + await finishWithOrgWorkflow(); } // case: no organization ID is present -- navigate to the select org page IF the user has any orgs // if the user has no orgs, navigate to the create org page @@ -162,42 +162,45 @@ export const PasswordStep = ({ }); if (isCliLoginSuccessful && isCliLoginSuccessful.success) { - if (isCliLoginSuccessful.mfaEnabled) { - // case: login requires MFA step - setStep(2); - setIsLoading(false); - return; - } const cliUrl = `http://127.0.0.1:${callbackPort}/`; // 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 { token: newJwtToken } = await selectOrganization({ organizationId }); + const finishWithOrgWorkflow = async () => { + const { token, isMfaEnabled } = await selectOrganization({ organizationId }); - console.log( - "organization id was present. new JWT token to be used in CLI:", - newJwtToken - ); + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + toggleShowMfa.on(); + setMfaSuccessCallback(() => finishWithOrgWorkflow); + return; + } - const instance = axios.create(); - const payload = { - ...isCliLoginSuccessful.loginResponse, - JTWToken: newJwtToken + console.log("organization id was present. new JWT token to be used in CLI:", token); + + const instance = axios.create(); + const payload = { + ...isCliLoginSuccessful.loginResponse, + JTWToken: token + }; + await instance.post(cliUrl, payload).catch(() => { + // if error happens to communicate we set the token with an expiry in sessino storage + // the cli-redirect page has logic to show this to user and ask them to paste it in terminal + sessionStorage.setItem( + SessionStorageKeys.CLI_TERMINAL_TOKEN, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 30)), + data: window.btoa(JSON.stringify(payload)) + }) + ); + }); + router.push("/cli-redirect"); }; - await instance.post(cliUrl, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - router.push("/cli-redirect"); + + await finishWithOrgWorkflow(); return; } + // case: no organization ID is present -- navigate to the select org page IF the user has any orgs // if the user has no orgs, navigate to the create org page const userOrgs = await fetchOrganizations(); @@ -221,16 +224,6 @@ export const PasswordStep = ({ if (loginAttempt && loginAttempt.success) { // case: login was successful - - if (loginAttempt.mfaEnabled) { - // TODO: deal with MFA - // case: login requires MFA step - setIsLoading(false); - setStep(2); - return; - } - - // case: login does not require MFA step setIsLoading(false); createNotification({ text: "Successfully logged in", @@ -284,6 +277,18 @@ export const PasswordStep = ({ setCaptchaToken(""); }; + if (shouldShowMfa) { + return ( +
+ toggleShowMfa.off()} + /> +
+ ); + } + if (hasExchangedPrivateKey) { return (
diff --git a/frontend/src/views/Login/components/index.tsx b/frontend/src/views/Login/components/index.tsx index 296b0503e..84ad4f73d 100644 --- a/frontend/src/views/Login/components/index.tsx +++ b/frontend/src/views/Login/components/index.tsx @@ -1,5 +1,4 @@ export { InitialStep } from "./InitialStep"; -export { MFAStep } from "./MFAStep"; export { SSOStep } from "./SSOStep"; // SSO-specific step diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index f4c4c0c5d..c0ff17ae6 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -16,6 +16,7 @@ import { LoginMethod } from "@app/hooks/api/admin/types"; import { LDAPModal } from "./LDAPModal"; import { OIDCModal } from "./OIDCModal"; import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection"; +import { OrgGenericAuthSection } from "./OrgGenericAuthSection"; import { OrgLDAPSection } from "./OrgLDAPSection"; import { OrgOIDCSection } from "./OrgOIDCSection"; import { OrgScimSection } from "./OrgSCIMSection"; @@ -161,6 +162,7 @@ export const OrgAuthTab = withPermission( return ( <> + {shouldShowCreateIdentityProviderView ? ( createIdentityProviderView ) : ( diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx new file mode 100644 index 000000000..3a6663dde --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx @@ -0,0 +1,73 @@ +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Switch, UpgradePlanModal } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +export const OrgGenericAuthSection = () => { + const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + const { mutateAsync } = useUpdateOrg(); + + const handleEnforceMfaToggle = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + if (!subscription?.enforceMfa) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + enforceMfa: value + }); + + createNotification({ + text: `Successfully ${value ? "enforced" : "un-enforced"} MFA`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: (err as { response: { data: { message: string } } }).response.data.message, + type: "error" + }); + } + }; + + return ( +
+
+
+

Enforce Multi-factor Authentication

+ + {(isAllowed) => ( + handleEnforceMfaToggle(value)} + isChecked={currentOrg?.enforceMfa ?? false} + isDisabled={!isAllowed} + /> + )} + +
+

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

+
+ handlePopUpToggle("upgradePlan", isOpen)} + text="You can enforce user MFA if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index f502d3c6b..5f63c4952 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -11,9 +11,11 @@ import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; 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 { fetchOrganizations } from "@app/hooks/api/organization/queries"; import ProjectService from "@app/services/ProjectService"; +import { Mfa } from "@app/views/Login/Mfa"; // eslint-disable-next-line new-cap const client = new jsrp.client(); @@ -54,9 +56,11 @@ export const UserInfoSSOStep = ({ const [organizationName, setOrganizationName] = useState(""); const [organizationNameError, setOrganizationNameError] = useState(false); const [attributionSource, setAttributionSource] = useState(""); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const { mutateAsync: selectOrganization } = useSelectOrganization(); + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); useEffect(() => { const randomPassword = crypto.randomBytes(32).toString("hex"); @@ -172,22 +176,37 @@ export const UserInfoSSOStep = ({ const userOrgs = await fetchOrganizations(); const orgId = userOrgs[0]?.id; - await selectOrganization({ - organizationId: orgId - }); + const completeSignupFlow = async () => { + try { + const { isMfaEnabled, token } = await selectOrganization({ + organizationId: orgId + }); - // only create example project if not joining existing org - if (!providerOrganizationName) { - const project = await ProjectService.initProject({ - projectName: "Example Project" - }); + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + toggleShowMfa.on(); + setMfaSuccessCallback(() => completeSignupFlow); + return; + } - localStorage.setItem("projectData.id", project.id); - } + // only create example project if not joining existing org + if (!providerOrganizationName) { + const project = await ProjectService.initProject({ + projectName: "Example Project" + }); - localStorage.setItem("orgData.id", orgId); + localStorage.setItem("projectData.id", project.id); + } - setStep(2); + localStorage.setItem("orgData.id", orgId); + setStep(2); + } catch (error) { + setIsLoading(false); + console.error(error); + } + }; + + await completeSignupFlow(); } catch (error) { setIsLoading(false); console.error(error); @@ -206,6 +225,17 @@ export const UserInfoSSOStep = ({ } }, [providerOrganizationName, password]); + if (shouldShowMfa) { + return ( + toggleShowMfa.off()} + /> + ); + } + return (