Merge pull request #464 from zitadel/verify-check-cookie

fix: Improve Invite flow by checking for same user agent and time limit
This commit is contained in:
Max Peintner
2025-05-21 14:19:05 +02:00
committed by GitHub
15 changed files with 273 additions and 129 deletions

View File

@@ -181,6 +181,7 @@
"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"
}
},

View File

@@ -181,6 +181,7 @@
"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"
}
},

View File

@@ -181,6 +181,7 @@
"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"
}
},

View File

@@ -181,6 +181,7 @@
"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"
}
},

View File

@@ -181,6 +181,7 @@
"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"
}
},

View File

@@ -181,6 +181,7 @@
"description": "Введите код из письма подтверждения.",
"noCodeReceived": "Не получили код?",
"resendCode": "Отправить код повторно",
"codeSent": "Код отправлен на ваш email.",
"submit": "Продолжить"
}
},

View File

@@ -181,6 +181,7 @@
"description": "输入验证邮件中的验证码。",
"noCodeReceived": "没有收到验证码?",
"resendCode": "重发验证码",
"codeSent": "刚刚发送了一封包含验证码的电子邮件。",
"submit": "继续"
}
},

View File

@@ -1,4 +1,4 @@
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";
@@ -6,6 +6,7 @@ import { VerifyRedirectButton } from "@/components/verify-redirect-button";
import { sendEmailCode } from "@/lib/server/verify";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { checkUserVerification } from "@/lib/verify-helper";
import {
getBrandingSettings,
getUserByID,
@@ -22,7 +23,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
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();
@@ -43,7 +44,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
let human: HumanUser | undefined;
let id: string | undefined;
const doSend = invite !== "true";
const doSend = send === "true";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@@ -61,7 +62,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
serviceUrl,
userId: sessionFactors?.factors?.user?.id,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not resend verification email", error);
@@ -74,7 +75,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
serviceUrl,
userId,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not resend verification email", error);
@@ -96,14 +97,23 @@ export default async function Page(props: { searchParams: Promise<any> }) {
id = userId ?? sessionFactors?.factors?.user?.id;
if (!id) {
throw Error("Failed to get user id");
}
let authMethods: AuthenticationMethodType[] | null = null;
if (human?.email?.isVerified) {
const authMethodsResponse = await listAuthenticationMethodTypes(userId);
const authMethodsResponse = await listAuthenticationMethodTypes({
serviceUrl,
userId,
});
if (authMethodsResponse.authMethodTypes) {
authMethods = authMethodsResponse.authMethodTypes;
}
}
const hasValidUserVerificationCheck = await checkUserVerification(id);
const params = new URLSearchParams({
userId: userId,
initial: "true", // defines that a code is not required and is therefore not shown in the UI
@@ -138,6 +148,12 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</>
)}
{id && send && (
<div className="py-4 w-full">
<Alert type={AlertType.INFO}>{t("verify.codeSent")}</Alert>
</div>
)}
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
@@ -155,27 +171,27 @@ export default async function Page(props: { searchParams: Promise<any> }) {
)
)}
{id &&
(human?.email?.isVerified ? (
// show page for already verified users
<VerifyRedirectButton
userId={id}
loginName={loginName}
organization={organization}
requestId={requestId}
authMethods={authMethods}
/>
) : (
// check if auth methods are set
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
))}
{/* show a button to setup auth method for the user otherwise show the UI for reverifying */}
{human?.email?.isVerified && hasValidUserVerificationCheck ? (
// show page for already verified users
<VerifyRedirectButton
userId={id}
loginName={loginName}
organization={organization}
requestId={requestId}
authMethods={authMethods}
/>
) : (
// check if auth methods are set
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
)}
</div>
</DynamicTheme>
);

View File

@@ -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",

View File

