diff --git a/apps/web/package.json b/apps/web/package.json index 9daf095907..d99d427cc7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -74,6 +74,7 @@ "handlebars": "^4.7.7", "ical.js": "^1.4.0", "ics": "^2.37.0", + "jose": "^4.11.1", "kbar": "^0.1.0-beta.36", "libphonenumber-js": "^1.10.12", "lodash": "^4.17.21", diff --git a/apps/web/pages/api/auth/[...nextauth].tsx b/apps/web/pages/api/auth/[...nextauth].tsx index 496bfae9d0..f4e67361b4 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -1,6 +1,8 @@ import { IdentityProvider, UserPermissionRole } from "@prisma/client"; +import { BinaryLike, hkdfSync, KeyObject } from "crypto"; import { readFileSync } from "fs"; import Handlebars from "handlebars"; +import * as jose from "jose"; import NextAuth, { Session } from "next-auth"; import { Provider } from "next-auth/providers"; import CredentialsProvider from "next-auth/providers/credentials"; @@ -9,6 +11,7 @@ import GoogleProvider from "next-auth/providers/google"; import nodemailer, { TransportOptions } from "nodemailer"; import { authenticator } from "otplib"; import path from "path"; +import { v4 as uuidv4 } from "uuid"; import checkLicense from "@calcom/features/ee/common/server/checkLicense"; import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider"; @@ -22,7 +25,7 @@ import rateLimit from "@calcom/lib/rateLimit"; import { serverConfig } from "@calcom/lib/serverConfig"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; -import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; +import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; import CalComAdapter from "@lib/auth/next-auth-custom-adapter"; @@ -60,6 +63,7 @@ const providers: Provider[] = [ username: true, name: true, email: true, + metadata: true, identityProvider: true, password: true, twoFactorEnabled: true, @@ -235,6 +239,9 @@ if (true) { ); } const calcomAdapter = CalComAdapter(prisma); +const getDerivedEncryptionKey = async (secret: BinaryLike | KeyObject) => { + return await hkdfSync("sha256", secret, "", "NextAuth.js Generated Encryption Key", 32); +}; export default NextAuth({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -242,6 +249,37 @@ export default NextAuth({ session: { strategy: "jwt", }, + jwt: { + encode: async ({ secret, token }) => { + if (!token || token.sub === undefined) throw new Error("Not valid token"); + const user = await prisma.user.findFirst({ + where: { id: Number(token.sub) }, + select: { metadata: true }, + }); + const encryptionSecret = await getDerivedEncryptionKey(secret); + const metadata = userMetadata.parse(user?.metadata); + return await new jose.EncryptJWT({ + sub: token?.sub, + name: token?.name, + email: token?.email, + }) + .setProtectedHeader({ + alg: "dir", + enc: "A256GCM", + }) + .setIssuedAt() + .setExpirationTime(metadata?.sessionTimeout ? `${metadata.sessionTimeout}m` : "30d") + .setJti(uuidv4()) + .encrypt(new Uint8Array(encryptionSecret)); + }, + decode: async ({ secret, token }) => { + const encryptionSecret = await getDerivedEncryptionKey(secret); + const { payload } = await jose.jwtDecrypt(token || "", new Uint8Array(encryptionSecret), { + clockTolerance: 15, + }); + return payload; + }, + }, cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")), pages: { signIn: "/auth/login", diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 9938b766c6..2f1bf8e41c 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -16,6 +16,8 @@ import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { RouterOutputs, trpc, TRPCClientError } from "@calcom/trpc/react"; import { + Avatar, + AvatarGroup, Badge, Button, ButtonGroup, @@ -31,8 +33,6 @@ import { EmptyScreen, showToast, Switch, - Avatar, - AvatarGroup, Tooltip, HorizontalTabs, } from "@calcom/ui"; diff --git a/apps/web/pages/settings/security/password.tsx b/apps/web/pages/settings/security/password.tsx index 44ea30df2c..bf4d114624 100644 --- a/apps/web/pages/settings/security/password.tsx +++ b/apps/web/pages/settings/security/password.tsx @@ -1,51 +1,96 @@ import { IdentityProvider } from "@prisma/client"; import { GetServerSidePropsContext } from "next"; import { Trans } from "next-i18next"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { identityProviderNameMap } from "@calcom/lib/auth"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { userMetadata } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; -import { Button, Form, Meta, PasswordField, showToast } from "@calcom/ui"; +import { Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui"; import { ssrInit } from "@server/lib/ssr"; -type ChangePasswordFormValues = { +type ChangePasswordSessionFormValues = { oldPassword: string; newPassword: string; + sessionTimeout?: number; }; const PasswordView = () => { const { t } = useLocale(); + const utils = trpc.useContext(); const { data: user } = trpc.viewer.me.useQuery(); + const metadata = userMetadata.parse(user?.metadata); - const mutation = trpc.viewer.auth.changePassword.useMutation({ + const sessionMutation = trpc.viewer.updateProfile.useMutation({ + onSuccess: () => { + showToast(t("session_timeout_changed"), "success"); + formMethods.reset(formMethods.getValues()); + }, + onSettled: () => { + utils.viewer.me.invalidate(); + }, + onMutate: async ({ metadata }) => { + await utils.viewer.me.cancel(); + const previousValue = utils.viewer.me.getData(); + const previousMetadata = userMetadata.parse(previousValue?.metadata); + + if (previousValue && metadata?.sessionTimeout) { + utils.viewer.me.setData(undefined, { + ...previousValue, + metadata: { ...previousMetadata, sessionTimeout: metadata?.sessionTimeout }, + }); + } + return { previousValue }; + }, + onError: (error, _, context) => { + if (context?.previousValue) { + utils.viewer.me.setData(undefined, context.previousValue); + } + showToast(`${t("session_timeout_change_error")}, ${error.message}`, "error"); + }, + }); + const passwordMutation = trpc.viewer.auth.changePassword.useMutation({ onSuccess: () => { showToast(t("password_has_been_changed"), "success"); + formMethods.resetField("oldPassword"); + formMethods.resetField("newPassword"); }, onError: (error) => { showToast(`${t("error_updating_password")}, ${error.message}`, "error"); }, }); - const formMethods = useForm({ + const formMethods = useForm({ defaultValues: { oldPassword: "", newPassword: "", + sessionTimeout: metadata?.sessionTimeout, }, }); - const { - register, - formState: { isSubmitting }, - } = formMethods; + const sessionTimeoutWatch = formMethods.watch("sessionTimeout"); - const handleSubmit = (values: ChangePasswordFormValues) => { - const { oldPassword, newPassword } = values; - mutation.mutate({ oldPassword, newPassword }); + const handleSubmit = (values: ChangePasswordSessionFormValues) => { + const { oldPassword, newPassword, sessionTimeout } = values; + if (oldPassword && newPassword) { + passwordMutation.mutate({ oldPassword, newPassword }); + } + if (metadata?.sessionTimeout !== sessionTimeout) { + sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout } }); + } }; + const timeoutOptions = [5, 10, 15].map((mins) => ({ + label: t("multiple_duration_mins", { count: mins }), + value: mins, + })); + + const isDisabled = formMethods.formState.isSubmitting || !formMethods.formState.isDirty; + return ( <> @@ -68,24 +113,59 @@ const PasswordView = () => {
- +
- +
-

+

Password must be at least at least 7 characters, mix of uppercase & lowercase letters, and - contain at least 1 number + contain at least 1 number.

+
+ { + if (!e) { + formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true }); + } else { + formMethods.setValue("sessionTimeout", 10, { shouldDirty: true }); + } + }} + /> + {sessionTimeoutWatch && ( +
+
+

{t("session_timeout_after")}

+