From 4bb03574e62760459ecb2678186786b6fc32ec4f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 17 Dec 2024 15:57:42 +0100 Subject: [PATCH 01/12] fix: verify email --- apps/login/src/components/verify-form.tsx | 2 +- .../src/components/verify-redirect-button.tsx | 2 +- apps/login/src/lib/server/email.ts | 138 ---------- apps/login/src/lib/server/password.ts | 243 +++++++++++------- apps/login/src/lib/server/verify.ts | 229 +++++++++++++++++ 5 files changed, 374 insertions(+), 240 deletions(-) delete mode 100644 apps/login/src/lib/server/email.ts create mode 100644 apps/login/src/lib/server/verify.ts diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 003b261b02..b575b4e9b5 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -1,7 +1,7 @@ "use client"; import { Alert, AlertType } from "@/components/alert"; -import { resendVerification, sendVerification } from "@/lib/server/email"; +import { resendVerification, sendVerification } from "@/lib/server/verify"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; diff --git a/apps/login/src/components/verify-redirect-button.tsx b/apps/login/src/components/verify-redirect-button.tsx index 4fe313cd1d..2a2f672326 100644 --- a/apps/login/src/components/verify-redirect-button.tsx +++ b/apps/login/src/components/verify-redirect-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { sendVerificationRedirectWithoutCheck } from "@/lib/server/email"; +import { sendVerificationRedirectWithoutCheck } from "@/lib/server/verify"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { useTranslations } from "next-intl"; import { useState } from "react"; diff --git a/apps/login/src/lib/server/email.ts b/apps/login/src/lib/server/email.ts deleted file mode 100644 index 51eb1a019b..0000000000 --- a/apps/login/src/lib/server/email.ts +++ /dev/null @@ -1,138 +0,0 @@ -"use server"; - -import { - getUserByID, - listAuthenticationMethodTypes, - resendEmailCode, - resendInviteCode, - verifyEmail, - verifyInviteCode, -} from "@/lib/zitadel"; -import { create } from "@zitadel/client"; -import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; -import { createSessionAndUpdateCookie } from "./cookie"; - -type VerifyUserByEmailCommand = { - userId: string; - code: string; - isInvite: boolean; - authRequestId?: string; -}; - -export async function sendVerification(command: VerifyUserByEmailCommand) { - const verifyResponse = command.isInvite - ? await verifyInviteCode(command.userId, command.code).catch(() => { - return { error: "Could not verify invite" }; - }) - : await verifyEmail(command.userId, command.code).catch(() => { - return { error: "Could not verify email" }; - }); - - if (!verifyResponse) { - return { error: "Could not verify user" }; - } - - const userResponse = await getUserByID(command.userId); - - if (!userResponse || !userResponse.user) { - return { error: "Could not load user" }; - } - - const checks = create(ChecksSchema, { - user: { - search: { - case: "loginName", - value: userResponse.user.preferredLoginName, - }, - }, - }); - - const session = await createSessionAndUpdateCookie( - checks, - undefined, - command.authRequestId, - ); - - const authMethodResponse = await listAuthenticationMethodTypes( - command.userId, - ); - - if (!authMethodResponse || !authMethodResponse.authMethodTypes) { - return { error: "Could not load possible authenticators" }; - } - // if no authmethods are found on the user, redirect to set one up - if ( - authMethodResponse && - authMethodResponse.authMethodTypes && - authMethodResponse.authMethodTypes.length == 0 - ) { - const params = new URLSearchParams({ - sessionId: session.id, - }); - - if (session.factors?.user?.loginName) { - params.set("loginName", session.factors?.user?.loginName); - } - return { redirect: `/authenticator/set?${params}` }; - } -} - -type resendVerifyEmailCommand = { - userId: string; - isInvite: boolean; -}; - -export async function resendVerification(command: resendVerifyEmailCommand) { - return command.isInvite - ? resendInviteCode(command.userId) - : resendEmailCode(command.userId); -} - -export async function sendVerificationRedirectWithoutCheck(command: { - userId: string; - authRequestId?: string; -}) { - const userResponse = await getUserByID(command.userId); - - if (!userResponse || !userResponse.user) { - return { error: "Could not load user" }; - } - - const checks = create(ChecksSchema, { - user: { - search: { - case: "loginName", - value: userResponse.user.preferredLoginName, - }, - }, - }); - - const session = await createSessionAndUpdateCookie( - checks, - undefined, - command.authRequestId, - ); - - const authMethodResponse = await listAuthenticationMethodTypes( - command.userId, - ); - - if (!authMethodResponse || !authMethodResponse.authMethodTypes) { - return { error: "Could not load possible authenticators" }; - } - // if no authmethods are found on the user, redirect to set one up - if ( - authMethodResponse && - authMethodResponse.authMethodTypes && - authMethodResponse.authMethodTypes.length == 0 - ) { - const params = new URLSearchParams({ - sessionId: session.id, - }); - - if (session.factors?.user?.loginName) { - params.set("loginName", session.factors?.user?.loginName); - } - return { redirect: `/authenticator/set?${params}` }; - } -} diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 47bf260eaa..3dc7df720a 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -17,6 +17,7 @@ import { import { create } from "@zitadel/client"; import { createUserServiceClient } from "@zitadel/client/v2"; import { createServerTransport } from "@zitadel/node"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Checks, ChecksSchema, @@ -168,108 +169,43 @@ export async function sendPassword(command: UpdateSessionCommand) { return { redirect: "/password/change?" + params }; } - const availableMultiFactors = authMethods?.filter( - (m: AuthenticationMethodType) => - m !== AuthenticationMethodType.PASSWORD && - m !== AuthenticationMethodType.PASSKEY, + // throw error if user is in initial state here and do not continue + + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + // TODO add check to see if user was verified + + if (!humanUser?.email?.isVerified) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (command.authRequestId) { + params.append("authRequestId", command.authRequestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? + (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify` + params }; + } + + checkMFAFactors( + session, + loginSettings, + authMethods, + command.organization, + command.authRequestId, ); - if (availableMultiFactors?.length == 1) { - const params = new URLSearchParams({ - loginName: session.factors?.user.loginName, - }); - - if (command.authRequestId) { - params.append("authRequestId", command.authRequestId); - } - - if (command.organization || session.factors?.user?.organizationId) { - params.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); - } - - const factor = availableMultiFactors[0]; - // if passwordless is other method, but user selected password as alternative, perform a login - if (factor === AuthenticationMethodType.TOTP) { - return { redirect: `/otp/time-based?` + params }; - } else if (factor === AuthenticationMethodType.OTP_SMS) { - return { redirect: `/otp/sms?` + params }; - } else if (factor === AuthenticationMethodType.OTP_EMAIL) { - return { redirect: `/otp/email?` + params }; - } else if (factor === AuthenticationMethodType.U2F) { - return { redirect: `/u2f?` + params }; - } - } else if (availableMultiFactors?.length >= 1) { - const params = new URLSearchParams({ - loginName: session.factors.user.loginName, - }); - - if (command.authRequestId) { - params.append("authRequestId", command.authRequestId); - } - - if (command.organization || session.factors?.user?.organizationId) { - params.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); - } - - return { redirect: `/mfa?` + params }; - } - // TODO: check if handling of userstate INITIAL is needed - else if (user.state === UserState.INITIAL) { - return { error: "Initial User not supported" }; - } else if ( - (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && - !availableMultiFactors.length - ) { - const params = new URLSearchParams({ - loginName: session.factors.user.loginName, - force: "true", // this defines if the mfa is forced in the settings - checkAfter: "true", // this defines if the check is directly made after the setup - }); - - if (command.authRequestId) { - params.append("authRequestId", command.authRequestId); - } - - if (command.organization || session.factors?.user?.organizationId) { - params.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); - } - - // 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 (authRequestId) { - // params.append("authRequestId", authRequestId); - // } - - // if (organization) { - // params.append("organization", organization); - // } - - // return router.push(`/passkey/set?` + params); - // } - else if (command.authRequestId && session.id) { + if (command.authRequestId && session.id) { const nextUrl = await getNextUrl( { sessionId: session.id, @@ -403,3 +339,110 @@ export async function checkSessionAndSetPassword({ }); } } + +function checkMFAFactors( + session: Session, + loginSettings: LoginSettings | undefined, + authMethods: AuthenticationMethodType[], + organization?: string, + authRequestId?: string, +) { + const availableMultiFactors = authMethods?.filter( + (m: AuthenticationMethodType) => + m !== AuthenticationMethodType.PASSWORD && + m !== AuthenticationMethodType.PASSKEY, + ); + + if (availableMultiFactors?.length == 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + const factor = availableMultiFactors[0]; + // if passwordless is other method, but user selected password as alternative, perform a login + if (factor === AuthenticationMethodType.TOTP) { + return { redirect: `/otp/time-based?` + params }; + } else if (factor === AuthenticationMethodType.OTP_SMS) { + return { redirect: `/otp/sms?` + params }; + } else if (factor === AuthenticationMethodType.OTP_EMAIL) { + return { redirect: `/otp/email?` + params }; + } else if (factor === AuthenticationMethodType.U2F) { + return { redirect: `/u2f?` + params }; + } + } else if (availableMultiFactors?.length >= 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/mfa?` + params }; + } else if ( + (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && + !availableMultiFactors.length + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "true", // this defines if the mfa is forced in the settings + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + 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 (authRequestId) { + // params.append("authRequestId", authRequestId); + // } + + // if (organization) { + // params.append("organization", organization); + // } + + // return router.push(`/passkey/set?` + params); + // } +} diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts new file mode 100644 index 0000000000..ff897ba844 --- /dev/null +++ b/apps/login/src/lib/server/verify.ts @@ -0,0 +1,229 @@ +"use server"; + +import { + getLoginSettings, + getUserByID, + listAuthenticationMethodTypes, + listUsers, + resendEmailCode, + resendInviteCode, + verifyEmail, + verifyInviteCode, +} from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getSessionCookieByLoginName } from "../cookies"; +import { + createSessionAndUpdateCookie, + setSessionAndUpdateCookie, +} from "./cookie"; + +type VerifyUserByEmailCommand = { + userId: string; + code: string; + isInvite: boolean; + authRequestId?: string; +}; + +export async function sendVerification(command: VerifyUserByEmailCommand) { + const verifyResponse = command.isInvite + ? await verifyInviteCode(command.userId, command.code).catch(() => { + return { error: "Could not verify invite" }; + }) + : await verifyEmail(command.userId, command.code).catch(() => { + return { error: "Could not verify email" }; + }); + + if (!verifyResponse) { + return { error: "Could not verify user" }; + } + + const userResponse = await getUserByID(command.userId); + + if (!userResponse || !userResponse.user) { + return { error: "Could not load user" }; + } + + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + const session = await createSessionAndUpdateCookie( + checks, + undefined, + command.authRequestId, + ); + + const authMethodResponse = await listAuthenticationMethodTypes( + command.userId, + ); + + if (!authMethodResponse || !authMethodResponse.authMethodTypes) { + return { error: "Could not load possible authenticators" }; + } + // if no authmethods are found on the user, redirect to set one up + if ( + authMethodResponse && + authMethodResponse.authMethodTypes && + authMethodResponse.authMethodTypes.length == 0 + ) { + const params = new URLSearchParams({ + sessionId: session.id, + }); + + if (session.factors?.user?.loginName) { + params.set("loginName", session.factors?.user?.loginName); + } + return { redirect: `/authenticator/set?${params}` }; + } +} + +type resendVerifyEmailCommand = { + userId: string; + isInvite: boolean; +}; + +export async function resendVerification(command: resendVerifyEmailCommand) { + return command.isInvite + ? resendInviteCode(command.userId) + : resendEmailCode(command.userId); +} + +type SendVerificationRedirectWithoutCheckCommand = + | { + loginName: string; + organization?: string; + authRequestId?: string; + } + | { + userId: string; + authRequestId?: string; + }; + +export async function sendVerificationRedirectWithoutCheck( + command: SendVerificationRedirectWithoutCheckCommand, +) { + if (!("loginName" in command || "userId" in command)) { + return { error: "No userId, nor loginname provided" }; + } + + let sessionCookie; + let loginSettings: LoginSettings | undefined; + let session; + let user: User; + + if ("loginName" in command) { + sessionCookie = await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); + }); + } else if (command.userId) { + const users = await listUsers({ + loginName: command.loginName, + organizationId: command.organization, + }); + + if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { + user = users.result[0]; + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: users.result[0].userId } }, + password: { password: command.checks.password?.password }, + }); + + loginSettings = await getLoginSettings(command.organization); + + session = await createSessionAndUpdateCookie( + checks, + undefined, + command.authRequestId, + loginSettings?.passwordCheckLifetime, + ); + } + + // this is a fake error message to hide that the user does not even exist + return { error: "Could not verify password" }; + } else { + session = await setSessionAndUpdateCookie( + sessionCookie, + command.checks, + undefined, + command.authRequestId, + loginSettings?.passwordCheckLifetime, + ); + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + const userResponse = await getUserByID(session?.factors?.user?.id); + + if (!userResponse.user) { + return { error: "Could not find user" }; + } + + user = userResponse.user; + } + + if (!loginSettings) { + loginSettings = await getLoginSettings( + command.organization ?? session.factors?.user?.organizationId, + ); + } + + if (!session?.factors?.user?.id || !sessionCookie) { + return { error: "Could not create session for user" }; + } + // const userResponse = await getUserByID(command.userId); + + // if (!userResponse || !userResponse.user) { + // return { error: "Could not load user" }; + // } + + // const checks = create(ChecksSchema, { + // user: { + // search: { + // case: "loginName", + // value: userResponse.user.preferredLoginName, + // }, + // }, + // }); + + // const session = await createSessionAndUpdateCookie( + // checks, + // undefined, + // command.authRequestId, + // ); + + const authMethodResponse = await listAuthenticationMethodTypes( + command.userId, + ); + + if (!authMethodResponse || !authMethodResponse.authMethodTypes) { + return { error: "Could not load possible authenticators" }; + } + // if no authmethods are found on the user, redirect to set one up + if ( + authMethodResponse && + authMethodResponse.authMethodTypes && + authMethodResponse.authMethodTypes.length == 0 + ) { + const params = new URLSearchParams({ + sessionId: session.id, + }); + + if (session.factors?.user?.loginName) { + params.set("loginName", session.factors?.user?.loginName); + } + return { redirect: `/authenticator/set?${params}` }; + } +} From 6cd0e7cb184359dfb178f4d48b8e2c5dd0167415 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Dec 2024 08:59:39 +0100 Subject: [PATCH 02/12] context on verification pages --- apps/login/src/app/(login)/verify/page.tsx | 50 +++-- apps/login/src/components/verify-form.tsx | 9 +- .../src/components/verify-redirect-button.tsx | 31 ++- apps/login/src/lib/server/password.ts | 2 +- apps/login/src/lib/server/verify.ts | 176 ++++++++++-------- 5 files changed, 164 insertions(+), 104 deletions(-) diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index fc11f45921..5250c2d8e9 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -3,6 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; import { VerifyRedirectButton } from "@/components/verify-redirect-button"; +import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID, @@ -23,9 +24,17 @@ export default async function Page(props: { searchParams: Promise }) { const branding = await getBrandingSettings(organization); + let sessionFactors; let user: User | undefined; let human: HumanUser | undefined; - if (userId) { + let id: string | undefined; + + if ("loginName" in searchParams) { + sessionFactors = await loadMostRecentSession({ + loginName, + organization, + }); + } else if ("userId" in searchParams && userId) { const userResponse = await getUserByID(userId); if (userResponse) { user = userResponse.user; @@ -35,6 +44,8 @@ export default async function Page(props: { searchParams: Promise }) { } } + id = userId ?? sessionFactors?.factors?.user?.id; + let authMethods: AuthenticationMethodType[] | null = null; if (human?.email?.isVerified) { const authMethodsResponse = await listAuthenticationMethodTypes(userId); @@ -66,7 +77,7 @@ export default async function Page(props: { searchParams: Promise }) {

{t("verify.title")}

{t("verify.description")}

- {!userId && ( + {!id && ( <>

{t("verify.title")}

{t("verify.description")}

@@ -85,21 +96,26 @@ export default async function Page(props: { searchParams: Promise }) { /> )} - {human?.email?.isVerified ? ( - - ) : ( - // check if auth methods are set - - )} + {id && + (human?.email?.isVerified ? ( + // show page for already verified users + + ) : ( + // check if auth methods are set + + ))} ); diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index b575b4e9b5..6f0ab7431d 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -17,12 +17,19 @@ type Inputs = { type Props = { userId: string; + loginName?: string; code?: string; isInvite: boolean; params: URLSearchParams; }; -export function VerifyForm({ userId, code, isInvite, params }: Props) { +export function VerifyForm({ + userId, + loginName, + code, + isInvite, + params, +}: Props) { const t = useTranslations("verify"); const router = useRouter(); diff --git a/apps/login/src/components/verify-redirect-button.tsx b/apps/login/src/components/verify-redirect-button.tsx index 2a2f672326..09a00efe37 100644 --- a/apps/login/src/components/verify-redirect-button.tsx +++ b/apps/login/src/components/verify-redirect-button.tsx @@ -1,6 +1,9 @@ "use client"; -import { sendVerificationRedirectWithoutCheck } from "@/lib/server/verify"; +import { + sendVerificationRedirectWithoutCheck, + SendVerificationRedirectWithoutCheckCommand, +} from "@/lib/server/verify"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -11,12 +14,16 @@ import { Spinner } from "./spinner"; export function VerifyRedirectButton({ userId, + loginName, authRequestId, authMethods, + organization, }: { - userId: string; + userId?: string; + loginName?: string; authRequestId: string; authMethods: AuthenticationMethodType[] | null; + organization?: string; }) { const t = useTranslations("verify"); const [error, setError] = useState(""); @@ -26,10 +33,24 @@ export function VerifyRedirectButton({ async function submitAndContinue(): Promise { setLoading(true); - await sendVerificationRedirectWithoutCheck({ - userId, + let command = { + organization, authRequestId, - }) + } as SendVerificationRedirectWithoutCheckCommand; + + if (userId) { + command = { + ...command, + userId, + } as SendVerificationRedirectWithoutCheckCommand; + } else if (loginName) { + command = { + ...command, + loginName, + } as SendVerificationRedirectWithoutCheckCommand; + } + + await sendVerificationRedirectWithoutCheck(command) .catch((error) => { setError("Could not verify user"); return; diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 3dc7df720a..d16692a4d6 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -340,7 +340,7 @@ export async function checkSessionAndSetPassword({ } } -function checkMFAFactors( +export function checkMFAFactors( session: Session, loginSettings: LoginSettings | undefined, authMethods: AuthenticationMethodType[], diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index ff897ba844..741a3e7ca9 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -2,23 +2,22 @@ import { getLoginSettings, + getSession, getUserByID, listAuthenticationMethodTypes, - listUsers, resendEmailCode, resendInviteCode, verifyEmail, verifyInviteCode, } from "@/lib/zitadel"; import { create } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; -import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getNextUrl } from "../client"; import { getSessionCookieByLoginName } from "../cookies"; -import { - createSessionAndUpdateCookie, - setSessionAndUpdateCookie, -} from "./cookie"; +import { createSessionAndUpdateCookie } from "./cookie"; +import { checkMFAFactors } from "./password"; type VerifyUserByEmailCommand = { userId: string; @@ -96,16 +95,13 @@ export async function resendVerification(command: resendVerifyEmailCommand) { : resendEmailCode(command.userId); } -type SendVerificationRedirectWithoutCheckCommand = - | { - loginName: string; - organization?: string; - authRequestId?: string; - } - | { - userId: string; - authRequestId?: string; - }; +export type SendVerificationRedirectWithoutCheckCommand = { + organization?: string; + authRequestId?: string; +} & ( + | { userId: string; loginName?: never } + | { userId?: never; loginName: string } +); export async function sendVerificationRedirectWithoutCheck( command: SendVerificationRedirectWithoutCheckCommand, @@ -114,52 +110,31 @@ export async function sendVerificationRedirectWithoutCheck( return { error: "No userId, nor loginname provided" }; } - let sessionCookie; - let loginSettings: LoginSettings | undefined; - let session; - let user: User; + let session: Session | undefined; + let user: User | undefined; + + const loginSettings = await getLoginSettings(command.organization); if ("loginName" in command) { - sessionCookie = await getSessionCookieByLoginName({ + const sessionCookie = await getSessionCookieByLoginName({ loginName: command.loginName, organization: command.organization, }).catch((error) => { console.warn("Ignored error:", error); }); - } else if (command.userId) { - const users = await listUsers({ - loginName: command.loginName, - organizationId: command.organization, - }); - if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { - user = users.result[0]; - - const checks = create(ChecksSchema, { - user: { search: { case: "userId", value: users.result[0].userId } }, - password: { password: command.checks.password?.password }, - }); - - loginSettings = await getLoginSettings(command.organization); - - session = await createSessionAndUpdateCookie( - checks, - undefined, - command.authRequestId, - loginSettings?.passwordCheckLifetime, - ); + if (!sessionCookie) { + return { error: "Could not load session cookie" }; } - // this is a fake error message to hide that the user does not even exist - return { error: "Could not verify password" }; - } else { - session = await setSessionAndUpdateCookie( - sessionCookie, - command.checks, - undefined, - command.authRequestId, - loginSettings?.passwordCheckLifetime, - ); + session = await getSession({ + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); if (!session?.factors?.user?.id) { return { error: "Could not create session for user" }; @@ -167,50 +142,57 @@ export async function sendVerificationRedirectWithoutCheck( const userResponse = await getUserByID(session?.factors?.user?.id); - if (!userResponse.user) { - return { error: "Could not find user" }; + if (!userResponse?.user) { + return { error: "Could not load user" }; } user = userResponse.user; - } + } else if ("userId" in command) { + const userResponse = await getUserByID(command.userId); - if (!loginSettings) { - loginSettings = await getLoginSettings( - command.organization ?? session.factors?.user?.organizationId, + if (!userResponse?.user) { + return { error: "Could not load user" }; + } + + user = userResponse.user; + + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie( + checks, + undefined, + command.authRequestId, ); + + // this is a fake error message to hide that the user does not even exist + return { error: "Could not verify password" }; } - if (!session?.factors?.user?.id || !sessionCookie) { + if (!session?.factors?.user?.id) { return { error: "Could not create session for user" }; } - // const userResponse = await getUserByID(command.userId); - // if (!userResponse || !userResponse.user) { - // return { error: "Could not load user" }; - // } + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } - // const checks = create(ChecksSchema, { - // user: { - // search: { - // case: "loginName", - // value: userResponse.user.preferredLoginName, - // }, - // }, - // }); + if (!user) { + return { error: "Could not load user" }; + } - // const session = await createSessionAndUpdateCookie( - // checks, - // undefined, - // command.authRequestId, - // ); - - const authMethodResponse = await listAuthenticationMethodTypes( - command.userId, - ); + const authMethodResponse = await listAuthenticationMethodTypes(user.userId); if (!authMethodResponse || !authMethodResponse.authMethodTypes) { return { error: "Could not load possible authenticators" }; } + // if no authmethods are found on the user, redirect to set one up if ( authMethodResponse && @@ -226,4 +208,38 @@ export async function sendVerificationRedirectWithoutCheck( } return { redirect: `/authenticator/set?${params}` }; } + + // redirect to mfa factor if user has one, or redirect to set one up + checkMFAFactors( + session, + loginSettings, + authMethodResponse.authMethodTypes, + command.organization, + command.authRequestId, + ); + + // login user if no additional steps are required + if (command.authRequestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + authRequestId: command.authRequestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; } From ab0399629771da2c1fd2c62d066cb92cdceaa6c3 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Dec 2024 09:08:18 +0100 Subject: [PATCH 03/12] env variable --- apps/login/.env.integration | 2 +- apps/login/src/lib/server/password.ts | 8 +++++--- turbo.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/login/.env.integration b/apps/login/.env.integration index 03bff984d9..90adb84eee 100644 --- a/apps/login/.env.integration +++ b/apps/login/.env.integration @@ -1,3 +1,3 @@ ZITADEL_API_URL=http://localhost:22222 -CACHE_REVALIDATION_INTERVAL_IN_SECONDS=3600 +EMAIL_VERIFICATION=true DEBUG=true \ No newline at end of file diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index d16692a4d6..cbe9660fd7 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -175,9 +175,11 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: "Initial User not supported" }; } - // TODO add check to see if user was verified - - if (!humanUser?.email?.isVerified) { + // add check to see if user was verified + if ( + !humanUser?.email?.isVerified && + process.env.EMAIL_VERIFICATION === "true" + ) { const params = new URLSearchParams({ loginName: session.factors?.user?.loginName as string, }); diff --git a/turbo.json b/turbo.json index a98ff8726d..2817c8c157 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,7 @@ "ZITADEL_SYSTEM_API_KEY", "ZITADEL_ISSUER", "ZITADEL_ADMIN_TOKEN", - "CACHE_REVALIDATION_INTERVAL_IN_SECONDS", + "EMAIL_VERIFICATION", "VERCEL_URL" ], "tasks": { From ab5bcb9eea2dfb64877d52e207c59807f108cf3a Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Dec 2024 11:28:35 +0100 Subject: [PATCH 04/12] verify commands --- apps/login/src/app/(login)/verify/page.tsx | 22 +- apps/login/src/components/verify-form.tsx | 9 +- apps/login/src/lib/server/loginname.ts | 3 +- apps/login/src/lib/server/password.ts | 111 +-------- apps/login/src/lib/server/verify.ts | 247 ++++++++++++++------- apps/login/src/lib/verify-helper.ts | 110 +++++++++ apps/login/src/lib/zitadel.ts | 32 ++- 7 files changed, 330 insertions(+), 204 deletions(-) create mode 100644 apps/login/src/lib/verify-helper.ts diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 5250c2d8e9..0a45ce5a3e 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -88,12 +88,21 @@ export default async function Page(props: { searchParams: Promise }) { )} - {user && ( + {sessionFactors ? ( + loginName={loginName ?? sessionFactors.factors?.user?.loginName} + displayName={sessionFactors.factors?.user?.displayName} + showDropdown + searchParams={searchParams} + > + ) : ( + user && ( + + ) )} {id && @@ -110,10 +119,11 @@ export default async function Page(props: { searchParams: Promise }) { // check if auth methods are set ))} diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 6f0ab7431d..2eea113398 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -18,17 +18,19 @@ type Inputs = { type Props = { userId: string; loginName?: string; + organization?: string; code?: string; isInvite: boolean; - params: URLSearchParams; + authRequestId?: string; }; export function VerifyForm({ userId, loginName, + organization, + authRequestId, code, isInvite, - params, }: Props) { const t = useTranslations("verify"); @@ -74,6 +76,9 @@ export function VerifyForm({ code: value.code, userId, isInvite: isInvite, + loginName: loginName, + organization: organization, + authRequestId: authRequestId, }) .catch(() => { setError("Could not verify user"); diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 295f9b455f..74d1bacdaa 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -350,8 +350,9 @@ export async function sendLoginname(command: SendLoginnameCommand) { if (command.authRequestId) { params.set("authRequestId", command.authRequestId); } + if (command.loginName) { - params.set("loginName", command.loginName); + params.set("email", command.loginName); } return { redirect: "/register?" + params }; diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index cbe9660fd7..d766b05746 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -17,7 +17,6 @@ import { import { create } from "@zitadel/client"; import { createUserServiceClient } from "@zitadel/client/v2"; import { createServerTransport } from "@zitadel/node"; -import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Checks, ChecksSchema, @@ -31,6 +30,7 @@ import { import { headers } from "next/headers"; import { getNextUrl } from "../client"; import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; +import { checkMFAFactors } from "../verify-helper"; type ResetPasswordCommand = { loginName: string; @@ -196,7 +196,7 @@ export async function sendPassword(command: UpdateSessionCommand) { ); } - return { redirect: `/verify` + params }; + return { redirect: `/verify?` + params }; } checkMFAFactors( @@ -341,110 +341,3 @@ export async function checkSessionAndSetPassword({ }); } } - -export function checkMFAFactors( - session: Session, - loginSettings: LoginSettings | undefined, - authMethods: AuthenticationMethodType[], - organization?: string, - authRequestId?: string, -) { - const availableMultiFactors = authMethods?.filter( - (m: AuthenticationMethodType) => - m !== AuthenticationMethodType.PASSWORD && - m !== AuthenticationMethodType.PASSKEY, - ); - - if (availableMultiFactors?.length == 1) { - const params = new URLSearchParams({ - loginName: session.factors?.user?.loginName as string, - }); - - if (authRequestId) { - params.append("authRequestId", authRequestId); - } - - if (organization || session.factors?.user?.organizationId) { - params.append( - "organization", - organization ?? (session.factors?.user?.organizationId as string), - ); - } - - const factor = availableMultiFactors[0]; - // if passwordless is other method, but user selected password as alternative, perform a login - if (factor === AuthenticationMethodType.TOTP) { - return { redirect: `/otp/time-based?` + params }; - } else if (factor === AuthenticationMethodType.OTP_SMS) { - return { redirect: `/otp/sms?` + params }; - } else if (factor === AuthenticationMethodType.OTP_EMAIL) { - return { redirect: `/otp/email?` + params }; - } else if (factor === AuthenticationMethodType.U2F) { - return { redirect: `/u2f?` + params }; - } - } else if (availableMultiFactors?.length >= 1) { - const params = new URLSearchParams({ - loginName: session.factors?.user?.loginName as string, - }); - - if (authRequestId) { - params.append("authRequestId", authRequestId); - } - - if (organization || session.factors?.user?.organizationId) { - params.append( - "organization", - organization ?? (session.factors?.user?.organizationId as string), - ); - } - - return { redirect: `/mfa?` + params }; - } else if ( - (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && - !availableMultiFactors.length - ) { - const params = new URLSearchParams({ - loginName: session.factors?.user?.loginName as string, - force: "true", // this defines if the mfa is forced in the settings - checkAfter: "true", // this defines if the check is directly made after the setup - }); - - if (authRequestId) { - params.append("authRequestId", authRequestId); - } - - 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 (authRequestId) { - // params.append("authRequestId", authRequestId); - // } - - // if (organization) { - // params.append("organization", organization); - // } - - // return router.push(`/passkey/set?` + params); - // } -} diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index 741a3e7ca9..a91ea9842a 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -14,13 +14,16 @@ import { create } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { headers } from "next/headers"; import { getNextUrl } from "../client"; import { getSessionCookieByLoginName } from "../cookies"; +import { checkMFAFactors } from "../verify-helper"; import { createSessionAndUpdateCookie } from "./cookie"; -import { checkMFAFactors } from "./password"; type VerifyUserByEmailCommand = { userId: string; + loginName?: string; // to determine already existing session + organization?: string; code: string; isInvite: boolean; authRequestId?: string; @@ -39,82 +42,9 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { return { error: "Could not verify user" }; } - const userResponse = await getUserByID(command.userId); - - if (!userResponse || !userResponse.user) { - return { error: "Could not load user" }; - } - - const checks = create(ChecksSchema, { - user: { - search: { - case: "loginName", - value: userResponse.user.preferredLoginName, - }, - }, - }); - - const session = await createSessionAndUpdateCookie( - checks, - undefined, - command.authRequestId, - ); - - const authMethodResponse = await listAuthenticationMethodTypes( - command.userId, - ); - - if (!authMethodResponse || !authMethodResponse.authMethodTypes) { - return { error: "Could not load possible authenticators" }; - } - // if no authmethods are found on the user, redirect to set one up - if ( - authMethodResponse && - authMethodResponse.authMethodTypes && - authMethodResponse.authMethodTypes.length == 0 - ) { - const params = new URLSearchParams({ - sessionId: session.id, - }); - - if (session.factors?.user?.loginName) { - params.set("loginName", session.factors?.user?.loginName); - } - return { redirect: `/authenticator/set?${params}` }; - } -} - -type resendVerifyEmailCommand = { - userId: string; - isInvite: boolean; -}; - -export async function resendVerification(command: resendVerifyEmailCommand) { - return command.isInvite - ? resendInviteCode(command.userId) - : resendEmailCode(command.userId); -} - -export type SendVerificationRedirectWithoutCheckCommand = { - organization?: string; - authRequestId?: string; -} & ( - | { userId: string; loginName?: never } - | { userId?: never; loginName: string } -); - -export async function sendVerificationRedirectWithoutCheck( - command: SendVerificationRedirectWithoutCheckCommand, -) { - if (!("loginName" in command || "userId" in command)) { - return { error: "No userId, nor loginname provided" }; - } - let session: Session | undefined; let user: User | undefined; - const loginSettings = await getLoginSettings(command.organization); - if ("loginName" in command) { const sessionCookie = await getSessionCookieByLoginName({ loginName: command.loginName, @@ -147,10 +77,10 @@ export async function sendVerificationRedirectWithoutCheck( } user = userResponse.user; - } else if ("userId" in command) { + } else { const userResponse = await getUserByID(command.userId); - if (!userResponse?.user) { + if (!userResponse || !userResponse.user) { return { error: "Could not load user" }; } @@ -170,9 +100,6 @@ export async function sendVerificationRedirectWithoutCheck( undefined, command.authRequestId, ); - - // this is a fake error message to hide that the user does not even exist - return { error: "Could not verify password" }; } if (!session?.factors?.user?.id) { @@ -187,6 +114,8 @@ export async function sendVerificationRedirectWithoutCheck( return { error: "Could not load user" }; } + const loginSettings = await getLoginSettings(user.details?.resourceOwner); + const authMethodResponse = await listAuthenticationMethodTypes(user.userId); if (!authMethodResponse || !authMethodResponse.authMethodTypes) { @@ -243,3 +172,163 @@ export async function sendVerificationRedirectWithoutCheck( return { redirect: url }; } + +type resendVerifyEmailCommand = { + userId: string; + isInvite: boolean; + authRequestId?: string; +}; + +export async function resendVerification(command: resendVerifyEmailCommand) { + const host = (await headers()).get("host"); + + return command.isInvite + ? resendInviteCode(command.userId) + : resendEmailCode(command.userId, host, command.authRequestId); +} + +export type SendVerificationRedirectWithoutCheckCommand = { + organization?: string; + authRequestId?: string; +} & ( + | { userId: string; loginName?: never } + | { userId?: never; loginName: string } +); + +export async function sendVerificationRedirectWithoutCheck( + command: SendVerificationRedirectWithoutCheckCommand, +) { + if (!("loginName" in command || "userId" in command)) { + return { error: "No userId, nor loginname provided" }; + } + + let session: Session | undefined; + let user: User | undefined; + + if ("loginName" in command) { + const sessionCookie = await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); + }); + + if (!sessionCookie) { + return { error: "Could not load session cookie" }; + } + + session = await getSession({ + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + const userResponse = await getUserByID(session?.factors?.user?.id); + + if (!userResponse?.user) { + return { error: "Could not load user" }; + } + + user = userResponse.user; + } else if ("userId" in command) { + const userResponse = await getUserByID(command.userId); + + if (!userResponse?.user) { + return { error: "Could not load user" }; + } + + user = userResponse.user; + + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie( + checks, + undefined, + command.authRequestId, + ); + } + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + if (!user) { + return { error: "Could not load user" }; + } + + const authMethodResponse = await listAuthenticationMethodTypes(user.userId); + + if (!authMethodResponse || !authMethodResponse.authMethodTypes) { + return { error: "Could not load possible authenticators" }; + } + + // if no authmethods are found on the user, redirect to set one up + if ( + authMethodResponse && + authMethodResponse.authMethodTypes && + authMethodResponse.authMethodTypes.length == 0 + ) { + const params = new URLSearchParams({ + sessionId: session.id, + }); + + if (session.factors?.user?.loginName) { + params.set("loginName", session.factors?.user?.loginName); + } + return { redirect: `/authenticator/set?${params}` }; + } + + const loginSettings = await getLoginSettings(user.details?.resourceOwner); + + // redirect to mfa factor if user has one, or redirect to set one up + checkMFAFactors( + session, + loginSettings, + authMethodResponse.authMethodTypes, + command.organization, + command.authRequestId, + ); + + // login user if no additional steps are required + if (command.authRequestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + authRequestId: command.authRequestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts new file mode 100644 index 0000000000..010ed18362 --- /dev/null +++ b/apps/login/src/lib/verify-helper.ts @@ -0,0 +1,110 @@ +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; + +export function checkMFAFactors( + session: Session, + loginSettings: LoginSettings | undefined, + authMethods: AuthenticationMethodType[], + organization?: string, + authRequestId?: string, +) { + const availableMultiFactors = authMethods?.filter( + (m: AuthenticationMethodType) => + m !== AuthenticationMethodType.PASSWORD && + m !== AuthenticationMethodType.PASSKEY, + ); + + if (availableMultiFactors?.length == 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + const factor = availableMultiFactors[0]; + // if passwordless is other method, but user selected password as alternative, perform a login + if (factor === AuthenticationMethodType.TOTP) { + return { redirect: `/otp/time-based?` + params }; + } else if (factor === AuthenticationMethodType.OTP_SMS) { + return { redirect: `/otp/sms?` + params }; + } else if (factor === AuthenticationMethodType.OTP_EMAIL) { + return { redirect: `/otp/email?` + params }; + } else if (factor === AuthenticationMethodType.U2F) { + return { redirect: `/u2f?` + params }; + } + } else if (availableMultiFactors?.length >= 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/mfa?` + params }; + } else if ( + (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && + !availableMultiFactors.length + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "true", // this defines if the mfa is forced in the settings + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + 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 (authRequestId) { + // params.append("authRequestId", authRequestId); + // } + + // 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 aaf0a7c05b..8cf756807b 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -12,6 +12,8 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { AddHumanUserRequest, + ResendEmailCodeRequest, + ResendEmailCodeRequestSchema, RetrieveIdentityProviderIntentRequest, SetPasswordRequest, SetPasswordRequestSchema, @@ -23,6 +25,7 @@ import { create, Duration } from "@zitadel/client"; import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb"; import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import { NotificationType, @@ -448,13 +451,28 @@ export async function verifyEmail(userId: string, verificationCode: string) { ); } -export async function resendEmailCode(userId: string) { - return userService.resendEmailCode( - { - userId, - }, - {}, - ); +export async function resendEmailCode( + userId: string, + host: string | null, + authRequestId?: string, +) { + let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, { + userId, + }); + + if (host) { + const medium = create(SendEmailVerificationCodeSchema, { + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (authRequestId ? `&authRequestId=${authRequestId}` : ""), + }); + + request = { ...request, verification: { case: "sendCode", value: medium } }; + } + + console.log(request); + + return userService.resendEmailCode(request, {}); } export function retrieveIDPIntent(id: string, token: string) { From ed584c59e196523670c521bf1e1535d77cb938f6 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Dec 2024 15:12:50 +0100 Subject: [PATCH 05/12] move email verification --- apps/login/src/components/login-passkey.tsx | 4 +- apps/login/src/lib/server/password.ts | 55 ++++++++------------- apps/login/src/lib/verify-helper.ts | 30 +++++++++++ packages/zitadel-client/src/index.ts | 1 + 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index 2f1cd53363..bf1ae01207 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -3,7 +3,7 @@ import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; import { getNextUrl } from "@/lib/client"; import { updateSession } from "@/lib/server/session"; -import { create } from "@zitadel/client"; +import { create, JsonObject } from "@zitadel/client"; import { RequestChallengesSchema, UserVerificationRequirement, @@ -118,7 +118,7 @@ export function LoginPasskey({ return session; } - async function submitLogin(data: any) { + async function submitLogin(data: JsonObject) { setLoading(true); const response = await updateSession({ loginName, diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index d766b05746..f35f71b6d9 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -30,7 +30,7 @@ import { import { headers } from "next/headers"; import { getNextUrl } from "../client"; import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; -import { checkMFAFactors } from "../verify-helper"; +import { checkEmailVerification, checkMFAFactors } from "../verify-helper"; type ResetPasswordCommand = { loginName: string; @@ -135,21 +135,6 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: "Could not create session for user" }; } - // if password, check if user has MFA methods - let authMethods; - if (command.checks && command.checks.password && session.factors?.user?.id) { - const response = await listAuthenticationMethodTypes( - session.factors.user.id, - ); - if (response.authMethodTypes && response.authMethodTypes.length) { - authMethods = response.authMethodTypes; - } - } - - if (!authMethods || !session.factors?.user?.loginName) { - return { error: "Could not verify password!" }; - } - const humanUser = user.type.case === "human" ? user.type.value : undefined; // check if the user has to change password first @@ -175,28 +160,28 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: "Initial User not supported" }; } - // add check to see if user was verified - if ( - !humanUser?.email?.isVerified && - process.env.EMAIL_VERIFICATION === "true" - ) { - const params = new URLSearchParams({ - loginName: session.factors?.user?.loginName as string, - }); + // check to see if user was verified - if (command.authRequestId) { - params.append("authRequestId", command.authRequestId); + checkEmailVerification( + session, + humanUser, + command.organization, + command.authRequestId, + ); + + // if password, check if user has MFA methods + let authMethods; + if (command.checks && command.checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes( + session.factors.user.id, + ); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; } + } - if (command.organization || session.factors?.user?.organizationId) { - params.append( - "organization", - command.organization ?? - (session.factors?.user?.organizationId as string), - ); - } - - return { redirect: `/verify?` + params }; + if (!authMethods) { + return { error: "Could not verify password!" }; } checkMFAFactors( diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index 010ed18362..02a940f160 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -1,7 +1,37 @@ import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +export function checkEmailVerification( + session: Session, + humanUser?: HumanUser, + organization?: string, + authRequestId?: string, +) { + if ( + !humanUser?.email?.isVerified && + process.env.EMAIL_VERIFICATION === "true" + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } +} + export function checkMFAFactors( session: Session, loginSettings: LoginSettings | undefined, diff --git a/packages/zitadel-client/src/index.ts b/packages/zitadel-client/src/index.ts index 7cf14163bf..64c3af5050 100644 --- a/packages/zitadel-client/src/index.ts +++ b/packages/zitadel-client/src/index.ts @@ -3,5 +3,6 @@ export { NewAuthorizationBearerInterceptor } from "./interceptors"; // TODO: Move this to `./protobuf.ts` and export it from there export { create, fromJson, toJson } from "@bufbuild/protobuf"; +export type { JsonObject } from "@bufbuild/protobuf"; export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; From f1f7d661cef813824496162ae0d718c49fe8fc97 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 20 Dec 2024 08:44:59 +0100 Subject: [PATCH 06/12] passkey actions cleanup --- apps/login/src/components/login-passkey.tsx | 34 +----- .../login/src/components/register-passkey.tsx | 7 +- apps/login/src/lib/server/passkeys.ts | 105 +++++++++++++++++- apps/login/src/lib/server/password.ts | 29 ++--- apps/login/src/lib/server/session.ts | 25 ++++- apps/login/src/lib/verify-helper.ts | 26 +++++ 6 files changed, 170 insertions(+), 56 deletions(-) diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index bf1ae01207..5e05cdb6a8 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -1,7 +1,7 @@ "use client"; import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; -import { getNextUrl } from "@/lib/client"; +import { sendPasskey } from "@/lib/server/passkeys"; import { updateSession } from "@/lib/server/session"; import { create, JsonObject } from "@zitadel/client"; import { @@ -120,7 +120,7 @@ export function LoginPasskey({ async function submitLogin(data: JsonObject) { setLoading(true); - const response = await updateSession({ + const response = await sendPasskey({ loginName, sessionId, organization, @@ -142,7 +142,9 @@ export function LoginPasskey({ return; } - return response; + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } } async function submitLoginAndContinue( @@ -192,31 +194,7 @@ export function LoginPasskey({ }, }; - return submitLogin(data).then(async (resp) => { - const url = - authRequestId && resp?.sessionId - ? await getNextUrl( - { - sessionId: resp.sessionId, - authRequestId: authRequestId, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : resp?.factors?.user?.loginName - ? await getNextUrl( - { - loginName: resp.factors.user.loginName, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : null; - - if (url) { - router.push(url); - } - }); + return submitLogin(data); }) .finally(() => { setLoading(false); diff --git a/apps/login/src/components/register-passkey.tsx b/apps/login/src/components/register-passkey.tsx index 9a71830261..e737168678 100644 --- a/apps/login/src/components/register-passkey.tsx +++ b/apps/login/src/components/register-passkey.tsx @@ -1,7 +1,10 @@ "use client"; import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; -import { registerPasskeyLink, verifyPasskey } from "@/lib/server/passkeys"; +import { + registerPasskeyLink, + verifyPasskeyRegistration, +} from "@/lib/server/passkeys"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -45,7 +48,7 @@ export function RegisterPasskey({ sessionId: string, ) { setLoading(true); - const response = await verifyPasskey({ + const response = await verifyPasskeyRegistration({ passkeyId, passkeyName, publicKeyCredential, diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index 181962cae1..f34702a61e 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -2,18 +2,28 @@ import { createPasskeyRegistrationLink, + getLoginSettings, getSession, + getUserByID, registerPasskey, - verifyPasskeyRegistration, + verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration, } from "@/lib/zitadel"; -import { create } from "@zitadel/client"; +import { create, Duration } from "@zitadel/client"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { RegisterPasskeyResponse, VerifyPasskeyRegistrationRequestSchema, } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { headers } from "next/headers"; import { userAgent } from "next/server"; -import { getSessionCookieById } from "../cookies"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { checkEmailVerification } from "../verify-helper"; +import { setSessionAndUpdateCookie } from "./cookie"; type VerifyPasskeyCommand = { passkeyId: string; @@ -69,7 +79,7 @@ export async function registerPasskeyLink( return registerPasskey(userId, registerLink.code, hostname); } -export async function verifyPasskey(command: VerifyPasskeyCommand) { +export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { // if no name is provided, try to generate one from the user agent let passkeyName = command.passkeyName; if (!!!passkeyName) { @@ -95,7 +105,7 @@ export async function verifyPasskey(command: VerifyPasskeyCommand) { throw new Error("Could not get session"); } - return verifyPasskeyRegistration( + return zitadelVerifyPasskeyRegistration( create(VerifyPasskeyRegistrationRequestSchema, { passkeyId: command.passkeyId, publicKeyCredential: command.publicKeyCredential, @@ -104,3 +114,88 @@ export async function verifyPasskey(command: VerifyPasskeyCommand) { }), ); } + +type SendPasskeyCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + authRequestId?: string; + lifetime?: Duration; +}; + +export async function sendPasskey(command: SendPasskeyCommand) { + let { loginName, sessionId, organization, checks, authRequestId } = command; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const host = (await headers()).get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const loginSettings = await getLoginSettings(organization); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + authRequestId, + lifetime, + ); + + if (!session || !session?.factors?.user?.id) { + return { error: "Could not update session" }; + } + + const userResponse = await getUserByID(session?.factors?.user?.id); + + if (!userResponse.user) { + return { error: "Could not find user" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + checkEmailVerification(session, humanUser, organization, authRequestId); + + const url = + authRequestId && session.id + ? await getNextUrl( + { + sessionId: session.id, + authRequestId: authRequestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : session?.factors?.user?.loginName + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + return { redirect: url }; +} diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index f35f71b6d9..c89cf02cea 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -30,7 +30,11 @@ import { import { headers } from "next/headers"; import { getNextUrl } from "../client"; import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; -import { checkEmailVerification, checkMFAFactors } from "../verify-helper"; +import { + checkEmailVerification, + checkMFAFactors, + checkPasswordChangeRequired, +} from "../verify-helper"; type ResetPasswordCommand = { loginName: string; @@ -138,30 +142,19 @@ export async function sendPassword(command: UpdateSessionCommand) { const humanUser = user.type.case === "human" ? user.type.value : undefined; // check if the user has to change password first - if (humanUser?.passwordChangeRequired) { - const params = new URLSearchParams({ - loginName: session.factors?.user?.loginName, - }); - - if (command.organization || session.factors?.user?.organizationId) { - params.append("organization", session.factors?.user?.organizationId); - } - - if (command.authRequestId) { - params.append("authRequestId", command.authRequestId); - } - - return { redirect: "/password/change?" + params }; - } + checkPasswordChangeRequired( + session, + humanUser, + command.organization, + command.authRequestId, + ); // throw error if user is in initial state here and do not continue - if (user.state === UserState.INITIAL) { return { error: "Initial User not supported" }; } // check to see if user was verified - checkEmailVerification( session, humanUser, diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 69aac95a10..566f4818d3 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -22,6 +22,7 @@ import { getSessionCookieByLoginName, removeSessionFromCookie, } from "../cookies"; +import { checkPasswordChangeRequired } from "../verify-helper"; type CreateNewSessionCommand = { userId: string; @@ -41,13 +42,15 @@ export async function createNewSessionForIdp(options: CreateNewSessionCommand) { throw new Error("No userId or loginName provided"); } - const user = await getUserByID(userId); + const userResponse = await getUserByID(userId); - if (!user) { + if (!userResponse || !userResponse.user) { return { error: "Could not find user" }; } - const loginSettings = await getLoginSettings(user.details?.resourceOwner); + const loginSettings = await getLoginSettings( + userResponse.user.details?.resourceOwner, + ); const session = await createSessionForIdpAndUpdateCookie( userId, @@ -60,6 +63,22 @@ export async function createNewSessionForIdp(options: CreateNewSessionCommand) { return { error: "Could not create session" }; } + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + // check if the user has to change password first + checkPasswordChangeRequired( + session, + humanUser, + session.factors.user.organizationId, + authRequestId, + ); + + // TODO: check if user has MFA methods + // checkMFAFactors(session, loginSettings, authMethods, organization, authRequestId); + const url = await getNextUrl( authRequestId && session.id ? { diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index 02a940f160..ec88807c71 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -3,6 +3,32 @@ import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +export function checkPasswordChangeRequired( + session: Session, + humanUser: HumanUser | undefined, + organization?: string, + authRequestId?: string, +) { + if (humanUser?.passwordChangeRequired) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + session.factors?.user?.organizationId as string, + ); + } + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + return { redirect: "/password/change?" + params }; + } +} + export function checkEmailVerification( session: Session, humanUser?: HumanUser, From 670ed71dd163b443e92d1525538f411507c56fc7 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 20 Dec 2024 10:57:56 +0100 Subject: [PATCH 07/12] cleanup idp, cleanup session actions --- apps/login/src/components/idp-signin.tsx | 4 +- apps/login/src/lib/server/idp.ts | 88 +++++++++++++++++++++++- apps/login/src/lib/server/passkeys.ts | 11 ++- apps/login/src/lib/server/password.ts | 12 +++- apps/login/src/lib/server/session.ts | 81 +--------------------- apps/login/src/lib/verify-helper.ts | 5 ++ 6 files changed, 115 insertions(+), 86 deletions(-) diff --git a/apps/login/src/components/idp-signin.tsx b/apps/login/src/components/idp-signin.tsx index 543cd64b2c..c2f3fe40b3 100644 --- a/apps/login/src/components/idp-signin.tsx +++ b/apps/login/src/components/idp-signin.tsx @@ -1,6 +1,6 @@ "use client"; -import { createNewSessionForIdp } from "@/lib/server/session"; +import { createNewSessionFromIdpIntent } from "@/lib/server/idp"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Alert } from "./alert"; @@ -27,7 +27,7 @@ export function IdpSignin({ const router = useRouter(); useEffect(() => { - createNewSessionForIdp({ + createNewSessionFromIdpIntent({ userId, idpIntent: { idpIntentId, diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index ebb755987e..35eadc70f9 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -1,7 +1,14 @@ "use server"; -import { startIdentityProviderFlow } from "@/lib/zitadel"; +import { + getLoginSettings, + getUserByID, + startIdentityProviderFlow, +} from "@/lib/zitadel"; import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { checkEmailVerification } from "../verify-helper"; +import { createSessionForIdpAndUpdateCookie } from "./cookie"; export type StartIDPFlowCommand = { idpId: string; @@ -32,3 +39,82 @@ export async function startIDPFlow(command: StartIDPFlowCommand) { } }); } + +type CreateNewSessionCommand = { + userId: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + loginName?: string; + password?: string; + organization?: string; + authRequestId?: string; +}; + +export async function createNewSessionFromIdpIntent( + command: CreateNewSessionCommand, +) { + if (!command.userId || !command.idpIntent) { + throw new Error("No userId or loginName provided"); + } + + const userResponse = await getUserByID(command.userId); + + if (!userResponse || !userResponse.user) { + return { error: "Could not find user" }; + } + + const loginSettings = await getLoginSettings( + userResponse.user.details?.resourceOwner, + ); + + const session = await createSessionForIdpAndUpdateCookie( + command.userId, + command.idpIntent, + command.authRequestId, + loginSettings?.externalLoginCheckLifetime, + ); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.authRequestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // TODO: check if user has MFA methods + // checkMFAFactors(session, loginSettings, authMethods, organization, authRequestId); + + const url = await getNextUrl( + command.authRequestId && session.id + ? { + sessionId: session.id, + authRequestId: command.authRequestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + if (url) { + return { redirect: url }; + } +} diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index f34702a61e..ca27d310f3 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -175,7 +175,16 @@ export async function sendPasskey(command: SendPasskeyCommand) { ? userResponse.user.type.value : undefined; - checkEmailVerification(session, humanUser, organization, authRequestId); + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + organization, + authRequestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } const url = authRequestId && session.id diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index c89cf02cea..68206f06a3 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -142,26 +142,34 @@ export async function sendPassword(command: UpdateSessionCommand) { const humanUser = user.type.case === "human" ? user.type.value : undefined; // check if the user has to change password first - checkPasswordChangeRequired( + const passwordChangedCheck = checkPasswordChangeRequired( session, humanUser, command.organization, command.authRequestId, ); + if (passwordChangedCheck?.redirect) { + return passwordChangedCheck; + } + // throw error if user is in initial state here and do not continue if (user.state === UserState.INITIAL) { return { error: "Initial User not supported" }; } // check to see if user was verified - checkEmailVerification( + const emailVerificationCheck = checkEmailVerification( session, humanUser, command.organization, command.authRequestId, ); + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + // if password, check if user has MFA methods let authMethods; if (command.checks && command.checks.password && session.factors?.user?.id) { diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 566f4818d3..70bc18f6d5 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -1,13 +1,9 @@ "use server"; -import { - createSessionForIdpAndUpdateCookie, - setSessionAndUpdateCookie, -} from "@/lib/server/cookie"; +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; import { deleteSession, getLoginSettings, - getUserByID, listAuthenticationMethodTypes, } from "@/lib/zitadel"; import { Duration } from "@zitadel/client"; @@ -22,81 +18,6 @@ import { getSessionCookieByLoginName, removeSessionFromCookie, } from "../cookies"; -import { checkPasswordChangeRequired } from "../verify-helper"; - -type CreateNewSessionCommand = { - userId: string; - idpIntent: { - idpIntentId: string; - idpIntentToken: string; - }; - loginName?: string; - password?: string; - authRequestId?: string; -}; - -export async function createNewSessionForIdp(options: CreateNewSessionCommand) { - const { userId, idpIntent, authRequestId } = options; - - if (!userId || !idpIntent) { - throw new Error("No userId or loginName provided"); - } - - const userResponse = await getUserByID(userId); - - if (!userResponse || !userResponse.user) { - return { error: "Could not find user" }; - } - - const loginSettings = await getLoginSettings( - userResponse.user.details?.resourceOwner, - ); - - const session = await createSessionForIdpAndUpdateCookie( - userId, - idpIntent, - authRequestId, - loginSettings?.externalLoginCheckLifetime, - ); - - if (!session || !session.factors?.user) { - return { error: "Could not create session" }; - } - - const humanUser = - userResponse.user.type.case === "human" - ? userResponse.user.type.value - : undefined; - - // check if the user has to change password first - checkPasswordChangeRequired( - session, - humanUser, - session.factors.user.organizationId, - authRequestId, - ); - - // TODO: check if user has MFA methods - // checkMFAFactors(session, loginSettings, authMethods, organization, authRequestId); - - const url = await getNextUrl( - authRequestId && session.id - ? { - sessionId: session.id, - authRequestId: authRequestId, - organization: session.factors.user.organizationId, - } - : { - loginName: session.factors.user.loginName, - organization: session.factors.user.organizationId, - }, - loginSettings?.defaultRedirectUri, - ); - - if (url) { - return { redirect: url }; - } -} export async function continueWithSession({ authRequestId, diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index ec88807c71..2161e08168 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -35,6 +35,11 @@ export function checkEmailVerification( organization?: string, authRequestId?: string, ) { + console.log( + humanUser?.email, + process.env.EMAIL_VERIFICATION, + process.env.EMAIL_VERIFICATION === "true", + ); if ( !humanUser?.email?.isVerified && process.env.EMAIL_VERIFICATION === "true" From 2951b617cea1d8d16cb4e2bd313e16817ab687e1 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 23 Dec 2024 16:26:20 +0100 Subject: [PATCH 08/12] verify check, mfa check response --- apps/login/src/lib/server/idp.ts | 5 ++++- apps/login/src/lib/server/password.ts | 6 +++++- apps/login/src/lib/server/register.ts | 31 +++++++++++++++++++++++---- apps/login/src/lib/server/verify.ts | 12 +++++++++-- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 35eadc70f9..fb9cf66a4f 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -98,7 +98,10 @@ export async function createNewSessionFromIdpIntent( } // TODO: check if user has MFA methods - // checkMFAFactors(session, loginSettings, authMethods, organization, authRequestId); + // const mfaFactorCheck = checkMFAFactors(session, loginSettings, authMethods, organization, authRequestId); + // if (mfaFactorCheck?.redirect) { + // return mfaFactorCheck; + // } const url = await getNextUrl( command.authRequestId && session.id diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 68206f06a3..3a6805e59e 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -185,7 +185,7 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: "Could not verify password!" }; } - checkMFAFactors( + const mfaFactorCheck = checkMFAFactors( session, loginSettings, authMethods, @@ -193,6 +193,10 @@ export async function sendPassword(command: UpdateSessionCommand) { command.authRequestId, ); + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + if (command.authRequestId && session.id) { const nextUrl = await getNextUrl( { diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 01ddb0d8c8..2902d9ac60 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -1,7 +1,7 @@ "use server"; import { createSessionAndUpdateCookie } from "@/lib/server/cookie"; -import { addHumanUser, getLoginSettings } from "@/lib/zitadel"; +import { addHumanUser, getLoginSettings, getUserByID } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { @@ -9,6 +9,7 @@ import { ChecksSchema, } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { getNextUrl } from "../client"; +import { checkEmailVerification } from "../verify-helper"; type RegisterUserCommand = { email: string; @@ -25,7 +26,7 @@ export type RegisterUserResponse = { factors: Factors | undefined; }; export async function registerUser(command: RegisterUserCommand) { - const human = await addHumanUser({ + const addResponse = await addHumanUser({ email: command.email, firstName: command.firstName, lastName: command.lastName, @@ -33,14 +34,14 @@ export async function registerUser(command: RegisterUserCommand) { organization: command.organization, }); - if (!human) { + if (!addResponse) { return { error: "Could not create user" }; } const loginSettings = await getLoginSettings(command.organization); let checkPayload: any = { - user: { search: { case: "userId", value: human.userId } }, + user: { search: { case: "userId", value: addResponse.userId } }, }; if (command.password) { @@ -75,6 +76,28 @@ export async function registerUser(command: RegisterUserCommand) { return { redirect: "/passkey/set?" + params }; } else { + const userResponse = await getUserByID(session?.factors?.user?.id); + + if (!userResponse.user) { + return { error: "Could not find user" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + session.factors.user.organizationId, + command.authRequestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + const url = await getNextUrl( command.authRequestId && session.id ? { diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index a91ea9842a..61c4bbb806 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -139,7 +139,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { } // redirect to mfa factor if user has one, or redirect to set one up - checkMFAFactors( + const mfaFactorCheck = checkMFAFactors( session, loginSettings, authMethodResponse.authMethodTypes, @@ -147,6 +147,10 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { command.authRequestId, ); + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + // login user if no additional steps are required if (command.authRequestId && session.id) { const nextUrl = await getNextUrl( @@ -299,7 +303,7 @@ export async function sendVerificationRedirectWithoutCheck( const loginSettings = await getLoginSettings(user.details?.resourceOwner); // redirect to mfa factor if user has one, or redirect to set one up - checkMFAFactors( + const mfaFactorCheck = checkMFAFactors( session, loginSettings, authMethodResponse.authMethodTypes, @@ -307,6 +311,10 @@ export async function sendVerificationRedirectWithoutCheck( command.authRequestId, ); + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + // login user if no additional steps are required if (command.authRequestId && session.id) { const nextUrl = await getNextUrl( From 53fc22e048be489b10d641ab340b6514b2edeff0 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 24 Dec 2024 09:15:12 +0100 Subject: [PATCH 09/12] skipsend, checkinvite --- apps/login/src/app/(login)/verify/page.tsx | 26 ++++++++++++++-- apps/login/src/components/verify-form.tsx | 4 +-- apps/login/src/lib/server/loginname.ts | 35 +++++++++------------- apps/login/src/lib/verify-helper.ts | 33 ++++++++++++++++---- 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 0a45ce5a3e..b4a46f7541 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -3,6 +3,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; import { VerifyRedirectButton } from "@/components/verify-redirect-button"; +import { resendVerification } from "@/lib/server/verify"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, @@ -19,8 +20,15 @@ export default async function Page(props: { searchParams: Promise }) { const t = await getTranslations({ locale, namespace: "verify" }); const tError = await getTranslations({ locale, namespace: "error" }); - const { userId, loginName, code, organization, authRequestId, invite } = - searchParams; + const { + userId, + loginName, + code, + organization, + authRequestId, + invite, + skipsend, + } = searchParams; const branding = await getBrandingSettings(organization); @@ -34,7 +42,21 @@ export default async function Page(props: { searchParams: Promise }) { loginName, organization, }); + + if (!skipsend && sessionFactors?.factors?.user?.id) { + await resendVerification({ + userId: sessionFactors?.factors?.user?.id, + isInvite: invite === "true", + }); + } } else if ("userId" in searchParams && userId) { + if (!skipsend) { + await resendVerification({ + userId, + isInvite: invite === "true", + }); + } + const userResponse = await getUserByID(userId); if (userResponse) { user = userResponse.user; diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 2eea113398..6b6189297e 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -88,12 +88,12 @@ export function VerifyForm({ setLoading(false); }); - if (response?.error) { + if (response && "error" in response && response?.error) { setError(response.error); return; } - if (response?.redirect) { + if (response && "redirect" in response && response?.redirect) { return router.push(response?.redirect); } }, diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 74d1bacdaa..54a989f986 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -8,6 +8,7 @@ import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { checkInvite } from "../verify-helper"; import { getActiveIdentityProviders, getIDPByID, @@ -171,29 +172,21 @@ export async function sendLoginname(command: SendLoginnameCommand) { ); if (!methods.authMethodTypes || !methods.authMethodTypes.length) { - if ( - potentialUsers[0].type.case === "human" && - potentialUsers[0].type.value.email && - !potentialUsers[0].type.value.email.isVerified - ) { - const paramsVerify = new URLSearchParams({ - loginName: session.factors?.user?.loginName, - userId: session.factors?.user?.id, // verify needs user id - invite: "true", // TODO: check - set this to true as we dont expect old email verification method here - }); + const humanUser = + potentialUsers[0].type.case === "human" + ? potentialUsers[0].type.value + : undefined; - if (command.organization || session.factors?.user?.organizationId) { - paramsVerify.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); - } + // redirect to /verify invite if no auth method is set and email is not verified + const inviteCheck = checkInvite( + session, + humanUser, + session.factors.user.organizationId, + command.authRequestId, + ); - if (command.authRequestId) { - paramsVerify.append("authRequestId", command.authRequestId); - } - - return { redirect: "/verify?" + paramsVerify }; + if (inviteCheck?.redirect) { + return inviteCheck; } const paramsAuthenticatorSetup = new URLSearchParams({ diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index 2161e08168..cc58f8c5dc 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -29,17 +29,40 @@ export function checkPasswordChangeRequired( } } +export function checkInvite( + session: Session, + humanUser?: HumanUser, + organization?: string, + authRequestId?: string, +) { + if (humanUser?.email && humanUser.email.isVerified) { + const paramsVerify = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + userId: session.factors?.user?.id as string, // verify needs user id + invite: "true", // TODO: check - set this to true as we dont expect old email verification method here + }); + + if (organization || session.factors?.user?.organizationId) { + paramsVerify.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + if (authRequestId) { + paramsVerify.append("authRequestId", authRequestId); + } + + return { redirect: "/verify?" + paramsVerify }; + } +} + export function checkEmailVerification( session: Session, humanUser?: HumanUser, organization?: string, authRequestId?: string, ) { - console.log( - humanUser?.email, - process.env.EMAIL_VERIFICATION, - process.env.EMAIL_VERIFICATION === "true", - ); if ( !humanUser?.email?.isVerified && process.env.EMAIL_VERIFICATION === "true" From 807f01f5b5b6184dd51f74a6d2a72e6bfcd25da5 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 24 Dec 2024 09:50:40 +0100 Subject: [PATCH 10/12] fix error handler, skip send --- apps/login/src/app/{ => (login)}/error.tsx | 0 apps/login/src/app/(login)/verify/page.tsx | 6 ++++++ apps/login/src/lib/server/register.ts | 1 + apps/login/src/lib/verify-helper.ts | 5 +++++ 4 files changed, 12 insertions(+) rename apps/login/src/app/{ => (login)}/error.tsx (100%) diff --git a/apps/login/src/app/error.tsx b/apps/login/src/app/(login)/error.tsx similarity index 100% rename from apps/login/src/app/error.tsx rename to apps/login/src/app/(login)/error.tsx diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index b4a46f7541..62f2284485 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -47,6 +47,9 @@ export default async function Page(props: { searchParams: Promise }) { await resendVerification({ userId: sessionFactors?.factors?.user?.id, isInvite: invite === "true", + }).catch((error) => { + console.error("Could not resend verification email", error); + throw Error("Could not request email"); }); } } else if ("userId" in searchParams && userId) { @@ -54,6 +57,9 @@ export default async function Page(props: { searchParams: Promise }) { await resendVerification({ userId, isInvite: invite === "true", + }).catch((error) => { + console.error("Could not resend verification email", error); + throw Error("Could not request email"); }); } diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 2902d9ac60..a73867deb0 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -92,6 +92,7 @@ export async function registerUser(command: RegisterUserCommand) { humanUser, session.factors.user.organizationId, command.authRequestId, + //true, // skip send as a mail was send during registration ); if (emailVerificationCheck?.redirect) { diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index cc58f8c5dc..85b5ca68e4 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -62,6 +62,7 @@ export function checkEmailVerification( humanUser?: HumanUser, organization?: string, authRequestId?: string, + skipSend?: boolean, ) { if ( !humanUser?.email?.isVerified && @@ -82,6 +83,10 @@ export function checkEmailVerification( ); } + if (skipSend) { + params.append("skipsend", "true"); + } + return { redirect: `/verify?` + params }; } } From c39f1b4e6db81a59ede0e068e0829c041a095985 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 27 Dec 2024 08:42:31 +0100 Subject: [PATCH 11/12] fix org context for accounts page - new user --- apps/login/src/app/(login)/accounts/page.tsx | 21 ++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/login/src/app/(login)/accounts/page.tsx b/apps/login/src/app/(login)/accounts/page.tsx index 9d2e6a0b9f..49fbad6202 100644 --- a/apps/login/src/app/(login)/accounts/page.tsx +++ b/apps/login/src/app/(login)/accounts/page.tsx @@ -49,6 +49,16 @@ export default async function Page(props: { organization ?? defaultOrganization, ); + const params = new URLSearchParams(); + + if (authRequestId) { + params.append("authRequestId", authRequestId); + } + + if (organization) { + params.append("organization", organization); + } + return (
@@ -57,16 +67,7 @@ export default async function Page(props: {
- +
From 13f6cbaf8fda8f3e4d3cfc608df5da0e290148fb Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 27 Dec 2024 09:55:16 +0100 Subject: [PATCH 12/12] not a module --- packages/zitadel-tsconfig/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/zitadel-tsconfig/package.json b/packages/zitadel-tsconfig/package.json index 238d7fbd70..b20542f468 100644 --- a/packages/zitadel-tsconfig/package.json +++ b/packages/zitadel-tsconfig/package.json @@ -2,7 +2,6 @@ "name": "@zitadel/tsconfig", "version": "0.0.0", "private": true, - "type": "module", "license": "MIT", "publishConfig": { "access": "public"