fix: Disallow changing username and email in case of Organization email invite (#12735)

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Hariom Balhara 2023-12-14 01:38:16 +05:30 committed by GitHub
parent 943c7a4c6c
commit 99e71365ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 263 additions and 69 deletions

View File

@ -7,10 +7,12 @@ import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
@ -27,7 +29,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui";
import { Button, HeadSeo, PasswordField, TextField, Form, Alert, showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@ -66,16 +68,6 @@ const FEATURES = [
},
];
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};
function UsernameField({
username,
setPremium,
@ -276,25 +268,29 @@ export default function Signup({
await signUp(updatedValues);
}}>
{/* Username */}
<UsernameField
label={t("username")}
username={watch("username")}
premium={premiumUsername}
usernameTaken={usernameTaken}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
/>
{!isOrgInviteByLink ? (
<UsernameField
label={t("username")}
username={watch("username") || ""}
premium={premiumUsername}
usernameTaken={usernameTaken}
disabled={!!orgSlug}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
/>
) : null}
{/* Email */}
<TextField
{...register("email")}
label={t("email")}
type="email"
disabled={prepopulateFormValues?.email}
data-testid="signup-emailfield"
/>
@ -322,7 +318,7 @@ export default function Signup({
: t("create_account")}
</Button>
</Form>
{/* Continue with Social Logins */}
{/* Continue with Social Logins - Only for non-invite links */}
{token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
<div className="mt-6">
<div className="relative flex items-center">
@ -334,7 +330,7 @@ export default function Signup({
</div>
</div>
)}
{/* Social Logins */}
{/* Social Logins - Only for non-invite links*/}
{!token && (
<div className="mt-6 flex flex-col gap-2 md:flex-row">
{isGoogleLoginEnabled ? (
@ -366,7 +362,7 @@ export default function Signup({
if (username) {
// If username is present we save it in query params to check for premium
const searchQueryParams = new URLSearchParams();
searchQueryParams.set("username", formMethods.getValues("username"));
searchQueryParams.set("username", username);
localStorage.setItem("username", username);
router.push(`${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`);
return;
@ -402,10 +398,14 @@ export default function Signup({
return;
}
const username = formMethods.getValues("username");
if (!username) {
showToast("error", t("username_required"));
return;
}
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", formMethods.getValues("username"));
sp.set("username", username);
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
@ -491,6 +491,7 @@ export default function Signup({
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}

View File

@ -1,5 +1,7 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { test } from "../lib/fixtures";
import { getInviteLink } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
@ -20,7 +22,9 @@ test.describe("Organization", () => {
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (external user)", async () => {
const invitedUserEmail = `rick@domain-${Date.now()}.com`;
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
@ -38,21 +42,24 @@ test.describe("Organization", () => {
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return null;
assertInviteLink(inviteLink);
// Follow invite link in new window
const context = await browser.newContext();
const newPage = await context.newPage();
newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
const signupPage = await context.newPage();
signupPage.goto(inviteLink);
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
await signupPage.waitForLoadState("networkidle");
// Check required fields
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await signupPage.locator("button[type=submit]").click();
await signupPage.waitForURL("/getting-started?from=signup");
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await context.close();
await newPage.close();
await signupPage.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
@ -80,10 +87,15 @@ test.describe("Organization", () => {
await expect(button).toBeVisible(); // email + 3 password hints
// Happy path
await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`);
const email = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
await inviteLinkPage.locator("input[name=email]").fill(email);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
});
});
@ -96,21 +108,74 @@ test.describe("Organization", () => {
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick-${Date.now()}@example.com`;
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
await expectInvitationEmailToBeReceived(
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
assertInviteLink(inviteLink);
// Check newly invited member exists and is not pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
// Follow invite link in new window
const context = await browser.newContext();
const signupPage = await context.newPage();
signupPage.goto(inviteLink);
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
await signupPage.waitForLoadState("networkidle");
// Check required fields
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await signupPage.locator("button[type=submit]").click();
await signupPage.waitForURL("/getting-started?from=signup");
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await context.close();
await signupPage.close();
});
await test.step("To the organization by invite link", async () => {
// Get the invite link
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await getInviteLink(page);
// Follow invite link in new window
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
// Check required fields
const button = inviteLinkPage.locator("button[type=submit][disabled]");
await expect(button).toBeVisible(); // email + 3 password hints
// Happy path
const email = `rick-${Date.now()}@example.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
await inviteLinkPage.locator("input[name=email]").fill(email);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
});
});
});
function assertInviteLink(inviteLink: string | null | undefined): asserts inviteLink is string {
if (!inviteLink) throw new Error("Invite link not found");
}

View File

@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { getEmailsReceivedByUser, localize } from "./lib/testUtils";
@ -103,6 +104,8 @@ test.describe("Signup Flow Test", async () => {
const userToCreate = users.buildForSignup({
username: "rick-jones",
password: "Password99!",
// Email intentonally kept as different from username
email: `rickjones${Math.random()}-${Date.now()}@example.com`,
});
await page.goto("/signup");
@ -120,6 +123,9 @@ test.describe("Signup Flow Test", async () => {
// Check that the URL matches the expected URL
expect(page.url()).toContain("/auth/verify-email");
const dbUser = await prisma.user.findUnique({ where: { email: userToCreate.email } });
// Verify that the username is the same as the one provided and isn't accidentally changed to email derived username - That happens only for organization member signup
expect(dbUser?.username).toBe(userToCreate.username);
});
test("Signup fields prefilled with query params", async ({ page, users }) => {
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";

View File

@ -10,13 +10,17 @@ import { HttpError } from "@calcom/lib/http-error";
import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username";
import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { signupSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "../utils/token";
async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
const {
@ -52,15 +56,33 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
await validateUsernameForTeam({ username, email, teamId: foundToken?.teamId ?? null });
username = await validateAndGetCorrectedUsernameForTeam({
username,
email,
teamId: foundToken?.teamId ?? null,
isSignup: true,
});
} else {
const usernameAndEmailValidation = await validateUsername(username, email);
const usernameAndEmailValidation = await validateAndGetCorrectedUsernameAndEmail({
username,
email,
isSignup: true,
});
if (!usernameAndEmailValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
if (!usernameAndEmailValidation.username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
username = usernameAndEmailValidation.username;
}
// Create the customer in Stripe

View File

@ -4,16 +4,21 @@ import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "../utils/token";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const data = req.body;
@ -28,15 +33,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
let correctedUsername = username;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
await validateUsernameForTeam({ username, email: userEmail, teamId: foundToken?.teamId });
correctedUsername = await validateAndGetCorrectedUsernameForTeam({
username,
email: userEmail,
teamId: foundToken?.teamId,
isSignup: true,
});
} else {
const userValidation = await validateUsername(username, userEmail);
const userValidation = await validateAndGetCorrectedUsernameAndEmail({
username,
email: userEmail,
isSignup: true,
});
if (!userValidation.isValid) {
logger.error("User validation failed", { userValidation });
return res.status(409).json({ message: "Username or email is already taken" });
}
if (!userValidation.username) {
return res.status(422).json({ message: "Invalid username" });
}
correctedUsername = userValidation.username;
}
const hashedPassword = await hashPassword(password);
@ -53,13 +73,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
username: correctedUsername,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
username: correctedUsername,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
@ -113,7 +133,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
} else {
if (IS_PREMIUM_USERNAME_ENABLED) {
const checkUsername = await checkPremiumUsername(username);
const checkUsername = await checkPremiumUsername(correctedUsername);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
@ -124,13 +144,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
username: correctedUsername,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
username: correctedUsername,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
@ -138,7 +158,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
await sendEmailVerification({
email: userEmail,
username,
username: correctedUsername,
language,
});
}

View File

@ -0,0 +1,11 @@
import slugify from "@calcom/lib/slugify";
export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};

View File

@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import { validateUsernameInTeam } from "@calcom/lib/validateUsername";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
@ -35,21 +35,31 @@ export function throwIfTokenExpired(expires?: Date) {
}
}
export async function validateUsernameForTeam({
export async function validateAndGetCorrectedUsernameForTeam({
username,
email,
teamId,
isSignup,
}: {
username: string;
email: string;
teamId: number | null;
isSignup: boolean;
}) {
if (!teamId) return;
const teamUserValidation = await validateUsernameInTeam(username, email, teamId);
if (!teamId) return username;
const teamUserValidation = await validateAndGetCorrectedUsernameInTeam(username, email, teamId, isSignup);
if (!teamUserValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
if (!teamUserValidation.username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
return teamUserValidation.username;
}

View File

@ -1,7 +1,45 @@
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const validateUsername = async (username: string, email: string, organizationId?: number) => {
export const getUsernameForOrgMember = async ({
email,
orgAutoAcceptEmail,
isSignup,
username,
}: {
username?: string;
email: string;
orgAutoAcceptEmail?: string;
isSignup: boolean;
}) => {
if (isSignup) {
// We ensure that the username is always derived from the email during signup.
return getOrgUsernameFromEmail(email, orgAutoAcceptEmail || "");
}
if (!username) {
throw new Error("Username is required");
}
// Right now it's not possible to change username in an org by the user but when that's allowed we would simply accept the provided username
return username;
};
export const validateAndGetCorrectedUsernameAndEmail = async ({
username,
email,
organizationId,
orgAutoAcceptEmail,
isSignup,
}: {
username: string;
email: string;
organizationId?: number;
orgAutoAcceptEmail?: string;
isSignup: boolean;
}) => {
if (username.includes("+")) {
return { isValid: false, username: undefined, email };
}
// There is an existingUser if, within an org context or not, the username matches
// OR if the email matches AND either the email is verified
// or both username and password are set
@ -31,10 +69,24 @@ export const validateUsername = async (username: string, email: string, organiza
email: true,
},
});
return { isValid: !existingUser, email: existingUser?.email };
let validatedUsername = username;
if (organizationId) {
validatedUsername = await getUsernameForOrgMember({
email,
orgAutoAcceptEmail,
isSignup,
});
}
return { isValid: !existingUser, username: validatedUsername, email: existingUser?.email };
};
export const validateUsernameInTeam = async (username: string, email: string, teamId: number) => {
export const validateAndGetCorrectedUsernameInTeam = async (
username: string,
email: string,
teamId: number,
isSignup: boolean
) => {
try {
const team = await prisma.team.findFirst({
where: {
@ -49,15 +101,22 @@ export const validateUsernameInTeam = async (username: string, email: string, te
const teamData = { ...team, metadata: teamMetadataSchema.parse(team?.metadata) };
if (teamData.metadata?.isOrganization || teamData.parentId) {
const orgMetadata = teamData.metadata;
// Organization context -> org-context username check
const orgId = teamData.parentId || teamId;
return validateUsername(username, email, orgId);
return validateAndGetCorrectedUsernameAndEmail({
username,
email,
organizationId: orgId,
orgAutoAcceptEmail: orgMetadata?.orgAutoAcceptEmail || "",
isSignup,
});
} else {
// Regular team context -> regular username check
return validateUsername(username, email);
return validateAndGetCorrectedUsernameAndEmail({ username, email, isSignup });
}
} catch (error) {
console.error(error);
return { isValid: false, email: undefined };
return { isValid: false, username: undefined, email: undefined };
}
};

View File

@ -610,9 +610,9 @@ export const emailSchemaRefinement = (value: string) => {
};
export const signupSchema = z.object({
username: z.string().refine((value) => !value.includes("+"), {
message: "String should not contain a plus symbol (+).",
}),
// Username is marked optional here because it's requirement depends on if it's the Organization invite or a team invite which isn't easily done in zod
// It's better handled beyond zod in `validateAndGetCorrectedUsernameAndEmail`
username: z.string().optional(),
email: z.string().email(),
password: z.string().superRefine((data, ctx) => {
const isStrict = false;