From 8da2213bf1e351d0523532dc553e71e60f446009 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 16 Oct 2024 22:44:04 +0800 Subject: [PATCH 1/9] misc: removed mfa from existing login --- backend/src/server/routes/v1/sso-router.ts | 4 - backend/src/server/routes/v3/login-router.ts | 43 +++++----- .../src/services/auth/auth-login-service.ts | 79 +++++++------------ frontend/src/hooks/api/auth/queries.tsx | 2 +- 4 files changed, 52 insertions(+), 76 deletions(-) diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 18c8595af..e79b18e6d 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: "/", diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index b5f523a54..ca9bf0108 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,18 @@ 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({ + 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() + }) } }, handler: async (req, res) => { @@ -118,10 +123,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: "/", diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index a80f0bc85..a6c3a5bf6 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -298,30 +298,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 +309,7 @@ export const authLoginServiceFactory = ({ organizationId }); - return { token, isMfaEnabled: false, user: userEnc } as const; + return { token, user: userEnc } as const; }; const selectOrganization = async ({ @@ -373,6 +349,30 @@ export const authLoginServiceFactory = ({ }); } + // send multi factor auth token if they it enabled + if (user.isMfaEnabled && user.email) { + 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, @@ -381,7 +381,10 @@ export const authLoginServiceFactory = ({ organizationId }); - return tokens; + return { + ...tokens, + isMfaEnabled: false + }; }; /* @@ -629,7 +632,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 +646,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/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 28d5f3941..91c2e7be1 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 ); From 9192c5caa2867f04e353362b238ddd12d9770de5 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 17 Oct 2024 02:01:22 +0800 Subject: [PATCH 2/9] feat: created reusable mfa flow --- .../server/plugins/auth/inject-identity.ts | 4 +- backend/src/server/routes/v1/auth-router.ts | 3 +- .../src/services/auth/auth-login-service.ts | 18 +- backend/src/services/auth/auth-type.ts | 2 + .../src/pages/login/select-organization.tsx | 115 +++++++------ frontend/src/views/Login/Mfa.tsx | 156 ++++++++++++++++++ 6 files changed, 240 insertions(+), 58 deletions(-) create mode 100644 frontend/src/views/Login/Mfa.tsx 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/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index a6c3a5bf6..39f3162d1 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 } @@ -350,7 +354,7 @@ export const authLoginServiceFactory = ({ } // send multi factor auth token if they it enabled - if (user.isMfaEnabled && user.email) { + if (user.isMfaEnabled && user.email && !decodedToken.isMfaVerified) { enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); const mfaToken = jwt.sign( @@ -378,7 +382,8 @@ export const authLoginServiceFactory = ({ user, userAgent, ip: ipAddress, - organizationId + organizationId, + isMfaVerified: decodedToken.isMfaVerified }); return { @@ -507,7 +512,8 @@ export const authLoginServiceFactory = ({ ip, userAgent, organizationId: orgId, - authMethod: decodedToken.authMethod + authMethod: decodedToken.authMethod, + isMfaVerified: true }); return { token, user: userEnc }; 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/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 5b9c0f72c..319a6d6df 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,18 @@ 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 { useToggle } from "@app/hooks"; import { useGetOrganizations, 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,7 +40,10 @@ export default function LoginPage() { const organizations = useGetOrganizations(); const selectOrg = useSelectOrganization(); + const { user, isLoading: userLoading } = useUser(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const queryParams = new URLSearchParams(window.location.search); const callbackPort = queryParams.get("callback_port"); @@ -77,11 +83,19 @@ 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"); @@ -178,56 +192,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 ? ( + toggleShowMfa.off()} /> + ) : ( +
+ +
+ 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/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx new file mode 100644 index 000000000..677f0d22a --- /dev/null +++ b/frontend/src/views/Login/Mfa.tsx @@ -0,0 +1,156 @@ +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 { useUser } from "@app/context"; +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; + closeMfa: () => void; +}; + +export const Mfa = ({ successCallback, closeMfa }: Props) => { + const [mfaCode, setMfaCode] = useState(""); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingResend, setIsLoadingResend] = useState(false); + const [triesLeft, setTriesLeft] = useState(undefined); + const { user } = useUser(); + + const sendMfaToken = useSendMfaToken(); + + const verifyMfa = async () => { + if (!user.email) { + return; + } + + setIsLoading(true); + try { + const { token } = await verifyMfaToken({ + email: user.email, + mfaCode + }); + + SecurityClient.setMfaToken(""); + SecurityClient.setToken(token); + + successCallback(); + 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 () => { + if (!user?.email) { + return; + } + + try { + setIsLoadingResend(true); + await sendMfaToken.mutateAsync({ email: user.email }); + setIsLoadingResend(false); + } catch (err) { + console.error(err); + setIsLoadingResend(false); + } + }; + + return ( +
+ +
+ Infisical logo +
+ +

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

+

{user.email}

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

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

+
+
+ ); +}; From bd1ed2614e8b06aa5b13d92f40087e51687e2866 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 17 Oct 2024 03:02:26 +0800 Subject: [PATCH 3/9] feat: added enforceMfa toggle for orgs --- .../20241016183616_add-org-enforce-mfa.ts | 19 +++++ backend/src/db/schemas/organizations.ts | 3 +- .../src/ee/services/license/license-fns.ts | 3 +- .../src/ee/services/license/license-types.ts | 1 + .../server/routes/v1/organization-router.ts | 3 +- backend/src/services/org/org-service.ts | 5 +- backend/src/services/org/org-types.ts | 1 + .../src/hooks/api/organization/queries.tsx | 13 +++- frontend/src/hooks/api/organization/types.ts | 2 + frontend/src/hooks/api/subscriptions/types.ts | 1 + .../components/OrgAuthTab/OrgAuthTab.tsx | 2 + .../OrgAuthTab/OrgGenericAuthSection.tsx | 73 +++++++++++++++++++ 12 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 backend/src/db/migrations/20241016183616_add-org-enforce-mfa.ts create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx 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/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 00f039723..f6d5cacef 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -226,7 +226,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/services/org/org-service.ts b/backend/src/services/org/org-service.ts index b4b8775f0..f5e15bbda 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -268,7 +268,7 @@ export const orgServiceFactory = ({ actorOrgId, actorAuthMethod, orgId, - data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug } + data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa } }: TUpdateOrgDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -317,7 +317,8 @@ export const orgServiceFactory = ({ slug: slug ? slugify(slug) : undefined, authEnforced, scimEnabled, - defaultMembershipRole + defaultMembershipRole, + enforceMfa }); if (!org) throw new NotFoundError({ message: "Organization 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/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 1de64e058..caab2408d 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -82,13 +82,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/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." + /> +
+ ); +}; From 7a77dc7343de0f1ea3f84082c82439fb9a9fd4ed Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 17 Oct 2024 22:29:38 +0800 Subject: [PATCH 4/9] feat: added mfa popup for all select org --- .../src/services/auth/auth-login-service.ts | 2 +- frontend/src/hooks/api/auth/queries.tsx | 2 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 32 +- .../src/pages/login/select-organization.tsx | 14 +- frontend/src/pages/signupinvite.tsx | 43 ++- frontend/src/views/Login/Login.tsx | 11 +- frontend/src/views/Login/Login.utils.tsx | 16 +- frontend/src/views/Login/LoginSSO.tsx | 6 +- frontend/src/views/Login/Mfa.tsx | 32 +- .../components/InitialStep/InitialStep.tsx | 7 - .../Login/components/MFAStep/MFAStep.tsx | 338 ------------------ .../views/Login/components/MFAStep/index.tsx | 1 - .../components/PasswordStep/PasswordStep.tsx | 136 ++++--- frontend/src/views/Login/components/index.tsx | 1 - .../UserInfoSSOStep/UserInfoSSOStep.tsx | 54 ++- 15 files changed, 223 insertions(+), 472 deletions(-) delete mode 100644 frontend/src/views/Login/components/MFAStep/MFAStep.tsx delete mode 100644 frontend/src/views/Login/components/MFAStep/index.tsx diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 39f3162d1..83da4724e 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -354,7 +354,7 @@ export const authLoginServiceFactory = ({ } // send multi factor auth token if they it enabled - if (user.isMfaEnabled && user.email && !decodedToken.isMfaVerified) { + if ((selectedOrg.enforceMfa || user.isMfaEnabled) && user.email && !decodedToken.isMfaVerified) { enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); const mfaToken = jwt.sign( diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 91c2e7be1..dce930418 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -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/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 6e5c18ea0..f0737c32d 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); }; @@ -334,6 +345,18 @@ export const AppLayout = ({ children }: LayoutProps) => { } }; + if (shouldShowMfa) { + return ( +
+ toggleShowMfa.off()} + /> +
+ ); + } + return ( <>
@@ -749,7 +772,8 @@ export const AppLayout = ({ children }: LayoutProps) => { - {(window.location.origin.includes("https://app.infisical.com") || window.location.origin.includes("https://eu.infisical.com") || + {(window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://eu.infisical.com") || window.location.origin.includes("https://gamma.infisical.com")) && ( diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 319a6d6df..d4b3236e2 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -46,7 +46,9 @@ export default function LoginPage() { 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 () => { @@ -179,6 +181,12 @@ export default function LoginPage() { } }, [organizations.isLoading, organizations.data]); + useEffect(() => { + if (defaultSelectedOrg) { + handleSelectOrganization(defaultSelectedOrg); + } + }, [defaultSelectedOrg]); + if (userLoading || !user) { return ; } @@ -193,7 +201,11 @@ export default function LoginPage() { {shouldShowMfa ? ( - toggleShowMfa.off()} /> + toggleShowMfa.off()} + /> ) : (
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..5d81fdaec 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; @@ -33,10 +33,6 @@ export const LoginSSO = ({ providerAuthToken }: Props) => { setStep={setStep} /> ); - case 2: - return ( - - ); default: return
; } diff --git a/frontend/src/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx index 677f0d22a..cd8d64059 100644 --- a/frontend/src/views/Login/Mfa.tsx +++ b/frontend/src/views/Login/Mfa.tsx @@ -8,7 +8,6 @@ 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 { useUser } from "@app/context"; import { useSendMfaToken } from "@app/hooks/api"; import { verifyMfaToken } from "@app/hooks/api/auth/queries"; @@ -35,27 +34,24 @@ const codeInputProps = { type Props = { successCallback: () => void; closeMfa: () => void; + hideLogo?: boolean; + email: string; }; -export const Mfa = ({ successCallback, closeMfa }: Props) => { +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 { user } = useUser(); const sendMfaToken = useSendMfaToken(); const verifyMfa = async () => { - if (!user.email) { - return; - } - setIsLoading(true); try { const { token } = await verifyMfaToken({ - email: user.email, + email, mfaCode }); @@ -84,13 +80,9 @@ export const Mfa = ({ successCallback, closeMfa }: Props) => { }; const handleResendMfaCode = async () => { - if (!user?.email) { - return; - } - try { setIsLoadingResend(true); - await sendMfaToken.mutateAsync({ email: user.email }); + await sendMfaToken.mutateAsync({ email }); setIsLoadingResend(false); } catch (err) { console.error(err); @@ -100,13 +92,15 @@ export const Mfa = ({ successCallback, closeMfa }: Props) => { return (
- -
- Infisical logo -
- + {!hideLogo && ( + +
+ Infisical logo +
+ + )}

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

-

{user.email}

+

{email}

{ - 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..e1dc88312 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -14,11 +14,13 @@ 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; @@ -40,6 +42,8 @@ export const PasswordStep = ({ 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 +60,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 +72,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 @@ -172,32 +179,41 @@ 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 }); + 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(); @@ -284,6 +300,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/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 (

From bb079b3e46247b26358954a9fe7bc2089dc8447f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 18 Oct 2024 01:59:24 +0800 Subject: [PATCH 5/9] misc: updated cli interactive to support mfa in org select --- cli/packages/api/model.go | 1 + cli/packages/cmd/init.go | 37 +++++++++++++++++++++++++++++++ cli/packages/cmd/login.go | 46 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 3 deletions(-) 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 f66197517..140d96128 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -477,7 +477,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 @@ -710,7 +710,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() @@ -739,11 +739,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 } From 8eb668cd72c4146cde860e16726c1d86436b9a32 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 18 Oct 2024 03:26:48 +0800 Subject: [PATCH 6/9] misc: removed remaining mfa handling --- frontend/src/views/Login/LoginSSO.tsx | 1 - .../components/InitialStep/InitialStep.tsx | 10 -------- .../components/PasswordStep/PasswordStep.tsx | 25 +------------------ 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/frontend/src/views/Login/LoginSSO.tsx b/frontend/src/views/Login/LoginSSO.tsx index 5d81fdaec..7741602d8 100644 --- a/frontend/src/views/Login/LoginSSO.tsx +++ b/frontend/src/views/Login/LoginSSO.tsx @@ -30,7 +30,6 @@ export const LoginSSO = ({ providerAuthToken }: Props) => { email={username} password={password} setPassword={setPassword} - setStep={setStep} /> ); default: diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index e51d475aa..e2f8f99b9 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -102,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/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index e1dc88312..827603067 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -27,16 +27,9 @@ type Props = { 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(); @@ -169,12 +162,6 @@ 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 @@ -237,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", From 25b30e441a1d0d7e790b005b602e9e49b52ef333 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 18 Oct 2024 19:51:31 +0800 Subject: [PATCH 7/9] misc: added missing enforcement checks --- backend/src/services/org/org-service.ts | 15 +++++++++++++++ frontend/src/pages/login/select-organization.tsx | 16 ++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index f5e15bbda..27e62304d 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -270,11 +270,26 @@ export const orgServiceFactory = ({ orgId, 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({ diff --git a/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index d4b3236e2..69957a4f7 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -15,9 +15,13 @@ 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 { useToggle } from "@app/hooks"; -import { useGetOrganizations, useLogoutUser, useSelectOrganization } from "@app/hooks/api"; +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"; @@ -40,9 +44,9 @@ 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 [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const queryParams = new URLSearchParams(window.location.search); @@ -104,7 +108,7 @@ export default function LoginPage() { 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) { @@ -117,7 +121,7 @@ export default function LoginPage() { const payload = { JTWToken: token, - email: user.email, + email: user?.email, privateKey } as IsCliLoginSuccessful["loginResponse"]; From 571709370d1537ac62928045178cf82aae185040 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 23 Oct 2024 00:00:15 +0800 Subject: [PATCH 8/9] misc: addressed ux issues --- backend/src/server/routes/v1/sso-router.ts | 1 - backend/src/server/routes/v3/login-router.ts | 2 -- .../src/pages/login/select-organization.tsx | 20 +++++++++++-------- frontend/src/views/Login/Mfa.tsx | 10 ++++++---- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index e79b18e6d..9007ca828 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -288,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 ca9bf0108..67f8e2c4c 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -95,7 +95,6 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - mfaEnabled: z.literal(false), encryptionVersion: z.number().default(1).nullable().optional(), protectedKey: z.string().nullable(), protectedKeyIV: z.string().nullable(), @@ -131,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/frontend/src/pages/login/select-organization.tsx b/frontend/src/pages/login/select-organization.tsx index 69957a4f7..cc19cc572 100644 --- a/frontend/src/pages/login/select-organization.tsx +++ b/frontend/src/pages/login/select-organization.tsx @@ -46,6 +46,7 @@ export default function LoginPage() { const selectOrg = useSelectOrganization(); const { data: user, isLoading: userLoading } = useGetUser(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); @@ -169,19 +170,22 @@ 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]); @@ -191,7 +195,11 @@ export default function LoginPage() { } }, [defaultSelectedOrg]); - if (userLoading || !user) { + if ( + userLoading || + !user || + ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) + ) { return ; } @@ -205,11 +213,7 @@ export default function LoginPage() { {shouldShowMfa ? ( - toggleShowMfa.off()} - /> + ) : (

diff --git a/frontend/src/views/Login/Mfa.tsx b/frontend/src/views/Login/Mfa.tsx index cd8d64059..4b43d2863 100644 --- a/frontend/src/views/Login/Mfa.tsx +++ b/frontend/src/views/Login/Mfa.tsx @@ -32,8 +32,8 @@ const codeInputProps = { } as const; type Props = { - successCallback: () => void; - closeMfa: () => void; + successCallback: () => void | Promise; + closeMfa?: () => void; hideLogo?: boolean; email: string; }; @@ -58,8 +58,10 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email }: Props) => { SecurityClient.setMfaToken(""); SecurityClient.setToken(token); - successCallback(); - closeMfa(); + await successCallback(); + if (closeMfa) { + closeMfa(); + } } catch (error) { if (triesLeft) { setTriesLeft((left) => { From 2101040a776ab2fccc24c6d279490c2dc9795566 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 23 Oct 2024 00:13:10 +0800 Subject: [PATCH 9/9] misc: updated e2e --- backend/e2e-test/routes/v1/login.spec.ts | 2 -- 1 file changed, 2 deletions(-) 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(); }); });