Merge pull request #351 from zitadel/password-attempts

fix: password lockout, redirect to set new password when outdated
This commit is contained in:
Max Peintner
2025-02-03 09:43:55 +01:00
committed by GitHub
6 changed files with 199 additions and 73 deletions

View File

@@ -389,10 +389,6 @@ In future, self service options to jump to are shown below, like:
## Currently NOT Supported ## 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 - Login Settings: multifactor init prompt
- forceMFA on login settings is not checked for IDPs - forceMFA on login settings is not checked for IDPs

View File

@@ -7,7 +7,12 @@ import {
getSession, getSession,
setSession, setSession,
} from "@/lib/zitadel"; } 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 { import {
Challenges, Challenges,
RequestChallenges, RequestChallenges,
@@ -28,6 +33,19 @@ type CustomCookieData = {
authRequestId?: string; // if its linked to an OIDC flow 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( export async function createSessionAndUpdateCookie(
checks: Checks, checks: Checks,
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
@@ -107,6 +125,15 @@ export async function createSessionForIdpAndUpdateCookie(
userId, userId,
idpIntent, idpIntent,
lifetime, 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) { if (!createdSession) {
@@ -173,59 +200,61 @@ export async function setSessionAndUpdateCookie(
challenges, challenges,
checks, checks,
lifetime, lifetime,
}).then((updatedSession) => { })
if (updatedSession) { .then((updatedSession) => {
const sessionCookie: CustomCookieData = { if (updatedSession) {
id: recentCookie.id, const sessionCookie: CustomCookieData = {
token: updatedSession.sessionToken, id: recentCookie.id,
creationTs: recentCookie.creationTs, token: updatedSession.sessionToken,
expirationTs: recentCookie.expirationTs, creationTs: recentCookie.creationTs,
// just overwrite the changeDate with the new one expirationTs: recentCookie.expirationTs,
changeTs: updatedSession.details?.changeDate // just overwrite the changeDate with the new one
? `${timestampMs(updatedSession.details.changeDate)}` changeTs: updatedSession.details?.changeDate
: "", ? `${timestampMs(updatedSession.details.changeDate)}`
loginName: recentCookie.loginName, : "",
organization: recentCookie.organization, loginName: recentCookie.loginName,
}; organization: recentCookie.organization,
};
if (authRequestId) { if (authRequestId) {
sessionCookie.authRequestId = 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";
} }
});
} else { return getSession({
throw "Session not be set"; 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);
} }

View File

@@ -5,7 +5,9 @@ import {
setSessionAndUpdateCookie, setSessionAndUpdateCookie,
} from "@/lib/server/cookie"; } from "@/lib/server/cookie";
import { import {
getLockoutSettings,
getLoginSettings, getLoginSettings,
getPasswordExpirySettings,
getSession, getSession,
getUserByID, getUserByID,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
@@ -122,24 +124,64 @@ export async function sendPassword(command: UpdateSessionCommand) {
organization: command.organization, organization: command.organization,
}); });
session = await createSessionAndUpdateCookie( try {
checks, session = await createSessionAndUpdateCookie(
undefined, checks,
command.authRequestId, undefined,
loginSettings?.passwordCheckLifetime, 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 // this is a fake error message to hide that the user does not even exist
return { error: "Could not verify password" }; return { error: "Could not verify password" };
} else { } else {
session = await setSessionAndUpdateCookie( try {
sessionCookie, session = await setSessionAndUpdateCookie(
command.checks, sessionCookie,
undefined, command.checks,
command.authRequestId, undefined,
loginSettings?.passwordCheckLifetime, 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) { if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" }; 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 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 // check if the user has to change password first
const passwordChangedCheck = checkPasswordChangeRequired( const passwordChangedCheck = checkPasswordChangeRequired(
expirySettings,
session, session,
humanUser, humanUser,
command.organization, command.organization,

View File

@@ -1,15 +1,29 @@
import { timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_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 { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import moment from "moment";
export function checkPasswordChangeRequired( export function checkPasswordChangeRequired(
expirySettings: PasswordExpirySettings | undefined,
session: Session, session: Session,
humanUser: HumanUser | undefined, humanUser: HumanUser | undefined,
organization?: string, organization?: string,
authRequestId?: 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({ const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string, loginName: session.factors?.user?.loginName as string,
}); });

View File

@@ -91,6 +91,44 @@ export async function getLoginSettings({
return useCache ? cacheWrapper(callback) : callback; return useCache ? cacheWrapper(callback) : callback;
} }
export async function getLockoutSettings({
serviceUrl,
serviceRegion,
orgId,
}: {
serviceUrl: string;
serviceRegion: string;
orgId?: string;
}) {
const settingsService: Client<typeof SettingsService> =
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<typeof SettingsService> =
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({ export async function listIDPLinks({
serviceUrl, serviceUrl,
serviceRegion, serviceRegion,

View File

@@ -7,4 +7,4 @@ export type { JsonObject } from "@bufbuild/protobuf";
export type { GenService } from "@bufbuild/protobuf/codegenv1"; export type { GenService } from "@bufbuild/protobuf/codegenv1";
export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt";
export type { Duration, Timestamp } 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";