Session Timeout (#6439)

* Implementation

* Unifying UI to password screen

* Tweaks for session in password section

* Update apps/web/pages/settings/security/password.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update packages/features/kbar/Kbar.tsx

Co-authored-by: Omar López <zomars@me.com>

* Update packages/features/settings/layouts/SettingsLayout.tsx

Co-authored-by: Omar López <zomars@me.com>

* Relying on extra db query

* Upgrades typescript

* Update yarn lock

* Typings

* Hotfix: ping,riverside,whereby and around not showing up in list (#6712)

* Hotfix: ping,riverside,whereby and around not showing up in list (#6712) (#6713)

* Adds deployment settings to DB (#6706)

* WIP

* Adds DeploymentTheme

* Add missing migrations

* Adds client extensions for deployment

* Cleanup

* Using same version as other deps

* Reverting prisma changes and fixing things

* Uneeded tx-expect-error

* Fixing default value

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
Leo Giovanetti 2023-01-31 17:44:14 -03:00 committed by GitHub
parent 9ab5a3149c
commit 36b6c351ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 197 additions and 981 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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";

View File

@ -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<ChangePasswordFormValues>({
const formMethods = useForm<ChangePasswordSessionFormValues>({
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 (
<>
<Meta title={t("password")} description={t("password_description")} />
@ -68,24 +113,59 @@ const PasswordView = () => {
<Form form={formMethods} handleSubmit={handleSubmit}>
<div className="max-w-[38rem] sm:flex sm:space-x-4">
<div className="flex-grow">
<PasswordField {...register("oldPassword")} label={t("old_password")} />
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
</div>
<div className="flex-grow">
<PasswordField {...register("newPassword")} label={t("new_password")} />
<PasswordField {...formMethods.register("newPassword")} label={t("new_password")} />
</div>
</div>
<p className="text-sm text-gray-600">
<p className="mt-4 max-w-[38rem] text-sm text-gray-600">
<Trans i18nKey="invalid_password_hint">
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.
</Trans>
</p>
<div className="mt-8 border-t border-gray-200 py-8">
<SettingsToggle
title={t("session_timeout")}
description={t("session_timeout_description")}
checked={sessionTimeoutWatch !== undefined}
data-testid="session-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("sessionTimeout", undefined, { shouldDirty: true });
} else {
formMethods.setValue("sessionTimeout", 10, { shouldDirty: true });
}
}}
/>
{sessionTimeoutWatch && (
<div className="mt-4 text-sm">
<div className="flex items-center">
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("session_timeout_after")}</p>
<Select
options={timeoutOptions}
defaultValue={
metadata?.sessionTimeout
? timeoutOptions.find((tmo) => tmo.value === metadata.sessionTimeout)
: timeoutOptions[1]
}
isSearchable={false}
className="block h-[36px] !w-auto min-w-0 flex-none rounded-md text-sm"
onChange={(event) => {
formMethods.setValue("sessionTimeout", event?.value, { shouldDirty: true });
}}
/>
</div>
</div>
)}
</div>
{/* TODO: Why is this Form not submitting? Hacky fix but works */}
<Button
color="primary"
className="mt-8"
type="submit"
disabled={isSubmitting || mutation.isLoading}>
disabled={isDisabled || passwordMutation.isLoading || sessionMutation.isLoading}>
{t("update")}
</Button>
</Form>

View File

@ -414,6 +414,8 @@
"password_updated_successfully": "Password updated successfully",
"password_has_been_changed": "Your password has been successfully changed.",
"error_changing_password": "Error changing password",
"session_timeout_changed": "Your session configuration has been updated successfully.",
"session_timeout_change_error": "Error updating session configuration",
"something_went_wrong": "Something went wrong.",
"something_doesnt_look_right": "Something doesn't look right?",
"please_try_again": "Please try again.",
@ -939,6 +941,11 @@
"current_location": "Current Location",
"user_phone": "Your phone number",
"new_location": "New Location",
"session": "Session",
"session_description": "Control your account session",
"session_timeout_after": "Timeout session after",
"session_timeout": "Session timeout",
"session_timeout_description": "Invalidate your session after a certain amount of time.",
"no_location": "No location defined",
"set_location": "Set Location",
"update_location": "Update Location",

View File

@ -3,7 +3,7 @@ import { Dispatch, SetStateAction } from "react";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import { SerializableForm, Response } from "../types/types";
import { Response, SerializableForm } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;

View File

@ -7,7 +7,7 @@ import DOMPurify from "dompurify";
import { useSession } from "next-auth/react";
import React, { AriaRole, ComponentType, Fragment } from "react";
import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui";
import { FiAlertTriangle } from "@calcom/ui/components/icon";

View File

@ -208,6 +208,7 @@ export const userMetadata = z
stripeCustomerId: z.string().optional(),
vitalSettings: vitalSettingsUpdateSchema.optional(),
isPremium: z.boolean().optional(),
sessionTimeout: z.number().optional(), // Minutes
})
.nullable();

View File

@ -19,7 +19,6 @@ import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
import { IS_SELF_HOSTED, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
@ -586,12 +585,14 @@ const loggedInViewerRouter = router({
locale: z.string().optional(),
timeFormat: z.number().optional(),
disableImpersonation: z.boolean().optional(),
metadata: userMetadata.optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { user, prisma } = ctx;
const data: Prisma.UserUpdateInput = {
...input,
metadata: input.metadata as Prisma.InputJsonValue,
};
let isPremiumUsername = false;
if (input.username) {
@ -1129,7 +1130,7 @@ const loggedInViewerRouter = router({
roomName: z.string(),
})
)
.query(async ({ ctx, input }) => {
.query(async ({ input }) => {
const { roomName } = input;
try {
const res = await getRecordingsOfCalVideoByRoomName(roomName);

1004
yarn.lock

File diff suppressed because it is too large Load Diff