mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-19 01:14:48 -05:00
fix(login): prevent duplicate email-code verification issue (#11893)
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> # Which Problems Are Solved - With Login V2 and EMAIL_VERIFICATION=true, opening the verify page with send=true can trigger email code generation twice. - The second code invalidates the first one, so users may receive two emails where the first code is always invalid. - This causes failed verification attempts and blocks onboarding for affected users. # How the Problems Are Solved - Removed side-effectful email-code sending from direct server component execution path. - Ensured code sending is executed only once in a safe server-side flow, so React Server Component re-fetches do not generate additional codes. - Kept verify flow behavior intact while preventing duplicate code invalidation. # Additional Changes - Added/updated logic around the verify flow to make repeated render/re-fetch paths idempotent for email-code sending. - Improved reliability of the Login V2 email verification step under App Router navigation behavior. # Additional Context Fixes #11857 - Tested manually with Login V2 and EMAIL_VERIFICATION=true by completing a new user registration flow to /verify?send=true. - Verified that only one verification email is sent and the first received code is valid (no duplicate invalidating code generated). Related bug report with reproduction details: [https://github.com/zitadel/zitadel/issues/11857](vscode-file://vscode-app/Applications/Visual%20Studio%20Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) --------- Co-authored-by: Max Peintner <max@caos.ch>
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { Alert } from "@/components/alert";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { VerifyForm } from "@/components/verify-form";
|
||||
import { UNKNOWN_USER_ID } from "@/lib/constants";
|
||||
import { getPublicHostWithProtocol } from "@/lib/server/host";
|
||||
import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify";
|
||||
import { getServiceConfig } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import { getBrandingSettings, getLoginSettings, getUserByID, searchUsers } from "@/lib/zitadel";
|
||||
@@ -36,39 +34,10 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
let id: string | undefined;
|
||||
let loginSettings: LoginSettings | undefined;
|
||||
|
||||
let error: string | undefined;
|
||||
|
||||
const doSend = send === "true";
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
const autoSubmitCode = process.env.NEXT_PUBLIC_AUTO_SUBMIT_CODE === "true";
|
||||
|
||||
async function sendEmail(userId: string) {
|
||||
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
|
||||
|
||||
if (invite === "true") {
|
||||
await sendInviteEmailCode({
|
||||
userId,
|
||||
urlTemplate:
|
||||
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
|
||||
(requestId ? `&requestId=${requestId}` : ""),
|
||||
}).catch((apiError) => {
|
||||
console.error("Could not send invitation email", apiError);
|
||||
error = "inviteSendFailed";
|
||||
});
|
||||
} else {
|
||||
await sendEmailCode({
|
||||
userId,
|
||||
urlTemplate:
|
||||
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
|
||||
(requestId ? `&requestId=${requestId}` : ""),
|
||||
}).catch((apiError) => {
|
||||
console.error("Could not send verification email", apiError);
|
||||
error = "emailSendFailed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ("loginName" in searchParams) {
|
||||
sessionFactors = await loadMostRecentSession({
|
||||
serviceConfig,
|
||||
@@ -84,15 +53,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
// ignore error, as we might not have a session yet
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (doSend && sessionFactors?.factors?.user?.id) {
|
||||
await sendEmail(sessionFactors.factors.user.id);
|
||||
}
|
||||
} else if ("userId" in searchParams && userId) {
|
||||
if (doSend) {
|
||||
await sendEmail(userId);
|
||||
}
|
||||
|
||||
const userResponse = await getUserByID({ serviceConfig, userId });
|
||||
if (userResponse) {
|
||||
user = userResponse.user;
|
||||
@@ -128,11 +89,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
if (user.type.case === "human") {
|
||||
human = user.type.value as HumanUser;
|
||||
}
|
||||
|
||||
// If we found the user and need to send email, do it now
|
||||
if (doSend && id) {
|
||||
await sendEmail(id);
|
||||
}
|
||||
} else if (loginSettings?.ignoreUnknownUsernames) {
|
||||
// Prevent enumeration by pretending we found a user
|
||||
id = UNKNOWN_USER_ID;
|
||||
@@ -189,14 +145,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{error && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey={`errors.${error}`} namespace="verify" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!id && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
@@ -205,14 +153,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id && send && (
|
||||
<div className="w-full py-4">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="verify.codeSent" namespace="verify" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<VerifyForm
|
||||
loginName={loginName}
|
||||
@@ -222,6 +162,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
isInvite={invite === "true"}
|
||||
requestId={requestId}
|
||||
submit={autoSubmitCode}
|
||||
doSend={doSend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { handleServerActionResponse } from "@/lib/client-utils";
|
||||
import { UNKNOWN_USER_ID } from "@/lib/constants";
|
||||
import { resendVerification, sendVerification } from "@/lib/server/verify";
|
||||
import { initialSendVerification, resendVerification, sendVerification } from "@/lib/server/verify";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -27,9 +27,10 @@ type Props = {
|
||||
isInvite: boolean;
|
||||
requestId?: string;
|
||||
submit: boolean;
|
||||
doSend?: boolean;
|
||||
};
|
||||
|
||||
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) {
|
||||
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit, doSend }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
@@ -46,8 +47,27 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const initialSendDone = useRef(false);
|
||||
const [initialSendError, setInitialSendError] = useState<string>("");
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (doSend && userId && userId !== UNKNOWN_USER_ID && !initialSendDone.current) {
|
||||
initialSendDone.current = true;
|
||||
setError("");
|
||||
initialSendVerification({ userId, isInvite, requestId })
|
||||
.then(() => {
|
||||
setCodeSent(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setInitialSendError(isInvite ? t("errors.couldNotResendInvite") : t("errors.couldNotResendEmail"));
|
||||
});
|
||||
}
|
||||
}, [doSend, userId, isInvite, requestId, t]);
|
||||
|
||||
async function resendCode() {
|
||||
setError("");
|
||||
setInitialSendError("");
|
||||
setLoading(true);
|
||||
|
||||
// do not send code for dummy userid that is set to prevent user enumeration
|
||||
@@ -83,6 +103,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
const fcn = useCallback(
|
||||
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
|
||||
setError("");
|
||||
setInitialSendError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -115,6 +136,13 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
return (
|
||||
<>
|
||||
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
|
||||
{codeSent && !initialSendError && (
|
||||
<div className="w-full py-4">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="verify.codeSent" namespace="verify" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<form className="w-full">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<div className="flex flex-row">
|
||||
@@ -146,9 +174,9 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{(error || initialSendError) && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error}</Alert>
|
||||
<Alert>{error || initialSendError}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { sendVerification } from "./verify";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { initialSendVerification, sendVerification } from "./verify";
|
||||
|
||||
import { getSession, getUserByID, listAuthenticationMethodTypes, verifyEmail } from "@/lib/zitadel";
|
||||
import {
|
||||
createInviteCode,
|
||||
getSession,
|
||||
getUserByID,
|
||||
listAuthenticationMethodTypes,
|
||||
verifyEmail,
|
||||
sendEmailCode as zitadelSendEmailCode,
|
||||
} from "@/lib/zitadel";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSessionCookieByLoginName } from "../cookies";
|
||||
import { createSessionAndUpdateCookie } from "./cookie";
|
||||
@@ -14,6 +21,8 @@ vi.mock("@/lib/zitadel", () => ({
|
||||
getSession: vi.fn(),
|
||||
listAuthenticationMethodTypes: vi.fn(),
|
||||
getLoginSettings: vi.fn(),
|
||||
sendEmailCode: vi.fn(),
|
||||
createInviteCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
@@ -37,6 +46,10 @@ vi.mock("../fingerprint", () => ({
|
||||
getOrSetFingerprintId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./host", () => ({
|
||||
getPublicHostWithProtocol: vi.fn(() => "https://example.com"),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: vi.fn(() => (key: string) => key),
|
||||
}));
|
||||
@@ -111,3 +124,102 @@ describe("sendVerification", () => {
|
||||
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialSendVerification", () => {
|
||||
let mockSendEmailCode: any;
|
||||
let mockCreateInviteCode: any;
|
||||
let originalBasePath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalBasePath = process.env.NEXT_PUBLIC_BASE_PATH;
|
||||
process.env.NEXT_PUBLIC_BASE_PATH = "/ui/v2/login";
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockSendEmailCode = zitadelSendEmailCode;
|
||||
mockCreateInviteCode = createInviteCode;
|
||||
mockSendEmailCode.mockResolvedValue({});
|
||||
mockCreateInviteCode.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBasePath === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_BASE_PATH;
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_BASE_PATH = originalBasePath;
|
||||
}
|
||||
});
|
||||
|
||||
test("should call sendEmailCode with correct URL template for non-invite", async () => {
|
||||
await initialSendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
});
|
||||
|
||||
expect(mockSendEmailCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
urlTemplate: "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}",
|
||||
});
|
||||
expect(mockCreateInviteCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should call createInviteCode with correct URL template for invite", async () => {
|
||||
await initialSendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: true,
|
||||
});
|
||||
|
||||
expect(mockCreateInviteCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
urlTemplate:
|
||||
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true",
|
||||
});
|
||||
expect(mockSendEmailCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should include URL-encoded requestId in URL template", async () => {
|
||||
await initialSendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
requestId: "req-123",
|
||||
});
|
||||
|
||||
expect(mockSendEmailCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
urlTemplate:
|
||||
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&requestId=req-123",
|
||||
});
|
||||
});
|
||||
|
||||
test("should URL-encode special characters in requestId", async () => {
|
||||
await initialSendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
requestId: "req&id=injected",
|
||||
});
|
||||
|
||||
expect(mockSendEmailCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
urlTemplate:
|
||||
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&requestId=req%26id%3Dinjected",
|
||||
});
|
||||
});
|
||||
|
||||
test("should include invite=true and requestId for invite with requestId", async () => {
|
||||
await initialSendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: true,
|
||||
requestId: "req-456",
|
||||
});
|
||||
|
||||
expect(mockCreateInviteCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
urlTemplate:
|
||||
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true&requestId=req-456",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +236,25 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildVerificationUrlTemplate(
|
||||
hostWithProtocol: string,
|
||||
basePath: string,
|
||||
isInvite: boolean,
|
||||
requestId?: string,
|
||||
): string {
|
||||
let urlTemplate = `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`;
|
||||
|
||||
if (isInvite) {
|
||||
urlTemplate += "&invite=true";
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
urlTemplate += `&requestId=${encodeURIComponent(requestId)}`;
|
||||
}
|
||||
|
||||
return urlTemplate;
|
||||
}
|
||||
|
||||
type resendVerifyEmailCommand = {
|
||||
userId: string;
|
||||
isInvite: boolean;
|
||||
@@ -249,14 +268,13 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
|
||||
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId);
|
||||
|
||||
return command.isInvite
|
||||
? createInviteCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate:
|
||||
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
|
||||
(command.requestId ? `&requestId=${command.requestId}` : ""),
|
||||
urlTemplate,
|
||||
}).catch((error) => {
|
||||
if (error.code === 9) {
|
||||
return { error: t("errors.userAlreadyVerified") };
|
||||
@@ -266,9 +284,7 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
|
||||
: zitadelSendEmailCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate:
|
||||
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
|
||||
(command.requestId ? `&requestId=${command.requestId}` : ""),
|
||||
urlTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,3 +306,32 @@ export async function sendInviteEmailCode(command: SendEmailCommand) {
|
||||
|
||||
return createInviteCode({ serviceConfig, userId: command.userId, urlTemplate: command.urlTemplate });
|
||||
}
|
||||
|
||||
type InitialSendVerificationCommand = {
|
||||
userId: string;
|
||||
isInvite: boolean;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export async function initialSendVerification(command: InitialSendVerificationCommand) {
|
||||
const _headers = await headers();
|
||||
const { serviceConfig } = getServiceConfig(_headers);
|
||||
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId);
|
||||
|
||||
if (command.isInvite) {
|
||||
return createInviteCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate,
|
||||
});
|
||||
} else {
|
||||
return zitadelSendEmailCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user