Merge branch 'main' into qa

This commit is contained in:
Elio Bischof
2025-01-13 22:31:05 +01:00
committed by GitHub
8 changed files with 323 additions and 49 deletions

View File

@@ -22,7 +22,7 @@ services:
- POSTGRES_HOST_AUTH_METHOD=trust - POSTGRES_HOST_AUTH_METHOD=trust
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready" ] test: ["CMD-SHELL", "pg_isready"]
interval: "10s" interval: "10s"
timeout: "30s" timeout: "30s"
retries: 5 retries: 5

View File

@@ -395,6 +395,5 @@ Timebased features like the multifactor init prompt or password expiry, are not
- Password Expiry Settings - Password Expiry Settings
- Login Settings: multifactor init prompt - Login Settings: multifactor init prompt
- forceMFA on login settings is not checked for IDPs - forceMFA on login settings is not checked for IDPs
- disablePhone / disableEmail from loginSettings will be implemented right after https://github.com/zitadel/zitadel/issues/9016 is merged
Also note that IDP logins are considered as valid MFA. An additional MFA check will be implemented in future if enforced. Also note that IDP logins are considered as valid MFA. An additional MFA check will be implemented in future if enforced.

View File

@@ -20,6 +20,7 @@ export default async function Page(props: {
const loginName = searchParams?.loginName; const loginName = searchParams?.loginName;
const authRequestId = searchParams?.authRequestId; const authRequestId = searchParams?.authRequestId;
const organization = searchParams?.organization; const organization = searchParams?.organization;
const suffix = searchParams?.suffix;
const submit: boolean = searchParams?.submit === "true"; const submit: boolean = searchParams?.submit === "true";
let defaultOrganization; let defaultOrganization;
@@ -34,6 +35,8 @@ export default async function Page(props: {
organization ?? defaultOrganization, organization ?? defaultOrganization,
); );
const contextLoginSettings = await getLoginSettings(organization);
const identityProviders = await getActiveIdentityProviders( const identityProviders = await getActiveIdentityProviders(
organization ?? defaultOrganization, organization ?? defaultOrganization,
).then((resp) => { ).then((resp) => {
@@ -54,6 +57,8 @@ export default async function Page(props: {
loginName={loginName} loginName={loginName}
authRequestId={authRequestId} authRequestId={authRequestId}
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
loginSettings={contextLoginSettings}
suffix={suffix}
submit={submit} submit={submit}
allowRegister={!!loginSettings?.allowRegister} allowRegister={!!loginSettings?.allowRegister}
> >

View File

@@ -300,6 +300,7 @@ export async function GET(request: NextRequest) {
const { authRequest } = await getAuthRequest({ authRequestId }); const { authRequest } = await getAuthRequest({ authRequestId });
let organization = ""; let organization = "";
let suffix = "";
let idpId = ""; let idpId = "";
if (authRequest?.scope) { if (authRequest?.scope) {
@@ -326,6 +327,7 @@ export async function GET(request: NextRequest) {
const orgs = await getOrgsByDomain(orgDomain); const orgs = await getOrgsByDomain(orgDomain);
if (orgs.result && orgs.result.length === 1) { if (orgs.result && orgs.result.length === 1) {
organization = orgs.result[0].id ?? ""; organization = orgs.result[0].id ?? "";
suffix = orgDomain;
} }
} }
} }
@@ -448,6 +450,9 @@ export async function GET(request: NextRequest) {
if (organization) { if (organization) {
loginNameUrl.searchParams.set("organization", organization); loginNameUrl.searchParams.set("organization", organization);
} }
if (suffix) {
loginNameUrl.searchParams.set("suffix", suffix);
}
return NextResponse.redirect(loginNameUrl); return NextResponse.redirect(loginNameUrl);
} else if (authRequest.prompt.includes(Prompt.NONE)) { } else if (authRequest.prompt.includes(Prompt.NONE)) {
/** /**

View File

@@ -15,6 +15,7 @@ export type TextInputProps = DetailedHTMLProps<
HTMLInputElement HTMLInputElement
> & { > & {
label: string; label: string;
suffix?: string;
placeholder?: string; placeholder?: string;
defaultValue?: string; defaultValue?: string;
error?: string | ReactNode; error?: string | ReactNode;
@@ -45,6 +46,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
label, label,
placeholder, placeholder,
defaultValue, defaultValue,
suffix,
required = false, required = false,
error, error,
disabled, disabled,
@@ -56,7 +58,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
ref, ref,
) => { ) => {
return ( return (
<label className="flex flex-col text-12px text-input-light-label dark:text-input-dark-label"> <label className="relative flex flex-col text-12px text-input-light-label dark:text-input-dark-label">
<span <span
className={`leading-3 mb-1 ${ className={`leading-3 mb-1 ${
error ? "text-warn-light-500 dark:text-warn-dark-500" : "" error ? "text-warn-light-500 dark:text-warn-dark-500" : ""
@@ -78,6 +80,12 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
{...props} {...props}
/> />
{suffix && (
<span className="z-30 absolute right-[3px] bottom-[22px] transform translate-y-1/2 bg-background-light-500 dark:bg-background-dark-500 p-2 rounded-sm">
@{suffix}
</span>
)}
<div className="leading-14.5px h-14.5px text-warn-light-500 dark:text-warn-dark-500 flex flex-row items-center text-12px"> <div className="leading-14.5px h-14.5px text-warn-light-500 dark:text-warn-dark-500 flex flex-row items-center text-12px">
<span>{error ? error : " "}</span> <span>{error ? error : " "}</span>
</div> </div>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { sendLoginname } from "@/lib/server/loginname"; import { sendLoginname } from "@/lib/server/loginname";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
@@ -18,7 +19,9 @@ type Inputs = {
type Props = { type Props = {
loginName: string | undefined; loginName: string | undefined;
authRequestId: string | undefined; authRequestId: string | undefined;
loginSettings: LoginSettings | undefined;
organization?: string; organization?: string;
suffix?: string;
submit: boolean; submit: boolean;
allowRegister: boolean; allowRegister: boolean;
children?: ReactNode; children?: ReactNode;
@@ -28,6 +31,8 @@ export function UsernameForm({
loginName, loginName,
authRequestId, authRequestId,
organization, organization,
suffix,
loginSettings,
submit, submit,
allowRegister, allowRegister,
children, children,
@@ -52,6 +57,7 @@ export function UsernameForm({
loginName: values.loginName, loginName: values.loginName,
organization, organization,
authRequestId, authRequestId,
suffix,
}) })
.catch(() => { .catch(() => {
setError("An internal error occurred"); setError("An internal error occurred");
@@ -80,6 +86,18 @@ export function UsernameForm({
} }
}, []); }, []);
let inputLabel = "Loginname";
if (
loginSettings?.disableLoginWithEmail &&
loginSettings?.disableLoginWithPhone
) {
inputLabel = "Username";
} else if (loginSettings?.disableLoginWithEmail) {
inputLabel = "Username or phone number";
} else if (loginSettings?.disableLoginWithPhone) {
inputLabel = "Username or email";
}
return ( return (
<form className="w-full"> <form className="w-full">
<div className=""> <div className="">
@@ -87,8 +105,9 @@ export function UsernameForm({
type="text" type="text"
autoComplete="username" autoComplete="username"
{...register("loginName", { required: "This field is required" })} {...register("loginName", { required: "This field is required" })}
label="Loginname" label={inputLabel}
data-testid="username-text-input" data-testid="username-text-input"
suffix={suffix}
/> />
{allowRegister && ( {allowRegister && (
<button <button

View File

@@ -16,7 +16,8 @@ import {
getOrgsByDomain, getOrgsByDomain,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
listIDPLinks, listIDPLinks,
listUsers, searchUsers,
SearchUsersCommand,
startIdentityProviderFlow, startIdentityProviderFlow,
} from "../zitadel"; } from "../zitadel";
import { createSessionAndUpdateCookie } from "./cookie"; import { createSessionAndUpdateCookie } from "./cookie";
@@ -25,26 +26,36 @@ export type SendLoginnameCommand = {
loginName: string; loginName: string;
authRequestId?: string; authRequestId?: string;
organization?: string; organization?: string;
suffix?: string;
}; };
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
export async function sendLoginname(command: SendLoginnameCommand) { export async function sendLoginname(command: SendLoginnameCommand) {
const users = await listUsers({ const loginSettingsByContext = await getLoginSettings(command.organization);
loginName: command.loginName,
if (!loginSettingsByContext) {
return { error: "Could not get login settings" };
}
let searchUsersRequest: SearchUsersCommand = {
searchValue: command.loginName,
organizationId: command.organization, organizationId: command.organization,
}); loginSettings: loginSettingsByContext,
suffix: command.suffix,
};
const loginSettings = await getLoginSettings(command.organization); const searchResult = await searchUsers(searchUsersRequest);
const potentialUsers = users.result.filter((u) => { if ("error" in searchResult && searchResult.error) {
const human = u.type.case === "human" ? u.type.value : undefined; return searchResult;
return loginSettings?.disableLoginWithEmail }
? human?.email?.isVerified && human?.email?.email !== command.loginName
: loginSettings?.disableLoginWithPhone if (!("result" in searchResult)) {
? human?.phone?.isVerified && human?.phone?.phone !== command.loginName return { error: "Could not search users" };
: true; }
});
const { result: potentialUsers } = searchResult;
const redirectUserToSingleIDPIfAvailable = async () => { const redirectUserToSingleIDPIfAvailable = async () => {
const identityProviders = await getActiveIdentityProviders( const identityProviders = await getActiveIdentityProviders(
@@ -145,9 +156,50 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} }
}; };
if (potentialUsers.length == 1 && potentialUsers[0].userId) { if (potentialUsers.length > 1) {
return { error: "More than one user found. Provide a unique identifier." };
} else if (potentialUsers.length == 1 && potentialUsers[0].userId) {
const user = potentialUsers[0];
const userId = potentialUsers[0].userId; const userId = potentialUsers[0].userId;
const userLoginSettings = await getLoginSettings(
user.details?.resourceOwner,
);
// compare with the concatenated suffix when set
const concatLoginname = command.suffix
? `${command.loginName}@${command.suffix}`
: command.loginName;
const humanUser =
potentialUsers[0].type.case === "human"
? potentialUsers[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 { error: "User not found in the system!" };
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (
user.preferredLoginName !== concatLoginname ||
humanUser?.phone?.phone !== command.loginName
) {
return { error: "User not found in the system!" };
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (
user.preferredLoginName !== concatLoginname ||
humanUser?.email?.email !== command.loginName
) {
return { error: "User not found in the system!" };
}
}
const checks = create(ChecksSchema, { const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: userId } }, user: { search: { case: "userId", value: userId } },
}); });
@@ -163,7 +215,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} }
// TODO: check if handling of userstate INITIAL is needed // TODO: check if handling of userstate INITIAL is needed
if (potentialUsers[0].state === UserState.INITIAL) { if (user.state === UserState.INITIAL) {
return { error: "Initial User not supported" }; return { error: "Initial User not supported" };
} }
@@ -173,11 +225,6 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// this can be expected to be an invite as users created in console have a password set. // this can be expected to be an invite as users created in console have a password set.
if (!methods.authMethodTypes || !methods.authMethodTypes.length) { if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
const humanUser =
potentialUsers[0].type.case === "human"
? potentialUsers[0].type.value
: undefined;
// redirect to /verify invite if no auth method is set and email is not verified // redirect to /verify invite if no auth method is set and email is not verified
const inviteCheck = checkInvite( const inviteCheck = checkInvite(
session, session,
@@ -213,7 +260,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const method = methods.authMethodTypes[0]; const method = methods.authMethodTypes[0];
switch (method) { switch (method) {
case AuthenticationMethodType.PASSWORD: // user has only password as auth method case AuthenticationMethodType.PASSWORD: // user has only password as auth method
if (!loginSettings?.allowUsernamePassword) { if (!userLoginSettings?.allowUsernamePassword) {
return { return {
error: error:
"Username Password not allowed! Contact your administrator for more information.", "Username Password not allowed! Contact your administrator for more information.",
@@ -240,7 +287,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}; };
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
if (loginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) { if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) {
return { return {
error: error:
"Passkeys not allowed! Contact your administrator for more information.", "Passkeys not allowed! Contact your administrator for more information.",
@@ -303,22 +350,24 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} }
} }
// user not found, check if register is enabled on organization // user not found, check if register is enabled on instance / organization context
if (loginSettings?.allowRegister && !loginSettings?.allowUsernamePassword) { if (
// TODO: do we need to handle login hints for IDPs here? loginSettingsByContext?.allowRegister &&
!loginSettingsByContext?.allowUsernamePassword
) {
const resp = await redirectUserToSingleIDPIfAvailable(); const resp = await redirectUserToSingleIDPIfAvailable();
if (resp) { if (resp) {
return resp; return resp;
} }
return { error: "User not found in the system" }; return { error: "User not found in the system" };
} else if ( } else if (
loginSettings?.allowRegister && loginSettingsByContext?.allowRegister &&
loginSettings?.allowUsernamePassword loginSettingsByContext?.allowUsernamePassword
) { ) {
let orgToRegisterOn: string | undefined = command.organization; let orgToRegisterOn: string | undefined = command.organization;
if ( if (
!loginSettings?.ignoreUnknownUsernames && !loginSettingsByContext?.ignoreUnknownUsernames &&
!orgToRegisterOn && !orgToRegisterOn &&
command.loginName && command.loginName &&
ORG_SUFFIX_REGEX.test(command.loginName) ORG_SUFFIX_REGEX.test(command.loginName)
@@ -338,7 +387,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} }
// do not register user if ignoreUnknownUsernames is set // do not register user if ignoreUnknownUsernames is set
if (orgToRegisterOn && !loginSettings?.ignoreUnknownUsernames) { if (orgToRegisterOn && !loginSettingsByContext?.ignoreUnknownUsernames) {
const params = new URLSearchParams({ organization: orgToRegisterOn }); const params = new URLSearchParams({ organization: orgToRegisterOn });
if (command.authRequestId) { if (command.authRequestId) {
@@ -353,7 +402,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} }
} }
if (loginSettings?.ignoreUnknownUsernames) { if (loginSettingsByContext?.ignoreUnknownUsernames) {
const paramsPasswordDefault = new URLSearchParams({ const paramsPasswordDefault = new URLSearchParams({
loginName: command.loginName, loginName: command.loginName,
}); });

View File

@@ -26,6 +26,7 @@ import { create, Duration } from "@zitadel/client";
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb"; import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb";
import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { import {
@@ -324,19 +325,24 @@ export async function createInviteCode(userId: string, host: string | null) {
); );
} }
export async function listUsers({ export type ListUsersCommand = {
loginName,
userName,
email,
organizationId,
}: {
loginName?: string; loginName?: string;
userName?: string; userName?: string;
email?: string; email?: string;
phone?: string;
organizationId?: string; organizationId?: string;
}) { };
export async function listUsers({
loginName,
userName,
phone,
email,
organizationId,
}: ListUsersCommand) {
const queries: SearchQuery[] = []; const queries: SearchQuery[] = [];
// either use loginName or userName, email, phone
if (loginName) { if (loginName) {
queries.push( queries.push(
create(SearchQuerySchema, { create(SearchQuerySchema, {
@@ -349,11 +355,11 @@ export async function listUsers({
}, },
}), }),
); );
} } else if (userName || email || phone) {
const orQueries: SearchQuery[] = [];
if (userName) { if (userName) {
queries.push( const userNameQuery = create(SearchQuerySchema, {
create(SearchQuerySchema, {
query: { query: {
case: "userNameQuery", case: "userNameQuery",
value: { value: {
@@ -361,6 +367,44 @@ export async function listUsers({
method: TextQueryMethod.EQUALS, method: TextQueryMethod.EQUALS,
}, },
}, },
});
orQueries.push(userNameQuery);
}
if (email) {
const emailQuery = create(SearchQuerySchema, {
query: {
case: "emailQuery",
value: {
emailAddress: email,
method: TextQueryMethod.EQUALS,
},
},
});
orQueries.push(emailQuery);
}
if (phone) {
const phoneQuery = create(SearchQuerySchema, {
query: {
case: "phoneQuery",
value: {
number: phone,
method: TextQueryMethod.EQUALS,
},
},
});
orQueries.push(phoneQuery);
}
queries.push(
create(SearchQuerySchema, {
query: {
case: "orQuery",
value: {
queries: orQueries,
},
},
}), }),
); );
} }
@@ -378,20 +422,165 @@ export async function listUsers({
); );
} }
if (email) { return userService.listUsers({ queries: queries });
}
export type SearchUsersCommand = {
searchValue: string;
loginSettings: LoginSettings;
organizationId?: string;
suffix?: string;
};
const PhoneQuery = (searchValue: string) =>
create(SearchQuerySchema, {
query: {
case: "phoneQuery",
value: {
number: searchValue,
method: TextQueryMethod.EQUALS,
},
},
});
const LoginNameQuery = (searchValue: string) =>
create(SearchQuerySchema, {
query: {
case: "loginNameQuery",
value: {
loginName: searchValue,
method: TextQueryMethod.EQUALS,
},
},
});
const EmailQuery = (searchValue: string) =>
create(SearchQuerySchema, {
query: {
case: "emailQuery",
value: {
emailAddress: searchValue,
method: TextQueryMethod.EQUALS,
},
},
});
/**
* this is a dedicated search function to search for users from the loginname page
* it searches users based on the loginName or userName and org suffix combination, and falls back to email and phone if no users are found
* */
export async function searchUsers({
searchValue,
loginSettings,
organizationId,
suffix,
}: SearchUsersCommand) {
const queries: SearchQuery[] = [];
// if a suffix is provided, we search for the userName concatenated with the suffix
if (suffix) {
const searchValueWithSuffix = `${searchValue}@${suffix}`;
const loginNameQuery = LoginNameQuery(searchValueWithSuffix);
queries.push(loginNameQuery);
} else {
const loginNameQuery = LoginNameQuery(searchValue);
queries.push(loginNameQuery);
}
if (organizationId) {
queries.push( queries.push(
create(SearchQuerySchema, { create(SearchQuerySchema, {
query: { query: {
case: "emailQuery", case: "organizationIdQuery",
value: { value: {
emailAddress: email, organizationId,
}, },
}, },
}), }),
); );
} }
return userService.listUsers({ queries: queries }); const loginNameResult = await userService.listUsers({ queries: queries });
if (!loginNameResult || !loginNameResult.details) {
return { error: "An error occurred." };
}
if (loginNameResult.result.length > 1) {
return { error: "Multiple users found" };
}
if (loginNameResult.result.length == 1) {
return loginNameResult;
}
const emailAndPhoneQueries: SearchQuery[] = [];
if (
loginSettings.disableLoginWithEmail &&
loginSettings.disableLoginWithPhone
) {
return { error: "User not found in the system" };
} else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) {
const phoneQuery = PhoneQuery(searchValue);
emailAndPhoneQueries.push(phoneQuery);
} else if (loginSettings.disableLoginWithPhone) {
const emailQuery = EmailQuery(searchValue);
emailAndPhoneQueries.push(emailQuery);
} else {
const emailAndPhoneOrQueries: SearchQuery[] = [];
const emailQuery = EmailQuery(searchValue);
emailAndPhoneOrQueries.push(emailQuery);
let phoneQuery;
if (searchValue.length <= 20) {
phoneQuery = PhoneQuery(searchValue);
emailAndPhoneOrQueries.push(phoneQuery);
}
emailAndPhoneQueries.push(
create(SearchQuerySchema, {
query: {
case: "orQuery",
value: {
queries: emailAndPhoneOrQueries,
},
},
}),
);
}
if (organizationId) {
queries.push(
create(SearchQuerySchema, {
query: {
case: "organizationIdQuery",
value: {
organizationId,
},
},
}),
);
}
const emailOrPhoneResult = await userService.listUsers({
queries: emailAndPhoneQueries,
});
if (!emailOrPhoneResult || !emailOrPhoneResult.details) {
return { error: "An error occurred." };
}
if (emailOrPhoneResult.result.length > 1) {
return { error: "Multiple users found." };
}
if (emailOrPhoneResult.result.length == 1) {
return loginNameResult;
}
return { error: "User not found in the system" };
} }
export async function getDefaultOrg(): Promise<Organization | null> { export async function getDefaultOrg(): Promise<Organization | null> {