Files
zitadel/apps/login/src/lib/server/password.ts
T
Elio BischofandGitHub b10455b51f chore: reproducible pipeline with dev containers (#10305)
# Which Problems Are Solved

- The previous monorepo in monorepo structure for the login app and its
related packages was fragmented, complicated and buggy.
- The process for building and testing the login container was
inconsistent between local development and CI.
- Lack of clear documentation as well as easy and reliable ways for
non-frontend developers to reproduce and fix failing PR checks locally.

# How the Problems Are Solved

- Consolidated the login app and its related npm packages by moving the
main package to `apps/login/apps/login` and merging
`apps/login/packages/integration` and `apps/login/packages/acceptance`
into the main `apps/login` package.
- Migrated from Docker Compose-based test setups to dev container-based
setups, adding support for multiple dev container configurations:
  - `.devcontainer/base`
  - `.devcontainer/turbo-lint-unit`
  - `.devcontainer/turbo-lint-unit-debug`
  - `.devcontainer/login-integration`
  - `.devcontainer/login-integration-debug`
- Added npm scripts to run the new dev container setups, enabling exact
reproduction of GitHub PR checks locally, and updated the pipeline to
use these containers.
- Cleaned up Dockerfiles and docker-bake.hcl files to only build the
production image for the login app.
- Cleaned up compose files to focus on dev environments in dev
containers.
- Updated `CONTRIBUTING.md` with guidance on running and debugging PR
checks locally using the new dev container approach.
- Introduced separate Dockerfiles for the login app to distinguish
between using published client packages and building clients from local
protos.
- Ensured the login container is always built in the pipeline for use in
integration and acceptance tests.
- Updated Makefile and GitHub Actions workflows to use
`--frozen-lockfile` for installing pnpm packages, ensuring reproducible
installs.
- Disabled GitHub release creation by the changeset action.
- Refactored the `/build` directory structure for clarity and
maintainability.
- Added a `clean` command to `docks/package.json`.
- Experimentally added `knip` to the `zitadel-client` package for
improved linting of dependencies and exports.

# Additional Changes

- Fixed Makefile commands for consistency and reliability.
- Improved the structure and clarity of the `/build` directory to
support seamless integration of the login build.
- Enhanced documentation and developer experience for running and
debugging CI checks locally.

# Additional Context

- See updated `CONTRIBUTING.md` for new local development and debugging
instructions.
- These changes are a prerequisite for further improvements to the CI
pipeline and local development workflow.
- Closes #10276
2025-07-24 14:22:32 +02:00

479 lines
13 KiB
TypeScript

"use server";
import {
createSessionAndUpdateCookie,
setSessionAndUpdateCookie,
} from "@/lib/server/cookie";
import {
getLockoutSettings,
getLoginSettings,
getPasswordExpirySettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
listUsers,
passwordReset,
setPassword,
setUserPassword,
} from "@/lib/zitadel";
import { ConnectError, create, Duration } from "@zitadel/client";
import { createUserServiceClient } from "@zitadel/client/v2";
import {
Checks,
ChecksSchema,
} from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
AuthenticationMethodType,
SetPasswordRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers";
import { getNextUrl } from "../client";
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
import { getServiceUrlFromHeaders } from "../service-url";
import {
checkEmailVerification,
checkMFAFactors,
checkPasswordChangeRequired,
checkUserVerification,
} from "../verify-helper";
import { createServerTransport } from "../zitadel";
type ResetPasswordCommand = {
loginName: string;
organization?: string;
requestId?: string;
};
export async function resetPassword(command: ResetPasswordCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host || typeof host !== "string") {
throw new Error("No host found");
}
const users = await listUsers({
serviceUrl,
loginName: command.loginName,
organizationId: command.organization,
});
if (
!users.details ||
users.details.totalResult !== BigInt(1) ||
!users.result[0].userId
) {
return { error: "Could not send Password Reset Link" };
}
const userId = users.result[0].userId;
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
return passwordReset({
serviceUrl,
userId,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(command.requestId ? `&requestId=${command.requestId}` : ""),
});
}
export type UpdateSessionCommand = {
loginName: string;
organization?: string;
checks: Checks;
requestId?: string;
};
export async function sendPassword(command: UpdateSessionCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
let sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error);
});
let session;
let user: User;
let loginSettings: LoginSettings | undefined;
if (!sessionCookie) {
const users = await listUsers({
serviceUrl,
loginName: command.loginName,
organizationId: command.organization,
});
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
user = users.result[0];
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: users.result[0].userId } },
password: { password: command.checks.password?.password },
});
loginSettings = await getLoginSettings({
serviceUrl,
organization: command.organization,
});
try {
session = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
lifetime: loginSettings?.passwordCheckLifetime,
});
} catch (error: any) {
if ("failedAttempts" in error && error.failedAttempts) {
const lockoutSettings = await getLockoutSettings({
serviceUrl,
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 {
loginSettings = await getLoginSettings({
serviceUrl,
organization: sessionCookie.organization,
});
if (!loginSettings) {
return { error: "Could not load login settings" };
}
let lifetime = loginSettings.passwordCheckLifetime;
if (!lifetime) {
console.warn("No password lifetime provided, defaulting to 24 hours");
lifetime = {
seconds: BigInt(60 * 60 * 24), // default to 24 hours
nanos: 0,
} as Duration;
}
try {
session = await setSessionAndUpdateCookie({
recentCookie: sessionCookie,
checks: command.checks,
requestId: command.requestId,
lifetime,
});
} catch (error: any) {
if ("failedAttempts" in error && error.failedAttempts) {
const lockoutSettings = await getLockoutSettings({
serviceUrl,
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" };
}
const userResponse = await getUserByID({
serviceUrl,
userId: session?.factors?.user?.id,
});
if (!userResponse.user) {
return { error: "User not found in the system" };
}
user = userResponse.user;
}
if (!loginSettings) {
loginSettings = await getLoginSettings({
serviceUrl,
organization:
command.organization ?? session.factors?.user?.organizationId,
});
}
if (!session?.factors?.user?.id || !sessionCookie) {
return { error: "Could not create session for user" };
}
const humanUser = user.type.case === "human" ? user.type.value : undefined;
const expirySettings = await getPasswordExpirySettings({
serviceUrl,
orgId: command.organization ?? session.factors?.user?.organizationId,
});
// check if the user has to change password first
const passwordChangedCheck = checkPasswordChangeRequired(
expirySettings,
session,
humanUser,
command.organization,
command.requestId,
);
if (passwordChangedCheck?.redirect) {
return passwordChangedCheck;
}
// throw error if user is in initial state here and do not continue
if (user.state === UserState.INITIAL) {
return { error: "Initial User not supported" };
}
// check to see if user was verified
const emailVerificationCheck = checkEmailVerification(
session,
humanUser,
command.organization,
command.requestId,
);
if (emailVerificationCheck?.redirect) {
return emailVerificationCheck;
}
// if password, check if user has MFA methods
let authMethods;
if (command.checks && command.checks.password && session.factors?.user?.id) {
const response = await listAuthenticationMethodTypes({
serviceUrl,
userId: session.factors.user.id,
});
if (response.authMethodTypes && response.authMethodTypes.length) {
authMethods = response.authMethodTypes;
}
}
if (!authMethods) {
return { error: "Could not verify password!" };
}
const mfaFactorCheck = await checkMFAFactors(
serviceUrl,
session,
loginSettings,
authMethods,
command.organization,
command.requestId,
);
if (mfaFactorCheck?.redirect) {
return mfaFactorCheck;
}
if (command.requestId && session.id) {
const nextUrl = await getNextUrl(
{
sessionId: session.id,
requestId: command.requestId,
organization:
command.organization ?? session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: nextUrl };
}
const url = await getNextUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: url };
}
// this function lets users with code set a password or users with valid User Verification Check
export async function changePassword(command: {
code?: string;
userId: string;
password: string;
}) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
// check for init state
const { user } = await getUserByID({
serviceUrl,
userId: command.userId,
});
if (!user || user.userId !== command.userId) {
return { error: "Could not send Password Reset Link" };
}
const userId = user.userId;
if (user.state === UserState.INITIAL) {
return { error: "User Initial State is not supported" };
}
// check if the user has no password set in order to set a password
if (!command.code) {
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId,
});
// if the user has no authmethods set, we need to check if the user was verified
if (authmethods.authMethodTypes.length !== 0) {
return {
error:
"You have to provide a code or have a valid User Verification Check",
};
}
// check if a verification was done earlier
const hasValidUserVerificationCheck = await checkUserVerification(
user.userId,
);
if (!hasValidUserVerificationCheck) {
return { error: "User Verification Check has to be done" };
}
}
return setUserPassword({
serviceUrl,
userId,
password: command.password,
code: command.code,
});
}
type CheckSessionAndSetPasswordCommand = {
sessionId: string;
password: string;
};
export async function checkSessionAndSetPassword({
sessionId,
password,
}: CheckSessionAndSetPasswordCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const sessionCookie = await getSessionCookieById({ sessionId });
const { session } = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
if (!session || !session.factors?.user?.id) {
return { error: "Could not load session" };
}
const payload = create(SetPasswordRequestSchema, {
userId: session.factors.user.id,
newPassword: {
password,
},
});
// check if the user has no password set in order to set a password
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId: session.factors.user.id,
});
if (!authmethods) {
return { error: "Could not load auth methods" };
}
const requiredAuthMethodsForForceMFA = [
AuthenticationMethodType.OTP_EMAIL,
AuthenticationMethodType.OTP_SMS,
AuthenticationMethodType.TOTP,
AuthenticationMethodType.U2F,
];
const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every(
(method) => !authmethods.authMethodTypes.includes(method),
);
const loginSettings = await getLoginSettings({
serviceUrl,
organization: session.factors.user.organizationId,
});
const forceMfa = !!(
loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly
);
// if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user
if (forceMfa && hasNoMFAMethods) {
return setPassword({ serviceUrl, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) {
return { error: "Failed precondition" };
} else {
throw error;
}
});
} else {
const transport = async (serviceUrl: string, token: string) => {
return createServerTransport(token, serviceUrl);
};
const myUserService = async (serviceUrl: string, sessionToken: string) => {
const transportPromise = await transport(serviceUrl, sessionToken);
return createUserServiceClient(transportPromise);
};
const selfService = await myUserService(
serviceUrl,
`${sessionCookie.token}`,
);
return selfService
.setPassword(
{
userId: session.factors.user.id,
newPassword: { password, changeRequired: false },
},
{},
)
.catch((error: ConnectError) => {
console.log(error);
if (error.code === 7) {
return { error: "Session is not valid." };
}
throw error;
});
}
}