mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-13 01:25:11 +00:00
fix(login): correctly redirect to /password when ignoreUnknownUsername is set (#11130)
Closes #11006 # Which Problems Are Solved This PR addresses an issue where the ignoreUnknownUsernames setting was not being respected in certain scenarios during the login flow. Specifically: - When a user was found but rejected due to login settings (e.g., disableLoginWithEmail), the system would return a "User not found" error instead of redirecting to the password page as dictated by ignoreUnknownUsernames. - When a user was not found, and the flow fell through the registration checks (e.g., IDP redirect failed), it would also return "User not found" without checking ignoreUnknownUsernames. # How the Problems Are Solved - Introduced a helper function `handleUserNotFound` in `apps/login/src/lib/server/loginname.ts` to centralize the logic for checking ignoreUnknownUsernames and redirecting to the password page. - Updated the user validation logic to call handleUserNotFound when a user is found but rejected by policy. - Updated the registration fallback logic to call handleUserNotFound instead of directly returning an error. - Added new test cases in `apps/login/src/lib/server/loginname.test.ts` to verify the fix and ensure no regressions. # Additional Changes - Fixed inconsistency in translation files --------- Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
co-authored by
Livio Spring
parent
e72203ca6d
commit
26f8238499
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { completeFlowOrGetUrl } from "@/lib/client";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { updateOrCreateSession } from "@/lib/server/session";
|
||||
import { create } from "@zitadel/client";
|
||||
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
@@ -94,7 +94,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const response = await updateSession({
|
||||
const response = await updateOrCreateSession({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
@@ -151,7 +151,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await updateSession({
|
||||
const response = await updateOrCreateSession({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock("@/lib/server/passkeys", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/session", () => ({
|
||||
updateSession: vi.fn(),
|
||||
updateOrCreateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock navigator.credentials
|
||||
@@ -67,10 +67,10 @@ describe("LoginPasskey Component", () => {
|
||||
mockCredentialsGet.mockClear();
|
||||
|
||||
const { sendPasskey } = await import("@/lib/server/passkeys");
|
||||
const { updateSession } = await import("@/lib/server/session");
|
||||
const { updateOrCreateSession } = await import("@/lib/server/session");
|
||||
|
||||
mockSendPasskey = vi.mocked(sendPasskey);
|
||||
mockUpdateSession = vi.mocked(updateSession);
|
||||
mockUpdateSession = vi.mocked(updateOrCreateSession);
|
||||
});
|
||||
|
||||
describe("Initialization and Challenge Request", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import { sendPasskey } from "@/lib/server/passkeys";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { updateOrCreateSession } from "@/lib/server/session";
|
||||
import { create, JsonObject } from "@zitadel/client";
|
||||
import { RequestChallengesSchema, UserVerificationRequirement } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
@@ -38,7 +38,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
setLoading(true);
|
||||
updateSessionForChallenge()
|
||||
updateOrCreateSessionForChallenge()
|
||||
.then((response) => {
|
||||
const pK = response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions?.publicKey;
|
||||
|
||||
@@ -50,7 +50,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
|
||||
return submitLoginAndContinue(pK)
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -58,7 +58,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -68,14 +68,14 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function updateSessionForChallenge(
|
||||
async function updateOrCreateSessionForChallenge(
|
||||
userVerificationRequirement: number = login
|
||||
? UserVerificationRequirement.REQUIRED
|
||||
: UserVerificationRequirement.DISCOURAGED,
|
||||
) {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const session = await updateSession({
|
||||
const sessionResponse = await updateOrCreateSession({
|
||||
loginName,
|
||||
sessionId,
|
||||
organization,
|
||||
@@ -87,7 +87,8 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
}),
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
return;
|
||||
})
|
||||
@@ -95,12 +96,12 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (session && "error" in session && session.error) {
|
||||
setError(session.error);
|
||||
if (sessionResponse && "error" in sessionResponse && sessionResponse.error) {
|
||||
setError(sessionResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
return session;
|
||||
return sessionResponse;
|
||||
}
|
||||
|
||||
async function submitLogin(data: JsonObject) {
|
||||
@@ -238,7 +239,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
const response = await updateSessionForChallenge().finally(() => {
|
||||
const response = await updateOrCreateSessionForChallenge().finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
@@ -253,7 +254,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
|
||||
|
||||
return submitLoginAndContinue(pK)
|
||||
.catch((error) => {
|
||||
setError(error);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -23,10 +23,11 @@ type Props = {
|
||||
loginSettings: LoginSettings | undefined;
|
||||
loginName: string;
|
||||
organization?: string;
|
||||
defaultOrganization?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function PasswordForm({ loginSettings, loginName, organization, requestId }: Props) {
|
||||
export function PasswordForm({ loginSettings, loginName, organization, defaultOrganization, requestId }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onChange",
|
||||
});
|
||||
@@ -47,6 +48,7 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
|
||||
const response = await sendPassword({
|
||||
loginName,
|
||||
organization,
|
||||
defaultOrganization,
|
||||
checks: create(ChecksSchema, {
|
||||
password: { password: values.password },
|
||||
}),
|
||||
@@ -78,10 +80,11 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
|
||||
const response = await resetPassword({
|
||||
loginName,
|
||||
organization,
|
||||
defaultOrganization,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t("verify.errors.couldNotResetPassword"));
|
||||
setError(t("errors.couldNotSendResetLink"));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -89,7 +92,7 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
|
||||
});
|
||||
|
||||
if (response && "error" in response) {
|
||||
setError(response.error);
|
||||
setError(response.error as string);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type Props = {
|
||||
loginName: string;
|
||||
userId: string;
|
||||
organization?: string;
|
||||
defaultOrganization?: string;
|
||||
requestId?: string;
|
||||
codeRequired: boolean;
|
||||
};
|
||||
@@ -38,6 +39,7 @@ type Props = {
|
||||
export function SetPasswordForm({
|
||||
passwordComplexitySettings,
|
||||
organization,
|
||||
defaultOrganization,
|
||||
requestId,
|
||||
loginName,
|
||||
userId,
|
||||
@@ -65,6 +67,7 @@ export function SetPasswordForm({
|
||||
const response = await resetPassword({
|
||||
loginName,
|
||||
organization,
|
||||
defaultOrganization,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -75,7 +78,7 @@ export function SetPasswordForm({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
if (response && "error" in response) {
|
||||
if (response && "error" in response && typeof response.error === "string") {
|
||||
setError(response.error);
|
||||
return;
|
||||
}
|
||||
@@ -83,9 +86,11 @@ export function SetPasswordForm({
|
||||
|
||||
async function submitPassword(values: Inputs) {
|
||||
setLoading(true);
|
||||
let payload: { userId: string; password: string; code?: string } = {
|
||||
|
||||
let payload: { userId: string; password: string; code?: string; organization?: string } = {
|
||||
userId: userId,
|
||||
password: values.password,
|
||||
organization,
|
||||
};
|
||||
|
||||
// this is not required for initial password setup
|
||||
|
||||
@@ -22,12 +22,22 @@ type Props = {
|
||||
requestId: string | undefined;
|
||||
loginSettings: LoginSettings | undefined;
|
||||
organization?: string;
|
||||
defaultOrganization?: string;
|
||||
suffix?: string;
|
||||
submit: boolean;
|
||||
allowRegister: boolean;
|
||||
};
|
||||
|
||||
export function UsernameForm({ loginName, requestId, organization, suffix, loginSettings, submit, allowRegister }: Props) {
|
||||
export function UsernameForm({
|
||||
loginName,
|
||||
requestId,
|
||||
organization,
|
||||
defaultOrganization,
|
||||
suffix,
|
||||
loginSettings,
|
||||
submit,
|
||||
allowRegister,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
@@ -48,8 +58,10 @@ export function UsernameForm({ loginName, requestId, organization, suffix, login
|
||||
const res = await sendLoginname({
|
||||
loginName: values.loginName,
|
||||
organization,
|
||||
defaultOrganization,
|
||||
requestId,
|
||||
suffix,
|
||||
ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames,
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t("errors.internalError"));
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { resendVerification, sendVerification } from "@/lib/server/verify";
|
||||
import { UNKNOWN_USER_ID } from "@/lib/constants";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { BackButton } from "./back-button";
|
||||
@@ -45,6 +46,13 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
// do not send code for dummy userid that is set to prevent user enumeration
|
||||
if (userId === UNKNOWN_USER_ID) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await resendVerification({
|
||||
userId,
|
||||
isInvite: isInvite,
|
||||
@@ -65,8 +73,11 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
return response;
|
||||
}
|
||||
|
||||
const processedCode = useRef<string | undefined>(undefined);
|
||||
|
||||
const fcn = useCallback(
|
||||
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
const response = await sendVerification({
|
||||
@@ -98,7 +109,8 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
if (code && code !== processedCode.current) {
|
||||
processedCode.current = code;
|
||||
fcn({ code });
|
||||
}
|
||||
}, [code, fcn]);
|
||||
|
||||
Reference in New Issue
Block a user