Merge pull request #225 from zitadel/reset-pwd

fix: password reset
This commit is contained in:
Max Peintner
2024-10-17 10:59:28 +02:00
committed by GitHub
23 changed files with 912 additions and 326 deletions

View File

@@ -115,14 +115,17 @@ You can already use the current state, and extend it with your needs.
passkey --> B[signedin] passkey --> B[signedin]
password -- hasMFA --> mfa password -- hasMFA --> mfa
password -- allowPasskeys --> passkey-add password -- allowPasskeys --> passkey-add
password -- reset --> password-set
email -- reset --> password-set
password-set --> B[signedin]
password-change --> B[signedin]
password -- userstate=initial --> password-change
mfa --> otp mfa --> otp
otp --> B[signedin] otp --> B[signedin]
mfa--> u2f mfa--> u2f
u2f -->B[signedin] u2f -->B[signedin]
register --> passkey-add register -- password/passkey --> B[signedin]
register --> password-set
password-set --> B[signedin]
passkey-add --> B[signedin]
password --> B[signedin] password --> B[signedin]
password-- forceMFA -->mfaset password-- forceMFA -->mfaset
mfaset --> u2fset mfaset --> u2fset

View File

@@ -104,16 +104,16 @@ describe("login", () => {
}, },
}); });
}); });
it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { // it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); // cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); // cy.location("pathname", { timeout: 10_000 }).should("eq", "/password");
cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); // cy.get('input[type="password"]').focus().type("MyStrongPassword!1");
cy.get('button[type="submit"]').click(); // cy.get('button[type="submit"]').click();
cy.location("pathname", { timeout: 10_000 }).should( // cy.location("pathname", { timeout: 10_000 }).should(
"eq", // "eq",
"/passkey/set", // "/passkey/set",
); // );
}); // });
}); });
}); });
describe("passkey login", () => { describe("passkey login", () => {

View File

@@ -14,10 +14,24 @@
"register": "Neuen Benutzer registrieren" "register": "Neuen Benutzer registrieren"
}, },
"password": { "password": {
"title": "Passwort", "verify": {
"description": "Geben Sie Ihr Passwort ein.", "title": "Passwort",
"resetPassword": "Passwort zurücksetzen", "description": "Geben Sie Ihr Passwort ein.",
"submit": "Weiter" "resetPassword": "Passwort zurücksetzen",
"submit": "Weiter"
},
"set": {
"title": "Passwort festlegen",
"description": "Legen Sie das Passwort für Ihr Konto fest",
"codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.",
"resend": "Erneut senden",
"submit": "Weiter"
},
"change": {
"title": "Passwort ändern",
"description": "Legen Sie das Passwort für Ihr Konto fest",
"submit": "Weiter"
}
}, },
"idp": { "idp": {
"title": "Mit SSO anmelden", "title": "Mit SSO anmelden",
@@ -134,6 +148,7 @@
}, },
"error": { "error": {
"unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.",
"sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut."
} }
} }

View File

@@ -14,10 +14,24 @@
"register": "Register new user" "register": "Register new user"
}, },
"password": { "password": {
"title": "Password", "verify": {
"description": "Enter your password.", "title": "Password",
"resetPassword": "Reset Password", "description": "Enter your password.",
"submit": "Continue" "resetPassword": "Reset Password",
"submit": "Continue"
},
"set": {
"title": "Set Password",
"description": "Set the password for your account",
"codeSent": "A code has been sent to your email address.",
"resend": "Resend code",
"submit": "Continue"
},
"change": {
"title": "Change Password",
"description": "Set the password for your account",
"submit": "Continue"
}
}, },
"idp": { "idp": {
"title": "Sign in with SSO", "title": "Sign in with SSO",
@@ -134,6 +148,7 @@
}, },
"error": { "error": {
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",
"sessionExpired": "Your current session has expired. Please login again." "sessionExpired": "Your current session has expired. Please login again.",
"failedLoading": "Failed to load data. Please try again."
} }
} }

View File

@@ -14,10 +14,24 @@
"register": "Registrar nuevo usuario" "register": "Registrar nuevo usuario"
}, },
"password": { "password": {
"title": "Contraseña", "verify": {
"description": "Introduce tu contraseña.", "title": "Contraseña",
"resetPassword": "Restablecer Contraseña", "description": "Introduce tu contraseña.",
"submit": "Continuar" "resetPassword": "Restablecer contraseña",
"submit": "Continuar"
},
"set": {
"title": "Establecer Contraseña",
"description": "Establece la contraseña para tu cuenta",
"codeSent": "Se ha enviado un código a su correo electrónico.",
"resend": "Reenviar código",
"submit": "Continuar"
},
"change": {
"title": "Cambiar Contraseña",
"description": "Establece la contraseña para tu cuenta",
"submit": "Continuar"
}
}, },
"idp": { "idp": {
"title": "Iniciar sesión con SSO", "title": "Iniciar sesión con SSO",
@@ -134,6 +148,7 @@
}, },
"error": { "error": {
"unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.",
"sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo." "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.",
"failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo."
} }
} }

