Files
zitadel/apps/login/src/lib/verify-helper.ts
T
Max PeintnerandGitHub 3a18cef281 fix: prevent double triggering of verification emails (#11995)
# Which Problems Are Solved

This fixes a critical bug where initial verification emails (or invite
codes) were occasionally sent twice, invalidating the first code and
confusing users.

Previously, the initial verification email was triggered via a
`send=true` URL parameter executing inside a frontend useEffect exactly
when the `/verify` page mounted. This was fragile and prone to race
conditions caused by component remounts or partial hydration.

# How the Problems Are Solved

- Removed `send=true` from URL state and ripped out the doSend effect in
`VerifyForm.tsx`.
- Shifted execution strictly to the Next.js server. The email is now
automatically dispatched via await `initialSendVerification(...)` during
the POST requests (acting over `sendLoginname`, `register`, `password`,
`passkeys`, and `idp`).
- The login flow is now idempotent and robust against unintended
frontend re-renders.
- Refactored `checkEmailVerification()` to be async and updated the
associated unit-test coverage (all tests passing).
2026-06-29 11:42:45 +03:00

140 lines
4.2 KiB
TypeScript

import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb";
import crypto from "crypto";
import moment from "moment";
import { cookies } from "next/headers";
import { getFingerprintIdCookie } from "./fingerprint";
import { trySendVerification } from "./server/verify";
export function checkPasswordChangeRequired(
expirySettings: PasswordExpirySettings | undefined,
session: Session,
humanUser: HumanUser | undefined,
organization?: string,
requestId?: string,
) {
let isOutdated = false;
if (expirySettings?.maxAgeDays && humanUser?.passwordChanged) {
const maxAgeDays = Number(expirySettings.maxAgeDays); // Convert bigint to number
const passwordChangedDate = moment(timestampDate(humanUser.passwordChanged));
const outdatedPassword = passwordChangedDate.add(maxAgeDays, "days");
isOutdated = moment().isAfter(outdatedPassword);
}
if (humanUser?.passwordChangeRequired || isOutdated) {
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 (requestId) {
params.append("requestId", requestId);
}
return { redirect: "/password/change?" + params };
}
}
export async function checkEmailVerified(
session: Session,
humanUser?: HumanUser,
organization?: string,
requestId?: string,
) {
if (!humanUser?.email?.isVerified) {
const codeSent = await trySendVerification({
userId: session.factors?.user?.id as string,
isInvite: false,
requestId,
});
const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
userId: session.factors?.user?.id as string, // verify needs user id
});
if (codeSent) {
paramsVerify.append("codeSent", "true");
}
if (organization || session.factors?.user?.organizationId) {
paramsVerify.append("organization", organization ?? (session.factors?.user?.organizationId as string));
}
if (requestId) {
paramsVerify.append("requestId", requestId);
}
return { redirect: "/verify?" + paramsVerify };
}
}
export async function checkEmailVerification(
session: Session,
humanUser?: HumanUser,
organization?: string,
requestId?: string,
) {
if (!humanUser?.email?.isVerified && process.env.EMAIL_VERIFICATION === "true") {
const codeSent = await trySendVerification({
userId: session.factors?.user?.id as string,
isInvite: false,
requestId,
});
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (codeSent) {
params.append("codeSent", "true");
}
if (requestId) {
params.append("requestId", requestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
}
return { redirect: `/verify?` + params };
}
}
// Re-export MFA helpers for backward compatibility
export { checkMFAFactors, shouldEnforceMFA } from "./mfa-helper";
export async function checkUserVerification(userId: string): Promise<boolean> {
// 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;
}