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 diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 587c7b4c76..03a421674d 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -7,7 +7,12 @@ 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 { Challenges, RequestChallenges, @@ -28,6 +33,19 @@ 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, @@ -107,6 +125,15 @@ 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) { @@ -173,59 +200,61 @@ 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({ - serviceUrl, - serviceRegion, - 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({ + serviceUrl, + serviceRegion, + 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(passwordAttemptsHandler); } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 15459fa734..9a464e22d8 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -5,7 +5,9 @@ import { setSessionAndUpdateCookie, } from "@/lib/server/cookie"; import { + getLockoutSettings, getLoginSettings, + getPasswordExpirySettings, getSession, getUserByID, listAuthenticationMethodTypes, @@ -122,24 +124,64 @@ export async function sendPassword(command: UpdateSessionCommand) { organization: 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({ + serviceUrl, + serviceRegion, + orgId: command.organization, + }); + + return { + 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" }; + } } // 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({ + serviceUrl, + serviceRegion, + orgId: command.organization, + }); + + return { + 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; + } if (!session?.factors?.user?.id) { return { error: "Could not create session for user" }; @@ -173,8 +215,15 @@ export async function sendPassword(command: UpdateSessionCommand) { const humanUser = user.type.case === "human" ? user.type.value : undefined; + 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( + 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 ba96481e38..535f4fd4cb 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -91,6 +91,44 @@ export async function getLoginSettings({ return useCache ? cacheWrapper(callback) : callback; } +export async function getLockoutSettings({ + serviceUrl, + serviceRegion, + orgId, +}: { + serviceUrl: string; + serviceRegion: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl, serviceRegion); + + const callback = settingsService + .getLockoutSettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordExpirySettings({ + serviceUrl, + serviceRegion, + orgId, +}: { + serviceUrl: string; + serviceRegion: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl, serviceRegion); + + const callback = settingsService + .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + export async function listIDPLinks({ serviceUrl, serviceRegion, diff --git a/packages/zitadel-client/src/index.ts b/packages/zitadel-client/src/index.ts index 85962bdec0..66bb55c561 100644 --- a/packages/zitadel-client/src/index.ts +++ b/packages/zitadel-client/src/index.ts @@ -7,4 +7,4 @@ export type { JsonObject } from "@bufbuild/protobuf"; export type { GenService } from "@bufbuild/protobuf/codegenv1"; export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; -export type { Client } from "@connectrpc/connect"; +export type { Client, Code, ConnectError } from "@connectrpc/connect";