diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index d67e7b562..6e39d0451 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -108,7 +108,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { tokenVersionId: tokenVersion.id, accessVersion: tokenVersion.accessVersion, organizationId: decodedToken.organizationId, - isMfaVerified: decodedToken.isMfaVerified + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod }, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_AUTH_LIFETIME } diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 30d032e13..07f795779 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -15,7 +15,7 @@ import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -259,7 +259,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { message: "Membership role must be a valid slug" }) .optional(), - enforceMfa: z.boolean().optional() + enforceMfa: z.boolean().optional(), + selectedMfaMethod: z.nativeEnum(MfaMethod).optional() }), response: { 200: z.object({ diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 2eb92af06..a580af2f5 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -396,7 +396,8 @@ export const authLoginServiceFactory = ({ userAgent, ip: ipAddress, organizationId, - isMfaVerified: decodedToken.isMfaVerified + isMfaVerified: decodedToken.isMfaVerified, + mfaMethod: decodedToken.mfaMethod }); return { diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 10227f595..c1bf2b6fb 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -72,6 +72,7 @@ export type AuthModeRefreshJwtTokenPayload = { refreshVersion: number; organizationId?: string; isMfaVerified?: boolean; + mfaMethod?: MfaMethod; }; export type AuthModeProviderJwtTokenPayload = { diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 3d74f80b6..1d656c4d5 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -268,7 +268,7 @@ export const orgServiceFactory = ({ actorOrgId, actorAuthMethod, orgId, - data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa } + data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, selectedMfaMethod } }: TUpdateOrgDTO) => { const appCfg = getConfig(); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); @@ -333,7 +333,8 @@ export const orgServiceFactory = ({ authEnforced, scimEnabled, defaultMembershipRole, - enforceMfa + enforceMfa, + selectedMfaMethod }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 5b44eeea5..7d4a203de 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,6 +1,6 @@ import { TOrgPermission } from "@app/lib/types"; -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType, MfaMethod } from "../auth/auth-type"; export type TUpdateOrgMembershipDTO = { userId: string; @@ -65,6 +65,7 @@ export type TUpdateOrgDTO = { scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; + selectedMfaMethod: MfaMethod; }>; } & TOrgPermission; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index ad44d280a..4923177ba 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -91,7 +91,8 @@ export const useUpdateOrg = () => { slug, orgId, defaultMembershipRoleSlug, - enforceMfa + enforceMfa, + selectedMfaMethod }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -99,7 +100,8 @@ export const useUpdateOrg = () => { scimEnabled, slug, defaultMembershipRoleSlug, - enforceMfa + enforceMfa, + selectedMfaMethod }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index b644cb325..5fbbd1863 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -1,6 +1,8 @@ import { OrderByDirection } from "@app/hooks/api/generic/types"; import { IdentityMembershipOrg } from "@app/hooks/api/identities/types"; +import { MfaMethod } from "../auth/types"; + export type Organization = { id: string; name: string; @@ -12,6 +14,7 @@ export type Organization = { slug: string; defaultMembershipRole: string; enforceMfa: boolean; + selectedMfaMethod?: MfaMethod; }; export type UpdateOrgDTO = { @@ -22,6 +25,7 @@ export type UpdateOrgDTO = { slug?: string; defaultMembershipRoleSlug?: string; enforceMfa?: boolean; + selectedMfaMethod?: MfaMethod; }; export type BillingDetails = { diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index d4d60d274..d6164cba6 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -78,6 +78,7 @@ import { useLogoutUser, useSelectOrganization } from "@app/hooks/api"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; import { Workspace } from "@app/hooks/api/types"; @@ -143,6 +144,7 @@ export const AppLayout = ({ children }: LayoutProps) => { const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const workspacesWithFaveProp = useMemo( @@ -214,12 +216,15 @@ export const AppLayout = ({ children }: LayoutProps) => { }; const changeOrg = async (orgId: string) => { - const { token, isMfaEnabled } = await selectOrganization({ + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId: orgId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => () => changeOrg(orgId)); return; @@ -365,6 +370,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
toggleShowMfa.off()} /> diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index 827603067..156854b70 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -16,6 +16,7 @@ import { Button, Input, Spinner } from "@app/components/v2"; import { SessionStorageKeys } from "@app/const"; import { useToggle } from "@app/hooks"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; @@ -36,6 +37,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } const { mutateAsync: selectOrganization } = useSelectOrganization(); const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); const { navigateToSelectOrganization } = useNavigateToSelectOrganization(); @@ -66,10 +68,13 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { - const { token, isMfaEnabled } = await selectOrganization({ organizationId }); + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => finishWithOrgWorkflow); return; @@ -167,10 +172,15 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { - const { token, isMfaEnabled } = await selectOrganization({ organizationId }); + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ + organizationId + }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => finishWithOrgWorkflow); return; @@ -283,6 +293,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword } toggleShowMfa.off()} />
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx index 3a6663dde..3687eb0cf 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgGenericAuthSection.tsx @@ -1,6 +1,6 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Switch, UpgradePlanModal } from "@app/components/v2"; +import { FormControl, Select, SelectItem, Switch, UpgradePlanModal } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -8,6 +8,7 @@ import { useSubscription } from "@app/context"; import { useUpdateOrg } from "@app/hooks/api"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { usePopUp } from "@app/hooks/usePopUp"; export const OrgGenericAuthSection = () => { @@ -43,6 +44,32 @@ export const OrgGenericAuthSection = () => { } }; + const handleUpdateSelectedMfa = async (selectedMfaMethod: MfaMethod) => { + try { + if (!currentOrg?.id) return; + if (!subscription?.enforceMfa) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + selectedMfaMethod + }); + + createNotification({ + text: "Successfully updated preferred MFA method", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: (err as { response: { data: { message: string } } }).response.data.message, + type: "error" + }); + } + }; + return (
@@ -62,6 +89,22 @@ export const OrgGenericAuthSection = () => {

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

+ {currentOrg?.enforceMfa && ( + + + + )}
{ Delete
- {shouldShowRecoveryCodes && totpConfiguration.recoveryCodes.length && ( + {shouldShowRecoveryCodes && totpConfiguration.recoveryCodes && (
{totpConfiguration.recoveryCodes.map((code) => (
{code}
diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 5f63c4952..4156b93d4 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -13,6 +13,7 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Input } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries"; +import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import ProjectService from "@app/services/ProjectService"; import { Mfa } from "@app/views/Login/Mfa"; @@ -57,6 +58,7 @@ export const UserInfoSSOStep = ({ const [organizationNameError, setOrganizationNameError] = useState(false); const [attributionSource, setAttributionSource] = useState(""); const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const { mutateAsync: selectOrganization } = useSelectOrganization(); @@ -178,12 +180,15 @@ export const UserInfoSSOStep = ({ const completeSignupFlow = async () => { try { - const { isMfaEnabled, token } = await selectOrganization({ + const { isMfaEnabled, token, mfaMethod } = await selectOrganization({ organizationId: orgId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } toggleShowMfa.on(); setMfaSuccessCallback(() => completeSignupFlow); return; @@ -231,6 +236,7 @@ export const UserInfoSSOStep = ({ hideLogo email={username} successCallback={mfaSuccessCallback} + method={requiredMfaMethod} closeMfa={() => toggleShowMfa.off()} /> );