mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-30 02:52:16 +00:00
# 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
479 lines
13 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|