verify commands

This commit is contained in:
Max Peintner
2024-12-19 11:28:35 +01:00
parent ab03996297
commit ab5bcb9eea
7 changed files with 330 additions and 204 deletions

View File

@@ -88,12 +88,21 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</>
)}
{user && (
{sessionFactors ? (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
) : (
user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)
)}
{id &&
@@ -110,10 +119,11 @@ export default async function Page(props: { searchParams: Promise<any> }) {
// check if auth methods are set
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
params={params}
authRequestId={authRequestId}
/>
))}
</div>

View File

@@ -18,17 +18,19 @@ type Inputs = {
type Props = {
userId: string;
loginName?: string;
organization?: string;
code?: string;
isInvite: boolean;
params: URLSearchParams;
authRequestId?: string;
};
export function VerifyForm({
userId,
loginName,
organization,
authRequestId,
code,
isInvite,
params,
}: Props) {
const t = useTranslations("verify");
@@ -74,6 +76,9 @@ export function VerifyForm({
code: value.code,
userId,
isInvite: isInvite,
loginName: loginName,
organization: organization,
authRequestId: authRequestId,
})
.catch(() => {
setError("Could not verify user");

View File

@@ -350,8 +350,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (command.authRequestId) {
params.set("authRequestId", command.authRequestId);
}
if (command.loginName) {
params.set("loginName", command.loginName);
params.set("email", command.loginName);
}
return { redirect: "/register?" + params };

View File

@@ -17,7 +17,6 @@ import {
import { create } from "@zitadel/client";
import { createUserServiceClient } from "@zitadel/client/v2";
import { createServerTransport } from "@zitadel/node";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import {
Checks,
ChecksSchema,
@@ -31,6 +30,7 @@ import {
import { headers } from "next/headers";
import { getNextUrl } from "../client";
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
import { checkMFAFactors } from "../verify-helper";
type ResetPasswordCommand = {
loginName: string;
@@ -196,7 +196,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
);
}
return { redirect: `/verify` + params };
return { redirect: `/verify?` + params };
}
checkMFAFactors(
@@ -341,110 +341,3 @@ export async function checkSessionAndSetPassword({
});
}
}
export function checkMFAFactors(
session: Session,
loginSettings: LoginSettings | undefined,
authMethods: AuthenticationMethodType[],
organization?: string,
authRequestId?: string,
) {
const availableMultiFactors = authMethods?.filter(
(m: AuthenticationMethodType) =>
m !== AuthenticationMethodType.PASSWORD &&
m !== AuthenticationMethodType.PASSKEY,
);
if (availableMultiFactors?.length == 1) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
const factor = availableMultiFactors[0];
// if passwordless is other method, but user selected password as alternative, perform a login
if (factor === AuthenticationMethodType.TOTP) {
return { redirect: `/otp/time-based?` + params };
} else if (factor === AuthenticationMethodType.OTP_SMS) {
return { redirect: `/otp/sms?` + params };
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
return { redirect: `/otp/email?` + params };
} else if (factor === AuthenticationMethodType.U2F) {
return { redirect: `/u2f?` + params };
}
} else if (availableMultiFactors?.length >= 1) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
return { redirect: `/mfa?` + params };
} else if (
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
!availableMultiFactors.length
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
force: "true", // this defines if the mfa is forced in the settings
checkAfter: "true", // this defines if the check is directly made after the setup
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
// TODO: provide a way to setup passkeys on mfa page?
return { redirect: `/mfa/set?` + params };
}
// TODO: implement passkey setup
// else if (
// submitted.factors &&
// !submitted.factors.webAuthN && // if session was not verified with a passkey
// promptPasswordless && // if explicitly prompted due policy
// !isAlternative // escaped if password was used as an alternative method
// ) {
// const params = new URLSearchParams({
// loginName: submitted.factors.user.loginName,
// prompt: "true",
// });
// if (authRequestId) {
// params.append("authRequestId", authRequestId);
// }
// if (organization) {
// params.append("organization", organization);
// }
// return router.push(`/passkey/set?` + params);
// }
}

View File

@@ -14,13 +14,16 @@ import { create } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { headers } from "next/headers";
import { getNextUrl } from "../client";
import { getSessionCookieByLoginName } from "../cookies";
import { checkMFAFactors } from "../verify-helper";
import { createSessionAndUpdateCookie } from "./cookie";
import { checkMFAFactors } from "./password";
type VerifyUserByEmailCommand = {
userId: string;
loginName?: string; // to determine already existing session
organization?: string;
code: string;
isInvite: boolean;
authRequestId?: string;
@@ -39,82 +42,9 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
return { error: "Could not verify user" };
}
const userResponse = await getUserByID(command.userId);
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
const session = await createSessionAndUpdateCookie(
checks,
undefined,
command.authRequestId,
);
const authMethodResponse = await listAuthenticationMethodTypes(
command.userId,
);
if (!authMethodResponse || !authMethodResponse.authMethodTypes) {
return { error: "Could not load possible authenticators" };
}
// if no authmethods are found on the user, redirect to set one up
if (
authMethodResponse &&
authMethodResponse.authMethodTypes &&
authMethodResponse.authMethodTypes.length == 0
) {
const params = new URLSearchParams({
sessionId: session.id,
});
if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName);
}
return { redirect: `/authenticator/set?${params}` };
}
}
type resendVerifyEmailCommand = {
userId: string;
isInvite: boolean;
};
export async function resendVerification(command: resendVerifyEmailCommand) {
return command.isInvite
? resendInviteCode(command.userId)
: resendEmailCode(command.userId);
}
export type SendVerificationRedirectWithoutCheckCommand = {
organization?: string;
authRequestId?: string;
} & (
| { userId: string; loginName?: never }
| { userId?: never; loginName: string }
);
export async function sendVerificationRedirectWithoutCheck(
command: SendVerificationRedirectWithoutCheckCommand,
) {
if (!("loginName" in command || "userId" in command)) {
return { error: "No userId, nor loginname provided" };
}
let session: Session | undefined;
let user: User | undefined;
const loginSettings = await getLoginSettings(command.organization);
if ("loginName" in command) {
const sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
@@ -147,10 +77,10 @@ export async function sendVerificationRedirectWithoutCheck(
}
user = userResponse.user;
} else if ("userId" in command) {
} else {
const userResponse = await getUserByID(command.userId);
if (!userResponse?.user) {
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
@@ -170,9 +100,6 @@ export async function sendVerificationRedirectWithoutCheck(
undefined,
command.authRequestId,
);
// this is a fake error message to hide that the user does not even exist
return { error: "Could not verify password" };
}
if (!session?.factors?.user?.id) {
@@ -187,6 +114,8 @@ export async function sendVerificationRedirectWithoutCheck(
return { error: "Could not load user" };
}
const loginSettings = await getLoginSettings(user.details?.resourceOwner);
const authMethodResponse = await listAuthenticationMethodTypes(user.userId);
if (!authMethodResponse || !authMethodResponse.authMethodTypes) {
@@ -243,3 +172,163 @@ export async function sendVerificationRedirectWithoutCheck(
return { redirect: url };
}
type resendVerifyEmailCommand = {
userId: string;
isInvite: boolean;
authRequestId?: string;
};
export async function resendVerification(command: resendVerifyEmailCommand) {
const host = (await headers()).get("host");
return command.isInvite
? resendInviteCode(command.userId)
: resendEmailCode(command.userId, host, command.authRequestId);
}
export type SendVerificationRedirectWithoutCheckCommand = {
organization?: string;
authRequestId?: string;
} & (
| { userId: string; loginName?: never }
| { userId?: never; loginName: string }
);
export async function sendVerificationRedirectWithoutCheck(
command: SendVerificationRedirectWithoutCheckCommand,
) {
if (!("loginName" in command || "userId" in command)) {
return { error: "No userId, nor loginname provided" };
}
let session: Session | undefined;
let user: User | undefined;
if ("loginName" in command) {
const sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error);
});
if (!sessionCookie) {
return { error: "Could not load session cookie" };
}
session = await getSession({
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
}).then((response) => {
if (response?.session) {
return response.session;
}
});
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
const userResponse = await getUserByID(session?.factors?.user?.id);
if (!userResponse?.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
} else if ("userId" in command) {
const userResponse = await getUserByID(command.userId);
if (!userResponse?.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
session = await createSessionAndUpdateCookie(
checks,
undefined,
command.authRequestId,
);
}
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
if (!user) {
return { error: "Could not load user" };
}
const authMethodResponse = await listAuthenticationMethodTypes(user.userId);
if (!authMethodResponse || !authMethodResponse.authMethodTypes) {
return { error: "Could not load possible authenticators" };
}
// if no authmethods are found on the user, redirect to set one up
if (
authMethodResponse &&
authMethodResponse.authMethodTypes &&
authMethodResponse.authMethodTypes.length == 0
) {
const params = new URLSearchParams({
sessionId: session.id,
});
if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName);
}
return { redirect: `/authenticator/set?${params}` };
}
const loginSettings = await getLoginSettings(user.details?.resourceOwner);
// redirect to mfa factor if user has one, or redirect to set one up
checkMFAFactors(
session,
loginSettings,
authMethodResponse.authMethodTypes,
command.organization,
command.authRequestId,
);
// login user if no additional steps are required
if (command.authRequestId && session.id) {
const nextUrl = await getNextUrl(
{
sessionId: session.id,
authRequestId: command.authRequestId,
organization:
command.organization ?? session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: nextUrl };
}
const url = await getNextUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: url };
}

View File

@@ -0,0 +1,110 @@
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
export function checkMFAFactors(
session: Session,
loginSettings: LoginSettings | undefined,
authMethods: AuthenticationMethodType[],
organization?: string,
authRequestId?: string,
) {
const availableMultiFactors = authMethods?.filter(
(m: AuthenticationMethodType) =>
m !== AuthenticationMethodType.PASSWORD &&
m !== AuthenticationMethodType.PASSKEY,
);
if (availableMultiFactors?.length == 1) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
const factor = availableMultiFactors[0];
// if passwordless is other method, but user selected password as alternative, perform a login
if (factor === AuthenticationMethodType.TOTP) {
return { redirect: `/otp/time-based?` + params };
} else if (factor === AuthenticationMethodType.OTP_SMS) {
return { redirect: `/otp/sms?` + params };
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
return { redirect: `/otp/email?` + params };
} else if (factor === AuthenticationMethodType.U2F) {
return { redirect: `/u2f?` + params };
}
} else if (availableMultiFactors?.length >= 1) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
return { redirect: `/mfa?` + params };
} else if (
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
!availableMultiFactors.length
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
force: "true", // this defines if the mfa is forced in the settings
checkAfter: "true", // this defines if the check is directly made after the setup
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
// TODO: provide a way to setup passkeys on mfa page?
return { redirect: `/mfa/set?` + params };
}
// TODO: implement passkey setup
// else if (
// submitted.factors &&
// !submitted.factors.webAuthN && // if session was not verified with a passkey
// promptPasswordless && // if explicitly prompted due policy
// !isAlternative // escaped if password was used as an alternative method
// ) {
// const params = new URLSearchParams({
// loginName: submitted.factors.user.loginName,
// prompt: "true",
// });
// if (authRequestId) {
// params.append("authRequestId", authRequestId);
// }
// if (organization) {
// params.append("organization", organization);
// }
// return router.push(`/passkey/set?` + params);
// }
}

View File

@@ -12,6 +12,8 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import {
AddHumanUserRequest,
ResendEmailCodeRequest,
ResendEmailCodeRequestSchema,
RetrieveIdentityProviderIntentRequest,
SetPasswordRequest,
SetPasswordRequestSchema,
@@ -23,6 +25,7 @@ import { create, Duration } from "@zitadel/client";
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb";
import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import {
NotificationType,
@@ -448,13 +451,28 @@ export async function verifyEmail(userId: string, verificationCode: string) {
);
}
export async function resendEmailCode(userId: string) {
return userService.resendEmailCode(
{
userId,
},
{},
);
export async function resendEmailCode(
userId: string,
host: string | null,
authRequestId?: string,
) {
let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, {
userId,
});
if (host) {
const medium = create(SendEmailVerificationCodeSchema, {
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
});
request = { ...request, verification: { case: "sendCode", value: medium } };
}
console.log(request);
return userService.resendEmailCode(request, {});
}
export function retrieveIDPIntent(id: string, token: string) {