-
+
diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts
index a93f7a06e7b28c..32f3d85bc3f497 100644
--- a/apps/web/lib/auth.ts
+++ b/apps/web/lib/auth.ts
@@ -3,23 +3,24 @@ import { compare, hash } from "bcryptjs";
import { Session } from "next-auth";
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
+/** @deprecated use the one from `@calcom/lib/auth` */
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}
-
+/** @deprecated use the one from `@calcom/lib/auth` */
export async function verifyPassword(password: string, hashedPassword: string) {
const isValid = await compare(password, hashedPassword);
return isValid;
}
-
+/** @deprecated use the one from `@calcom/lib/auth` */
export async function getSession(options: GetSessionParams): Promise {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
return session as Session | null;
}
-
+/** @deprecated use the one from `@calcom/lib/auth` */
export enum ErrorCode {
UserNotFound = "user-not-found",
IncorrectPassword = "incorrect-password",
@@ -35,7 +36,7 @@ export enum ErrorCode {
RateLimitExceeded = "rate-limit-exceeded",
InvalidPassword = "invalid-password",
}
-
+/** @deprecated use the one from `@calcom/lib/auth` */
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
[IdentityProvider.CAL]: "Cal",
[IdentityProvider.GOOGLE]: "Google",
diff --git a/apps/web/pages/api/auth/two-factor/totp/disable.ts b/apps/web/pages/api/auth/two-factor/totp/disable.ts
index 84ba99d9a6e93d..02e83a66f9233a 100644
--- a/apps/web/pages/api/auth/two-factor/totp/disable.ts
+++ b/apps/web/pages/api/auth/two-factor/totp/disable.ts
@@ -1,5 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
+import { authenticator } from "otplib";
+import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
@@ -37,7 +39,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
+ // if user has 2fa
+ if (user.twoFactorEnabled) {
+ if (!req.body.code) {
+ return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
+ // throw new Error(ErrorCode.SecondFactorRequired);
+ }
+ if (!user.twoFactorSecret) {
+ console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ if (!process.env.CALENDSO_ENCRYPTION_KEY) {
+ console.error(`"Missing encryption key; cannot proceed with two factor login."`);
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
+ if (secret.length !== 32) {
+ console.error(
+ `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
+ );
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ // If user has 2fa enabled, check if body.code is correct
+ const isValidToken = authenticator.check(req.body.code, secret);
+ if (!isValidToken) {
+ return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode });
+
+ // throw new Error(ErrorCode.IncorrectTwoFactorCode);
+ }
+ }
+ // If it is, disable users 2fa
await prisma.user.update({
where: {
id: session.user.id,
diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx
index 37903f386d8478..4a14dcb9878ea9 100644
--- a/apps/web/pages/auth/login.tsx
+++ b/apps/web/pages/auth/login.tsx
@@ -161,7 +161,7 @@ export default function Login({
- {twoFactorRequired &&
diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx
index dd2f1e9140e059..ab1b28241f16bd 100644
--- a/apps/web/pages/settings/profile.tsx
+++ b/apps/web/pages/settings/profile.tsx
@@ -2,7 +2,17 @@ import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { useRouter } from "next/router";
-import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
+import {
+ ComponentProps,
+ RefObject,
+ FormEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ BaseSyntheticEvent,
+} from "react";
+import { useForm } from "react-hook-form";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -17,16 +27,19 @@ import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
+import { Form, PasswordField } from "@calcom/ui/form/fields";
+import { Label } from "@calcom/ui/form/fields";
import { withQuery } from "@lib/QueryCell";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
-import { getSession } from "@lib/auth";
+import { ErrorCode, getSession } from "@lib/auth";
import { nameOfDay } from "@lib/core/i18n/weekday";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import ImageUploader from "@components/ImageUploader";
import SettingsShell from "@components/SettingsShell";
+import TwoFactor from "@components/auth/TwoFactor";
import Avatar from "@components/ui/Avatar";
import InfoBadge from "@components/ui/InfoBadge";
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
@@ -68,9 +81,14 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject
>
);
}
+interface DeleteAccountValues {
+ totpCode: string;
+}
function SettingsView(props: ComponentProps & { localeProp: string }) {
const { user } = props;
+ const form = useForm();
+
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
@@ -93,15 +111,11 @@ function SettingsView(props: ComponentProps & { localeProp: str
},
});
- const deleteAccount = async () => {
- await fetch("/api/user/me", {
- method: "DELETE",
- headers: {
- "Content-Type": "application/json",
- },
- }).catch((e) => {
- console.error(`Error Removing user: ${user.id}, email: ${user.email} :`, e);
- });
+ const onDeleteMeSuccessMutation = async () => {
+ await utils.invalidateQueries(["viewer.me"]);
+ showToast(t("Your account was deleted"), "success");
+
+ setHasDeleteErrors(false); // dismiss any open errors
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
signOut({ callbackUrl: "/auth/logout?survey=true" });
} else {
@@ -109,6 +123,18 @@ function SettingsView(props: ComponentProps & { localeProp: str
}
};
+ const onDeleteMeErrorMutation = (error: TRPCClientErrorLike) => {
+ setHasDeleteErrors(true);
+ setDeleteErrorMessage(errorMessages[error.message]);
+ };
+ const deleteMeMutation = trpc.useMutation("viewer.deleteMe", {
+ onSuccess: onDeleteMeSuccessMutation,
+ onError: onDeleteMeErrorMutation,
+ async onSettled() {
+ await utils.invalidateQueries(["viewer.me"]);
+ },
+ });
+
const localeOptions = useMemo(() => {
return (router.locales || []).map((locale) => ({
value: locale,
@@ -126,6 +152,7 @@ function SettingsView(props: ComponentProps & { localeProp: str
{ value: 24, label: t("24_hour") },
];
const usernameRef = useRef(null!);
+ const passwordRef = useRef(null!);
const nameRef = useRef(null!);
const emailRef = useRef(null!);
const descriptionRef = useRef(null!);
@@ -149,7 +176,19 @@ function SettingsView(props: ComponentProps & { localeProp: str
});
const [imageSrc, setImageSrc] = useState(user.avatar || "");
const [hasErrors, setHasErrors] = useState(false);
+ const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
+
+ const errorMessages: { [key: string]: string } = {
+ [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
+ [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
+ [ErrorCode.UserNotFound]: t("no_account_exists"),
+ [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
+ [ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
+ [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
+ };
+
+ const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
@@ -161,6 +200,17 @@ function SettingsView(props: ComponentProps & { localeProp: str
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const onConfirmButton = (e: FormEvent) => {
+ e.preventDefault();
+ const totpCode = form.getValues("totpCode");
+ const password = passwordRef.current.value;
+ deleteMeMutation.mutate({ password, totpCode });
+ };
+ const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
+ e?.preventDefault();
+ const password = passwordRef.current.value;
+ deleteMeMutation.mutate({ password, totpCode });
+ };
async function updateProfileHandler(event: FormEvent) {
event.preventDefault();
@@ -487,8 +537,26 @@ function SettingsView(props: ComponentProps & { localeProp: str
{t("confirm_delete_account")}
}
- onConfirm={() => deleteAccount()}>
- {t("delete_account_confirmation_message")}
+ onConfirm={onConfirmButton}>
+ {t("delete_account_confirmation_message")}
+
+
+ {user.twoFactorEnabled && (
+
+ )}
+
+ {hasDeleteErrors && }
@@ -547,6 +615,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
brandColor: true,
darkBrandColor: true,
metadata: true,
+ twoFactorEnabled: true,
timeFormat: true,
allowDynamicBooking: true,
},
diff --git a/apps/web/playwright/auth/delete-account.test.ts b/apps/web/playwright/auth/delete-account.test.ts
index 2bc1bb7a8ca93a..821cf514ec0436 100644
--- a/apps/web/playwright/auth/delete-account.test.ts
+++ b/apps/web/playwright/auth/delete-account.test.ts
@@ -12,8 +12,9 @@ test("Can delete user account", async ({ page, users }) => {
await page.goto(`/settings/profile`);
await page.click("[data-testid=delete-account]");
-
await expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible();
+ if (!user.username) throw Error(`Test user doesn't have a username`);
+ await page.fill("[data-testid=password]", user.username);
await Promise.all([
page.waitForNavigation({ url: "/auth/logout" }),
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index dbbbef9ddf87fd..f7f54ef848cdc2 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -1068,6 +1068,8 @@
"select_which_cal":"Select which calendar to add bookings to",
"custom_event_name":"Custom event name",
"custom_event_name_description":"Create customised event names to display on calendar event",
+ "2fa_required": "Two factor authentication required",
+ "incorrect_2fa": "Incorrect two factor authentication code",
"which_event_type_apply": "Which event type will this apply to?",
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
"create_workflow": "Create a workflow",
diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx
index 2667985901fd60..7a3359b1faad4f 100644
--- a/packages/trpc/server/routers/viewer.tsx
+++ b/packages/trpc/server/routers/viewer.tsx
@@ -1,9 +1,11 @@
-import { AppCategories, BookingStatus, MembershipRole, Prisma } from "@prisma/client";
+import { AppCategories, BookingStatus, IdentityProvider, MembershipRole, Prisma } from "@prisma/client";
import _ from "lodash";
+import { authenticator } from "otplib";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
+import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
@@ -12,6 +14,8 @@ import { DailyLocationType } from "@calcom/core/location";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
+import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
+import { symmetricDecrypt } from "@calcom/lib/crypto";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import jackson from "@calcom/lib/jackson";
import {
@@ -28,8 +32,8 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
- updateWebUser as syncServicesUpdateWebUser,
deleteWebUser as syncServicesDeleteWebUser,
+ updateWebUser as syncServicesUpdateWebUser,
} from "@calcom/lib/sync/SyncServiceManager";
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
@@ -108,18 +112,76 @@ const loggedInViewerRouter = createProtectedRouter()
},
})
.mutation("deleteMe", {
- async resolve({ ctx }) {
- // Remove me from Stripe
-
- // Remove my account
- const deletedUser = await ctx.prisma.user.delete({
+ input: z.object({
+ password: z.string(),
+ totpCode: z.string().optional(),
+ }),
+ async resolve({ input, ctx }) {
+ // Check if input.password is correct
+ const user = await prisma.user.findUnique({
where: {
- id: ctx.user.id,
+ email: ctx.user.email.toLowerCase(),
},
});
+ if (!user) {
+ throw new Error(ErrorCode.UserNotFound);
+ }
- // Sync Services
- syncServicesDeleteWebUser(deletedUser);
+ if (user.identityProvider !== IdentityProvider.CAL) {
+ throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
+ }
+
+ if (!user.password) {
+ throw new Error(ErrorCode.UserMissingPassword);
+ }
+
+ const isCorrectPassword = await verifyPassword(input.password, user.password);
+ if (!isCorrectPassword) {
+ throw new Error(ErrorCode.IncorrectPassword);
+ }
+
+ if (user.twoFactorEnabled) {
+ if (!input.totpCode) {
+ throw new Error(ErrorCode.SecondFactorRequired);
+ }
+
+ if (!user.twoFactorSecret) {
+ console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ if (!process.env.CALENDSO_ENCRYPTION_KEY) {
+ console.error(`"Missing encryption key; cannot proceed with two factor login."`);
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
+ if (secret.length !== 32) {
+ console.error(
+ `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
+ );
+ throw new Error(ErrorCode.InternalServerError);
+ }
+
+ const isValidToken = authenticator.check(input.totpCode, secret);
+ if (!isValidToken) {
+ throw new Error(ErrorCode.IncorrectTwoFactorCode);
+ }
+ // If user has 2fa enabled, check if input.totpCode is correct
+ // If it is, delete the user from stripe and database
+
+ // Remove me from Stripe
+ await deleteStripeCustomer(user).catch(console.warn);
+
+ // Remove my account
+ const deletedUser = await ctx.prisma.user.delete({
+ where: {
+ id: ctx.user.id,
+ },
+ });
+ // Sync Services
+ syncServicesDeleteWebUser(deletedUser);
+ }
return;
},
@@ -1280,6 +1342,7 @@ const loggedInViewerRouter = createProtectedRouter()
export const viewerRouter = createRouter()
.merge("public.", publicViewerRouter)
.merge(loggedInViewerRouter)
+ .merge("auth.", authRouter)
.merge("bookings.", bookingsRouter)
.merge("eventTypes.", eventTypesRouter)
.merge("availability.", availabilityRouter)
@@ -1288,7 +1351,6 @@ export const viewerRouter = createRouter()
.merge("apiKeys.", apiKeysRouter)
.merge("slots.", slotsRouter)
.merge("workflows.", workflowsRouter)
- .merge("auth.", authRouter)
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.
diff --git a/packages/trpc/server/routers/viewer/auth.tsx b/packages/trpc/server/routers/viewer/auth.tsx
index 2e1d73f033e565..e8959d43607ea5 100644
--- a/packages/trpc/server/routers/viewer/auth.tsx
+++ b/packages/trpc/server/routers/viewer/auth.tsx
@@ -1,7 +1,7 @@
import { IdentityProvider } from "@prisma/client";
import { z } from "zod";
-import { hashPassword, verifyPassword, validPassword } from "@calcom/lib/auth";
+import { hashPassword, validPassword, verifyPassword } from "@calcom/lib/auth";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
diff --git a/packages/ui/form/fields.tsx b/packages/ui/form/fields.tsx
index 79cb3b578b1f02..af8c3431b0328d 100644
--- a/packages/ui/form/fields.tsx
+++ b/packages/ui/form/fields.tsx
@@ -104,7 +104,9 @@ export const PasswordField = forwardRef