@@ -9,7 +9,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 { getServiceUrlFromHeaders } from "../service-url";
import { checkInvite } from "../verify-helper";
import { checkEmailVerified, checkUserVerification } from "../verify-helper";
import {
getActiveIdentityProviders,
getIDPByID,
@@ -257,7 +257,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// this can be expected to be an invite as users created in console have a password 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(
const inviteCheck = checkEmailVerified(
session,
humanUser,
session.factors.user.organizationId,
@@ -268,6 +268,31 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return inviteCheck;
}
// check if user was verified recently
const isUserVerified = await checkUserVerification(
session.factors.user.id,
);
if (!isUserVerified) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
send: "true", // set this to true to request a new code immediately
});
if (command.requestId) {
params.append("requestId", command.requestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ??
(session.factors?.user?.organizationId as string),
);
}
return { redirect: `/verify?` + params };
}
const paramsAuthenticatorSetup = new URLSearchParams({
loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id

View File

@@ -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<Session>): {
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<RegisterPasskeyResponse> {
): Promise<RegisterPasskeyResponse | { error: string }> {
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) {

View File

@@ -13,7 +13,6 @@ import {
listAuthenticationMethodTypes,
listUsers,
passwordReset,
setPassword,
setUserPassword,
} from "@/lib/zitadel";
import { ConnectError, create } from "@zitadel/client";
@@ -25,10 +24,7 @@ import {
} from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
AuthenticationMethodType,
SetPasswordRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers";
import { getNextUrl } from "../client";
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
@@ -37,6 +33,7 @@ import {
checkEmailVerification,
checkMFAFactors,
checkPasswordChangeRequired,
checkUserVerification,
} from "../verify-helper";
type ResetPasswordCommand = {
@@ -297,6 +294,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 +314,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,
});
}
@@ -366,67 +392,32 @@ export async function checkSessionAndSetPassword({
return { error: "Could not load auth methods" };
}
const requiredAuthMethodsForForceMFA = [
AuthenticationMethodType.OTP_EMAIL,
AuthenticationMethodType.OTP_SMS,
AuthenticationMethodType.TOTP,
AuthenticationMethodType.U2F,
];
const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every(
(method) => !authmethods.authMethodTypes.includes(method),
);
const loginSettings = await getLoginSettings({
serviceUrl,
organization: session.factors.user.organizationId,
});
const forceMfa = !!(
loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly
);
// if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user
if (forceMfa && hasNoMFAMethods) {
return setPassword({ serviceUrl, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) {
return { error: "Failed precondition" };
} else {
throw error;
}
const transport = async (serviceUrl: string, token: string) => {
return createServerTransport(token, {
baseUrl: serviceUrl,
});
} else {
const transport = async (serviceUrl: string, token: string) => {
return createServerTransport(token, {
baseUrl: serviceUrl,
});
};
};
const myUserService = async (serviceUrl: string, sessionToken: string) => {
const transportPromise = await transport(serviceUrl, sessionToken);
return createUserServiceClient(transportPromise);
};
const myUserService = async (serviceUrl: string, sessionToken: string) => {
const transportPromise = await transport(serviceUrl, sessionToken);
return createUserServiceClient(transportPromise);
};
const selfService = await myUserService(
serviceUrl,
`${sessionCookie.token}`,
);
const selfService = await myUserService(serviceUrl, `${sessionCookie.token}`);
return selfService
.setPassword(
{
userId: session.factors.user.id,
newPassword: { password, changeRequired: false },
},
{},
)
.catch((error: ConnectError) => {
console.log(error);
if (error.code === 7) {
return { error: "Session is not valid." };
}
throw error;
});
}
return selfService
.setPassword(
{
userId: session.factors.user.id,
newPassword: { password, changeRequired: false },
},
{},
)
.catch((error: ConnectError) => {
console.log(error);
if (error.code === 7) {
return { error: "Session is not valid." };
}
throw error;
});
}

View File

@@ -1,24 +1,26 @@
"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";
@@ -193,6 +195,24 @@ 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}` };
}
@@ -253,20 +273,26 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// create a new invite whenever the resend is called
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}` : ""),
}) //resendInviteCode({ serviceUrl, userId: command.userId })
: sendEmailCode({
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;
userId: string;
urlTemplate: string;
};

View File

@@ -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<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;
}

View File

@@ -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,
@@ -1170,13 +1166,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 +1180,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,