From 11d48edd84de9ebc28609c01fa2290aaddf4e555 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Wed, 18 Feb 2026 00:30:22 -0800 Subject: [PATCH] =?UTF-8?q?fix(login):=20update=20login=20logic=20to=20cor?= =?UTF-8?q?rectly=20handle=20email=20and=20phone=20re=E2=80=A6=20(#11618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Enabling `Disable Phone Login` (or `Disable Email Login`) in the login policy caused some users to receive "User not found in the system" even when logging in with a valid, non-phone identifier. Users whose preferred login name looked like an email address (e.g. `user@test.com` in an org-scoped context) were rejected incorrectly. Toggling the setting back off immediately restored login for those users. Fixes #11518 ## Root Cause In `loginname.ts` and `password.ts`, the post-lookup guard that enforces the disable-phone/email restriction used `||` instead of `&&`: ```ts // BEFORE (buggy): rejects if preferredLoginName doesn't match OR email doesn't match // → any user whose preferredLoginName ≠ raw email is rejected if (user.preferredLoginName !== concatLoginname || humanUser?.email?.email !== command.loginName) { return preventUserEnumeration(...); } // AFTER (correct): rejects only if preferredLoginName doesn't match AND email doesn't match // → user passes through if they matched by either their preferred login name or their email if (user.preferredLoginName !== concatLoginname && humanUser?.email?.email !== command.loginName) { return preventUserEnumeration(...); } ``` The intent is to allow the user through if they matched by their preferred login name **or** by the still-permitted identifier. By De Morgan's law, the rejection condition must be `¬A ∧ ¬B`, not `¬A ∨ ¬B`. ## Changes - **`loginname.ts`**: fixed 2 `||` → `&&` in the `disableLoginWithEmail` and `disableLoginWithPhone` guards - **`password.ts`**: fixed 4 `||` → `&&` across the same guards in both `resetPassword` and `sendPassword` - **`loginname.test.ts`**: added two regression tests: - `disableLoginWithPhone=true`, user logs in with their email-format preferred login name → must succeed (was broken) - `disableLoginWithPhone=true`, user logs in with their actual phone number → must be blocked (feature still works) ## Testing All 654 unit tests pass. Co-authored-by: Max Peintner --- apps/login/src/lib/server/loginname.test.ts | 69 +++++++++++++++++++++ apps/login/src/lib/server/loginname.ts | 4 +- apps/login/src/lib/server/password.ts | 8 +-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index 9411ffa2c..b401cc713 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -731,6 +731,75 @@ describe("sendLoginname", () => { expect((result as any).redirect).toMatch(/^\/password\?/); }); + test("should allow login with email when disableLoginWithPhone is true and preferred login name differs from email", async () => { + // Regression test for: https://github.com/zitadel/zitadel/issues/11518 + // When preferredLoginName (e.g. username@org-domain) differs from the user's email, logging + // in with the email while disableLoginWithPhone=true must NOT be blocked. + const mockUser = { + userId: "user123", + preferredLoginName: "user@orgdomain.com", // org-domain scoped login name + details: { resourceOwner: "org123" }, + type: { + case: "human", + value: { email: { email: "user@test.com" }, phone: { phone: "+1234567890" } }, + }, + state: UserState.ACTIVE, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@orgdomain.com", + organizationId: "org123", + }, + }, + }; + + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockGetLoginSettings.mockResolvedValue({ + disableLoginWithPhone: true, + allowLocalAuthentication: true, + }); + mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@test.com", // logging in with email, not phone + }); + + // Must NOT return "User not found" — email-based login must be allowed when only phone is disabled + expect(result).not.toEqual({ error: "errors.userNotFound" }); + expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled(); + }); + + test("should block login with phone number when disableLoginWithPhone is true", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@orgdomain.com", + details: { resourceOwner: "org123" }, + type: { + case: "human", + value: { email: { email: "user@example.com" }, phone: { phone: "+1234567890" } }, + }, + state: UserState.ACTIVE, + }; + + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockGetLoginSettings.mockResolvedValue({ + disableLoginWithPhone: true, + }); + + const result = await sendLoginname({ + loginName: "+1234567890", // logging in with phone — must be blocked + }); + + expect(result).toEqual({ error: "errors.userNotFound" }); + expect(mockCreateSessionAndUpdateCookie).not.toHaveBeenCalled(); + }); + test("should redirect to password when ignoreUnknownUsernames is true and more than one user found", async () => { mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true, diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index d29a2ce72..803226ae1 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -250,11 +250,11 @@ export async function sendLoginname(command: SendLoginnameCommand) { return preventUserEnumeration(command.organization); } } else if (userLoginSettings?.disableLoginWithEmail) { - if (user.preferredLoginName !== concatLoginname || humanUser?.phone?.phone !== command.loginName) { + if (user.preferredLoginName !== concatLoginname && humanUser?.phone?.phone !== command.loginName) { return preventUserEnumeration(command.organization); } } else if (userLoginSettings?.disableLoginWithPhone) { - if (user.preferredLoginName !== concatLoginname || humanUser?.email?.email !== command.loginName) { + if (user.preferredLoginName !== concatLoginname && humanUser?.email?.email !== command.loginName) { return preventUserEnumeration(command.organization); } } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index ed8e11027..8fc4fee23 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -90,7 +90,7 @@ export async function resetPassword(command: ResetPasswordCommand) { return { error: t("errors.couldNotSendResetLink") }; } } else if (userLoginSettings?.disableLoginWithEmail) { - if (user.preferredLoginName !== command.loginName || humanUser?.phone?.phone !== command.loginName) { + if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) { if (userLoginSettings?.ignoreUnknownUsernames) { await new Promise((resolve) => setTimeout(resolve, 2000)); return {}; @@ -98,7 +98,7 @@ export async function resetPassword(command: ResetPasswordCommand) { return { error: t("errors.couldNotSendResetLink") }; } } else if (userLoginSettings?.disableLoginWithPhone) { - if (user.preferredLoginName !== command.loginName || humanUser?.email?.email !== command.loginName) { + if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) { if (userLoginSettings?.ignoreUnknownUsernames) { await new Promise((resolve) => setTimeout(resolve, 2000)); return {}; @@ -221,14 +221,14 @@ export async function sendPassword( return { error: t("errors.couldNotVerifyPassword") }; } } else if (userLoginSettings?.disableLoginWithEmail) { - if (user.preferredLoginName !== command.loginName || humanUser?.phone?.phone !== command.loginName) { + if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) { if (loginSettingsByContext?.ignoreUnknownUsernames) { return { error: t("errors.failedToAuthenticateNoLimit") }; } return { error: t("errors.couldNotVerifyPassword") }; } } else if (userLoginSettings?.disableLoginWithPhone) { - if (user.preferredLoginName !== command.loginName || humanUser?.email?.email !== command.loginName) { + if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) { if (loginSettingsByContext?.ignoreUnknownUsernames) { return { error: t("errors.failedToAuthenticateNoLimit") }; }