diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index a2c137cf43..8b3d4b311e 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "Keine Benutzer-ID angegeben!", - "success": "Erfolgreich verifiziert", + "successTitle": "Benutzer verifiziert", + "successDescription": "Der Benutzer wurde erfolgreich verifiziert.", "setupAuthenticator": "Authentifikator einrichten", "verify": { "title": "Benutzer verifizieren", "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", "noCodeReceived": "Keinen Code erhalten?", "resendCode": "Code erneut senden", + "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", "submit": "Weiter" } }, diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 63a45c7d15..daaaeba108 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "No userId provided!", - "success": "The user has been verified successfully.", + "successTitle": "User verified", + "successDescription": "The user has been verified successfully.", "setupAuthenticator": "Setup authenticator", "verify": { "title": "Verify user", "description": "Enter the Code provided in the verification email.", "noCodeReceived": "Didn't receive a code?", "resendCode": "Resend code", + "codeSent": "A code has just been sent to your email address.", "submit": "Continue" } }, diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 60570eceb0..b7dd57b4c0 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "¡No se proporcionó userId!", - "success": "¡Verificación exitosa!", + "successTitle": "Usuario verificado", + "successDescription": "El usuario ha sido verificado con éxito.", "setupAuthenticator": "Configurar autenticador", "verify": { "title": "Verificar usuario", "description": "Introduce el código proporcionado en el correo electrónico de verificación.", "noCodeReceived": "¿No recibiste un código?", "resendCode": "Reenviar código", + "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", "submit": "Continuar" } }, diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 53894fdf5d..f476da3402 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "Nessun userId fornito!", - "success": "Verifica effettuata con successo!", + "successTitle": "Utente verificato", + "successDescription": "L'utente è stato verificato con successo.", "setupAuthenticator": "Configura autenticatore", "verify": { "title": "Verifica utente", "description": "Inserisci il codice fornito nell'email di verifica.", "noCodeReceived": "Non hai ricevuto un codice?", "resendCode": "Invia di nuovo il codice", + "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", "submit": "Continua" } }, diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 52b802eccb..4dd607f3cb 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "Nie podano identyfikatora użytkownika!", - "success": "Użytkownik został pomyślnie zweryfikowany.", + "successTitle": "Weryfikacja zakończona", + "successDescription": "Użytkownik został pomyślnie zweryfikowany.", "setupAuthenticator": "Skonfiguruj uwierzytelnianie", "verify": { "title": "Zweryfikuj użytkownika", "description": "Wprowadź kod z wiadomości weryfikacyjnej.", "noCodeReceived": "Nie otrzymałeś kodu?", "resendCode": "Wyślij kod ponownie", + "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", "submit": "Kontynuuj" } }, diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 197b9663be..e8bbac212b 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "Не указан userId!", - "success": "Пользователь успешно подтверждён.", + "successTitle": "Пользователь подтверждён", + "successDescription": "Пользователь успешно подтверждён.", "setupAuthenticator": "Настроить аутентификатор", "verify": { "title": "Подтверждение пользователя", "description": "Введите код из письма подтверждения.", "noCodeReceived": "Не получили код?", "resendCode": "Отправить код повторно", + "codeSent": "Код отправлен на ваш email.", "submit": "Продолжить" } }, diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index d4319dc051..7bc4ecf68a 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -174,13 +174,15 @@ }, "verify": { "userIdMissing": "未提供用户 ID!", - "success": "用户验证成功。", + "successTitle": "用户已验证", + "successDescription": "用户已成功验证。", "setupAuthenticator": "设置认证器", "verify": { "title": "验证用户", "description": "输入验证邮件中的验证码。", "noCodeReceived": "没有收到验证码?", "resendCode": "重发验证码", + "codeSent": "刚刚发送了一封包含验证码的电子邮件。", "submit": "继续" } }, diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 3e1b49eed0..95b89af92d 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -7,6 +7,7 @@ import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; +import { checkUserVerification } from "@/lib/verify-helper"; import { getActiveIdentityProviders, getBrandingSettings, @@ -18,6 +19,7 @@ import { import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +import { redirect } from "next/navigation"; export default async function Page(props: { searchParams: Promise>; @@ -92,20 +94,50 @@ export default async function Page(props: { }); } - if (!sessionWithData) { + if ( + !sessionWithData || + !sessionWithData.factors || + !sessionWithData.factors.user + ) { return {tError("unknownContext")}; } const branding = await getBrandingSettings({ serviceUrl, - organization: sessionWithData.factors?.user?.organizationId, + organization: sessionWithData.factors.user?.organizationId, }); const loginSettings = await getLoginSettings({ serviceUrl, - organization: sessionWithData.factors?.user?.organizationId, + organization: sessionWithData.factors.user?.organizationId, }); + // check if user was verified recently + const isUserVerified = await checkUserVerification( + sessionWithData.factors.user?.id, + ); + + if (!isUserVerified) { + const params = new URLSearchParams({ + loginName: sessionWithData.factors.user.loginName as string, + invite: "true", + send: "true", // set this to true to request a new code immediately + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || sessionWithData.factors.user.organizationId) { + params.append( + "organization", + organization ?? (sessionWithData.factors.user.organizationId as string), + ); + } + + redirect(`/verify?` + params); + } + const identityProviders = await getActiveIdentityProviders({ serviceUrl, orgId: sessionWithData.factors?.user?.organizationId, @@ -152,13 +184,12 @@ export default async function Page(props: { > )} - {loginSettings?.allowExternalIdp && identityProviders && ( + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( <> - {identityProviders.length && ( -
-

{t("linkWithIDP")}

-
- )} +
+

{t("linkWithIDP")}

+
+ >; -}) { - const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "invite" }); - - let { firstname, lastname, email, organization } = searchParams; - - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - - if (!organization) { - const org = await getDefaultOrg({ serviceUrl }); - if (!org) { - throw new Error("No default organization found"); - } - - organization = org.id; - } - - const loginSettings = await getLoginSettings({ - serviceUrl, - organization, - }); - - const passwordComplexitySettings = await getPasswordComplexitySettings({ - serviceUrl, - organization, - }); - - const branding = await getBrandingSettings({ - serviceUrl, - organization, - }); - - return ( - -
-

{t("title")}

-

{t("description")}

- - {!loginSettings?.allowRegister ? ( - {t("notAllowed")} - ) : ( - {t("info")} - )} - - {passwordComplexitySettings && loginSettings?.allowRegister && ( - - )} -
-
- ); -} diff --git a/apps/login/src/app/(login)/invite/success/page.tsx b/apps/login/src/app/(login)/invite/success/page.tsx deleted file mode 100644 index 1b12a5b903..0000000000 --- a/apps/login/src/app/(login)/invite/success/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Alert, AlertType } from "@/components/alert"; -import { Button, ButtonVariants } from "@/components/button"; -import { DynamicTheme } from "@/components/dynamic-theme"; -import { UserAvatar } from "@/components/user-avatar"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { getBrandingSettings, getDefaultOrg, getUserByID } from "@/lib/zitadel"; -import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { getLocale, getTranslations } from "next-intl/server"; -import { headers } from "next/headers"; -import Link from "next/link"; - -export default async function Page(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "invite" }); - - let { userId, organization } = searchParams; - - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - - if (!organization) { - const org = await getDefaultOrg({ serviceUrl }); - if (!org) { - throw new Error("No default organization found"); - } - - organization = org.id; - } - - const branding = await getBrandingSettings({ - serviceUrl, - organization, - }); - - let user: User | undefined; - let human: HumanUser | undefined; - if (userId) { - const userResponse = await getUserByID({ - serviceUrl, - userId, - }); - if (userResponse) { - user = userResponse.user; - if (user?.type.case === "human") { - human = user.type.value as HumanUser; - } - } - } - - return ( - -
-

{t("success.title")}

-

{t("success.description")}

- {user && ( - - )} - {human?.email?.isVerified ? ( - {t("success.verified")} - ) : ( - {t("success.notVerifiedYet")} - )} -
- - - - -
-
-
- ); -} diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 198a46a5fe..7634ff063a 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -1,18 +1,12 @@ -import { Alert } from "@/components/alert"; +import { Alert, AlertType } from "@/components/alert"; 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 { sendEmailCode } from "@/lib/server/verify"; +import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; -import { - getBrandingSettings, - getUserByID, - listAuthenticationMethodTypes, -} from "@/lib/zitadel"; +import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; @@ -22,16 +16,11 @@ 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, requestId, invite } = + const { userId, loginName, code, organization, requestId, invite, send } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = _headers.get("host"); - - if (!host || typeof host !== "string") { - throw new Error("No host found"); - } const branding = await getBrandingSettings({ serviceUrl, @@ -43,10 +32,40 @@ export default async function Page(props: { searchParams: Promise }) { let human: HumanUser | undefined; let id: string | undefined; - const doSend = invite !== "true"; + const doSend = send === "true"; const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + async function sendEmail(userId: string) { + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + if (invite === "true") { + await sendInviteEmailCode({ + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (requestId ? `&requestId=${requestId}` : ""), + }).catch((error) => { + console.error("Could not send invitation email", error); + throw Error("Failed to send invitation email"); + }); + } else { + await sendEmailCode({ + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (requestId ? `&requestId=${requestId}` : ""), + }).catch((error) => { + console.error("Could not send verification email", error); + throw Error("Failed to send verification email"); + }); + } + } + if ("loginName" in searchParams) { sessionFactors = await loadMostRecentSession({ serviceUrl, @@ -57,29 +76,11 @@ export default async function Page(props: { searchParams: Promise }) { }); if (doSend && sessionFactors?.factors?.user?.id) { - await sendEmailCode({ - serviceUrl, - userId: sessionFactors?.factors?.user?.id, - urlTemplate: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + - (requestId ? `&requestId=${requestId}` : ""), - }).catch((error) => { - console.error("Could not resend verification email", error); - throw Error("Failed to send verification email"); - }); + await sendEmail(sessionFactors.factors.user.id); } } else if ("userId" in searchParams && userId) { if (doSend) { - await sendEmailCode({ - serviceUrl, - userId, - urlTemplate: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + - (requestId ? `&requestId=${requestId}` : ""), - }).catch((error) => { - console.error("Could not resend verification email", error); - throw Error("Failed to send verification email"); - }); + await sendEmail(userId); } const userResponse = await getUserByID({ @@ -96,12 +97,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); - if (authMethodsResponse.authMethodTypes) { - authMethods = authMethodsResponse.authMethodTypes; - } + if (!id) { + throw Error("Failed to get user id"); } const params = new URLSearchParams({ @@ -138,6 +135,12 @@ export default async function Page(props: { searchParams: Promise }) { )} + {id && send && ( +
+ {t("verify.codeSent")} +
+ )} + {sessionFactors ? ( }) { ) )} - {id && - (human?.email?.isVerified ? ( - // show page for already verified users - - ) : ( - // check if auth methods are set - - ))} + ); diff --git a/apps/login/src/app/(login)/verify/success/page.tsx b/apps/login/src/app/(login)/verify/success/page.tsx new file mode 100644 index 0000000000..678687a7f6 --- /dev/null +++ b/apps/login/src/app/(login)/verify/success/page.tsx @@ -0,0 +1,109 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, + getUserByID, +} from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, +) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); +} + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "verify" }); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, userId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }).catch((error) => { + console.warn("Error loading session:", error); + }); + + let loginSettings; + if (!requestId) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + } + + const id = userId ?? sessionFactors?.factors?.user?.id; + + if (!id) { + throw Error("Failed to get user id"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: id, + }); + + let user: User | undefined; + let human: HumanUser | undefined; + + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + } + + return ( + +
+

{t("successTitle")}

+

{t("successDescription")}

+ + {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} +
+
+ ); +} diff --git a/apps/login/src/components/register-passkey.tsx b/apps/login/src/components/register-passkey.tsx index 163ab507b8..8687312bbc 100644 --- a/apps/login/src/components/register-passkey.tsx +++ b/apps/login/src/components/register-passkey.tsx @@ -83,6 +83,16 @@ export function RegisterPasskey({ return; } + if ("error" in resp && resp.error) { + setError(resp.error); + return; + } + + if (!("passkeyId" in resp)) { + setError("An error on registering passkey"); + return; + } + const passkeyId = resp.passkeyId; const options: CredentialCreationOptions = (resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? @@ -92,6 +102,7 @@ export function RegisterPasskey({ setError("An error on registering passkey"); return; } + options.publicKey.challenge = coerceToArrayBuffer( options.publicKey.challenge, "challenge", diff --git a/apps/login/src/components/sign-in-with-idp.tsx b/apps/login/src/components/sign-in-with-idp.tsx index f08ebd821b..7632a29cc1 100644 --- a/apps/login/src/components/sign-in-with-idp.tsx +++ b/apps/login/src/components/sign-in-with-idp.tsx @@ -74,7 +74,7 @@ export function SignInWithIdp({ return (
- {identityProviders?.map(renderIDPButton)} + {!!identityProviders.length && identityProviders?.map(renderIDPButton)} {state?.error && (
{state?.error} diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index e09642eecf..0933f598dd 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -63,6 +63,11 @@ export function VerifyForm({ setLoading(false); }); + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + return response; } diff --git a/apps/login/src/components/verify-redirect-button.tsx b/apps/login/src/components/verify-redirect-button.tsx deleted file mode 100644 index 009dda3ffd..0000000000 --- a/apps/login/src/components/verify-redirect-button.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client"; - -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"; -import { Alert, AlertType } from "./alert"; -import { BackButton } from "./back-button"; -import { Button, ButtonVariants } from "./button"; -import { Spinner } from "./spinner"; - -export function VerifyRedirectButton({ - userId, - loginName, - requestId, - authMethods, - organization, -}: { - userId?: string; - loginName?: string; - requestId: string; - authMethods: AuthenticationMethodType[] | null; - organization?: string; -}) { - const t = useTranslations("verify"); - const [error, setError] = useState(""); - - const [loading, setLoading] = useState(false); - - async function submitAndContinue(): Promise { - setLoading(true); - - let command = { - organization, - requestId, - } as SendVerificationRedirectWithoutCheckCommand; - - if (userId) { - command = { - ...command, - userId, - } as SendVerificationRedirectWithoutCheckCommand; - } else if (loginName) { - command = { - ...command, - loginName, - } as SendVerificationRedirectWithoutCheckCommand; - } - - await sendVerificationRedirectWithoutCheck(command) - .catch(() => { - setError("Could not verify"); - return; - }) - .finally(() => { - setLoading(false); - }); - } - - return ( - <> - {t("success")} - - {error && ( -
- {error} -
- )} - -
- - - {authMethods?.length === 0 && ( - - )} -
- - ); -} diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 2ea6004fdc..fa75929702 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -9,7 +9,6 @@ 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 { getServiceUrlFromHeaders } from "../service-url"; -import { checkInvite } from "../verify-helper"; import { getActiveIdentityProviders, getIDPByID, @@ -254,37 +253,27 @@ export async function sendLoginname(command: SendLoginnameCommand) { userId: session.factors?.user?.id, }); - // this can be expected to be an invite as users created in console have a password set. + // always resend invite if user has no auth method set if (!methods.authMethodTypes || !methods.authMethodTypes.length) { - // 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.requestId, - ); - - if (inviteCheck?.redirect) { - return inviteCheck; - } - - const paramsAuthenticatorSetup = new URLSearchParams({ - loginName: session.factors?.user?.loginName, - userId: session.factors?.user?.id, // verify needs user id + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true to request a new code immediately + invite: "true", }); + if (command.requestId) { + params.append("requestId", command.requestId); + } + if (command.organization || session.factors?.user?.organizationId) { - paramsAuthenticatorSetup.append( + params.append( "organization", - command.organization ?? session.factors?.user?.organizationId, + command.organization ?? + (session.factors?.user?.organizationId as string), ); } - if (command.requestId) { - paramsAuthenticatorSetup.append("requestId", command.requestId); - } - - return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup }; + return { redirect: `/verify?` + params }; } if (methods.authMethodTypes.length == 1) { diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index 73d12043b0..3470629f24 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -5,10 +5,12 @@ import { getLoginSettings, getSession, getUserByID, + listAuthenticationMethodTypes, registerPasskey, verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration, } from "@/lib/zitadel"; -import { create, Duration } from "@zitadel/client"; +import { create, Duration, Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { RegisterPasskeyResponse, @@ -23,7 +25,10 @@ import { getSessionCookieByLoginName, } from "../cookies"; import { getServiceUrlFromHeaders } from "../service-url"; -import { checkEmailVerification } from "../verify-helper"; +import { + checkEmailVerification, + checkUserVerification, +} from "../verify-helper"; import { setSessionAndUpdateCookie } from "./cookie"; type VerifyPasskeyCommand = { @@ -37,9 +42,25 @@ type RegisterPasskeyCommand = { sessionId: string; }; +function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey; + const valid = !!((validPassword || validPasskey) && stillValid); + + return { valid, verifiedAt }; +} + export async function registerPasskeyLink( command: RegisterPasskeyCommand, -): Promise { +): Promise { const { sessionId } = command; const _headers = await headers(); @@ -57,6 +78,36 @@ export async function registerPasskeyLink( sessionToken: sessionCookie.token, }); + if (!session?.session?.factors?.user?.id) { + return { error: "Could not determine user from session" }; + } + + const sessionValid = isSessionValid(session.session); + + if (!sessionValid) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.session.factors.user.id, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to authenticate or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + session.session.factors.user.id, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + const [hostname, port] = host.split(":"); if (!hostname) { diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 9e256c71d9..3786145157 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -37,6 +37,7 @@ import { checkEmailVerification, checkMFAFactors, checkPasswordChangeRequired, + checkUserVerification, } from "../verify-helper"; type ResetPasswordCommand = { @@ -297,6 +298,7 @@ export async function sendPassword(command: UpdateSessionCommand) { return { redirect: url }; } +// this function lets users with code set a password or users with valid User Verification Check export async function changePassword(command: { code?: string; userId: string; @@ -316,11 +318,39 @@ export async function changePassword(command: { } const userId = user.userId; + if (user.state === UserState.INITIAL) { + return { error: "User Initial State is not supported" }; + } + + // check if the user has no password set in order to set a password + if (!command.code) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to provide a code or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + user.userId, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + return setUserPassword({ serviceUrl, userId, password: command.password, - user, code: command.code, }); } diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index e7c9f5e715..cf60f739b3 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -1,24 +1,25 @@ "use server"; import { + createInviteCode, getLoginSettings, getSession, getUserByID, listAuthenticationMethodTypes, - resendEmailCode, - resendInviteCode, verifyEmail, verifyInviteCode, verifyTOTPRegistration, sendEmailCode as zitadelSendEmailCode, } from "@/lib/zitadel"; +import crypto from "crypto"; + 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 { cookies, headers } from "next/headers"; import { getNextUrl } from "../client"; import { getSessionCookieByLoginName } from "../cookies"; +import { getOrSetFingerprintId } from "../fingerprint"; import { getServiceUrlFromHeaders } from "../service-url"; import { loadMostRecentSession } from "../session"; import { checkMFAFactors } from "../verify-helper"; @@ -69,14 +70,16 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { serviceUrl, userId: command.userId, verificationCode: command.code, - }).catch(() => { + }).catch((error) => { + console.warn(error); return { error: "Could not verify invite" }; }) : await verifyEmail({ serviceUrl, userId: command.userId, verificationCode: command.code, - }).catch(() => { + }).catch((error) => { + console.warn(error); return { error: "Could not verify email" }; }); @@ -89,20 +92,26 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { } let session: Session | undefined; - let user: User | undefined; + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); - if ("loginName" in command) { - const sessionCookie = await getSessionCookieByLoginName({ - loginName: command.loginName, - organization: command.organization, - }).catch((error) => { - console.warn("Ignored error:", error); - }); + if (!userResponse || !userResponse.user) { + return { error: "Could not load user" }; + } - if (!sessionCookie) { - return { error: "Could not load session cookie" }; - } + const user = userResponse.user; + const sessionCookie = await getSessionCookieByLoginName({ + loginName: + "loginName" in command ? command.loginName : user.preferredLoginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); // checked later + }); + + if (sessionCookie) { session = await getSession({ serviceUrl, sessionId: sessionCookie.id, @@ -112,65 +121,9 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { return response.session; } }); - - if (!session?.factors?.user?.id) { - return { error: "Could not create session for user" }; - } - - const userResponse = await getUserByID({ - serviceUrl, - userId: session?.factors?.user?.id, - }); - - if (!userResponse?.user) { - return { error: "Could not load user" }; - } - - user = userResponse.user; - } else { - const userResponse = await getUserByID({ - serviceUrl, - userId: command.userId, - }); - - if (!userResponse || !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, - requestId: command.requestId, - }); } - 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 loginSettings = await getLoginSettings({ - serviceUrl, - organization: user.details?.resourceOwner, - }); - + // load auth methods for user const authMethodResponse = await listAuthenticationMethodTypes({ serviceUrl, userId: user.userId, @@ -186,6 +139,26 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { authMethodResponse.authMethodTypes && authMethodResponse.authMethodTypes.length == 0 ) { + if (!sessionCookie) { + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + } + + if (!session) { + return { error: "Could not create session" }; + } + const params = new URLSearchParams({ sessionId: session.id, }); @@ -193,9 +166,62 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { if (session.factors?.user?.loginName) { params.set("loginName", session.factors?.user?.loginName); } + + // set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId + const cookiesList = await cookies(); + const userAgentId = await getOrSetFingerprintId(); + + const verificationCheck = crypto + .createHash("sha256") + .update(`${user.userId}:${userAgentId}`) + .digest("hex"); + + await cookiesList.set({ + name: "verificationCheck", + value: verificationCheck, + httpOnly: true, + path: "/", + maxAge: 300, // 5 minutes + }); + return { redirect: `/authenticator/set?${params}` }; } + // if no session found only show success page, + // if user is invited, recreate invite flow to not depend on session + if (!session?.factors?.user?.id) { + const verifySuccessParams = new URLSearchParams({}); + + if (command.userId) { + verifySuccessParams.set("userId", command.userId); + } + + if ( + ("loginName" in command && command.loginName) || + user.preferredLoginName + ) { + verifySuccessParams.set( + "loginName", + "loginName" in command && command.loginName + ? command.loginName + : user.preferredLoginName, + ); + } + if (command.requestId) { + verifySuccessParams.set("requestId", command.requestId); + } + if (command.organization) { + verifySuccessParams.set("organization", command.organization); + } + + return { redirect: `/verify/success?${verifySuccessParams}` }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + // redirect to mfa factor if user has one, or redirect to set one up const mfaFactorCheck = await checkMFAFactors( serviceUrl, @@ -254,193 +280,50 @@ export async function resendVerification(command: resendVerifyEmailCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; return command.isInvite - ? resendInviteCode({ serviceUrl, userId: command.userId }) - : resendEmailCode({ + ? createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }).catch((error) => { + if (error.code === 9) { + return { error: "User is already verified!" }; + } + return { error: "Could not resend invite" }; + }) + : zitadelSendEmailCode({ userId: command.userId, serviceUrl, urlTemplate: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + (command.requestId ? `&requestId=${command.requestId}` : ""), }); } -type sendEmailCommand = { - serviceUrl: string; - +type SendEmailCommand = { userId: string; urlTemplate: string; }; -export async function sendEmailCode(command: sendEmailCommand) { +export async function sendEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + return zitadelSendEmailCode({ - serviceUrl: command.serviceUrl, + serviceUrl, userId: command.userId, urlTemplate: command.urlTemplate, }); } -export type SendVerificationRedirectWithoutCheckCommand = { - organization?: string; - requestId?: string; -} & ( - | { userId: string; loginName?: never } - | { userId?: never; loginName: string } -); - -export async function sendVerificationRedirectWithoutCheck( - command: SendVerificationRedirectWithoutCheckCommand, -) { +export async function sendInviteEmailCode(command: SendEmailCommand) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - 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({ - serviceUrl, - 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({ - serviceUrl, - userId: 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({ - serviceUrl, - userId: 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, - requestId: command.requestId, - }); - } - - 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({ + return createInviteCode({ serviceUrl, - userId: user.userId, + userId: command.userId, + urlTemplate: command.urlTemplate, }); - - 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({ - serviceUrl, - organization: user.details?.resourceOwner, - }); - - // redirect to mfa factor if user has one, or redirect to set one up - const mfaFactorCheck = await checkMFAFactors( - serviceUrl, - session, - loginSettings, - authMethodResponse.authMethodTypes, - command.organization, - command.requestId, - ); - - if (mfaFactorCheck?.redirect) { - return mfaFactorCheck; - } - - // login user if no additional steps are required - if (command.requestId && session.id) { - const nextUrl = await getNextUrl( - { - sessionId: session.id, - requestId: command.requestId, - 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 index 704d7bbef6..dbd9b2796b 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -4,7 +4,10 @@ import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import crypto from "crypto"; import moment from "moment"; +import { cookies } from "next/headers"; +import { getFingerprintIdCookie } from "./fingerprint"; import { getUserByID } from "./zitadel"; export function checkPasswordChangeRequired( @@ -44,7 +47,7 @@ export function checkPasswordChangeRequired( } } -export function checkInvite( +export function checkEmailVerified( session: Session, humanUser?: HumanUser, organization?: string, @@ -54,7 +57,7 @@ export function checkInvite( 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 + send: "true", // we request a new email code once the page is loaded }); if (organization || session.factors?.user?.organizationId) { @@ -84,6 +87,7 @@ export function checkEmailVerification( ) { const params = new URLSearchParams({ loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true as we dont expect old email codes to be valid anymore }); if (requestId) { @@ -248,3 +252,38 @@ export async function checkMFAFactors( return { redirect: `/mfa/set?` + params }; } } + +export async function checkUserVerification(userId: string): Promise { + // check if a verification was done earlier + const cookiesList = await cookies(); + + // only read cookie to prevent issues on page.tsx + const fingerPrintCookie = await getFingerprintIdCookie(); + + if (!fingerPrintCookie || !fingerPrintCookie.value) { + return false; + } + + const verificationCheck = crypto + .createHash("sha256") + .update(`${userId}:${fingerPrintCookie.value}`) + .digest("hex"); + + const cookieValue = await cookiesList.get("verificationCheck")?.value; + + if (!cookieValue) { + console.warn( + "User verification check cookie not found. User verification check failed.", + ); + return false; + } + + if (cookieValue !== verificationCheck) { + console.warn( + `User verification check failed. Expected ${verificationCheck} but got ${cookieValue}`, + ); + return false; + } + + return true; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 508baf1667..a0e91a021c 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -29,11 +29,7 @@ import { SearchQuery, SearchQuerySchema, } from "@zitadel/proto/zitadel/user/v2/query_pb"; -import { - SendInviteCodeSchema, - User, - UserState, -} from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AddHumanUserRequest, ResendEmailCodeRequest, @@ -506,21 +502,6 @@ export async function verifyInviteCode({ return userService.verifyInviteCode({ userId, verificationCode }, {}); } -export async function resendInviteCode({ - serviceUrl, - userId, -}: { - serviceUrl: string; - userId: string; -}) { - const userService: Client = await createServiceForHost( - UserService, - serviceUrl, - ); - - return userService.resendInviteCode({ userId }, {}); -} - export async function sendEmailCode({ serviceUrl, userId, @@ -1170,13 +1151,11 @@ export async function setUserPassword({ serviceUrl, userId, password, - user, code, }: { serviceUrl: string; userId: string; password: string; - user: User; code?: string; }) { let payload = create(SetPasswordRequestSchema, { @@ -1186,22 +1165,6 @@ export async function setUserPassword({ }, }); - // check if the user has no password set in order to set a password - if (!code) { - const authmethods = await listAuthenticationMethodTypes({ - serviceUrl, - userId, - }); - - // if the user has no authmethods set, we can set a password otherwise we need a code - if ( - !(authmethods.authMethodTypes.length === 0) && - user.state !== UserState.INITIAL - ) { - return { error: "Provide a code to set a password" }; - } - } - if (code) { payload = { ...payload,