feat: add current password field to password reset (#11534)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

The password change flow in Login V2 does not ask the user to confirm
their current password before setting a new one.

# How the Problems Are Solved

- Added a "Current Password" field to the change password form
- The current password is verified via a session password check before
the new password is set
- If the current password is incorrect, the user sees a clear error
message
- Removed the previous 5-minute session freshness check, which is no
longer needed since the current password is verified inline

# Additional Changes

Added translations for the new field labels and error messages across
all supported languages

# Additional Context

- Closes #11533

---------

Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Liam Neville
2026-02-18 10:05:26 +00:00
committed by GitHub
co-authored by Max Peintner
parent 11d48edd84
commit 64065bbe3c
16 changed files with 271 additions and 104 deletions
+3
View File
@@ -109,14 +109,17 @@
"description": "تعيين كلمة المرور لحسابك", "description": "تعيين كلمة المرور لحسابك",
"submit": "متابعة", "submit": "متابعة",
"labels": { "labels": {
"currentPassword": "كلمة المرور الحالية",
"newPassword": "كلمة المرور الجديدة", "newPassword": "كلمة المرور الجديدة",
"confirmPassword": "تأكيد كلمة المرور" "confirmPassword": "تأكيد كلمة المرور"
}, },
"required": { "required": {
"currentPassword": "عليك تقديم كلمة المرور الحالية!",
"newPassword": "عليك تقديم كلمة مرور جديدة!", "newPassword": "عليك تقديم كلمة مرور جديدة!",
"confirmPassword": "هذا الحقل مطلوب" "confirmPassword": "هذا الحقل مطلوب"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "كلمة المرور الحالية غير صحيحة.",
"couldNotChangePassword": "تعذر تغيير كلمة المرور", "couldNotChangePassword": "تعذر تغيير كلمة المرور",
"couldNotVerifyPassword": "تعذر التحقق من كلمة المرور", "couldNotVerifyPassword": "تعذر التحقق من كلمة المرور",
"unknownError": "خطأ غير معروف" "unknownError": "خطأ غير معروف"
+3
View File
@@ -108,14 +108,17 @@
"description": "Legen Sie das Passwort für Ihr Konto fest", "description": "Legen Sie das Passwort für Ihr Konto fest",
"submit": "Weiter", "submit": "Weiter",
"labels": { "labels": {
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort", "newPassword": "Neues Passwort",
"confirmPassword": "Neues Passwort wiederholen" "confirmPassword": "Neues Passwort wiederholen"
}, },
"required": { "required": {
"currentPassword": "Bitte geben Sie Ihr aktuelles Passwort ein!",
"newPassword": "Bitte geben Sie ein neues Passwort ein!", "newPassword": "Bitte geben Sie ein neues Passwort ein!",
"confirmPassword": "Dieses Feld ist erforderlich" "confirmPassword": "Dieses Feld ist erforderlich"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Das aktuelle Passwort ist falsch.",
"couldNotChangePassword": "Passwort konnte nicht geändert werden", "couldNotChangePassword": "Passwort konnte nicht geändert werden",
"couldNotVerifyPassword": "Passwort konnte nicht verifiziert werden", "couldNotVerifyPassword": "Passwort konnte nicht verifiziert werden",
"unknownError": "Unbekannter Fehler" "unknownError": "Unbekannter Fehler"
+3
View File
@@ -108,14 +108,17 @@
"description": "Set the password for your account", "description": "Set the password for your account",
"submit": "Continue", "submit": "Continue",
"labels": { "labels": {
"currentPassword": "Current Password",
"newPassword": "New Password", "newPassword": "New Password",
"confirmPassword": "Confirm Password" "confirmPassword": "Confirm Password"
}, },
"required": { "required": {
"currentPassword": "You have to provide your current password!",
"newPassword": "You have to provide a new password!", "newPassword": "You have to provide a new password!",
"confirmPassword": "This field is required" "confirmPassword": "This field is required"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Current password is incorrect.",
"couldNotChangePassword": "Could not change password", "couldNotChangePassword": "Could not change password",
"couldNotVerifyPassword": "Could not verify password", "couldNotVerifyPassword": "Could not verify password",
"unknownError": "Unknown error" "unknownError": "Unknown error"
+3
View File
@@ -108,14 +108,17 @@
"description": "Establece la contraseña para tu cuenta", "description": "Establece la contraseña para tu cuenta",
"submit": "Continuar", "submit": "Continuar",
"labels": { "labels": {
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña", "newPassword": "Nueva contraseña",
"confirmPassword": "Confirmar contraseña" "confirmPassword": "Confirmar contraseña"
}, },
"required": { "required": {
"currentPassword": "¡Debes proporcionar tu contraseña actual!",
"newPassword": "¡Debes proporcionar una nueva contraseña!", "newPassword": "¡Debes proporcionar una nueva contraseña!",
"confirmPassword": "Este campo es obligatorio" "confirmPassword": "Este campo es obligatorio"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "La contraseña actual es incorrecta.",
"couldNotChangePassword": "No se pudo cambiar la contraseña", "couldNotChangePassword": "No se pudo cambiar la contraseña",
"couldNotVerifyPassword": "No se pudo verificar la contraseña", "couldNotVerifyPassword": "No se pudo verificar la contraseña",
"unknownError": "Error desconocido" "unknownError": "Error desconocido"
+3
View File
@@ -108,14 +108,17 @@
"description": "Définissez le mot de passe de votre compte", "description": "Définissez le mot de passe de votre compte",
"submit": "Continuer", "submit": "Continuer",
"labels": { "labels": {
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe", "newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmez le mot de passe" "confirmPassword": "Confirmez le mot de passe"
}, },
"required": { "required": {
"currentPassword": "Vous devez fournir votre mot de passe actuel !",
"newPassword": "Vous devez fournir un nouveau mot de passe !", "newPassword": "Vous devez fournir un nouveau mot de passe !",
"confirmPassword": "Ce champ est requis" "confirmPassword": "Ce champ est requis"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Le mot de passe actuel est incorrect.",
"couldNotChangePassword": "Impossible de changer le mot de passe", "couldNotChangePassword": "Impossible de changer le mot de passe",
"couldNotVerifyPassword": "Impossible de vérifier le mot de passe", "couldNotVerifyPassword": "Impossible de vérifier le mot de passe",
"unknownError": "Erreur inconnue" "unknownError": "Erreur inconnue"
+3
View File
@@ -108,14 +108,17 @@
"description": "Imposta la password per il tuo account", "description": "Imposta la password per il tuo account",
"submit": "Continua", "submit": "Continua",
"labels": { "labels": {
"currentPassword": "Password attuale",
"newPassword": "Nuova password", "newPassword": "Nuova password",
"confirmPassword": "Conferma password" "confirmPassword": "Conferma password"
}, },
"required": { "required": {
"currentPassword": "Devi fornire la tua password attuale!",
"newPassword": "Devi fornire una nuova password!", "newPassword": "Devi fornire una nuova password!",
"confirmPassword": "Questo campo è obbligatorio" "confirmPassword": "Questo campo è obbligatorio"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "La password attuale non è corretta.",
"couldNotChangePassword": "Impossibile modificare la password", "couldNotChangePassword": "Impossibile modificare la password",
"couldNotVerifyPassword": "Impossibile verificare la password", "couldNotVerifyPassword": "Impossibile verificare la password",
"unknownError": "Errore sconosciuto" "unknownError": "Errore sconosciuto"
+3
View File
@@ -101,14 +101,17 @@
"description": "アカウントのパスワードを設定してください", "description": "アカウントのパスワードを設定してください",
"submit": "続行", "submit": "続行",
"labels": { "labels": {
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード", "newPassword": "新しいパスワード",
"confirmPassword": "パスワード確認" "confirmPassword": "パスワード確認"
}, },
"required": { "required": {
"currentPassword": "現在のパスワードを入力してください!",
"newPassword": "新しいパスワードを入力してください!", "newPassword": "新しいパスワードを入力してください!",
"confirmPassword": "必須項目です" "confirmPassword": "必須項目です"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "現在のパスワードが正しくありません。",
"couldNotChangePassword": "パスワードを変更できませんでした", "couldNotChangePassword": "パスワードを変更できませんでした",
"couldNotVerifyPassword": "パスワードを確認できませんでした", "couldNotVerifyPassword": "パスワードを確認できませんでした",
"unknownError": "不明なエラー" "unknownError": "不明なエラー"
+3
View File
@@ -108,14 +108,17 @@
"description": "Stel het wachtwoord voor jouw account in", "description": "Stel het wachtwoord voor jouw account in",
"submit": "Verder", "submit": "Verder",
"labels": { "labels": {
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord", "newPassword": "Nieuw wachtwoord",
"confirmPassword": "Bevestig wachtwoord" "confirmPassword": "Bevestig wachtwoord"
}, },
"required": { "required": {
"currentPassword": "Je moet je huidige wachtwoord opgeven!",
"newPassword": "Je moet een nieuw wachtwoord opgeven!", "newPassword": "Je moet een nieuw wachtwoord opgeven!",
"confirmPassword": "Dit veld is verplicht" "confirmPassword": "Dit veld is verplicht"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Het huidige wachtwoord is onjuist.",
"couldNotChangePassword": "Kon wachtwoord niet wijzigen", "couldNotChangePassword": "Kon wachtwoord niet wijzigen",
"couldNotVerifyPassword": "Kon wachtwoord niet verifiëren", "couldNotVerifyPassword": "Kon wachtwoord niet verifiëren",
"unknownError": "Onbekende fout" "unknownError": "Onbekende fout"
+3
View File
@@ -108,14 +108,17 @@
"description": "Ustaw nowe hasło dla swojego konta", "description": "Ustaw nowe hasło dla swojego konta",
"submit": "Kontynuuj", "submit": "Kontynuuj",
"labels": { "labels": {
"currentPassword": "Aktualne hasło",
"newPassword": "Nowe hasło", "newPassword": "Nowe hasło",
"confirmPassword": "Potwierdź nowe hasło" "confirmPassword": "Potwierdź nowe hasło"
}, },
"required": { "required": {
"currentPassword": "Musisz podać aktualne hasło!",
"newPassword": "Musisz podać nowe hasło!", "newPassword": "Musisz podać nowe hasło!",
"confirmPassword": "To pole jest wymagane" "confirmPassword": "To pole jest wymagane"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Aktualne hasło jest nieprawidłowe.",
"couldNotChangePassword": "Nie można zmienić hasła", "couldNotChangePassword": "Nie można zmienić hasła",
"couldNotVerifyPassword": "Nie można zweryfikować hasła", "couldNotVerifyPassword": "Nie można zweryfikować hasła",
"unknownError": "Nieznany błąd" "unknownError": "Nieznany błąd"
+3
View File
@@ -108,14 +108,17 @@
"description": "Установите пароль для вашего аккаунта", "description": "Установите пароль для вашего аккаунта",
"submit": "Продолжить", "submit": "Продолжить",
"labels": { "labels": {
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль", "newPassword": "Новый пароль",
"confirmPassword": "Подтвердите пароль" "confirmPassword": "Подтвердите пароль"
}, },
"required": { "required": {
"currentPassword": "Вы должны указать текущий пароль!",
"newPassword": "Вы должны указать новый пароль!", "newPassword": "Вы должны указать новый пароль!",
"confirmPassword": "Это поле обязательно для заполнения" "confirmPassword": "Это поле обязательно для заполнения"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Текущий пароль неверен.",
"couldNotChangePassword": "Не удалось изменить пароль", "couldNotChangePassword": "Не удалось изменить пароль",
"couldNotVerifyPassword": "Не удалось проверить пароль", "couldNotVerifyPassword": "Не удалось проверить пароль",
"unknownError": "Неизвестная ошибка" "unknownError": "Неизвестная ошибка"
+3
View File
@@ -108,14 +108,17 @@
"description": "Hesabınız için şifre belirleyin", "description": "Hesabınız için şifre belirleyin",
"submit": "Devam", "submit": "Devam",
"labels": { "labels": {
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre", "newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla" "confirmPassword": "Şifreyi Onayla"
}, },
"required": { "required": {
"currentPassword": "Mevcut şifrenizi girmelisiniz!",
"newPassword": "Yeni bir şifre sağlamalısınız!", "newPassword": "Yeni bir şifre sağlamalısınız!",
"confirmPassword": "Bu alan zorunludur" "confirmPassword": "Bu alan zorunludur"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Mevcut şifre yanlış.",
"couldNotChangePassword": "Şifre değiştirilemedi", "couldNotChangePassword": "Şifre değiştirilemedi",
"couldNotVerifyPassword": "Şifre doğrulanamadı", "couldNotVerifyPassword": "Şifre doğrulanamadı",
"unknownError": "Bilinmeyen hata" "unknownError": "Bilinmeyen hata"
+3
View File
@@ -108,14 +108,17 @@
"description": "Встановіть пароль для свого облікового запису", "description": "Встановіть пароль для свого облікового запису",
"submit": "Продовжити", "submit": "Продовжити",
"labels": { "labels": {
"currentPassword": "Поточний пароль",
"newPassword": "Новий пароль", "newPassword": "Новий пароль",
"confirmPassword": "Підтвердити пароль" "confirmPassword": "Підтвердити пароль"
}, },
"required": { "required": {
"currentPassword": "Ви повинні вказати поточний пароль!",
"newPassword": "Ви повинні вказати новий пароль!", "newPassword": "Ви повинні вказати новий пароль!",
"confirmPassword": "Це поле обов'язкове" "confirmPassword": "Це поле обов'язкове"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "Поточний пароль невірний.",
"couldNotChangePassword": "Не вдалося змінити пароль", "couldNotChangePassword": "Не вдалося змінити пароль",
"couldNotVerifyPassword": "Не вдалося перевірити пароль", "couldNotVerifyPassword": "Не вдалося перевірити пароль",
"unknownError": "Невідома помилка" "unknownError": "Невідома помилка"
+3
View File
@@ -108,14 +108,17 @@
"description": "为您的账户设置密码", "description": "为您的账户设置密码",
"submit": "继续", "submit": "继续",
"labels": { "labels": {
"currentPassword": "当前密码",
"newPassword": "新密码", "newPassword": "新密码",
"confirmPassword": "确认密码" "confirmPassword": "确认密码"
}, },
"required": { "required": {
"currentPassword": "必须提供当前密码!",
"newPassword": "必须提供新密码!", "newPassword": "必须提供新密码!",
"confirmPassword": "此字段为必填项" "confirmPassword": "此字段为必填项"
}, },
"errors": { "errors": {
"currentPasswordInvalid": "当前密码不正确。",
"couldNotChangePassword": "无法更改密码", "couldNotChangePassword": "无法更改密码",
"couldNotVerifyPassword": "无法验证密码", "couldNotVerifyPassword": "无法验证密码",
"unknownError": "未知错误" "unknownError": "未知错误"
@@ -21,6 +21,7 @@ import { AutoSubmitForm } from "./auto-submit-form";
type Inputs = type Inputs =
| { | {
currentPassword: string;
password: string; password: string;
confirmPassword: string; confirmPassword: string;
} }
@@ -40,8 +41,9 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
const { register, handleSubmit, watch, formState } = useForm<Inputs>({ const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
currentPassword: "",
password: "", password: "",
comfirmPassword: "", confirmPassword: "",
}, },
}); });
@@ -56,6 +58,7 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
const changeResponse = await checkSessionAndSetPassword({ const changeResponse = await checkSessionAndSetPassword({
sessionId, sessionId,
currentPassword: values.currentPassword,
password: values.password, password: values.password,
}).catch(() => { }).catch(() => {
setError(t("change.errors.couldNotChangePassword")); setError(t("change.errors.couldNotChangePassword"));
@@ -122,6 +125,19 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />} {samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full"> <form className="w-full">
<div className="mb-4 grid grid-cols-1 gap-4 pt-4"> <div className="mb-4 grid grid-cols-1 gap-4 pt-4">
<div className="">
<TextInput
type="password"
autoComplete="current-password"
required
{...register("currentPassword", {
required: t("change.required.currentPassword"),
})}
label={t("change.labels.currentPassword")}
error={errors.currentPassword?.message as string}
data-testid="password-change-current-text-input"
/>
</div>
<div className=""> <div className="">
<TextInput <TextInput
type="password" type="password"
+158 -87
View File
@@ -69,7 +69,9 @@ describe("checkSessionAndSetPassword", () => {
let mockGetSession: any; let mockGetSession: any;
let mockListAuthenticationMethodTypes: any; let mockListAuthenticationMethodTypes: any;
let mockGetLoginSettings: any; let mockGetLoginSettings: any;
let mockSetPassword: any; // Service account let mockGetLockoutSettings: any;
let mockSetPassword: any;
let mockSetSessionAndUpdateCookie: any;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -77,7 +79,9 @@ describe("checkSessionAndSetPassword", () => {
const { headers } = await import("next/headers"); const { headers } = await import("next/headers");
const { getServiceConfig } = await import("../service-url"); const { getServiceConfig } = await import("../service-url");
const { getSessionCookieById } = await import("../cookies"); const { getSessionCookieById } = await import("../cookies");
const { getSession, listAuthenticationMethodTypes, getLoginSettings, setPassword } = await import("../zitadel"); const { getSession, listAuthenticationMethodTypes, getLoginSettings, getLockoutSettings, setPassword } =
await import("../zitadel");
const { setSessionAndUpdateCookie } = await import("./cookie");
mockHeaders = vi.mocked(headers); mockHeaders = vi.mocked(headers);
mockGetServiceConfig = vi.mocked(getServiceConfig); mockGetServiceConfig = vi.mocked(getServiceConfig);
@@ -85,18 +89,20 @@ describe("checkSessionAndSetPassword", () => {
mockGetSession = vi.mocked(getSession); mockGetSession = vi.mocked(getSession);
mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes); mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes);
mockGetLoginSettings = vi.mocked(getLoginSettings); mockGetLoginSettings = vi.mocked(getLoginSettings);
mockGetLockoutSettings = vi.mocked(getLockoutSettings);
mockSetPassword = vi.mocked(setPassword); mockSetPassword = vi.mocked(setPassword);
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
mockHeaders.mockResolvedValue({}); mockHeaders.mockResolvedValue({});
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } }); mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
// Default session setup // Default session setup
mockGetSessionCookieById.mockResolvedValue({ id: "session123", token: "token123" }); mockGetSessionCookieById.mockResolvedValue({ id: "session123", token: "token123", organization: "org123" });
mockGetSession.mockResolvedValue({ mockGetSession.mockResolvedValue({
session: { session: {
factors: { factors: {
user: { id: "user123", organizationId: "org123" }, user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } }, // Password verified recently password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } },
}, },
}, },
}); });
@@ -106,111 +112,176 @@ describe("checkSessionAndSetPassword", () => {
authMethodTypes: [AuthenticationMethodType.PASSWORD], authMethodTypes: [AuthenticationMethodType.PASSWORD],
}); });
// Default: No forced MFA // Default: login settings with password check lifetime
mockGetLoginSettings.mockResolvedValue({ forceMfa: false }); mockGetLoginSettings.mockResolvedValue({
forceMfa: false,
passwordCheckLifetime: { seconds: BigInt(60 * 60 * 24), nanos: 0 },
});
// Mock user service client // Default: current password verification succeeds
mockSetSessionAndUpdateCookie.mockResolvedValue({});
mockSetPassword.mockResolvedValue({}); mockSetPassword.mockResolvedValue({});
}); });
test("should use service account when no MFA is configured", async () => { test("should verify current password and set new password", async () => {
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" }); await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "oldpassword",
password: "newpassword",
});
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
expect.objectContaining({
recentCookie: expect.objectContaining({ id: "session123" }),
}),
);
expect(mockSetPassword).toHaveBeenCalled(); expect(mockSetPassword).toHaveBeenCalled();
}); });
test("should use service account when MFA is configured but NOT verified in session", async () => { test("should return error when current password is incorrect", async () => {
// User has TOTP configured mockSetSessionAndUpdateCookie.mockRejectedValue(new Error("invalid password"));
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP], const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "wrongpassword",
password: "newpassword",
}); });
// Session only has password verified (no OTP) expect(result).toEqual({ error: "change.errors.currentPasswordInvalid" });
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 60 } }, // Verified 1 minute ago
// otp missing
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
test("should use service account when MFA is configured AND verified in session", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has both password and OTP verified
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } },
totp: { verifiedAt: { seconds: Math.floor(Date.now() / 1000) } }, // Verified
},
},
});
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(mockSetPassword).toHaveBeenCalled();
});
test("should fail when MFA is configured but not verified, and password verification is too old", async () => {
// User has TOTP configured
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP],
});
// Session has password verified 10 minutes ago (600 seconds)
const now = Math.floor(Date.now() / 1000);
mockGetSession.mockResolvedValue({
session: {
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 600 } },
// otp missing
},
},
});
const result = await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" });
expect(result).toEqual({ error: "errors.passwordVerificationTooOld" });
expect(mockSetPassword).not.toHaveBeenCalled(); expect(mockSetPassword).not.toHaveBeenCalled();
}); });
test("should use service account when MFA is configured but not verified, and password verification is recent", async () => { test("should return generic error when current password is incorrect and ignoreUnknownUsernames is true", async () => {
// User has TOTP configured mockGetLoginSettings.mockResolvedValue({
mockListAuthenticationMethodTypes.mockResolvedValue({ forceMfa: false,
authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP], passwordCheckLifetime: { seconds: BigInt(60 * 60 * 24), nanos: 0 },
ignoreUnknownUsernames: true,
});
mockSetSessionAndUpdateCookie.mockRejectedValue(new Error("invalid password"));
const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "wrongpassword",
password: "newpassword",
}); });
// Session has password verified 1 minute ago (60 seconds) expect(result).toEqual({ error: "change.errors.couldNotVerifyPassword" });
const now = Math.floor(Date.now() / 1000); expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should pass requestId from session cookie to setSessionAndUpdateCookie", async () => {
mockGetSessionCookieById.mockResolvedValue({
id: "session123",
token: "token123",
organization: "org123",
requestId: "oidc-request-456",
});
await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "oldpassword",
password: "newpassword",
});
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
expect.objectContaining({
requestId: "oidc-request-456",
}),
);
});
test("should return lockout error with attempt counts when failedAttempts error is thrown", async () => {
mockSetSessionAndUpdateCookie.mockRejectedValue({ failedAttempts: 3 });
mockGetLockoutSettings.mockResolvedValue({ maxPasswordAttempts: BigInt(5) });
const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "wrongpassword",
password: "newpassword",
});
expect(result).toEqual({ error: "errors.failedToAuthenticate" });
expect(mockGetLockoutSettings).toHaveBeenCalled();
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should return generic error for failedAttempts when ignoreUnknownUsernames is true", async () => {
mockGetLoginSettings.mockResolvedValue({
forceMfa: false,
passwordCheckLifetime: { seconds: BigInt(60 * 60 * 24), nanos: 0 },
ignoreUnknownUsernames: true,
});
mockSetSessionAndUpdateCookie.mockRejectedValue({ failedAttempts: 3 });
const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "wrongpassword",
password: "newpassword",
});
expect(result).toEqual({ error: "errors.failedToAuthenticateNoLimit" });
expect(mockGetLockoutSettings).not.toHaveBeenCalled();
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should return error when session cookie not found", async () => {
mockGetSessionCookieById.mockResolvedValue(null);
const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "oldpassword",
password: "newpassword",
});
expect(result).toEqual({ error: "Could not load session cookie" });
expect(mockSetSessionAndUpdateCookie).not.toHaveBeenCalled();
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should return error when session has no user", async () => {
mockGetSession.mockResolvedValue({ mockGetSession.mockResolvedValue({
session: { session: { factors: {} },
factors: {
user: { id: "user123", organizationId: "org123" },
password: { verifiedAt: { seconds: now - 60 } },
// otp missing
},
},
}); });
await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" }); const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "oldpassword",
password: "newpassword",
});
expect(result).toEqual({ error: "errors.couldNotLoadSession" });
expect(mockSetPassword).not.toHaveBeenCalled();
});
test("should use default lifetime when login settings have no passwordCheckLifetime", async () => {
mockGetLoginSettings.mockResolvedValue({ forceMfa: false });
await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "oldpassword",
password: "newpassword",
});
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
expect.objectContaining({
lifetime: expect.objectContaining({ seconds: BigInt(60 * 60 * 24) }),
}),
);
expect(mockSetPassword).toHaveBeenCalled(); expect(mockSetPassword).toHaveBeenCalled();
}); });
test("should handle setPassword failure with failed precondition", async () => {
mockSetPassword.mockRejectedValue({ code: 9, message: "User is not yet initialized" });
const result = await checkSessionAndSetPassword({
sessionId: "session123",
currentPassword: "oldpassword",
password: "newpassword",
});
expect(result).toEqual({ error: "errors.failedPrecondition" });
});
}); });
describe("sendPassword", () => { describe("sendPassword", () => {
+57 -16
View File
@@ -13,7 +13,7 @@ import {
setPassword, setPassword,
setUserPassword, setUserPassword,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create, Duration, timestampDate } from "@zitadel/client"; import { create, Duration } from "@zitadel/client";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { Checks, 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 { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
@@ -448,10 +448,15 @@ export async function changePassword(command: { code?: string; userId: string; p
type CheckSessionAndSetPasswordCommand = { type CheckSessionAndSetPasswordCommand = {
sessionId: string; sessionId: string;
currentPassword: string;
password: string; password: string;
}; };
export async function checkSessionAndSetPassword({ sessionId, password }: CheckSessionAndSetPasswordCommand) { export async function checkSessionAndSetPassword({
sessionId,
currentPassword,
password,
}: CheckSessionAndSetPasswordCommand) {
const _headers = await headers(); const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers); const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("password"); const t = await getTranslations("password");
@@ -479,6 +484,56 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
return { error: t("errors.couldNotLoadSession") }; return { error: t("errors.couldNotLoadSession") };
} }
const loginSettings = await getLoginSettings({
serviceConfig,
organization: sessionCookie.organization,
});
let lifetime = loginSettings?.passwordCheckLifetime;
if (!lifetime || !lifetime.seconds) {
lifetime = {
seconds: BigInt(60 * 60 * 24),
nanos: 0,
} as Duration;
}
const checks = create(ChecksSchema, {
password: { password: currentPassword },
});
try {
await setSessionAndUpdateCookie({
recentCookie: sessionCookie,
checks,
lifetime,
requestId: sessionCookie.requestId,
});
} catch (error: any) {
if ("failedAttempts" in error && error.failedAttempts) {
if (loginSettings?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
}
const lockoutSettings = await getLockoutSettings({ serviceConfig, orgId: sessionCookie.organization });
const hasLimit =
lockoutSettings?.maxPasswordAttempts !== undefined && lockoutSettings?.maxPasswordAttempts > BigInt(0);
const locked = hasLimit && error.failedAttempts >= lockoutSettings?.maxPasswordAttempts;
const messageKey = hasLimit ? "errors.failedToAuthenticate" : "errors.failedToAuthenticateNoLimit";
return {
error: t(messageKey, {
failedAttempts: error.failedAttempts,
maxPasswordAttempts: hasLimit ? (lockoutSettings?.maxPasswordAttempts).toString() : "?",
lockoutMessage: locked ? t("errors.accountLockedContactAdmin") : "",
}),
};
}
if (loginSettings?.ignoreUnknownUsernames) {
return { error: t("change.errors.couldNotVerifyPassword") };
}
return { error: t("change.errors.currentPasswordInvalid") };
}
const payload = create(SetPasswordRequestSchema, { const payload = create(SetPasswordRequestSchema, {
userId: session.factors.user.id, userId: session.factors.user.id,
newPassword: { newPassword: {
@@ -486,20 +541,6 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
}, },
}); });
// check if the password factor is set and not older than 5 minutes
const passwordVerifiedAt = session.factors?.password?.verifiedAt;
if (!passwordVerifiedAt) {
return { error: t("errors.passwordVerificationMissing") };
}
const passwordVerifiedDate = timestampDate(passwordVerifiedAt);
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
if (passwordVerifiedDate < fiveMinutesAgo) {
return { error: t("errors.passwordVerificationTooOld") };
}
return setPassword({ serviceConfig, payload }).catch((error) => { return setPassword({ serviceConfig, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized) // throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) { if (error.code === 9 && error.message) {