View File

@@ -14,10 +14,24 @@
"register": "Registrati come nuovo utente" "register": "Registrati come nuovo utente"
}, },
"password": { "password": {
"title": "Password", "verify": {
"description": "Inserisci la tua password.", "title": "Password",
"resetPassword": "Reimposta Password", "description": "Inserisci la tua password.",
"submit": "Continua" "resetPassword": "Reimposta Password",
"submit": "Continua"
},
"set": {
"title": "Imposta Password",
"description": "Imposta la password per il tuo account",
"codeSent": "Un codice è stato inviato al tuo indirizzo email.",
"resend": "Invia di nuovo",
"submit": "Continua"
},
"change": {
"title": "Cambia Password",
"description": "Imposta la password per il tuo account",
"submit": "Continua"
}
}, },
"idp": { "idp": {
"title": "Accedi con SSO", "title": "Accedi con SSO",
@@ -134,6 +148,7 @@
}, },
"error": { "error": {
"unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.",
"sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso." "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.",
"failedLoading": "Impossibile caricare i dati. Riprova."
} }
} }

View File

@@ -24,14 +24,17 @@ This diagram shows the available pages and flows.
passkey --> B[signedin] passkey --> B[signedin]
password -- hasMFA --> mfa password -- hasMFA --> mfa
password -- allowPasskeys --> passkey-add password -- allowPasskeys --> passkey-add
password -- reset --> password-set
email -- reset --> password-set
password-set --> B[signedin]
password-change --> B[signedin]
password -- userstate=initial --> password-change
mfa --> otp mfa --> otp
otp --> B[signedin] otp --> B[signedin]
mfa--> u2f mfa--> u2f
u2f -->B[signedin] u2f -->B[signedin]
register --> passkey-add register -- password/passkey --> B[signedin]
register --> password-set
password-set --> B[signedin]
passkey-add --> B[signedin]
password --> B[signedin] password --> B[signedin]
password-- forceMFA -->mfaset password-- forceMFA -->mfaset
mfaset --> u2fset mfaset --> u2fset
@@ -103,10 +106,14 @@ Requests to the APIs made:
- `listAuthenticationMethodTypes` - `listAuthenticationMethodTypes`
- `getSession()` - `getSession()`
- `updateSession()` - `updateSession()`
- `listUsers()`
- `getUserById()`
**MFA AVAILABLE:** After the password has been submitted, additional authentication methods are loaded. **MFA AVAILABLE:** After the password has been submitted, additional authentication methods are loaded.
If the user has set up an additional **single** second factor, it is redirected to add the next factor. Depending on the available method he is redirected to `/otp/time-based`,`/otp/sms?`, `/otp/email?` or `/u2f?`. If the user has multiple second factors, he is redirected to `/mfa` to select his preferred method to continue. If the user has set up an additional **single** second factor, it is redirected to add the next factor. Depending on the available method he is redirected to `/otp/time-based`,`/otp/sms?`, `/otp/email?` or `/u2f?`. If the user has multiple second factors, he is redirected to `/mfa` to select his preferred method to continue.
**NO MFA, USER STATE INITIAL** If the user has no MFA methods and is in an initial state, we redirect to `/password/change` where a new password can be set.
**NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor. **NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor.
**PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType === PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped. **PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType === PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped.
@@ -115,6 +122,38 @@ If none of the previous conditions apply, we continue to sign in.
> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) > NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615)
### /password/change
This page allows to change the password. It is used after a user is in an initial state and is required to change the password, or it can be directly invoked with an active session.
<img src="./screenshots/password_change.png" alt="/password/change" width="400px" />
Requests to the APIs made:
- `getLoginSettings(org?)`
- `getPasswordComplexitySettings(user?)`
- `getBrandingSettings(org?)`
- `getSession()`
- `setPassword()`
> NOTE: The request to change the password is using the session of the user itself not the service user, therefore no code is required.
### /password/set
This page allows to set a password. It is used after a user has requested to reset the password on the `/password` page.
<img src="./screenshots/password_set.png" alt="/password/set" width="400px" />
Requests to the APIs made:
- `getLoginSettings(org?)`
- `getPasswordComplexitySettings(user?)`
- `getBrandingSettings(org?)`
- `getUserByID()`
- `setPassword()`
The page allows to enter a code or be invoked directly from a email link which prefills the code. The user can enter a new password and submit.
### /otp/[method] ### /otp/[method]
This page shows a code field to check an otp method. The session of the user is then hydrated with the respective factor. Supported methods are `time-based`, `sms` and `email`. This page shows a code field to check an otp method. The session of the user is then hydrated with the respective factor. Supported methods are `time-based`, `sms` and `email`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -1,78 +0,0 @@
import { Alert } from "@/components/alert";
import { ChangePasswordForm } from "@/components/change-password-form";
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import {
getBrandingSettings,
getPasswordComplexitySettings,
getSession,
} from "@/lib/zitadel";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { sessionId } = searchParams;
if (!sessionId) {
return (
<div>
<h1>Session ID not found</h1>
</div>
);
}
const sessionCookie = await getSessionCookieById({
sessionId,
});
const { session } = await getSession({
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
const passwordComplexitySettings = await getPasswordComplexitySettings(
session?.factors?.user?.organizationId,
);
const branding = await getBrandingSettings(
session?.factors?.user?.organizationId,
);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Set Password</h1>
<p className="ztdl-p">Set the password for your account</p>
{!session && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{session && (
<UserAvatar
loginName={session.factors?.user?.loginName}
displayName={session.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{passwordComplexitySettings && session?.factors?.user?.id && (
<ChangePasswordForm
passwordComplexitySettings={passwordComplexitySettings}
userId={session.factors.user.id}
sessionId={sessionId}
></ChangePasswordForm>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,80 @@
import { Alert } from "@/components/alert";
import { ChangePasswordForm } from "@/components/change-password-form";
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getLoginSettings,
getPasswordComplexitySettings,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "password" });
const { loginName, organization, authRequestId, code } = searchParams;
// also allow no session to be found (ignoreUnkownUsername)
const sessionFactors = await loadMostRecentSession({
loginName,
organization,
});
const branding = await getBrandingSettings(organization);
const passwordComplexity = await getPasswordComplexitySettings(
sessionFactors?.factors?.user?.organizationId,
);
const loginSettings = await getLoginSettings(organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>
{sessionFactors?.factors?.user?.displayName ?? t("change.title")}
</h1>
<p className="ztdl-p mb-6 block">{t("change.description")}</p>
{/* show error only if usernames should be shown to be unknown */}
{(!sessionFactors || !loginName) &&
!loginSettings?.ignoreUnknownUsernames && (
<div className="py-4">
<Alert>{t("error:unknownContext")}</Alert>
</div>
)}
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{passwordComplexity &&
loginName &&
sessionFactors?.factors?.user?.id ? (
<ChangePasswordForm
sessionId={sessionFactors.id}
loginName={loginName}
authRequestId={authRequestId}
organization={organization}
passwordComplexitySettings={passwordComplexity}
/>
) : (
<div className="py-4">
<Alert>{t("error:failedLoading")}</Alert>
</div>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -35,8 +35,10 @@ export default async function Page({
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<h1>{sessionFactors?.factors?.user?.displayName ?? t("title")}</h1> <h1>
<p className="ztdl-p mb-6 block">{t("description")}</p> {sessionFactors?.factors?.user?.displayName ?? t("verify.title")}
</h1>
<p className="ztdl-p mb-6 block">{t("verify.description")}</p>
{/* show error only if usernames should be shown to be unknown */} {/* show error only if usernames should be shown to be unknown */}
{(!sessionFactors || !loginName) && {(!sessionFactors || !loginName) &&

View File

@@ -0,0 +1,81 @@
import { Alert, AlertType } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { SetPasswordForm } from "@/components/set-password-form";
import { UserAvatar } from "@/components/user-avatar";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getLoginSettings,
getPasswordComplexitySettings,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "password" });
const { loginName, organization, authRequestId, code } = searchParams;
// also allow no session to be found (ignoreUnkownUsername)
const sessionFactors = await loadMostRecentSession({
loginName,
organization,
});
const branding = await getBrandingSettings(organization);
const passwordComplexity = await getPasswordComplexitySettings(
sessionFactors?.factors?.user?.organizationId,
);
const loginSettings = await getLoginSettings(organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{sessionFactors?.factors?.user?.displayName ?? t("set.title")}</h1>
<p className="ztdl-p mb-6 block">{t("set.description")}</p>
{/* show error only if usernames should be shown to be unknown */}
{(!sessionFactors || !loginName) &&
!loginSettings?.ignoreUnknownUsernames && (
<div className="py-4">
<Alert>{t("error:unknownContext")}</Alert>
</div>
)}
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<Alert type={AlertType.INFO}>{t("set.codeSent")}</Alert>
{passwordComplexity &&
loginName &&
sessionFactors?.factors?.user?.id ? (
<SetPasswordForm
code={code}
userId={sessionFactors.factors.user.id}
loginName={loginName}
authRequestId={authRequestId}
organization={organization}
passwordComplexitySettings={passwordComplexity}
/>
) : (
<div className="py-4">
<Alert>{t("error:failedLoading")}</Alert>
</div>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -1,6 +1,6 @@
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { RegisterFormWithoutPassword } from "@/components/register-form-without-password"; import { RegisterFormWithoutPassword } from "@/components/register-form-without-password";
import { SetPasswordForm } from "@/components/set-password-form"; import { SetRegisterPasswordForm } from "@/components/set-register-password-form";
import { import {
getBrandingSettings, getBrandingSettings,
getLegalAndSupportSettings, getLegalAndSupportSettings,
@@ -38,14 +38,14 @@ export default async function Page({
<p className="ztdl-p">{t("description")}</p> <p className="ztdl-p">{t("description")}</p>
{legal && passwordComplexitySettings && ( {legal && passwordComplexitySettings && (
<SetPasswordForm <SetRegisterPasswordForm
passwordComplexitySettings={passwordComplexitySettings} passwordComplexitySettings={passwordComplexitySettings}
email={email} email={email}
firstname={firstname} firstname={firstname}
lastname={lastname} lastname={lastname}
organization={organization} organization={organization}
authRequestId={authRequestId} authRequestId={authRequestId}
></SetPasswordForm> ></SetRegisterPasswordForm>
)} )}
</div> </div>
</DynamicTheme> </DynamicTheme>

View File

@@ -6,8 +6,12 @@ import {
symbolValidator, symbolValidator,
upperCaseValidator, upperCaseValidator,
} from "@/helpers/validators"; } from "@/helpers/validators";
import { setPassword } from "@/lib/self"; import { setMyPassword } from "@/lib/self";
import { sendPassword } from "@/lib/server/password";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form"; import { FieldValues, useForm } from "react-hook-form";
@@ -27,15 +31,21 @@ type Inputs =
type Props = { type Props = {
passwordComplexitySettings: PasswordComplexitySettings; passwordComplexitySettings: PasswordComplexitySettings;
userId: string;
sessionId: string; sessionId: string;
loginName: string;
authRequestId?: string;
organization?: string;
}; };
export function ChangePasswordForm({ export function ChangePasswordForm({
passwordComplexitySettings, passwordComplexitySettings,
userId,
sessionId, sessionId,
loginName,
authRequestId,
organization,
}: Props) { }: Props) {
const t = useTranslations("password");
const { register, handleSubmit, watch, formState } = useForm<Inputs>({ const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
@@ -51,9 +61,8 @@ export function ChangePasswordForm({
async function submitChange(values: Inputs) { async function submitChange(values: Inputs) {
setLoading(true); setLoading(true);
const response = await setPassword({ const changeResponse = await setMyPassword({
sessionId: sessionId, sessionId: sessionId,
userId: userId,
password: values.password, password: values.password,
}).catch(() => { }).catch(() => {
setError("Could not change password"); setError("Could not change password");
@@ -61,12 +70,40 @@ export function ChangePasswordForm({
setLoading(false); setLoading(false);
if (!response) { if (changeResponse && "error" in changeResponse) {
setError(changeResponse.error);
return;
}
if (!changeResponse) {
setError("Could not change password"); setError("Could not change password");
return; return;
} }
return response; const passwordResponse = await sendPassword({
loginName,
organization,
checks: create(ChecksSchema, {
password: { password: values.password },
}),
authRequestId,
}).catch(() => {
setLoading(false);
setError("Could not verify password");
return;
});
setLoading(false);
if (
passwordResponse &&
"error" in passwordResponse &&
passwordResponse.error
) {
setError(passwordResponse.error);
}
return;
} }
const { errors } = formState; const { errors } = formState;
@@ -99,9 +136,9 @@ export function ChangePasswordForm({
autoComplete="new-password" autoComplete="new-password"
required required
{...register("password", { {...register("password", {
required: "You have to provide a password!", required: "You have to provide a new password!",
})} })}
label="Password" label="New Password"
error={errors.password?.message as string} error={errors.password?.message as string}
/> />
</div> </div>
@@ -143,7 +180,7 @@ export function ChangePasswordForm({
onClick={handleSubmit(submitChange)} onClick={handleSubmit(submitChange)}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
continue {t("change.submit")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -194,7 +194,7 @@ export function LoginOTP({
<Alert type={AlertType.INFO}> <Alert type={AlertType.INFO}>
<div className="flex flex-row"> <div className="flex flex-row">
<span className="flex-1 mr-auto text-left"> <span className="flex-1 mr-auto text-left">
{t("noCodeReceived")} {t("verify.noCodeReceived")}
</span> </span>
<button <button
aria-label="Resend OTP Code" aria-label="Resend OTP Code"
@@ -212,7 +212,7 @@ export function LoginOTP({
}); });
}} }}
> >
{t("resendCode")} {t("verify.resendCode")}
</button> </button>
</div> </div>
</Alert> </Alert>
@@ -244,7 +244,7 @@ export function LoginOTP({
})} })}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")} {t("verify.submit")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -4,7 +4,6 @@ import { resetPassword, sendPassword } from "@/lib/server/password";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@@ -60,16 +59,19 @@ export function PasswordForm({
password: { password: values.password }, password: { password: values.password },
}), }),
authRequestId, authRequestId,
forceMfa: loginSettings?.forceMfa,
}).catch(() => { }).catch(() => {
setLoading(false);
setError("Could not verify password"); setError("Could not verify password");
return;
}); });
setLoading(false);
if (response && "error" in response && response.error) { if (response && "error" in response && response.error) {
setError(response.error); setError(response.error);
} }
setLoading(false);
return response; return response;
} }
@@ -85,146 +87,29 @@ export function PasswordForm({
setError("Could not reset password"); setError("Could not reset password");
}); });
if (response && "error" in response) {
setError(response.error);
} else {
setInfo("Password was reset. Please check your email.");
}
setLoading(false); setLoading(false);
return response; if (response && "error" in response) {
} setError(response.error);
async function submitPasswordAndContinue(
value: Inputs,
): Promise<boolean | void> {
const submitted = await submitPassword(value);
setInfo("");
// if user has mfa -> /otp/[method] or /u2f
// if mfa is forced and user has no mfa -> /mfa/set
// if no passwordless -> /passkey/set
// exclude password and passwordless
if (
!submitted ||
!submitted.authMethods ||
!submitted.factors?.user?.loginName
) {
return; return;
} }
const availableSecondFactors = submitted?.authMethods?.filter( setInfo("Password was reset. Please check your email.");
(m: AuthenticationMethodType) =>
m !== AuthenticationMethodType.PASSWORD &&
m !== AuthenticationMethodType.PASSKEY,
);
if (availableSecondFactors?.length == 1) { const params = new URLSearchParams({
const params = new URLSearchParams({ loginName: loginName,
loginName: submitted.factors?.user.loginName, });
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
const factor = availableSecondFactors[0];
// if passwordless is other method, but user selected password as alternative, perform a login
if (factor === AuthenticationMethodType.TOTP) {
return router.push(`/otp/time-based?` + params);
} else if (factor === AuthenticationMethodType.OTP_SMS) {
return router.push(`/otp/sms?` + params);
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
return router.push(`/otp/email?` + params);
} else if (factor === AuthenticationMethodType.U2F) {
return router.push(`/u2f?` + params);
}
} else if (availableSecondFactors?.length >= 1) {
const params = new URLSearchParams({
loginName: submitted.factors.user.loginName,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/mfa?` + params);
} else if (loginSettings?.forceMfa && !availableSecondFactors.length) {
const params = new URLSearchParams({
loginName: submitted.factors.user.loginName,
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) {
params.append("organization", organization);
}
// TODO: provide a way to setup passkeys on mfa page?
return router.push(`/mfa/set?` + params);
} 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);
} else if (authRequestId && submitted.sessionId) {
const params = new URLSearchParams({
sessionId: submitted.sessionId,
authRequest: authRequestId,
});
if (organization) {
params.append("organization", organization);
}
return router.push(`/login?` + params);
}
// without OIDC flow
const params = new URLSearchParams(
authRequestId
? {
loginName: submitted.factors.user.loginName,
authRequestId,
}
: {
loginName: submitted.factors.user.loginName,
},
);
if (organization) { if (organization) {
params.append("organization", organization); params.append("organization", organization);
} }
return router.push(`/signedin?` + params); if (authRequestId) {
params.append("authRequestId", authRequestId);
}
return router.push("/password/set?" + params);
} }
return ( return (
@@ -243,7 +128,7 @@ export function PasswordForm({
type="button" type="button"
disabled={loading} disabled={loading}
> >
{t("resetPassword")} {t("verify.resetPassword")}
</button> </button>
)} )}
@@ -277,10 +162,10 @@ export function PasswordForm({
className="self-end" className="self-end"
variant={ButtonVariants.Primary} variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid} disabled={loading || !formState.isValid}
onClick={handleSubmit(submitPasswordAndContinue)} onClick={handleSubmit(submitPassword)}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")} {t("verify.submit")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -6,7 +6,9 @@ import {
symbolValidator, symbolValidator,
upperCaseValidator, upperCaseValidator,
} from "@/helpers/validators"; } from "@/helpers/validators";
import { registerUser, RegisterUserResponse } from "@/lib/server/register"; import { changePassword, sendPassword } from "@/lib/server/password";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -21,36 +23,35 @@ import { Spinner } from "./spinner";
type Inputs = type Inputs =
| { | {
code: string;
password: string; password: string;
confirmPassword: string; confirmPassword: string;
} }
| FieldValues; | FieldValues;
type Props = { type Props = {
code?: string;
passwordComplexitySettings: PasswordComplexitySettings; passwordComplexitySettings: PasswordComplexitySettings;
email: string; loginName: string;
firstname: string; userId: string;
lastname: string;
organization?: string; organization?: string;
authRequestId?: string; authRequestId?: string;
}; };
export function SetPasswordForm({ export function SetPasswordForm({
passwordComplexitySettings, passwordComplexitySettings,
email,
firstname,
lastname,
organization, organization,
authRequestId, authRequestId,
loginName,
userId,
code,
}: Props) { }: Props) {
const t = useTranslations("register"); const t = useTranslations("password");
const { register, handleSubmit, watch, formState } = useForm<Inputs>({ const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
email: email ?? "", code: code ?? "",
firstname: firstname ?? "",
lastname: lastname ?? "",
}, },
}); });
@@ -61,58 +62,73 @@ export function SetPasswordForm({
async function submitRegister(values: Inputs) { async function submitRegister(values: Inputs) {
setLoading(true); setLoading(true);
const response = await registerUser({ const changeResponse = await changePassword({
email: email, userId: userId,
firstName: firstname,
lastName: lastname,
organization: organization,
authRequestId: authRequestId,
password: values.password, password: values.password,
code: values.code,
}).catch(() => { }).catch(() => {
setError("Could not register user"); setError("Could not register user");
}); });
if (response && "error" in response) { if (changeResponse && "error" in changeResponse) {
setError(response.error); setError(changeResponse.error);
} }
setLoading(false); setLoading(false);
if (!response) { if (!changeResponse) {
setError("Could not register user"); setError("Could not register user");
return; return;
} }
const userResponse = response as RegisterUserResponse; const params = new URLSearchParams({});
const params = new URLSearchParams({ userId: userResponse.userId }); if (loginName) {
params.append("loginName", loginName);
if (userResponse.factors?.user?.loginName) {
params.append("loginName", userResponse.factors.user.loginName);
} }
if (organization) { if (organization) {
params.append("organization", organization); params.append("organization", organization);
} }
if (userResponse && userResponse.sessionId) {
params.append("sessionId", userResponse.sessionId); const passwordResponse = await sendPassword({
loginName,
organization,
checks: create(ChecksSchema, {
password: { password: values.password },
}),
authRequestId,
}).catch(() => {
setLoading(false);
setError("Could not verify password");
return;
});
setLoading(false);
if (
passwordResponse &&
"error" in passwordResponse &&
passwordResponse.error
) {
setError(passwordResponse.error);
} }
// skip verification for now as it is an app based flow // // skip verification for now as it is an app based flow
// return router.push(`/verify?` + params); // // return router.push(`/verify?` + params);
// check for mfa force to continue with mfa setup // // check for mfa force to continue with mfa setup
if (authRequestId && userResponse.sessionId) { // if (authRequestId && changeResponse.sessionId) {
if (authRequestId) { // if (authRequestId) {
params.append("authRequest", authRequestId); // params.append("authRequest", authRequestId);
} // }
return router.push(`/login?` + params); // return router.push(`/login?` + params);
} else { // } else {
if (authRequestId) { // if (authRequestId) {
params.append("authRequestId", authRequestId); // params.append("authRequestId", authRequestId);
} // }
return router.push(`/signedin?` + params); // return router.push(`/signedin?` + params);
} // }
} }
const { errors } = formState; const { errors } = formState;
@@ -139,6 +155,24 @@ export function SetPasswordForm({
return ( return (
<form className="w-full"> <form className="w-full">
<div className="pt-4 grid grid-cols-1 gap-4 mb-4"> <div className="pt-4 grid grid-cols-1 gap-4 mb-4">
<div className="flex flex-row items-end">
<div className="flex-1">
<TextInput
type="text"
required
{...register("code", {
required: "This field is required",
})}
label="Code"
error={errors.code?.message as string}
/>
</div>
<div className="ml-4 mb-1">
<Button variant={ButtonVariants.Secondary}>
{t("set.resend")}
</Button>
</div>
</div>
<div className=""> <div className="">
<TextInput <TextInput
type="password" type="password"
@@ -147,7 +181,7 @@ export function SetPasswordForm({
{...register("password", { {...register("password", {
required: "You have to provide a password!", required: "You have to provide a password!",
})} })}
label="Password" label="New Password"
error={errors.password?.message as string} error={errors.password?.message as string}
/> />
</div> </div>
@@ -189,7 +223,7 @@ export function SetPasswordForm({
onClick={handleSubmit(submitRegister)} onClick={handleSubmit(submitRegister)}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
{t("password.submit")} {t("set.submit")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,197 @@
"use client";
import {
lowerCaseValidator,
numberValidator,
symbolValidator,
upperCaseValidator,
} from "@/helpers/validators";
import { registerUser, RegisterUserResponse } from "@/lib/server/register";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { PasswordComplexity } from "./password-complexity";
import { Spinner } from "./spinner";
type Inputs =
| {
password: string;
confirmPassword: string;
}
| FieldValues;
type Props = {
passwordComplexitySettings: PasswordComplexitySettings;
email: string;
firstname: string;
lastname: string;
organization?: string;
authRequestId?: string;
};
export function SetRegisterPasswordForm({
passwordComplexitySettings,
email,
firstname,
lastname,
organization,
authRequestId,
}: Props) {
const t = useTranslations("register");
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
email: email ?? "",
firstname: firstname ?? "",
lastname: lastname ?? "",
},
});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const router = useRouter();
async function submitRegister(values: Inputs) {
setLoading(true);
const response = await registerUser({
email: email,
firstName: firstname,
lastName: lastname,
organization: organization,
authRequestId: authRequestId,
password: values.password,
}).catch(() => {
setError("Could not register user");
});
if (response && "error" in response) {
setError(response.error);
}
setLoading(false);
if (!response) {
setError("Could not register user");
return;
}
const userResponse = response as RegisterUserResponse;
const params = new URLSearchParams({ userId: userResponse.userId });
if (userResponse.factors?.user?.loginName) {
params.append("loginName", userResponse.factors.user.loginName);
}
if (organization) {
params.append("organization", organization);
}
if (userResponse && userResponse.sessionId) {
params.append("sessionId", userResponse.sessionId);
}
// skip verification for now as it is an app based flow
// return router.push(`/verify?` + params);
// check for mfa force to continue with mfa setup
if (authRequestId && userResponse.sessionId) {
if (authRequestId) {
params.append("authRequest", authRequestId);
}
return router.push(`/login?` + params);
} else {
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
return router.push(`/signedin?` + params);
}
}
const { errors } = formState;
const watchPassword = watch("password", "");
const watchConfirmPassword = watch("confirmPassword", "");
const hasMinLength =
passwordComplexitySettings &&
watchPassword?.length >= passwordComplexitySettings.minLength;
const hasSymbol = symbolValidator(watchPassword);
const hasNumber = numberValidator(watchPassword);
const hasUppercase = upperCaseValidator(watchPassword);
const hasLowercase = lowerCaseValidator(watchPassword);
const policyIsValid =
passwordComplexitySettings &&
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
hasMinLength;
return (
<form className="w-full">
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
<div className="">
<TextInput
type="password"
autoComplete="new-password"
required
{...register("password", {
required: "You have to provide a password!",
})}
label="Password"
error={errors.password?.message as string}
/>
</div>
<div className="">
<TextInput
type="password"
required
autoComplete="new-password"
{...register("confirmPassword", {
required: "This field is required",
})}
label="Confirm Password"
error={errors.confirmPassword?.message as string}
/>
</div>
</div>
{passwordComplexitySettings && (
<PasswordComplexity
passwordComplexitySettings={passwordComplexitySettings}
password={watchPassword}
equals={!!watchPassword && watchPassword === watchConfirmPassword}
/>
)}
{error && <Alert>{error}</Alert>}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton />
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={
loading ||
!policyIsValid ||
!formState.isValid ||
watchPassword !== watchConfirmPassword
}
onClick={handleSubmit(submitRegister)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("password.submit")}
</Button>
</div>
</form>
);
}

View File

@@ -6,6 +6,7 @@ import {
} from "@zitadel/client/v2"; } from "@zitadel/client/v2";
import { createServerTransport } from "@zitadel/node"; import { createServerTransport } from "@zitadel/node";
import { getSessionCookieById } from "./cookies"; import { getSessionCookieById } from "./cookies";
import { getSession } from "./zitadel";
const transport = (token: string) => const transport = (token: string) =>
createServerTransport(token, { createServerTransport(token, {
@@ -19,26 +20,46 @@ const sessionService = (sessionId: string) => {
}); });
}; };
const userService = (sessionId: string) => { const myUserService = (sessionToken: string) => {
return getSessionCookieById({ sessionId }).then((session) => { return createUserServiceClient(transport(sessionToken));
return createUserServiceClient(transport(session.token));
});
}; };
export async function setPassword({ export async function setMyPassword({
sessionId, sessionId,
userId,
password, password,
}: { }: {
sessionId: string; sessionId: string;
userId: string;
password: string; password: string;
}) { }) {
return (await userService(sessionId)).setPassword( const sessionCookie = await getSessionCookieById({ sessionId });
{
userId, const { session } = await getSession({
newPassword: { password, changeRequired: false }, sessionId: sessionCookie.id,
}, sessionToken: sessionCookie.token,
{}, });
);
if (!session) {
return { error: "Could not load session" };
}
const service = await myUserService(sessionCookie.token);
if (!session?.factors?.user?.id) {
return { error: "No user id found in session" };
}
return service
.setPassword(
{
userId: session.factors.user.id,
newPassword: { password, changeRequired: false },
},
{},
)
.catch((error) => {
if (error.code === 7) {
return { error: "Session is not valid." };
}
throw error;
});
} }

View File

@@ -2,6 +2,7 @@
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -135,6 +136,25 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return { error: "Could not create session for user" }; return { error: "Could not create session for user" };
} }
if (users.result[0].state === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
});
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
if (command.authRequestId) {
params.append("authRequestid", command.authRequestId);
}
return redirect("/password/set?" + params);
}
const methods = await listAuthenticationMethodTypes( const methods = await listAuthenticationMethodTypes(
session.factors?.user?.id, session.factors?.user?.id,
); );

View File

@@ -5,15 +5,20 @@ import {
setSessionAndUpdateCookie, setSessionAndUpdateCookie,
} from "@/lib/server/cookie"; } from "@/lib/server/cookie";
import { import {
getUserByID,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
listUsers, listUsers,
passwordReset, passwordReset,
setPassword,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { import {
Checks, Checks,
ChecksSchema, ChecksSchema,
} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { redirect } from "next/navigation";
import { getSessionCookieByLoginName } from "../cookies"; import { getSessionCookieByLoginName } from "../cookies";
type ResetPasswordCommand = { type ResetPasswordCommand = {
@@ -44,6 +49,7 @@ export type UpdateSessionCommand = {
organization?: string; organization?: string;
checks: Checks; checks: Checks;
authRequestId?: string; authRequestId?: string;
forceMfa?: boolean;
}; };
export async function sendPassword(command: UpdateSessionCommand) { export async function sendPassword(command: UpdateSessionCommand) {
@@ -55,13 +61,18 @@ export async function sendPassword(command: UpdateSessionCommand) {
}); });
let session; let session;
let user: User;
if (!sessionCookie) { if (!sessionCookie) {
const users = await listUsers({ const users = await listUsers({
loginName: command.loginName, loginName: command.loginName,
organizationId: command.organization, organizationId: command.organization,
}); });
console.log(users);
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
user = users.result[0];
const checks = create(ChecksSchema, { const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: users.result[0].userId } }, user: { search: { case: "userId", value: users.result[0].userId } },
password: { password: command.checks.password?.password }, password: { password: command.checks.password?.password },
@@ -83,6 +94,18 @@ export async function sendPassword(command: UpdateSessionCommand) {
undefined, undefined,
command.authRequestId, command.authRequestId,
); );
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 find user" };
}
user = userResponse.user;
} }
if (!session?.factors?.user?.id || !sessionCookie) { if (!session?.factors?.user?.id || !sessionCookie) {
@@ -100,10 +123,165 @@ export async function sendPassword(command: UpdateSessionCommand) {
} }
} }
return { const submitted = {
sessionId: session.id, sessionId: session.id,
factors: session.factors, factors: session.factors,
challenges: session.challenges, challenges: session.challenges,
authMethods, authMethods,
userState: user.state,
}; };
if (
!submitted ||
!submitted.authMethods ||
!submitted.factors?.user?.loginName
) {
return { error: "Could not verify password!" };
}
const availableSecondFactors = submitted?.authMethods?.filter(
(m: AuthenticationMethodType) =>
m !== AuthenticationMethodType.PASSWORD &&
m !== AuthenticationMethodType.PASSKEY,
);
if (availableSecondFactors?.length == 1) {
const params = new URLSearchParams({
loginName: submitted.factors?.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization) {
params.append("organization", command.organization);
}
const factor = availableSecondFactors[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 (availableSecondFactors?.length >= 1) {
const params = new URLSearchParams({
loginName: submitted.factors.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization) {
params.append("organization", command.organization);
}
return redirect(`/mfa?` + params);
} else if (submitted.userState === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: submitted.factors.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization) {
params.append("organization", command.organization);
}
return redirect(`/password/change?` + params);
} else if (command.forceMfa && !availableSecondFactors.length) {
const params = new URLSearchParams({
loginName: submitted.factors.user.loginName,
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 (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization) {
params.append("organization", command.organization);
}
// 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);
// }
else if (command.authRequestId && submitted.sessionId) {
const params = new URLSearchParams({
sessionId: submitted.sessionId,
authRequest: command.authRequestId,
});
if (command.organization) {
params.append("organization", command.organization);
}
return redirect(`/login?` + params);
}
// without OIDC flow
const params = new URLSearchParams(
command.authRequestId
? {
loginName: submitted.factors.user.loginName,
authRequestId: command.authRequestId,
}
: {
loginName: submitted.factors.user.loginName,
},
);
if (command.organization) {
params.append("organization", command.organization);
}
return redirect(`/signedin?` + params);
}
export async function changePassword(command: {
code: string;
userId: string;
password: string;
}) {
// check for init state
const { user } = await getUserByID(command.userId);
if (!user || user.userId !== command.userId) {
return { error: "Could not send Password Reset Link" };
}
const userId = user.userId;
return setPassword(userId, command.password, command.code);
} }

View File

@@ -28,6 +28,7 @@ import {
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { NotificationType } from "@zitadel/proto/zitadel/user/v2/password_pb";
import { import {
SearchQuery, SearchQuery,
SearchQuerySchema, SearchQuerySchema,
@@ -495,6 +496,32 @@ export async function passwordReset(userId: string) {
return userService.passwordReset( return userService.passwordReset(
{ {
userId, userId,
medium: {
case: "sendLink",
value: {
notificationType: NotificationType.Email,
},
},
},
{},
);
}
export async function setPassword(
userId: string,
password: string,
code: string,
) {
return userService.setPassword(
{
userId,
newPassword: {
password,
},
verification: {
case: "verificationCode",
value: code,
},
}, },
{}, {},
); );