diff --git a/acceptance/zitadel.yaml b/acceptance/zitadel.yaml index 5a17264eb6..0678e8ff86 100644 --- a/acceptance/zitadel.yaml +++ b/acceptance/zitadel.yaml @@ -16,6 +16,26 @@ FirstInstance: ExpirationDate: 2099-01-01T00:00:00Z DefaultInstance: + LoginPolicy: + AllowUsernamePassword: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWUSERNAMEPASSWORD + AllowRegister: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER + AllowExternalIDP: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWEXTERNALIDP + ForceMFA: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_FORCEMFA + HidePasswordReset: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_HIDEPASSWORDRESET + IgnoreUnknownUsernames: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_IGNOREUNKNOWNUSERNAMES + AllowDomainDiscovery: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWDOMAINDISCOVERY + # 1 is allowed, 0 is not allowed + PasswordlessType: 1 # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDLESSTYPE + # DefaultRedirectURL is empty by default because we use the Console UI + DefaultRedirectURI: # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_DEFAULTREDIRECTURI + # 240h = 10d + PasswordCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDCHECKLIFETIME + # 240h = 10d + ExternalLoginCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_EXTERNALLOGINCHECKLIFETIME + # 720h = 30d + MfaInitSkipLifetime: 0h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME + SecondFactorCheckLifetime: 18h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_SECONDFACTORCHECKLIFETIME + MultiFactorCheckLifetime: 12h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MULTIFACTORCHECKLIFETIME PrivacyPolicy: TOSLink: "https://zitadel.com/docs/legal/terms-of-service" PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy" diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 5e8756c89b..db46321b05 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -71,7 +71,8 @@ }, "set": { "title": "2-Faktor einrichten", - "description": "Wählen Sie einen der folgenden zweiten Faktoren." + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" } }, "otp": { diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 3101f222d5..36776ccbd9 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -71,7 +71,8 @@ }, "set": { "title": "Set up 2-Factor", - "description": "Choose one of the following second factors." + "description": "Choose one of the following second factors.", + "skip": "Skip" } }, "otp": { diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 5a9b6f6324..4eba3a9696 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -71,7 +71,8 @@ }, "set": { "title": "Configurar autenticación de 2 factores", - "description": "Elige uno de los siguientes factores secundarios." + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" } }, "otp": { diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 1423c43cfe..d0969c86b3 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -71,7 +71,8 @@ }, "set": { "title": "Configura l'autenticazione a 2 fattori", - "description": "Scegli uno dei seguenti secondi fattori." + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" } }, "otp": { diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index acd03cc5b6..9c87a53a65 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -71,7 +71,8 @@ }, "set": { "title": "设置双因素认证", - "description": "选择以下的一个第二因素。" + "description": "选择以下的一个第二因素。", + "skip": "跳过" } }, "otp": { diff --git a/apps/login/readme.md b/apps/login/readme.md index 4df81f9f9d..120fad3cd7 100644 --- a/apps/login/readme.md +++ b/apps/login/readme.md @@ -389,7 +389,6 @@ In future, self service options to jump to are shown below, like: ## Currently NOT Supported -- Login Settings: multifactor init prompt - forceMFA on login settings is not checked for IDPs Also note that IDP logins are considered as valid MFA. An additional MFA check will be implemented in future if enforced. diff --git a/apps/login/src/app/(login)/mfa/set/page.tsx b/apps/login/src/app/(login)/mfa/set/page.tsx index a885a36c7a..498b063428 100644 --- a/apps/login/src/app/(login)/mfa/set/page.tsx +++ b/apps/login/src/app/(login)/mfa/set/page.tsx @@ -49,10 +49,10 @@ export default async function Page(props: { const { serviceUrl } = getServiceUrlFromHeaders(_headers); const sessionWithData = sessionId - ? await loadSessionById(serviceUrl, sessionId, organization) - : await loadSessionByLoginname(serviceUrl, loginName, organization); + ? await loadSessionById(sessionId, organization) + : await loadSessionByLoginname(loginName, organization); - async function getAuthMethodsAndUser(host: string, session?: Session) { + async function getAuthMethodsAndUser(session?: Session) { const userId = session?.factors?.user?.id; if (!userId) { @@ -80,7 +80,6 @@ export default async function Page(props: { } async function loadSessionByLoginname( - host: string, loginName?: string, organization?: string, ) { @@ -92,23 +91,18 @@ export default async function Page(props: { organization, }, }).then((session) => { - return getAuthMethodsAndUser(serviceUrl, session); + return getAuthMethodsAndUser(session); }); } - async function loadSessionById( - host: string, - sessionId: string, - organization?: string, - ) { + async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, - sessionId: recent.id, sessionToken: recent.token, }).then((sessionResponse) => { - return getAuthMethodsAndUser(serviceUrl, sessionResponse.session); + return getAuthMethodsAndUser(sessionResponse.session); }); } @@ -147,8 +141,10 @@ export default async function Page(props: { {isSessionValid(sessionWithData).valid && loginSettings && - sessionWithData && ( + sessionWithData && + sessionWithData.factors?.user?.id && ( )} diff --git a/apps/login/src/components/choose-second-factor-to-setup.tsx b/apps/login/src/components/choose-second-factor-to-setup.tsx index 21f7aff8a6..e56379e147 100644 --- a/apps/login/src/components/choose-second-factor-to-setup.tsx +++ b/apps/login/src/components/choose-second-factor-to-setup.tsx @@ -1,13 +1,17 @@ "use client"; +import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session"; import { LoginSettings, SecondFactorType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; type Props = { + userId: string; loginName?: string; sessionId?: string; requestId?: string; @@ -17,9 +21,11 @@ type Props = { checkAfter: boolean; phoneVerified: boolean; emailVerified: boolean; + force: boolean; }; export function ChooseSecondFactorToSetup({ + userId, loginName, sessionId, requestId, @@ -29,7 +35,10 @@ export function ChooseSecondFactorToSetup({ checkAfter, phoneVerified, emailVerified, + force, }: Props) { + const t = useTranslations("mfa"); + const router = useRouter(); const params = new URLSearchParams({}); if (loginName) { @@ -49,39 +58,63 @@ export function ChooseSecondFactorToSetup({ } return ( -
- {loginSettings.secondFactors.map((factor) => { - switch (factor) { - case SecondFactorType.OTP: - return TOTP( - userMethods.includes(AuthenticationMethodType.TOTP), - "/otp/time-based/set?" + params, - ); - case SecondFactorType.U2F: - return U2F( - userMethods.includes(AuthenticationMethodType.U2F), - "/u2f/set?" + params, - ); - case SecondFactorType.OTP_EMAIL: - return ( - emailVerified && - EMAIL( - userMethods.includes(AuthenticationMethodType.OTP_EMAIL), - "/otp/email/set?" + params, - ) - ); - case SecondFactorType.OTP_SMS: - return ( - phoneVerified && - SMS( - userMethods.includes(AuthenticationMethodType.OTP_SMS), - "/otp/sms/set?" + params, - ) - ); - default: - return null; - } - })} -
+ <> +
+ {loginSettings.secondFactors.map((factor) => { + switch (factor) { + case SecondFactorType.OTP: + return TOTP( + userMethods.includes(AuthenticationMethodType.TOTP), + "/otp/time-based/set?" + params, + ); + case SecondFactorType.U2F: + return U2F( + userMethods.includes(AuthenticationMethodType.U2F), + "/u2f/set?" + params, + ); + case SecondFactorType.OTP_EMAIL: + return ( + emailVerified && + EMAIL( + userMethods.includes(AuthenticationMethodType.OTP_EMAIL), + "/otp/email/set?" + params, + ) + ); + case SecondFactorType.OTP_SMS: + return ( + phoneVerified && + SMS( + userMethods.includes(AuthenticationMethodType.OTP_SMS), + "/otp/sms/set?" + params, + ) + ); + default: + return null; + } + })} +
+ {!force && ( + + )} + ); } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 98c34a3de6..e5a2f58562 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -16,7 +16,7 @@ import { setPassword, setUserPassword, } from "@/lib/zitadel"; -import { create } from "@zitadel/client"; +import { ConnectError, create } from "@zitadel/client"; import { createServerTransport } from "@zitadel/client/node"; import { createUserServiceClient } from "@zitadel/client/v2"; import { @@ -72,7 +72,6 @@ export async function resetPassword(command: ResetPasswordCommand) { return passwordReset({ serviceUrl, - userId, urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + @@ -267,7 +266,8 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: "Could not verify password!" }; } - const mfaFactorCheck = checkMFAFactors( + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, session, loginSettings, authMethods, @@ -433,7 +433,7 @@ export async function checkSessionAndSetPassword({ }, {}, ) - .catch((error) => { + .catch((error: ConnectError) => { console.log(error); if (error.code === 7) { return { error: "Session is not valid." }; diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 7ba37011ad..6a1aa5af9b 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -4,6 +4,7 @@ import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; import { deleteSession, getLoginSettings, + humanMFAInitSkipped, listAuthenticationMethodTypes, } from "@/lib/zitadel"; import { Duration } from "@zitadel/client"; @@ -20,6 +21,53 @@ import { } from "../cookies"; import { getServiceUrlFromHeaders } from "../service"; +export async function skipMFAAndContinueWithNextUrl({ + userId, + requestId, + loginName, + sessionId, + organization, +}: { + userId: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization, + }); + + await humanMFAInitSkipped({ serviceUrl, userId }); + + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + export async function continueWithSession({ requestId, ...session diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index 38bddb9428..4de697e772 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -203,7 +203,8 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { } // redirect to mfa factor if user has one, or redirect to set one up - const mfaFactorCheck = checkMFAFactors( + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, session, loginSettings, authMethodResponse.authMethodTypes, @@ -407,12 +408,12 @@ export async function sendVerificationRedirectWithoutCheck( const loginSettings = await getLoginSettings({ serviceUrl, - organization: user.details?.resourceOwner, }); // redirect to mfa factor if user has one, or redirect to set one up - const mfaFactorCheck = checkMFAFactors( + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, session, loginSettings, authMethodResponse.authMethodTypes, diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index e8afef6890..704d7bbef6 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -5,6 +5,7 @@ import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/passw import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import moment from "moment"; +import { getUserByID } from "./zitadel"; export function checkPasswordChangeRequired( expirySettings: PasswordExpirySettings | undefined, @@ -100,7 +101,8 @@ export function checkEmailVerification( } } -export function checkMFAFactors( +export async function checkMFAFactors( + serviceUrl: string, session: Session, loginSettings: LoginSettings | undefined, authMethods: AuthenticationMethodType[], @@ -188,31 +190,61 @@ export function checkMFAFactors( ); } + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } else if ( + loginSettings?.mfaInitSkipLifetime && + (loginSettings.mfaInitSkipLifetime.nanos > 0 || + loginSettings.mfaInitSkipLifetime.seconds > 0) && + !availableMultiFactors.length && + session?.factors?.user?.id + ) { + const userResponse = await getUserByID({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + const humanUser = + userResponse?.user?.type.case === "human" + ? userResponse?.user.type.value + : undefined; + + if (humanUser?.mfaInitSkipped) { + const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped); + + const mfaInitSkipLifetimeMillis = + Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + + loginSettings.mfaInitSkipLifetime.nanos / 1000000; + const currentTime = Date.now(); + const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime(); + const timeDifference = currentTime - mfaInitSkippedTime; + + if (!(timeDifference > mfaInitSkipLifetimeMillis)) { + // if the time difference is smaller than the lifetime, skip the mfa setup + return; + } + } + + // the user has never skipped the mfa init but we have a setting so we redirect + + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "false", // this defines if the mfa is not forced in the settings and can be skipped + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + // TODO: provide a way to setup passkeys on mfa page? return { redirect: `/mfa/set?` + params }; } - - // TODO: implement passkey setup - - // else if ( - // submitted.factors && - // !submitted.factors.webAuthN && // if session was not verified with a passkey - // promptPasswordless && // if explicitly prompted due policy - // !isAlternative // escaped if password was used as an alternative method - // ) { - // const params = new URLSearchParams({ - // loginName: submitted.factors.user.loginName, - // prompt: "true", - // }); - - // if (requestId) { - // params.append("requestId", requestId); - // } - - // if (organization) { - // params.append("organization", organization); - // } - - // return router.push(`/passkey/set?` + params); - // } } diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 77d656beda..5e3fdec323 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -457,6 +457,21 @@ export async function getUserByID({ return userService.getUserByID({ userId }, {}); } +export async function humanMFAInitSkipped({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.humanMFAInitSkipped({ userId }, {}); +} + export async function verifyInviteCode({ serviceUrl, userId,