skip button and server action

This commit is contained in:
Max Peintner
2025-02-28 15:22:56 +01:00
parent 546edee64f
commit 83df30e525
5 changed files with 173 additions and 81 deletions

View File

@@ -49,10 +49,10 @@ export default async function Page(props: {
const { serviceUrl } = getServiceUrlFromHeaders(_headers); const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const sessionWithData = sessionId const sessionWithData = sessionId
? await loadSessionById(serviceUrl, sessionId, organization) ? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(serviceUrl, loginName, organization); : await loadSessionByLoginname(loginName, organization);
async function getAuthMethodsAndUser(host: string, session?: Session) { async function getAuthMethodsAndUser(session?: Session) {
const userId = session?.factors?.user?.id; const userId = session?.factors?.user?.id;
if (!userId) { if (!userId) {
@@ -80,7 +80,6 @@ export default async function Page(props: {
} }
async function loadSessionByLoginname( async function loadSessionByLoginname(
host: string,
loginName?: string, loginName?: string,
organization?: string, organization?: string,
) { ) {
@@ -92,23 +91,18 @@ export default async function Page(props: {
organization, organization,
}, },
}).then((session) => { }).then((session) => {
return getAuthMethodsAndUser(serviceUrl, session); return getAuthMethodsAndUser(session);
}); });
} }
async function loadSessionById( async function loadSessionById(sessionId: string, organization?: string) {
host: string,
sessionId: string,
organization?: string,
) {
const recent = await getSessionCookieById({ sessionId, organization }); const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ return getSession({
serviceUrl, serviceUrl,
sessionId: recent.id, sessionId: recent.id,
sessionToken: recent.token, sessionToken: recent.token,
}).then((sessionResponse) => { }).then((sessionResponse) => {
return getAuthMethodsAndUser(serviceUrl, sessionResponse.session); return getAuthMethodsAndUser(sessionResponse.session);
}); });
} }
@@ -147,8 +141,10 @@ export default async function Page(props: {
{isSessionValid(sessionWithData).valid && {isSessionValid(sessionWithData).valid &&
loginSettings && loginSettings &&
sessionWithData && ( sessionWithData &&
sessionWithData.factors?.user?.id && (
<ChooseSecondFactorToSetup <ChooseSecondFactorToSetup
userId={sessionWithData.factors?.user?.id}
loginName={loginName} loginName={loginName}
sessionId={sessionId} sessionId={sessionId}
requestId={requestId} requestId={requestId}
@@ -158,15 +154,10 @@ export default async function Page(props: {
phoneVerified={sessionWithData.phoneVerified ?? false} phoneVerified={sessionWithData.phoneVerified ?? false}
emailVerified={sessionWithData.emailVerified ?? false} emailVerified={sessionWithData.emailVerified ?? false}
checkAfter={checkAfter === "true"} checkAfter={checkAfter === "true"}
force={force === "true"}
></ChooseSecondFactorToSetup> ></ChooseSecondFactorToSetup>
)} )}
{force !== "true" && (
<div>
<p>{t("set.skip")}</p>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center"> <div className="mt-8 flex w-full flex-row items-center">
<BackButton /> <BackButton />
<span className="flex-grow"></span> <span className="flex-grow"></span>

View File

@@ -1,13 +1,17 @@
"use client"; "use client";
import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session";
import { import {
LoginSettings, LoginSettings,
SecondFactorType, SecondFactorType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_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"; import { EMAIL, SMS, TOTP, U2F } from "./auth-methods";
type Props = { type Props = {
userId: string;
loginName?: string; loginName?: string;
sessionId?: string; sessionId?: string;
requestId?: string; requestId?: string;
@@ -17,9 +21,11 @@ type Props = {
checkAfter: boolean; checkAfter: boolean;
phoneVerified: boolean; phoneVerified: boolean;
emailVerified: boolean; emailVerified: boolean;
force: boolean;
}; };
export function ChooseSecondFactorToSetup({ export function ChooseSecondFactorToSetup({
userId,
loginName, loginName,
sessionId, sessionId,
requestId, requestId,
@@ -29,7 +35,10 @@ export function ChooseSecondFactorToSetup({
checkAfter, checkAfter,
phoneVerified, phoneVerified,
emailVerified, emailVerified,
force,
}: Props) { }: Props) {
const t = useTranslations("mfa");
const router = useRouter();
const params = new URLSearchParams({}); const params = new URLSearchParams({});
if (loginName) { if (loginName) {
@@ -49,39 +58,63 @@ export function ChooseSecondFactorToSetup({
} }
return ( return (
<div className="grid grid-cols-1 gap-5 w-full pt-4"> <>
{loginSettings.secondFactors.map((factor) => { <div className="grid grid-cols-1 gap-5 w-full pt-4">
switch (factor) { {loginSettings.secondFactors.map((factor) => {
case SecondFactorType.OTP: switch (factor) {
return TOTP( case SecondFactorType.OTP:
userMethods.includes(AuthenticationMethodType.TOTP), return TOTP(
"/otp/time-based/set?" + params, userMethods.includes(AuthenticationMethodType.TOTP),
); "/otp/time-based/set?" + params,
case SecondFactorType.U2F: );
return U2F( case SecondFactorType.U2F:
userMethods.includes(AuthenticationMethodType.U2F), return U2F(
"/u2f/set?" + params, userMethods.includes(AuthenticationMethodType.U2F),
); "/u2f/set?" + params,
case SecondFactorType.OTP_EMAIL: );
return ( case SecondFactorType.OTP_EMAIL:
emailVerified && return (
EMAIL( emailVerified &&
userMethods.includes(AuthenticationMethodType.OTP_EMAIL), EMAIL(
"/otp/email/set?" + params, userMethods.includes(AuthenticationMethodType.OTP_EMAIL),
) "/otp/email/set?" + params,
); )
case SecondFactorType.OTP_SMS: );
return ( case SecondFactorType.OTP_SMS:
phoneVerified && return (
SMS( phoneVerified &&
userMethods.includes(AuthenticationMethodType.OTP_SMS), SMS(
"/otp/sms/set?" + params, userMethods.includes(AuthenticationMethodType.OTP_SMS),
) "/otp/sms/set?" + params,
); )
default: );
return null; default:
} return null;
})} }
</div> })}
</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

@@ -4,6 +4,7 @@ import { setSessionAndUpdateCookie } from "@/lib/server/cookie";
import { import {
deleteSession, deleteSession,
getLoginSettings, getLoginSettings,
humanMFAInitSkipped,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { Duration } from "@zitadel/client"; import { Duration } from "@zitadel/client";
@@ -20,6 +21,53 @@ import {
} from "../cookies"; } from "../cookies";
import { getServiceUrlFromHeaders } from "../service"; 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,
});
const skip = 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({ export async function continueWithSession({
requestId, requestId,
...session ...session

View File

@@ -199,18 +199,18 @@ export async function checkMFAFactors(
!availableMultiFactors.length && !availableMultiFactors.length &&
session?.factors?.user?.id session?.factors?.user?.id
) { ) {
const user = await getUserByID({ const userResponse = await getUserByID({
serviceUrl, serviceUrl,
userId: session.factors?.user?.id, userId: session.factors?.user?.id,
}); });
if ( const humanUser =
user.user?.type?.case === "human" && userResponse?.user?.type.case === "human"
user.user?.type?.value.mfaInitSkipped ? userResponse?.user.type.value
) { : undefined;
const mfaInitSkippedTimestamp = timestampDate(
user.user.type.value.mfaInitSkipped, if (humanUser?.mfaInitSkipped) {
); const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped);
const mfaInitSkipLifetimeMillis = const mfaInitSkipLifetimeMillis =
Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 +
@@ -219,27 +219,32 @@ export async function checkMFAFactors(
const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime(); const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime();
const timeDifference = currentTime - mfaInitSkippedTime; const timeDifference = currentTime - mfaInitSkippedTime;
if (timeDifference > mfaInitSkipLifetimeMillis) { if (!(timeDifference > mfaInitSkipLifetimeMillis)) {
const params = new URLSearchParams({ // if the time difference is smaller than the lifetime, skip the mfa setup
loginName: session.factors?.user?.loginName as string, return;
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 };
} }
} }
// 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 };
} }
} }

View File

@@ -457,6 +457,21 @@ export async function getUserByID({
return userService.getUserByID({ userId }, {}); 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({ export async function verifyInviteCode({
serviceUrl, serviceUrl,
userId, userId,