mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
fix: made email addresses case insensitive
This commit is contained in:
@@ -1,7 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getValidLocaleFromUILocales, isRSCRequest, isValidLanguage, validateAuthRequest } from "./auth-utils";
|
||||
import {
|
||||
equalsIgnoreCase,
|
||||
getValidLocaleFromUILocales,
|
||||
isRSCRequest,
|
||||
isValidLanguage,
|
||||
validateAuthRequest,
|
||||
} from "./auth-utils";
|
||||
|
||||
describe("auth-utils", () => {
|
||||
describe("equalsIgnoreCase", () => {
|
||||
it("should match identifiers regardless of case", () => {
|
||||
expect(equalsIgnoreCase("jackson@example.com", "Jackson@Example.com")).toBe(true);
|
||||
expect(equalsIgnoreCase("USER@ORGDOMAIN.COM", "user@orgdomain.com")).toBe(true);
|
||||
expect(equalsIgnoreCase("user", "user")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match different identifiers", () => {
|
||||
expect(equalsIgnoreCase("jackson@example.com", "jane@example.com")).toBe(false);
|
||||
expect(equalsIgnoreCase("user@example.com", "user@other.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when either value is undefined", () => {
|
||||
expect(equalsIgnoreCase(undefined, "user@example.com")).toBe(false);
|
||||
expect(equalsIgnoreCase("user@example.com", undefined)).toBe(false);
|
||||
expect(equalsIgnoreCase(undefined, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidLanguage", () => {
|
||||
it("should return true for valid language codes", () => {
|
||||
expect(isValidLanguage("en")).toBe(true);
|
||||
|
||||
@@ -4,6 +4,21 @@ import { LANGS } from "@/lib/i18n";
|
||||
* Authentication utility functions that don't require server actions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compare two identifiers (loginname, email, phone) case insensitively.
|
||||
*
|
||||
* User lookup is performed with EQUALS_IGNORE_CASE, so any follow up check that
|
||||
* verifies *which* identifier the user was found by has to ignore case as well.
|
||||
* Otherwise entering "Jackson@example.com" for the user "jackson@example.com"
|
||||
* resolves the user but is then rejected as unknown.
|
||||
*/
|
||||
export function equalsIgnoreCase(a: string | undefined, b: string | undefined): boolean {
|
||||
if (a === undefined || b === undefined) {
|
||||
return false;
|
||||
}
|
||||
return a.toLowerCase() === b.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language code is valid (supported by the login UI)
|
||||
*/
|
||||
|
||||
@@ -948,6 +948,76 @@ describe("sendLoginname", () => {
|
||||
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should allow login with a differently cased login name when email and phone login are disabled", async () => {
|
||||
// Regression test for: https://github.com/zitadel/zitadel/issues/12025
|
||||
// The user search matches case insensitively, so the follow up check that verifies
|
||||
// the user was found by login name must not reject "Jackson@Example.com".
|
||||
const mockUser = {
|
||||
userId: "user123",
|
||||
preferredLoginName: "jackson@example.com",
|
||||
details: { resourceOwner: "org123" },
|
||||
type: { case: "human", value: { email: { email: "jackson@example.com" } } },
|
||||
state: UserState.ACTIVE,
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
factors: { user: { id: "user123", loginName: "jackson@example.com", organizationId: "org123" } },
|
||||
};
|
||||
|
||||
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
|
||||
mockGetLoginSettings.mockResolvedValue({
|
||||
disableLoginWithEmail: true,
|
||||
disableLoginWithPhone: true,
|
||||
allowLocalAuthentication: true,
|
||||
});
|
||||
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({
|
||||
authMethodTypes: [AuthenticationMethodType.PASSWORD],
|
||||
});
|
||||
|
||||
const result = await sendLoginname({
|
||||
loginName: "Jackson@Example.com",
|
||||
});
|
||||
|
||||
expect(result).not.toEqual({ error: "errors.userNotFound" });
|
||||
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should allow login with a differently cased email when disableLoginWithPhone is true", async () => {
|
||||
// Regression test for: https://github.com/zitadel/zitadel/issues/12025
|
||||
const mockUser = {
|
||||
userId: "user123",
|
||||
preferredLoginName: "jackson@orgdomain.com",
|
||||
details: { resourceOwner: "org123" },
|
||||
type: {
|
||||
case: "human",
|
||||
value: { email: { email: "jackson@example.com" }, phone: { phone: "+1234567890" } },
|
||||
},
|
||||
state: UserState.ACTIVE,
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
factors: { user: { id: "user123", loginName: "jackson@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: "Jackson@Example.com",
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { equalsIgnoreCase } from "@/lib/auth-utils";
|
||||
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { create } from "@zitadel/client";
|
||||
@@ -258,15 +259,21 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
|
||||
// recheck login settings after user discovery, as the search might have been done without org scope
|
||||
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
|
||||
if (user.preferredLoginName !== concatLoginname) {
|
||||
if (!equalsIgnoreCase(user.preferredLoginName, concatLoginname)) {
|
||||
return preventUserEnumeration(command.organization);
|
||||
}
|
||||
} else if (userLoginSettings?.disableLoginWithEmail) {
|
||||
if (user.preferredLoginName !== concatLoginname && humanUser?.phone?.phone !== command.loginName) {
|
||||
if (
|
||||
!equalsIgnoreCase(user.preferredLoginName, concatLoginname) &&
|
||||
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
|
||||
) {
|
||||
return preventUserEnumeration(command.organization);
|
||||
}
|
||||
} else if (userLoginSettings?.disableLoginWithPhone) {
|
||||
if (user.preferredLoginName !== concatLoginname && humanUser?.email?.email !== command.loginName) {
|
||||
if (
|
||||
!equalsIgnoreCase(user.preferredLoginName, concatLoginname) &&
|
||||
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
|
||||
) {
|
||||
return preventUserEnumeration(command.organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { equalsIgnoreCase } from "@/lib/auth-utils";
|
||||
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { recordAuthAttempt, recordAuthFailure, recordAuthSuccess } from "@/lib/metrics";
|
||||
@@ -91,7 +92,7 @@ export async function resetPassword(command: ResetPasswordCommand) {
|
||||
const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner });
|
||||
|
||||
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
|
||||
if (user.preferredLoginName !== command.loginName) {
|
||||
if (!equalsIgnoreCase(user.preferredLoginName, command.loginName)) {
|
||||
if (userLoginSettings?.ignoreUnknownUsernames) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
return {};
|
||||
@@ -99,7 +100,10 @@ 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 (
|
||||
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
|
||||
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
|
||||
) {
|
||||
if (userLoginSettings?.ignoreUnknownUsernames) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
return {};
|
||||
@@ -107,7 +111,10 @@ 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 (
|
||||
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
|
||||
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
|
||||
) {
|
||||
if (userLoginSettings?.ignoreUnknownUsernames) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
return {};
|
||||
@@ -239,7 +246,7 @@ export async function sendPassword(
|
||||
|
||||
// recheck login settings after user discovery, as the search might have been done without org scope
|
||||
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
|
||||
if (user.preferredLoginName !== command.loginName) {
|
||||
if (!equalsIgnoreCase(user.preferredLoginName, command.loginName)) {
|
||||
// emulate user not found to prevent enumeration (use context settings not user settings)
|
||||
recordAuthFailure("password", "login_name_mismatch", command.organization);
|
||||
if (loginSettingsByContext?.ignoreUnknownUsernames) {
|
||||
@@ -248,7 +255,10 @@ export async function sendPassword(
|
||||
return { error: t("errors.couldNotVerifyPassword") };
|
||||
}
|
||||
} else if (userLoginSettings?.disableLoginWithEmail) {
|
||||
if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) {
|
||||
if (
|
||||
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
|
||||
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
|
||||
) {
|
||||
recordAuthFailure("password", "login_name_mismatch", command.organization);
|
||||
if (loginSettingsByContext?.ignoreUnknownUsernames) {
|
||||
return { error: t("errors.failedToAuthenticateNoLimit") };
|
||||
@@ -256,7 +266,10 @@ export async function sendPassword(
|
||||
return { error: t("errors.couldNotVerifyPassword") };
|
||||
}
|
||||
} else if (userLoginSettings?.disableLoginWithPhone) {
|
||||
if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) {
|
||||
if (
|
||||
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
|
||||
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
|
||||
) {
|
||||
recordAuthFailure("password", "login_name_mismatch", command.organization);
|
||||
if (loginSettingsByContext?.ignoreUnknownUsernames) {
|
||||
return { error: t("errors.failedToAuthenticateNoLimit") };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
createServiceForHost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: vi.fn(() => (key: string) => key),
|
||||
}));
|
||||
|
||||
const serviceConfig = { serviceUrl: "https://api.example.com" } as any;
|
||||
|
||||
describe("zitadel queries are case insensitive", () => {
|
||||
let mockCreateServiceForHost: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { createServiceForHost } = await import("./service");
|
||||
mockCreateServiceForHost = vi.mocked(createServiceForHost);
|
||||
});
|
||||
|
||||
describe("getOrgsByDomain", () => {
|
||||
test("should look up the organization domain ignoring case", async () => {
|
||||
// Regression test for: https://github.com/zitadel/zitadel/issues/12025
|
||||
// Domain discovery for "jackson@Example.com" must find the org owning "example.com".
|
||||
const listOrganizations = vi.fn().mockResolvedValue({ result: [] });
|
||||
mockCreateServiceForHost.mockResolvedValue({ listOrganizations });
|
||||
|
||||
const { getOrgsByDomain } = await import("./zitadel");
|
||||
await getOrgsByDomain({ serviceConfig, domain: "Example.com" });
|
||||
|
||||
expect(listOrganizations).toHaveBeenCalledWith(
|
||||
{
|
||||
queries: [
|
||||
{
|
||||
query: {
|
||||
case: "domainQuery",
|
||||
value: { domain: "Example.com", method: TextQueryMethod.EQUALS_IGNORE_CASE },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listUsers", () => {
|
||||
test("should search login names ignoring case", async () => {
|
||||
const listUsersMock = vi.fn().mockResolvedValue({ result: [], details: {} });
|
||||
mockCreateServiceForHost.mockResolvedValue({ listUsers: listUsersMock });
|
||||
|
||||
const { listUsers } = await import("./zitadel");
|
||||
await listUsers({ serviceConfig, loginName: "Jackson@Example.com" });
|
||||
|
||||
const { queries } = listUsersMock.mock.calls[0][0];
|
||||
expect(queries[0].query.case).toBe("loginNameQuery");
|
||||
expect(queries[0].query.value.method).toBe(TextQueryMethod.EQUALS_IGNORE_CASE);
|
||||
});
|
||||
|
||||
test("should search user names and emails ignoring case", async () => {
|
||||
const listUsersMock = vi.fn().mockResolvedValue({ result: [], details: {} });
|
||||
mockCreateServiceForHost.mockResolvedValue({ listUsers: listUsersMock });
|
||||
|
||||
const { listUsers } = await import("./zitadel");
|
||||
await listUsers({ serviceConfig, userName: "Jackson", email: "Jackson@Example.com" });
|
||||
|
||||
const { queries } = listUsersMock.mock.calls[0][0];
|
||||
const orQueries = queries[0].query.value.queries;
|
||||
const methods = orQueries.map((q: any) => q.query.value.method);
|
||||
expect(orQueries.map((q: any) => q.query.case)).toEqual(["userNameQuery", "emailQuery"]);
|
||||
expect(methods).toEqual([TextQueryMethod.EQUALS_IGNORE_CASE, TextQueryMethod.EQUALS_IGNORE_CASE]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -610,6 +610,8 @@ const userLookupQuery = { limit: 2 };
|
||||
export async function listUsers({ serviceConfig, loginName, userName, phone, email, organizationId }: ListUsersCommand) {
|
||||
const queries: SearchQuery[] = [];
|
||||
|
||||
// loginnames, usernames and emails are matched case insensitively, mirroring how
|
||||
// uniqueness is enforced in the backend and how the loginname page searches users
|
||||
// either use loginName or userName, email, phone
|
||||
if (loginName) {
|
||||
queries.push(
|
||||
@@ -618,7 +620,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
|
||||
case: "loginNameQuery",
|
||||
value: {
|
||||
loginName,
|
||||
method: TextQueryMethod.EQUALS,
|
||||
method: TextQueryMethod.EQUALS_IGNORE_CASE,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -632,7 +634,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
|
||||
case: "userNameQuery",
|
||||
value: {
|
||||
userName,
|
||||
method: TextQueryMethod.EQUALS,
|
||||
method: TextQueryMethod.EQUALS_IGNORE_CASE,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -645,7 +647,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
|
||||
case: "emailQuery",
|
||||
value: {
|
||||
emailAddress: email,
|
||||
method: TextQueryMethod.EQUALS,
|
||||
method: TextQueryMethod.EQUALS_IGNORE_CASE,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -896,7 +898,9 @@ export async function getOrgsByDomain({ serviceConfig, domain }: WithServiceConf
|
||||
{
|
||||
query: {
|
||||
case: "domainQuery",
|
||||
value: { domain, method: TextQueryMethod.EQUALS },
|
||||
// domains are case insensitive, so a loginname like "jackson@Example.com"
|
||||
// must still discover the organization owning "example.com"
|
||||
value: { domain, method: TextQueryMethod.EQUALS_IGNORE_CASE },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user