From b93035eeb18a1a38d506f2337c5639e863511e7b Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 27 Jan 2025 13:26:20 +0100 Subject: [PATCH 1/7] check for outdated password --- apps/login/src/lib/server/password.ts | 6 ++++++ apps/login/src/lib/verify-helper.ts | 16 +++++++++++++++- apps/login/src/lib/zitadel.ts | 8 ++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 3b7a24a718..6f1b79be62 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -6,6 +6,7 @@ import { } from "@/lib/server/cookie"; import { getLoginSettings, + getPasswordExpirySettings, getSession, getUserByID, listAuthenticationMethodTypes, @@ -141,8 +142,13 @@ export async function sendPassword(command: UpdateSessionCommand) { const humanUser = user.type.case === "human" ? user.type.value : undefined; + const expirySettings = await getPasswordExpirySettings( + command.organization ?? session.factors?.user?.organizationId, + ); + // check if the user has to change password first const passwordChangedCheck = checkPasswordChangeRequired( + expirySettings, session, humanUser, command.organization, diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index b37287a959..053d1cc71f 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -1,15 +1,29 @@ +import { timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import moment from "moment"; export function checkPasswordChangeRequired( + expirySettings: PasswordExpirySettings | undefined, session: Session, humanUser: HumanUser | undefined, organization?: string, authRequestId?: string, ) { - if (humanUser?.passwordChangeRequired) { + let isOutdated = false; + if (expirySettings?.maxAgeDays && humanUser?.passwordChanged) { + const maxAgeDays = Number(expirySettings.maxAgeDays); // Convert bigint to number + const passwordChangedDate = moment( + timestampDate(humanUser.passwordChanged), + ); + const outdatedPassword = passwordChangedDate.add(maxAgeDays, "days"); + isOutdated = moment().isAfter(outdatedPassword); + } + + if (humanUser?.passwordChangeRequired || isOutdated) { const params = new URLSearchParams({ loginName: session.factors?.user?.loginName as string, }); diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 835b16a46b..589b5a43a0 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -81,6 +81,14 @@ export async function getLoginSettings(orgId?: string) { return useCache ? cacheWrapper(callback) : callback; } +export async function getPasswordExpirySettings(orgId?: string) { + const callback = settingsService + .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + export async function listIDPLinks(userId: string) { return userService.listIDPLinks( { From 63656e16fbf9cc8536469e59599c5472661891b4 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 27 Jan 2025 14:15:05 +0100 Subject: [PATCH 2/7] handle password attempts error --- apps/login/src/lib/server/cookie.ts | 128 +++++++++++++++----------- apps/login/src/lib/server/password.ts | 51 +++++++--- apps/login/src/lib/zitadel.ts | 8 ++ packages/zitadel-proto/package.json | 2 +- 4 files changed, 123 insertions(+), 66 deletions(-) diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 91447174f6..795a41cd8d 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -8,6 +8,10 @@ import { setSession, } from "@/lib/zitadel"; import { Duration, timestampMs } from "@zitadel/client"; +import { + CredentialsCheckError, + ErrorDetail, +} from "@zitadel/proto/zitadel/message_pb"; import { Challenges, RequestChallenges, @@ -89,7 +93,16 @@ export async function createSessionForIdpAndUpdateCookie( userId, idpIntent, lifetime, - ); + ).catch((error: ErrorDetail | CredentialsCheckError) => { + console.error("Could not set session", error); + if ("failedAttempts" in error && error.failedAttempts) { + throw { + error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`, + failedAttempts: error.failedAttempts, + }; + } + throw error; + }); if (!createdSession) { throw "Could not create session"; @@ -148,57 +161,68 @@ export async function setSessionAndUpdateCookie( challenges, checks, lifetime, - ).then((updatedSession) => { - if (updatedSession) { - const sessionCookie: CustomCookieData = { - id: recentCookie.id, - token: updatedSession.sessionToken, - creationTs: recentCookie.creationTs, - expirationTs: recentCookie.expirationTs, - // just overwrite the changeDate with the new one - changeTs: updatedSession.details?.changeDate - ? `${timestampMs(updatedSession.details.changeDate)}` - : "", - loginName: recentCookie.loginName, - organization: recentCookie.organization, - }; + ) + .then((updatedSession) => { + if (updatedSession) { + const sessionCookie: CustomCookieData = { + id: recentCookie.id, + token: updatedSession.sessionToken, + creationTs: recentCookie.creationTs, + expirationTs: recentCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: recentCookie.loginName, + organization: recentCookie.organization, + }; - if (authRequestId) { - sessionCookie.authRequestId = authRequestId; - } - - return getSession({ - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }).then((response) => { - if (response?.session && response.session.factors?.user?.loginName) { - const { session } = response; - const newCookie: CustomCookieData = { - id: sessionCookie.id, - token: updatedSession.sessionToken, - creationTs: sessionCookie.creationTs, - expirationTs: sessionCookie.expirationTs, - // just overwrite the changeDate with the new one - changeTs: updatedSession.details?.changeDate - ? `${timestampMs(updatedSession.details.changeDate)}` - : "", - loginName: session.factors?.user?.loginName ?? "", - organization: session.factors?.user?.organizationId ?? "", - }; - - if (sessionCookie.authRequestId) { - newCookie.authRequestId = sessionCookie.authRequestId; - } - - return updateSessionCookie(sessionCookie.id, newCookie).then(() => { - return { challenges: updatedSession.challenges, ...session }; - }); - } else { - throw "could not get session or session does not have loginName"; + if (authRequestId) { + sessionCookie.authRequestId = authRequestId; } - }); - } else { - throw "Session not be set"; - } - }); + + return getSession({ + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session && response.session.factors?.user?.loginName) { + const { session } = response; + const newCookie: CustomCookieData = { + id: sessionCookie.id, + token: updatedSession.sessionToken, + creationTs: sessionCookie.creationTs, + expirationTs: sessionCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: session.factors?.user?.loginName ?? "", + organization: session.factors?.user?.organizationId ?? "", + }; + + if (sessionCookie.authRequestId) { + newCookie.authRequestId = sessionCookie.authRequestId; + } + + return updateSessionCookie(sessionCookie.id, newCookie).then(() => { + return { challenges: updatedSession.challenges, ...session }; + }); + } else { + throw "could not get session or session does not have loginName"; + } + }); + } else { + throw "Session not be set"; + } + }) + .catch((error: ErrorDetail | CredentialsCheckError) => { + console.error("Could not set session", error); + if ("failedAttempts" in error && error.failedAttempts) { + throw { + error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`, + failedAttempts: error.failedAttempts, + }; + } + throw error; + }); } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 6f1b79be62..d69074aca3 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -5,6 +5,7 @@ import { setSessionAndUpdateCookie, } from "@/lib/server/cookie"; import { + getLockoutSettings, getLoginSettings, getPasswordExpirySettings, getSession, @@ -98,24 +99,48 @@ export async function sendPassword(command: UpdateSessionCommand) { loginSettings = await getLoginSettings(command.organization); - session = await createSessionAndUpdateCookie( - checks, - undefined, - command.authRequestId, - loginSettings?.passwordCheckLifetime, - ); + try { + session = await createSessionAndUpdateCookie( + checks, + undefined, + command.authRequestId, + loginSettings?.passwordCheckLifetime, + ); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings( + command.organization, + ); + + return { + error: `Failed to authenticate: You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.`, + }; + } + return { error: "Could not create session for user" }; + } } // this is a fake error message to hide that the user does not even exist return { error: "Could not verify password" }; } else { - session = await setSessionAndUpdateCookie( - sessionCookie, - command.checks, - undefined, - command.authRequestId, - loginSettings?.passwordCheckLifetime, - ); + try { + session = await setSessionAndUpdateCookie( + sessionCookie, + command.checks, + undefined, + command.authRequestId, + loginSettings?.passwordCheckLifetime, + ); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings(command.organization); + + return { + error: `Failed to authenticate: You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.`, + }; + } + throw error; + } if (!session?.factors?.user?.id) { return { error: "Could not create session for user" }; diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 589b5a43a0..c7aa7c262c 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -81,6 +81,14 @@ export async function getLoginSettings(orgId?: string) { return useCache ? cacheWrapper(callback) : callback; } +export async function getLockoutSettings(orgId?: string) { + const callback = settingsService + .getLockoutSettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + export async function getPasswordExpirySettings(orgId?: string) { const callback = settingsService .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) diff --git a/packages/zitadel-proto/package.json b/packages/zitadel-proto/package.json index 30c0932934..abd06bb05d 100644 --- a/packages/zitadel-proto/package.json +++ b/packages/zitadel-proto/package.json @@ -14,7 +14,7 @@ ], "sideEffects": false, "scripts": { - "generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", + "generate": "buf generate https://github.com/zitadel/zitadel.git#branch=fix/9198-user_password_lockout_error_response --path ./proto/zitadel", "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" }, "dependencies": { From b9d4ca824f8d580830a372df63d6ce1a7500279d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 27 Jan 2025 16:23:46 +0100 Subject: [PATCH 3/7] error handler --- apps/login/src/lib/server/cookie.ts | 32 +++++++++++++++++---------- apps/login/src/lib/server/password.ts | 14 ++++++++++-- packages/zitadel-client/src/index.ts | 1 + 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 795a41cd8d..32b39d6e9a 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -7,9 +7,10 @@ import { getSession, setSession, } from "@/lib/zitadel"; -import { Duration, timestampMs } from "@zitadel/client"; +import { ConnectError, Duration, timestampMs } from "@zitadel/client"; import { CredentialsCheckError, + CredentialsCheckErrorSchema, ErrorDetail, } from "@zitadel/proto/zitadel/message_pb"; import { @@ -30,13 +31,29 @@ type CustomCookieData = { authRequestId?: string; // if its linked to an OIDC flow }; +const passwordAttemptsHandler = (error: ConnectError) => { + const details = error.findDetails(CredentialsCheckErrorSchema); + + if (details[0] && "failedAttempts" in details[0]) { + const failedAttempts = details[0].failedAttempts; + throw { + error: `Failed to authenticate: You had ${failedAttempts} password attempts.`, + failedAttempts: failedAttempts, + }; + } + throw error; +}; + export async function createSessionAndUpdateCookie( checks: Checks, challenges: RequestChallenges | undefined, authRequestId: string | undefined, lifetime?: Duration, ): Promise { - const createdSession = await createSessionFromChecks(checks, challenges); + const createdSession = await createSessionFromChecks( + checks, + challenges, + ).catch(passwordAttemptsHandler); if (createdSession) { return getSession({ @@ -215,14 +232,5 @@ export async function setSessionAndUpdateCookie( throw "Session not be set"; } }) - .catch((error: ErrorDetail | CredentialsCheckError) => { - console.error("Could not set session", error); - if ("failedAttempts" in error && error.failedAttempts) { - throw { - error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`, - failedAttempts: error.failedAttempts, - }; - } - throw error; - }); + .catch(passwordAttemptsHandler); } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index d69074aca3..2f8afd87a2 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -113,7 +113,12 @@ export async function sendPassword(command: UpdateSessionCommand) { ); return { - error: `Failed to authenticate: You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.`, + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? "Contact your administrator to unlock your account" + : ""), }; } return { error: "Could not create session for user" }; @@ -136,7 +141,12 @@ export async function sendPassword(command: UpdateSessionCommand) { const lockoutSettings = await getLockoutSettings(command.organization); return { - error: `Failed to authenticate: You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.`, + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? " Contact your administrator to unlock your account" + : ""), }; } throw error; diff --git a/packages/zitadel-client/src/index.ts b/packages/zitadel-client/src/index.ts index 64c3af5050..4d50003d7a 100644 --- a/packages/zitadel-client/src/index.ts +++ b/packages/zitadel-client/src/index.ts @@ -6,3 +6,4 @@ export { create, fromJson, toJson } from "@bufbuild/protobuf"; export type { JsonObject } from "@bufbuild/protobuf"; export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; +export type { Code, ConnectError } from "@connectrpc/connect"; From dd58fa8f7bdd7c0c43f2cf823e7ff9e5faa6d30e Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 28 Jan 2025 11:29:32 +0100 Subject: [PATCH 4/7] update readme --- apps/login/readme.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/login/readme.md b/apps/login/readme.md index fd6ba6f48c..4df81f9f9d 100644 --- a/apps/login/readme.md +++ b/apps/login/readme.md @@ -389,10 +389,6 @@ In future, self service options to jump to are shown below, like: ## Currently NOT Supported -Timebased features like the multifactor init prompt or password expiry, are not supported due to a current limitation in the API. Lockout settings which keeps track of the password retries, will also be implemented in a later stage. - -- Lockout Settings -- Password Expiry Settings - Login Settings: multifactor init prompt - forceMFA on login settings is not checked for IDPs From 059bbbca1c19cdb32b336f37e4b38432eb697d74 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 28 Jan 2025 11:30:59 +0100 Subject: [PATCH 5/7] revert buf gen to main --- packages/zitadel-proto/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zitadel-proto/package.json b/packages/zitadel-proto/package.json index abd06bb05d..30c0932934 100644 --- a/packages/zitadel-proto/package.json +++ b/packages/zitadel-proto/package.json @@ -14,7 +14,7 @@ ], "sideEffects": false, "scripts": { - "generate": "buf generate https://github.com/zitadel/zitadel.git#branch=fix/9198-user_password_lockout_error_response --path ./proto/zitadel", + "generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" }, "dependencies": { From 79dcef37859a0872a91eae1741831750de3d342b Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 30 Jan 2025 11:48:17 +0100 Subject: [PATCH 6/7] fix build --- apps/login/src/lib/server/password.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index b1d123fb50..f9a0931b6b 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -133,9 +133,11 @@ export async function sendPassword(command: UpdateSessionCommand) { ); } catch (error: any) { if ("failedAttempts" in error && error.failedAttempts) { - const lockoutSettings = await getLockoutSettings( - command.organization, - ); + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + serviceRegion, + orgId: command.organization, + }); return { error: @@ -163,7 +165,11 @@ export async function sendPassword(command: UpdateSessionCommand) { ); } catch (error: any) { if ("failedAttempts" in error && error.failedAttempts) { - const lockoutSettings = await getLockoutSettings(command.organization); + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + serviceRegion, + orgId: command.organization, + }); return { error: From 3021332ba120863a111d80e5ace789a1b3264ba9 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 31 Jan 2025 15:26:03 +0100 Subject: [PATCH 7/7] fix build --- apps/login/src/lib/server/password.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index f9a0931b6b..9a464e22d8 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -215,9 +215,11 @@ export async function sendPassword(command: UpdateSessionCommand) { const humanUser = user.type.case === "human" ? user.type.value : undefined; - const expirySettings = await getPasswordExpirySettings( - command.organization ?? session.factors?.user?.organizationId, - ); + const expirySettings = await getPasswordExpirySettings({ + serviceUrl, + serviceRegion, + orgId: command.organization ?? session.factors?.user?.organizationId, + }); // check if the user has to change password first const passwordChangedCheck = checkPasswordChangeRequired(