mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 17:07:32 +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
|
## 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
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
});
|
});
|
||||||
|
@@ -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,
|
||||||
|
@@ -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";
|
||||||
|
Reference in New Issue
Block a user