From 64065bbe3c578536a3f05624638718844899e5fa Mon Sep 17 00:00:00 2001 From: Liam Neville Date: Wed, 18 Feb 2026 13:05:26 +0300 Subject: [PATCH] feat: add current password field to password reset (#11534) # 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 --- apps/login/locales/ar.json | 3 + apps/login/locales/de.json | 3 + apps/login/locales/en.json | 3 + apps/login/locales/es.json | 3 + apps/login/locales/fr.json | 3 + apps/login/locales/it.json | 3 + apps/login/locales/ja.json | 3 + apps/login/locales/nl.json | 3 + apps/login/locales/pl.json | 3 + apps/login/locales/ru.json | 3 + apps/login/locales/tr.json | 3 + apps/login/locales/uk.json | 3 + apps/login/locales/zh.json | 3 + .../src/components/change-password-form.tsx | 18 +- apps/login/src/lib/server/password.test.ts | 245 +++++++++++------- apps/login/src/lib/server/password.ts | 73 ++++-- 16 files changed, 271 insertions(+), 104 deletions(-) diff --git a/apps/login/locales/ar.json b/apps/login/locales/ar.json index a13826b2b..4c32edf54 100644 --- a/apps/login/locales/ar.json +++ b/apps/login/locales/ar.json @@ -109,14 +109,17 @@ "description": "تعيين كلمة المرور لحسابك", "submit": "متابعة", "labels": { + "currentPassword": "كلمة المرور الحالية", "newPassword": "كلمة المرور الجديدة", "confirmPassword": "تأكيد كلمة المرور" }, "required": { + "currentPassword": "عليك تقديم كلمة المرور الحالية!", "newPassword": "عليك تقديم كلمة مرور جديدة!", "confirmPassword": "هذا الحقل مطلوب" }, "errors": { + "currentPasswordInvalid": "كلمة المرور الحالية غير صحيحة.", "couldNotChangePassword": "تعذر تغيير كلمة المرور", "couldNotVerifyPassword": "تعذر التحقق من كلمة المرور", "unknownError": "خطأ غير معروف" diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 3ddc76690..3fccb474f 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -108,14 +108,17 @@ "description": "Legen Sie das Passwort für Ihr Konto fest", "submit": "Weiter", "labels": { + "currentPassword": "Aktuelles Passwort", "newPassword": "Neues Passwort", "confirmPassword": "Neues Passwort wiederholen" }, "required": { + "currentPassword": "Bitte geben Sie Ihr aktuelles Passwort ein!", "newPassword": "Bitte geben Sie ein neues Passwort ein!", "confirmPassword": "Dieses Feld ist erforderlich" }, "errors": { + "currentPasswordInvalid": "Das aktuelle Passwort ist falsch.", "couldNotChangePassword": "Passwort konnte nicht geändert werden", "couldNotVerifyPassword": "Passwort konnte nicht verifiziert werden", "unknownError": "Unbekannter Fehler" diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index ac19c5b37..735828fe7 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -108,14 +108,17 @@ "description": "Set the password for your account", "submit": "Continue", "labels": { + "currentPassword": "Current Password", "newPassword": "New Password", "confirmPassword": "Confirm Password" }, "required": { + "currentPassword": "You have to provide your current password!", "newPassword": "You have to provide a new password!", "confirmPassword": "This field is required" }, "errors": { + "currentPasswordInvalid": "Current password is incorrect.", "couldNotChangePassword": "Could not change password", "couldNotVerifyPassword": "Could not verify password", "unknownError": "Unknown error" diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 43d6bad05..af1c6c5e7 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -108,14 +108,17 @@ "description": "Establece la contraseña para tu cuenta", "submit": "Continuar", "labels": { + "currentPassword": "Contraseña actual", "newPassword": "Nueva contraseña", "confirmPassword": "Confirmar contraseña" }, "required": { + "currentPassword": "¡Debes proporcionar tu contraseña actual!", "newPassword": "¡Debes proporcionar una nueva contraseña!", "confirmPassword": "Este campo es obligatorio" }, "errors": { + "currentPasswordInvalid": "La contraseña actual es incorrecta.", "couldNotChangePassword": "No se pudo cambiar la contraseña", "couldNotVerifyPassword": "No se pudo verificar la contraseña", "unknownError": "Error desconocido" diff --git a/apps/login/locales/fr.json b/apps/login/locales/fr.json index 0e5ced94c..4b8682992 100644 --- a/apps/login/locales/fr.json +++ b/apps/login/locales/fr.json @@ -108,14 +108,17 @@ "description": "Définissez le mot de passe de votre compte", "submit": "Continuer", "labels": { + "currentPassword": "Mot de passe actuel", "newPassword": "Nouveau mot de passe", "confirmPassword": "Confirmez le mot de passe" }, "required": { + "currentPassword": "Vous devez fournir votre mot de passe actuel !", "newPassword": "Vous devez fournir un nouveau mot de passe !", "confirmPassword": "Ce champ est requis" }, "errors": { + "currentPasswordInvalid": "Le mot de passe actuel est incorrect.", "couldNotChangePassword": "Impossible de changer le mot de passe", "couldNotVerifyPassword": "Impossible de vérifier le mot de passe", "unknownError": "Erreur inconnue" diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 6484fa276..92ac3fcd6 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -108,14 +108,17 @@ "description": "Imposta la password per il tuo account", "submit": "Continua", "labels": { + "currentPassword": "Password attuale", "newPassword": "Nuova password", "confirmPassword": "Conferma password" }, "required": { + "currentPassword": "Devi fornire la tua password attuale!", "newPassword": "Devi fornire una nuova password!", "confirmPassword": "Questo campo è obbligatorio" }, "errors": { + "currentPasswordInvalid": "La password attuale non è corretta.", "couldNotChangePassword": "Impossibile modificare la password", "couldNotVerifyPassword": "Impossibile verificare la password", "unknownError": "Errore sconosciuto" diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 1f9502f2f..fd5893824 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -101,14 +101,17 @@ "description": "アカウントのパスワードを設定してください", "submit": "続行", "labels": { + "currentPassword": "現在のパスワード", "newPassword": "新しいパスワード", "confirmPassword": "パスワード確認" }, "required": { + "currentPassword": "現在のパスワードを入力してください!", "newPassword": "新しいパスワードを入力してください!", "confirmPassword": "必須項目です" }, "errors": { + "currentPasswordInvalid": "現在のパスワードが正しくありません。", "couldNotChangePassword": "パスワードを変更できませんでした", "couldNotVerifyPassword": "パスワードを確認できませんでした", "unknownError": "不明なエラー" diff --git a/apps/login/locales/nl.json b/apps/login/locales/nl.json index 9c08682ac..797bf269c 100644 --- a/apps/login/locales/nl.json +++ b/apps/login/locales/nl.json @@ -108,14 +108,17 @@ "description": "Stel het wachtwoord voor jouw account in", "submit": "Verder", "labels": { + "currentPassword": "Huidig wachtwoord", "newPassword": "Nieuw wachtwoord", "confirmPassword": "Bevestig wachtwoord" }, "required": { + "currentPassword": "Je moet je huidige wachtwoord opgeven!", "newPassword": "Je moet een nieuw wachtwoord opgeven!", "confirmPassword": "Dit veld is verplicht" }, "errors": { + "currentPasswordInvalid": "Het huidige wachtwoord is onjuist.", "couldNotChangePassword": "Kon wachtwoord niet wijzigen", "couldNotVerifyPassword": "Kon wachtwoord niet verifiëren", "unknownError": "Onbekende fout" diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 6551954d7..f45547eca 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -108,14 +108,17 @@ "description": "Ustaw nowe hasło dla swojego konta", "submit": "Kontynuuj", "labels": { + "currentPassword": "Aktualne hasło", "newPassword": "Nowe hasło", "confirmPassword": "Potwierdź nowe hasło" }, "required": { + "currentPassword": "Musisz podać aktualne hasło!", "newPassword": "Musisz podać nowe hasło!", "confirmPassword": "To pole jest wymagane" }, "errors": { + "currentPasswordInvalid": "Aktualne hasło jest nieprawidłowe.", "couldNotChangePassword": "Nie można zmienić hasła", "couldNotVerifyPassword": "Nie można zweryfikować hasła", "unknownError": "Nieznany błąd" diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index d723e3da6..a4b269b33 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -108,14 +108,17 @@ "description": "Установите пароль для вашего аккаунта", "submit": "Продолжить", "labels": { + "currentPassword": "Текущий пароль", "newPassword": "Новый пароль", "confirmPassword": "Подтвердите пароль" }, "required": { + "currentPassword": "Вы должны указать текущий пароль!", "newPassword": "Вы должны указать новый пароль!", "confirmPassword": "Это поле обязательно для заполнения" }, "errors": { + "currentPasswordInvalid": "Текущий пароль неверен.", "couldNotChangePassword": "Не удалось изменить пароль", "couldNotVerifyPassword": "Не удалось проверить пароль", "unknownError": "Неизвестная ошибка" diff --git a/apps/login/locales/tr.json b/apps/login/locales/tr.json index e11cc332b..8fd5c4710 100644 --- a/apps/login/locales/tr.json +++ b/apps/login/locales/tr.json @@ -108,14 +108,17 @@ "description": "Hesabınız için şifre belirleyin", "submit": "Devam", "labels": { + "currentPassword": "Mevcut Şifre", "newPassword": "Yeni Şifre", "confirmPassword": "Şifreyi Onayla" }, "required": { + "currentPassword": "Mevcut şifrenizi girmelisiniz!", "newPassword": "Yeni bir şifre sağlamalısınız!", "confirmPassword": "Bu alan zorunludur" }, "errors": { + "currentPasswordInvalid": "Mevcut şifre yanlış.", "couldNotChangePassword": "Şifre değiştirilemedi", "couldNotVerifyPassword": "Şifre doğrulanamadı", "unknownError": "Bilinmeyen hata" diff --git a/apps/login/locales/uk.json b/apps/login/locales/uk.json index 98d5fbd79..92e9f052b 100644 --- a/apps/login/locales/uk.json +++ b/apps/login/locales/uk.json @@ -108,14 +108,17 @@ "description": "Встановіть пароль для свого облікового запису", "submit": "Продовжити", "labels": { + "currentPassword": "Поточний пароль", "newPassword": "Новий пароль", "confirmPassword": "Підтвердити пароль" }, "required": { + "currentPassword": "Ви повинні вказати поточний пароль!", "newPassword": "Ви повинні вказати новий пароль!", "confirmPassword": "Це поле обов'язкове" }, "errors": { + "currentPasswordInvalid": "Поточний пароль невірний.", "couldNotChangePassword": "Не вдалося змінити пароль", "couldNotVerifyPassword": "Не вдалося перевірити пароль", "unknownError": "Невідома помилка" diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 57edc2736..d2ae3278d 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -108,14 +108,17 @@ "description": "为您的账户设置密码", "submit": "继续", "labels": { + "currentPassword": "当前密码", "newPassword": "新密码", "confirmPassword": "确认密码" }, "required": { + "currentPassword": "必须提供当前密码!", "newPassword": "必须提供新密码!", "confirmPassword": "此字段为必填项" }, "errors": { + "currentPasswordInvalid": "当前密码不正确。", "couldNotChangePassword": "无法更改密码", "couldNotVerifyPassword": "无法验证密码", "unknownError": "未知错误" diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index 540747307..c8d1aff2b 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -21,6 +21,7 @@ import { AutoSubmitForm } from "./auto-submit-form"; type Inputs = | { + currentPassword: string; password: string; confirmPassword: string; } @@ -40,8 +41,9 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi const { register, handleSubmit, watch, formState } = useForm({ mode: "onChange", defaultValues: { + currentPassword: "", password: "", - comfirmPassword: "", + confirmPassword: "", }, }); @@ -56,6 +58,7 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi const changeResponse = await checkSessionAndSetPassword({ sessionId, + currentPassword: values.currentPassword, password: values.password, }).catch(() => { setError(t("change.errors.couldNotChangePassword")); @@ -122,6 +125,19 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi {samlData && }
+
+ +
{ let mockGetSession: any; let mockListAuthenticationMethodTypes: any; let mockGetLoginSettings: any; - let mockSetPassword: any; // Service account + let mockGetLockoutSettings: any; + let mockSetPassword: any; + let mockSetSessionAndUpdateCookie: any; beforeEach(async () => { vi.clearAllMocks(); @@ -77,7 +79,9 @@ describe("checkSessionAndSetPassword", () => { const { headers } = await import("next/headers"); const { getServiceConfig } = await import("../service-url"); 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); mockGetServiceConfig = vi.mocked(getServiceConfig); @@ -85,18 +89,20 @@ describe("checkSessionAndSetPassword", () => { mockGetSession = vi.mocked(getSession); mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes); mockGetLoginSettings = vi.mocked(getLoginSettings); + mockGetLockoutSettings = vi.mocked(getLockoutSettings); mockSetPassword = vi.mocked(setPassword); + mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie); mockHeaders.mockResolvedValue({}); mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } }); // Default session setup - mockGetSessionCookieById.mockResolvedValue({ id: "session123", token: "token123" }); + mockGetSessionCookieById.mockResolvedValue({ id: "session123", token: "token123", organization: "org123" }); mockGetSession.mockResolvedValue({ session: { factors: { 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], }); - // Default: No forced MFA - mockGetLoginSettings.mockResolvedValue({ forceMfa: false }); + // Default: login settings with password check lifetime + 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({}); }); - test("should use service account when no MFA is configured", async () => { - await checkSessionAndSetPassword({ sessionId: "session123", password: "newpassword" }); + test("should verify current password and set new password", async () => { + await checkSessionAndSetPassword({ + sessionId: "session123", + currentPassword: "oldpassword", + password: "newpassword", + }); + expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith( + expect.objectContaining({ + recentCookie: expect.objectContaining({ id: "session123" }), + }), + ); expect(mockSetPassword).toHaveBeenCalled(); }); - test("should use service account when MFA is configured but NOT verified in session", async () => { - // User has TOTP configured - mockListAuthenticationMethodTypes.mockResolvedValue({ - authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP], + test("should return error when current password is incorrect", async () => { + mockSetSessionAndUpdateCookie.mockRejectedValue(new Error("invalid password")); + + const result = await checkSessionAndSetPassword({ + sessionId: "session123", + currentPassword: "wrongpassword", + password: "newpassword", }); - // Session only has password verified (no OTP) - 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(result).toEqual({ error: "change.errors.currentPasswordInvalid" }); expect(mockSetPassword).not.toHaveBeenCalled(); }); - test("should use service account when MFA is configured but not verified, and password verification is recent", async () => { - // User has TOTP configured - mockListAuthenticationMethodTypes.mockResolvedValue({ - authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.TOTP], + test("should return generic error when current password is incorrect and ignoreUnknownUsernames is true", async () => { + mockGetLoginSettings.mockResolvedValue({ + forceMfa: false, + 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) - const now = Math.floor(Date.now() / 1000); + expect(result).toEqual({ error: "change.errors.couldNotVerifyPassword" }); + 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({ - session: { - factors: { - user: { id: "user123", organizationId: "org123" }, - password: { verifiedAt: { seconds: now - 60 } }, - // otp missing - }, - }, + session: { factors: {} }, }); - 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(); }); + + 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", () => { diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 8fc4fee23..592ecfc1a 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -13,7 +13,7 @@ import { setPassword, setUserPassword, } 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 { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_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 = { sessionId: string; + currentPassword: string; password: string; }; -export async function checkSessionAndSetPassword({ sessionId, password }: CheckSessionAndSetPasswordCommand) { +export async function checkSessionAndSetPassword({ + sessionId, + currentPassword, + password, +}: CheckSessionAndSetPasswordCommand) { const _headers = await headers(); const { serviceConfig } = getServiceConfig(_headers); const t = await getTranslations("password"); @@ -479,6 +484,56 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS 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, { userId: session.factors.user.id, 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) => { // throw error if failed precondition (ex. User is not yet initialized) if (error.code === 9 && error.message) {