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")}

+
+
+ ); +};