From cc1d606ba8ef2884edb68197bcb116baf1d8b66a Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Sat, 4 Mar 2023 23:09:45 -0300 Subject: [PATCH] Implementing CAL-1173 (#7509) * Implementation * Added check when no pass is provided * Refactoring login url to function --- .vscode/launch.json | 42 ++++---------- apps/web/package.json | 1 + apps/web/pages/api/auth/[...nextauth].tsx | 68 ++++++++++++++++++----- apps/web/pages/auth/login.tsx | 58 +++++++++++++++++-- 4 files changed, 121 insertions(+), 48 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index e93ed3bcb2..af6f056b42 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,38 +2,20 @@ "version": "0.2.0", "configurations": [ { - "name": "Next.js: Server", - "type": "node-terminal", + "type": "node", "request": "launch", - "command": "npm run dev", - "skipFiles": ["/**"], - "outFiles": [ - "${workspaceFolder}/**/*.js", - "!**/node_modules/**" - ], - "sourceMaps": true, - "resolveSourceMapLocations": [ - "${workspaceFolder}/**", - "!**/node_modules/**" - ] - }, - { - "name": "Next.js: Client", - "type": "pwa-chrome", - "request": "launch", - "url": "http://localhost:3000" - }, - { - "name": "Next.js: Full Stack", - "type": "node-terminal", - "request": "launch", - "command": "npm run dev", + "name": "Next.js Node Debug", + "runtimeExecutable": "${workspaceFolder}/node_modules/next/dist/bin/next", + "env": { + "NODE_OPTIONS": "--inspect" + }, + "cwd": "${workspaceFolder}/apps/web", "console": "integratedTerminal", - "serverReadyAction": { - "pattern": "started server on .+, url: (https?://.+)", - "uriFormat": "%s", - "action": "debugWithChrome" + "sourceMapPathOverrides": { + "meteor://💻app/*": "${workspaceFolder}/*", + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack://?:*/*": "${workspaceFolder}/*" } } ] -} \ No newline at end of file +} diff --git a/apps/web/package.json b/apps/web/package.json index 875352abd9..58575af282 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -75,6 +75,7 @@ "handlebars": "^4.7.7", "ical.js": "^1.4.0", "ics": "^2.37.0", + "jose": "^4.13.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 961c2e2ae1..c58fbea361 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -2,6 +2,7 @@ import type { UserPermissionRole } from "@prisma/client"; import { IdentityProvider } from "@prisma/client"; import { readFileSync } from "fs"; import Handlebars from "handlebars"; +import { SignJWT } from "jose"; import type { Session } from "next-auth"; import NextAuth from "next-auth"; import { encode } from "next-auth/jwt"; @@ -18,7 +19,7 @@ import checkLicense from "@calcom/features/ee/common/server/checkLicense"; import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider"; import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth"; -import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultCookies } from "@calcom/lib/default-cookies"; import { randomString } from "@calcom/lib/random"; @@ -38,6 +39,21 @@ const transporter = nodemailer.createTransport({ const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase(); +const signJwt = async (payload: { email: string }) => { + const secret = new TextEncoder().encode(process.env.CALENDSO_ENCRYPTION_KEY); + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(payload.email) + .setIssuedAt() + .setIssuer(WEBSITE_URL) + .setAudience(`${WEBSITE_URL}/auth/login`) + .setExpirationTime("2m") + .sign(secret); +}; + +const loginWithTotp = async (user: { email: string }) => + `/auth/login?totp=${await signJwt({ email: user.email })}`; + const providers: Provider[] = [ CredentialsProvider({ id: "credentials", @@ -82,17 +98,19 @@ const providers: Provider[] = [ throw new Error(ErrorCode.IncorrectUsernamePassword); } - if (user.identityProvider !== IdentityProvider.CAL) { + if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) { throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); } - if (!user.password) { + if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) { throw new Error(ErrorCode.IncorrectUsernamePassword); } - const isCorrectPassword = await verifyPassword(credentials.password, user.password); - if (!isCorrectPassword) { - throw new Error(ErrorCode.IncorrectUsernamePassword); + if (user.password) { + const isCorrectPassword = await verifyPassword(credentials.password, user.password); + if (!isCorrectPassword) { + throw new Error(ErrorCode.IncorrectUsernamePassword); + } } if (user.twoFactorEnabled) { @@ -130,7 +148,7 @@ const providers: Provider[] = [ await limiter.check(10, user.email); // 10 requests per minute // Check if the user you are logging into has any active teams const hasActiveTeams = - user.teams.filter((m) => { + user.teams.filter((m: { team: { metadata: unknown } }) => { if (!IS_TEAM_BILLING_ENABLED) return true; const metadata = teamMetadataSchema.safeParse(m.team.metadata); if (metadata.success && metadata.data?.subscriptionId) return true; @@ -449,7 +467,11 @@ export default NextAuth({ console.error("Error while linking account of already existing user"); } } - return true; + if (existingUser.twoFactorEnabled) { + return loginWithTotp(existingUser); + } else { + return true; + } } // If the email address doesn't match, check if an account already exists @@ -461,7 +483,11 @@ export default NextAuth({ if (!userWithNewEmail) { await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } }); - return true; + if (existingUser.twoFactorEnabled) { + return loginWithTotp(existingUser); + } else { + return true; + } } else { return "/auth/error?error=new-email-conflict"; } @@ -477,7 +503,11 @@ export default NextAuth({ if (existingUserWithEmail) { // if self-hosted then we can allow auto-merge of identity providers if email is verified if (!hostedCal && existingUserWithEmail.emailVerified) { - return true; + if (existingUserWithEmail.twoFactorEnabled) { + return loginWithTotp(existingUserWithEmail); + } else { + return true; + } } // check if user was invited @@ -499,7 +529,11 @@ export default NextAuth({ }, }); - return true; + if (existingUserWithEmail.twoFactorEnabled) { + return loginWithTotp(existingUserWithEmail); + } else { + return true; + } } // User signs up with email/password and then tries to login with Google/SAML using the same email @@ -511,7 +545,11 @@ export default NextAuth({ where: { email: existingUserWithEmail.email }, data: { password: null }, }); - return true; + if (existingUserWithEmail.twoFactorEnabled) { + return loginWithTotp(existingUserWithEmail); + } else { + return true; + } } else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) { return "/auth/error?error=use-password-login"; } @@ -534,7 +572,11 @@ export default NextAuth({ const linkAccountNewUserData = { ...account, userId: newUser.id }; await calcomAdapter.linkAccount(linkAccountNewUserData); - return true; + if (account.twoFactorEnabled) { + return loginWithTotp(newUser); + } else { + return true; + } } return false; diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 9f1f6f148e..cdcec4c106 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import { jwtVerify } from "jose"; import type { GetServerSidePropsContext } from "next"; import { getCsrfToken, signIn } from "next-auth/react"; import Link from "next/link"; @@ -42,14 +43,14 @@ export default function Login({ isSAMLLoginEnabled, samlTenantID, samlProductID, + totpEmail, }: inferSSRProps & WithNonceProps) { const { t } = useLocale(); const router = useRouter(); const methods = useForm(); const { register, formState } = methods; - - const [twoFactorRequired, setTwoFactorRequired] = useState(false); + const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false); const [errorMessage, setErrorMessage] = useState(null); const errorMessages: { [key: string]: string } = { @@ -94,6 +95,16 @@ export default function Login({ ); + const ExternalTotpFooter = ( + + ); + const onSubmit = async (values: LoginValues) => { setErrorMessage(null); telemetry.event(telemetryEventTypes.login, collectPageParameters()); @@ -120,7 +131,9 @@ export default function Login({ heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")} footerText={ twoFactorRequired - ? TwoFactorFooter + ? !totpEmail + ? TwoFactorFooter + : ExternalTotpFooter : process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true" ? LoginFooter : null @@ -135,7 +148,7 @@ export default function Login({ @@ -211,6 +224,40 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer const session = await getSession({ req }); const ssr = await ssrInit(context); + const verifyJwt = (jwt: string) => { + const secret = new TextEncoder().encode(process.env.CALENDSO_ENCRYPTION_KEY); + + return jwtVerify(jwt, secret, { + issuer: WEBSITE_URL, + audience: `${WEBSITE_URL}/auth/login`, + algorithms: ["HS256"], + }); + }; + + let totpEmail = null; + if (context.query.totp) { + try { + const decryptedJwt = await verifyJwt(context.query.totp as string); + if (decryptedJwt.payload) { + totpEmail = decryptedJwt.payload.email as string; + } else { + return { + redirect: { + destination: "/auth/error?error=JWT%20Invalid%20Payload", + permanent: false, + }, + }; + } + } catch (e) { + return { + redirect: { + destination: "/auth/error?error=Invalid%20JWT%3A%20Please%20try%20again", + permanent: false, + }, + }; + } + } + if (session) { return { redirect: { @@ -238,6 +285,7 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer isSAMLLoginEnabled, samlTenantID, samlProductID, + totpEmail, }, }; };