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
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

View File

@@ -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,7 +200,8 @@ export async function setSessionAndUpdateCookie(
challenges,
checks,
lifetime,
}).then((updatedSession) => {
})
.then((updatedSession) => {
if (updatedSession) {
const sessionCookie: CustomCookieData = {
id: recentCookie.id,
@@ -227,5 +255,6 @@ export async function setSessionAndUpdateCookie(
} else {
throw "Session not be set";
}
});
})
.catch(passwordAttemptsHandler);
}

View File

@@ -5,7 +5,9 @@ import {
setSessionAndUpdateCookie,
} from "@/lib/server/cookie";
import {
getLockoutSettings,
getLoginSettings,
getPasswordExpirySettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
@@ -122,17 +124,38 @@ export async function sendPassword(command: UpdateSessionCommand) {
organization: command.organization,
});
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 {
try {
session = await setSessionAndUpdateCookie(
sessionCookie,
command.checks,
@@ -140,6 +163,25 @@ export async function sendPassword(command: UpdateSessionCommand) {
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,

View File

@@ -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,
});

View File

@@ -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<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({
serviceUrl,
serviceRegion,

View File

@@ -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";