mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 11:27:33 +00:00
Merge pull request #351 from zitadel/password-attempts
fix: password lockout, redirect to set new password when outdated
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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,
|
||||
|
@@ -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";
|
||||
|
Reference in New Issue
Block a user