Merge pull request #379 from zitadel/mfa-init-prompt

feat: MFA init prompt
This commit is contained in:
Max Peintner
2025-03-03 08:54:12 +01:00
committed by GitHub
14 changed files with 235 additions and 85 deletions

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -71,7 +71,8 @@
},
"set": {
"title": "设置双因素认证",
"description": "选择以下的一个第二因素。"
"description": "选择以下的一个第二因素。",
"skip": "跳过"
}
},
"otp": {

View File

@@ -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.

View File

@@ -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 && (
<ChooseSecondFactorToSetup
userId={sessionWithData.factors?.user?.id}
loginName={loginName}
sessionId={sessionId}
requestId={requestId}
@@ -158,6 +154,7 @@ export default async function Page(props: {
phoneVerified={sessionWithData.phoneVerified ?? false}
emailVerified={sessionWithData.emailVerified ?? false}
checkAfter={checkAfter === "true"}
force={force === "true"}
></ChooseSecondFactorToSetup>
)}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{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;
}
})}
</div>
<>
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{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;
}
})}
</div>
{!force && (
<button
className="transition-all text-sm hover:text-primary-light-500 dark:hover:text-primary-dark-500"
onClick={async () => {
const resp = await skipMFAAndContinueWithNextUrl({
userId,
loginName,
sessionId,
organization,
requestId,
});
if (resp?.redirect) {
return router.push(resp.redirect);
}
}}
type="button"
data-testid="reset-button"
>
{t("set.skip")}
</button>
)}
</>
);
}

View File

@@ -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." };

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
// }
}

View File

@@ -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<typeof UserService> = await createServiceForHost(
UserService,
serviceUrl,
);
return userService.humanMFAInitSkipped({ userId }, {});
}
export async function verifyInviteCode({
serviceUrl,
userId,