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) {