mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
## 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. `[email protected]` 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 <[email protected]>
550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
"use server";
|
|
|
|
import { create } from "@zitadel/client";
|
|
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
|
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { headers } from "next/headers";
|
|
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
|
|
|
|
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
|
import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb";
|
|
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
|
import { getServiceConfig } from "../service-url";
|
|
import {
|
|
getActiveIdentityProviders,
|
|
getIDPByID,
|
|
getLoginSettings,
|
|
getOrgsByDomain,
|
|
listAuthenticationMethodTypes,
|
|
listIDPLinks,
|
|
searchUsers,
|
|
SearchUsersCommand,
|
|
startIdentityProviderFlow,
|
|
} from "../zitadel";
|
|
import { createSessionAndUpdateCookie } from "./cookie";
|
|
import { getPublicHost } from "./host";
|
|
|
|
export type SendLoginnameCommand = {
|
|
loginName: string;
|
|
requestId?: string;
|
|
organization?: string;
|
|
defaultOrganization?: string;
|
|
suffix?: string;
|
|
ignoreUnknownUsernames?: boolean;
|
|
};
|
|
|
|
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
|
|
|
|
export async function sendLoginname(command: SendLoginnameCommand) {
|
|
const _headers = await headers();
|
|
const { serviceConfig } = getServiceConfig(_headers);
|
|
|
|
const t = await getTranslations("loginname");
|
|
|
|
const loginSettingsByContext = await getLoginSettings({ serviceConfig, organization: command.organization });
|
|
|
|
if (!loginSettingsByContext) {
|
|
return { error: t("errors.couldNotGetLoginSettings") };
|
|
}
|
|
|
|
let searchUsersRequest: SearchUsersCommand = {
|
|
serviceConfig,
|
|
searchValue: command.loginName,
|
|
organizationId: command.organization,
|
|
loginSettings: loginSettingsByContext,
|
|
suffix: command.suffix,
|
|
};
|
|
|
|
const searchResult = await searchUsers(searchUsersRequest);
|
|
|
|
// Safety check: ensure searchResult is defined
|
|
if (!searchResult) {
|
|
console.error("searchUsers returned undefined or null");
|
|
return { error: t("errors.couldNotSearchUsers") };
|
|
}
|
|
|
|
if ("error" in searchResult && searchResult.error) {
|
|
console.log("searchUsers returned error, returning early:", searchResult.error);
|
|
return searchResult;
|
|
}
|
|
|
|
if (!("result" in searchResult)) {
|
|
console.log("searchUsers has no result field");
|
|
return { error: t("errors.couldNotSearchUsers") };
|
|
}
|
|
|
|
const { result: potentialUsers } = searchResult;
|
|
|
|
// Additional safety check: treat undefined result as empty array
|
|
const users = potentialUsers ?? [];
|
|
|
|
if (users.length === 0) {
|
|
console.log("No users found, will proceed with org discovery");
|
|
}
|
|
|
|
const preventUserEnumeration = (organization: string | undefined) => {
|
|
if (command.ignoreUnknownUsernames) {
|
|
console.log("ignoreUnknownUsernames is true, redirecting to password");
|
|
const paramsPasswordDefault = new URLSearchParams({
|
|
loginName: command.loginName,
|
|
});
|
|
|
|
if (command.requestId) {
|
|
paramsPasswordDefault.append("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
paramsPasswordDefault.append("organization", organization);
|
|
}
|
|
|
|
return { redirect: "/password?" + paramsPasswordDefault };
|
|
}
|
|
return { error: t("errors.userNotFound") };
|
|
};
|
|
|
|
const redirectUserToIDP = async (userId?: string, organization?: string) => {
|
|
// If userId is provided, check for user-specific IDP links first
|
|
let identityProviders: IDPLink[] = [];
|
|
if (userId) {
|
|
identityProviders = await listIDPLinks({ serviceConfig, userId }).then((resp) => {
|
|
return resp.result;
|
|
});
|
|
}
|
|
|
|
// If no IDP links exist for the user (or no userId provided), try to get active IDPs from the organization
|
|
if (identityProviders.length === 0) {
|
|
const activeIdps = await getActiveIdentityProviders({ serviceConfig, orgId: organization }).then((resp) => {
|
|
return resp.identityProviders;
|
|
});
|
|
|
|
// If exactly one active IDP exists in the organization, redirect to it
|
|
if (activeIdps.length === 1) {
|
|
const _headers = await headers();
|
|
const { serviceConfig } = getServiceConfig(_headers);
|
|
const host = getPublicHost(_headers);
|
|
|
|
const identityProviderType = activeIdps[0].type;
|
|
const provider = idpTypeToSlug(identityProviderType);
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
if (userId) {
|
|
params.set("userId", userId);
|
|
}
|
|
|
|
if (command.requestId) {
|
|
params.set("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
params.set("organization", organization);
|
|
}
|
|
|
|
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
|
|
|
const response = await startIdentityProviderFlow({
|
|
serviceConfig,
|
|
idpId: activeIdps[0].id,
|
|
urls: {
|
|
successUrl:
|
|
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/process?` +
|
|
new URLSearchParams(params),
|
|
failureUrl:
|
|
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` +
|
|
new URLSearchParams(params),
|
|
},
|
|
});
|
|
|
|
if (!response || !response.url) {
|
|
return { error: t("errors.couldNotStartIDPFlow") };
|
|
}
|
|
|
|
if (response.fields) {
|
|
return { samlData: { url: response.url, fields: response.fields } };
|
|
}
|
|
|
|
return { redirect: response.url };
|
|
}
|
|
}
|
|
|
|
if (identityProviders.length === 1) {
|
|
const _headers = await headers();
|
|
const { serviceConfig } = getServiceConfig(_headers);
|
|
const host = getPublicHost(_headers);
|
|
|
|
const identityProviderId = identityProviders[0].idpId;
|
|
|
|
const idp = await getIDPByID({ serviceConfig, id: identityProviderId });
|
|
|
|
const idpType = idp?.type;
|
|
|
|
if (!idp || !idpType) {
|
|
throw new Error(t("errors.couldNotFindIdentityProvider"));
|
|
}
|
|
|
|
const identityProviderType = idpTypeToIdentityProviderType(idpType);
|
|
const provider = idpTypeToSlug(identityProviderType);
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
if (userId) {
|
|
params.set("userId", userId);
|
|
}
|
|
|
|
if (command.requestId) {
|
|
params.set("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
params.set("organization", organization);
|
|
}
|
|
|
|
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
|
|
|
const response = await startIdentityProviderFlow({
|
|
serviceConfig,
|
|
idpId: idp.id,
|
|
urls: {
|
|
successUrl:
|
|
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/process?` +
|
|
new URLSearchParams(params),
|
|
failureUrl:
|
|
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` +
|
|
new URLSearchParams(params),
|
|
},
|
|
});
|
|
|
|
if (!response || !response.url) {
|
|
return { error: t("errors.couldNotStartIDPFlow") };
|
|
}
|
|
|
|
if (response.fields) {
|
|
return { samlData: { url: response.url, fields: response.fields } };
|
|
}
|
|
|
|
return { redirect: response.url };
|
|
}
|
|
};
|
|
|
|
if (users.length > 1) {
|
|
console.log("multiple users found, returning error");
|
|
if (loginSettingsByContext?.ignoreUnknownUsernames) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
return { error: t("errors.moreThanOneUserFound") };
|
|
} else if (users.length == 1 && users[0].userId) {
|
|
const user = users[0];
|
|
const userId = users[0].userId;
|
|
|
|
const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner });
|
|
|
|
// compare with the concatenated suffix when set
|
|
const concatLoginname = command.suffix ? `${command.loginName}@${command.suffix}` : command.loginName;
|
|
|
|
const humanUser = users[0].type.case === "human" ? users[0].type.value : undefined;
|
|
|
|
// 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) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
} else if (userLoginSettings?.disableLoginWithEmail) {
|
|
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) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
}
|
|
|
|
let session;
|
|
if (!userLoginSettings?.ignoreUnknownUsernames) {
|
|
const checks = create(ChecksSchema, {
|
|
user: { search: { case: "userId", value: userId } },
|
|
});
|
|
|
|
const sessionOrError = await createSessionAndUpdateCookie({
|
|
checks,
|
|
requestId: command.requestId,
|
|
}).catch((error) => {
|
|
if (error?.rawMessage === "Errors.User.NotActive (SESSION-Gj4ko)") {
|
|
return { error: t("errors.userNotActive") };
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
if ("error" in sessionOrError) {
|
|
return sessionOrError;
|
|
}
|
|
|
|
session = sessionOrError.session;
|
|
}
|
|
|
|
if (session && !session.factors?.user?.id) {
|
|
return { error: t("errors.couldNotCreateSession") };
|
|
}
|
|
|
|
// TODO: check if handling of userstate INITIAL is needed
|
|
if (user.state === UserState.INITIAL) {
|
|
if (userLoginSettings?.ignoreUnknownUsernames) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
return { error: t("errors.initialUserNotSupported") };
|
|
}
|
|
|
|
// Resolve organization from command or session
|
|
let organization = command.organization ?? session?.factors?.user?.organizationId ?? user.details?.resourceOwner;
|
|
|
|
if (userLoginSettings?.ignoreUnknownUsernames) {
|
|
organization = command.organization;
|
|
if (!organization && ORG_SUFFIX_REGEX.test(command.loginName)) {
|
|
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
|
|
const suffix = matched?.[1] ?? "";
|
|
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
|
|
|
|
if (orgs.result && orgs.result.length === 1) {
|
|
const orgToCheckForDiscovery = orgs.result[0].id;
|
|
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
|
|
|
|
if (orgLoginSettings?.allowDomainDiscovery) {
|
|
organization = orgToCheckForDiscovery;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const methods = await listAuthenticationMethodTypes({
|
|
serviceConfig,
|
|
userId: session?.factors?.user?.id ?? userId,
|
|
});
|
|
|
|
// always resend invite if user has no auth method set
|
|
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
|
|
console.log("humanUser.email?.isVerified", humanUser?.email?.isVerified);
|
|
const params = new URLSearchParams({
|
|
loginName: (session?.factors?.user?.loginName ?? user.preferredLoginName) as string,
|
|
send: "true", // set this to true to request a new code immediately
|
|
invite: humanUser?.email?.isVerified ? "false" : "true", // sendInviteEmailCode results in an error if user is already initialized
|
|
});
|
|
|
|
if (command.requestId) {
|
|
params.append("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
params.append("organization", organization);
|
|
}
|
|
|
|
return { redirect: `/verify?` + params };
|
|
}
|
|
|
|
if (methods.authMethodTypes.length == 1) {
|
|
const method = methods.authMethodTypes[0];
|
|
switch (method) {
|
|
case AuthenticationMethodType.PASSWORD: // user has only password as auth method
|
|
if (!userLoginSettings?.allowLocalAuthentication) {
|
|
// Check if user has IDPs available as alternative, that could eventually be used to register/link.
|
|
const idpResp = await redirectUserToIDP(userId, organization);
|
|
if (idpResp?.redirect) {
|
|
return idpResp;
|
|
}
|
|
|
|
if (command.ignoreUnknownUsernames) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
|
|
return {
|
|
error: t("errors.localAuthenticationNotAllowed"),
|
|
};
|
|
}
|
|
|
|
const paramsPassword = new URLSearchParams({
|
|
loginName: command.ignoreUnknownUsernames
|
|
? command.loginName
|
|
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
|
|
});
|
|
|
|
if (organization) {
|
|
paramsPassword.append("organization", organization);
|
|
}
|
|
|
|
if (command.requestId) {
|
|
paramsPassword.append("requestId", command.requestId);
|
|
}
|
|
|
|
return {
|
|
redirect: "/password?" + paramsPassword,
|
|
};
|
|
|
|
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
|
|
if (
|
|
userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED ||
|
|
!userLoginSettings?.allowLocalAuthentication
|
|
) {
|
|
if (command.ignoreUnknownUsernames) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
return {
|
|
error: t("errors.passkeysNotAllowed"),
|
|
};
|
|
}
|
|
|
|
const paramsPasskey = new URLSearchParams({
|
|
loginName: command.ignoreUnknownUsernames
|
|
? command.loginName
|
|
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
|
|
});
|
|
if (command.requestId) {
|
|
paramsPasskey.append("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
paramsPasskey.append("organization", organization);
|
|
}
|
|
|
|
return { redirect: "/passkey?" + paramsPasskey };
|
|
|
|
case AuthenticationMethodType.IDP:
|
|
const resp = await redirectUserToIDP(userId, organization);
|
|
|
|
if (resp?.error) {
|
|
return { error: resp.error };
|
|
}
|
|
|
|
return resp;
|
|
}
|
|
} else {
|
|
// prefer passkey in favor of other methods
|
|
if (
|
|
methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY) &&
|
|
userLoginSettings?.passkeysType !== PasskeysType.NOT_ALLOWED &&
|
|
userLoginSettings?.allowLocalAuthentication
|
|
) {
|
|
const passkeyParams = new URLSearchParams({
|
|
loginName: command.ignoreUnknownUsernames
|
|
? command.loginName
|
|
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
|
|
altPassword: `${methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) && userLoginSettings?.allowLocalAuthentication}`, // show alternative password option only if allowed
|
|
});
|
|
|
|
if (command.requestId) {
|
|
passkeyParams.append("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
passkeyParams.append("organization", organization);
|
|
}
|
|
|
|
return { redirect: "/passkey?" + passkeyParams };
|
|
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) {
|
|
return redirectUserToIDP(userId, organization);
|
|
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) {
|
|
// Check if password authentication is allowed
|
|
if (!userLoginSettings?.allowLocalAuthentication) {
|
|
if (command.ignoreUnknownUsernames) {
|
|
return preventUserEnumeration(command.organization);
|
|
}
|
|
return {
|
|
error: t("errors.localAuthenticationNotAllowed"),
|
|
};
|
|
}
|
|
|
|
// user has no passkey setup and login settings allow passwords
|
|
const paramsPasswordDefault = new URLSearchParams({
|
|
loginName: command.ignoreUnknownUsernames
|
|
? command.loginName
|
|
: (session?.factors?.user?.loginName ?? user.preferredLoginName),
|
|
});
|
|
|
|
if (command.requestId) {
|
|
paramsPasswordDefault.append("requestId", command.requestId);
|
|
}
|
|
|
|
if (organization) {
|
|
paramsPasswordDefault.append("organization", organization);
|
|
}
|
|
|
|
return {
|
|
redirect: "/password?" + paramsPasswordDefault,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("user not found (0 potential users), checking registration options");
|
|
|
|
// user not found, perform organization discovery if no org context provided
|
|
let discoveredOrganization = command.organization;
|
|
let effectiveLoginSettings = loginSettingsByContext;
|
|
|
|
if (!command.organization && command.defaultOrganization) {
|
|
const defaultLoginSettings = await getLoginSettings({ serviceConfig, organization: command.defaultOrganization });
|
|
if (defaultLoginSettings) {
|
|
effectiveLoginSettings = defaultLoginSettings;
|
|
}
|
|
}
|
|
|
|
if (!discoveredOrganization && command.loginName && ORG_SUFFIX_REGEX.test(command.loginName)) {
|
|
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
|
|
const suffix = matched?.[1] ?? "";
|
|
|
|
// this just returns orgs where the suffix is set as primary domain
|
|
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
|
|
|
|
const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
|
|
|
|
if (orgToCheckForDiscovery) {
|
|
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
|
|
|
|
if (orgLoginSettings?.allowDomainDiscovery) {
|
|
console.log("org discovery successful, using org:", orgToCheckForDiscovery);
|
|
discoveredOrganization = orgToCheckForDiscovery;
|
|
// Use the discovered organization's login settings for subsequent checks
|
|
effectiveLoginSettings = orgLoginSettings;
|
|
} else {
|
|
console.log("org does not allow domain discovery");
|
|
}
|
|
} else {
|
|
console.log("no single org found for discovery");
|
|
}
|
|
}
|
|
|
|
// user not found, check if register is enabled on instance / organization context
|
|
if (effectiveLoginSettings?.allowRegister && !effectiveLoginSettings?.allowLocalAuthentication) {
|
|
console.log("redirecting to IDP (register allowed, password not allowed)");
|
|
const resp = await redirectUserToIDP(undefined, discoveredOrganization);
|
|
if (resp) {
|
|
return resp;
|
|
}
|
|
console.log("IDP redirect failed, returning user not found");
|
|
|
|
return preventUserEnumeration(discoveredOrganization);
|
|
} else if (effectiveLoginSettings?.allowRegister && effectiveLoginSettings?.allowLocalAuthentication) {
|
|
console.log("register and password both allowed");
|
|
// do not register user if ignoreUnknownUsernames is set
|
|
if (discoveredOrganization && !effectiveLoginSettings?.ignoreUnknownUsernames) {
|
|
console.log("redirecting to registration page with org:", discoveredOrganization);
|
|
const params = new URLSearchParams({ organization: discoveredOrganization });
|
|
|
|
if (command.requestId) {
|
|
params.set("requestId", command.requestId);
|
|
}
|
|
|
|
if (command.loginName) {
|
|
params.set("email", command.loginName);
|
|
}
|
|
|
|
return { redirect: "/register?" + params };
|
|
} else {
|
|
console.log("not redirecting to register:", {
|
|
hasDiscoveredOrg: !!discoveredOrganization,
|
|
ignoreUnknownUsernames: effectiveLoginSettings?.ignoreUnknownUsernames,
|
|
});
|
|
}
|
|
}
|
|
|
|
return preventUserEnumeration(discoveredOrganization);
|
|
}
|