Implementing CAL-1173 (#7509)

* Implementation

* Added check when no pass is provided

* Refactoring login url to function
This commit is contained in:
Leo Giovanetti 2023-03-04 23:09:45 -03:00 committed by GitHub
parent 262c8cf37c
commit cc1d606ba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 121 additions and 48 deletions

42
.vscode/launch.json vendored
View File

@ -2,38 +2,20 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Next.js: Server", "type": "node",
"type": "node-terminal",
"request": "launch", "request": "launch",
"command": "npm run dev", "name": "Next.js Node Debug",
"skipFiles": ["<node_internals>/**"], "runtimeExecutable": "${workspaceFolder}/node_modules/next/dist/bin/next",
"outFiles": [ "env": {
"${workspaceFolder}/**/*.js", "NODE_OPTIONS": "--inspect"
"!**/node_modules/**" },
], "cwd": "${workspaceFolder}/apps/web",
"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",
"console": "integratedTerminal", "console": "integratedTerminal",
"serverReadyAction": { "sourceMapPathOverrides": {
"pattern": "started server on .+, url: (https?://.+)", "meteor://💻app/*": "${workspaceFolder}/*",
"uriFormat": "%s", "webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"action": "debugWithChrome" "webpack://?:*/*": "${workspaceFolder}/*"
} }
} }
] ]
} }

View File

@ -75,6 +75,7 @@
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"ical.js": "^1.4.0", "ical.js": "^1.4.0",
"ics": "^2.37.0", "ics": "^2.37.0",
"jose": "^4.13.1",
"kbar": "^0.1.0-beta.36", "kbar": "^0.1.0-beta.36",
"libphonenumber-js": "^1.10.12", "libphonenumber-js": "^1.10.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -2,6 +2,7 @@ import type { UserPermissionRole } from "@prisma/client";
import { IdentityProvider } from "@prisma/client"; import { IdentityProvider } from "@prisma/client";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import Handlebars from "handlebars"; import Handlebars from "handlebars";
import { SignJWT } from "jose";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { encode } from "next-auth/jwt"; 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 ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth"; 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 { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies"; import { defaultCookies } from "@calcom/lib/default-cookies";
import { randomString } from "@calcom/lib/random"; import { randomString } from "@calcom/lib/random";
@ -38,6 +39,21 @@ const transporter = nodemailer.createTransport<TransportOptions>({
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase(); 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[] = [ const providers: Provider[] = [
CredentialsProvider({ CredentialsProvider({
id: "credentials", id: "credentials",
@ -82,17 +98,19 @@ const providers: Provider[] = [
throw new Error(ErrorCode.IncorrectUsernamePassword); throw new Error(ErrorCode.IncorrectUsernamePassword);
} }
if (user.identityProvider !== IdentityProvider.CAL) { if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
} }
if (!user.password) { if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.IncorrectUsernamePassword); throw new Error(ErrorCode.IncorrectUsernamePassword);
} }
const isCorrectPassword = await verifyPassword(credentials.password, user.password); if (user.password) {
if (!isCorrectPassword) { const isCorrectPassword = await verifyPassword(credentials.password, user.password);
throw new Error(ErrorCode.IncorrectUsernamePassword); if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectUsernamePassword);
}
} }
if (user.twoFactorEnabled) { if (user.twoFactorEnabled) {
@ -130,7 +148,7 @@ const providers: Provider[] = [
await limiter.check(10, user.email); // 10 requests per minute await limiter.check(10, user.email); // 10 requests per minute
// Check if the user you are logging into has any active teams // Check if the user you are logging into has any active teams
const hasActiveTeams = const hasActiveTeams =
user.teams.filter((m) => { user.teams.filter((m: { team: { metadata: unknown } }) => {
if (!IS_TEAM_BILLING_ENABLED) return true; if (!IS_TEAM_BILLING_ENABLED) return true;
const metadata = teamMetadataSchema.safeParse(m.team.metadata); const metadata = teamMetadataSchema.safeParse(m.team.metadata);
if (metadata.success && metadata.data?.subscriptionId) return true; 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"); 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 // If the email address doesn't match, check if an account already exists
@ -461,7 +483,11 @@ export default NextAuth({
if (!userWithNewEmail) { if (!userWithNewEmail) {
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } }); await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
return true; if (existingUser.twoFactorEnabled) {
return loginWithTotp(existingUser);
} else {
return true;
}
} else { } else {
return "/auth/error?error=new-email-conflict"; return "/auth/error?error=new-email-conflict";
} }
@ -477,7 +503,11 @@ export default NextAuth({
if (existingUserWithEmail) { if (existingUserWithEmail) {
// if self-hosted then we can allow auto-merge of identity providers if email is verified // if self-hosted then we can allow auto-merge of identity providers if email is verified
if (!hostedCal && existingUserWithEmail.emailVerified) { if (!hostedCal && existingUserWithEmail.emailVerified) {
return true; if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail);
} else {
return true;
}
} }
// check if user was invited // 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 // 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 }, where: { email: existingUserWithEmail.email },
data: { password: null }, data: { password: null },
}); });
return true; if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail);
} else {
return true;
}
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) { } else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
return "/auth/error?error=use-password-login"; return "/auth/error?error=use-password-login";
} }
@ -534,7 +572,11 @@ export default NextAuth({
const linkAccountNewUserData = { ...account, userId: newUser.id }; const linkAccountNewUserData = { ...account, userId: newUser.id };
await calcomAdapter.linkAccount(linkAccountNewUserData); await calcomAdapter.linkAccount(linkAccountNewUserData);
return true; if (account.twoFactorEnabled) {
return loginWithTotp(newUser);
} else {
return true;
}
} }
return false; return false;

View File

@ -1,4 +1,5 @@
import classNames from "classnames"; import classNames from "classnames";
import { jwtVerify } from "jose";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react"; import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
@ -42,14 +43,14 @@ export default function Login({
isSAMLLoginEnabled, isSAMLLoginEnabled,
samlTenantID, samlTenantID,
samlProductID, samlProductID,
totpEmail,
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) { }: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const methods = useForm<LoginValues>(); const methods = useForm<LoginValues>();
const { register, formState } = methods; const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = { const errorMessages: { [key: string]: string } = {
@ -94,6 +95,16 @@ export default function Login({
</Button> </Button>
); );
const ExternalTotpFooter = (
<Button
onClick={() => {
window.location.replace("/");
}}
color="minimal">
{t("cancel")}
</Button>
);
const onSubmit = async (values: LoginValues) => { const onSubmit = async (values: LoginValues) => {
setErrorMessage(null); setErrorMessage(null);
telemetry.event(telemetryEventTypes.login, collectPageParameters()); telemetry.event(telemetryEventTypes.login, collectPageParameters());
@ -120,7 +131,9 @@ export default function Login({
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")} heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
footerText={ footerText={
twoFactorRequired twoFactorRequired
? TwoFactorFooter ? !totpEmail
? TwoFactorFooter
: ExternalTotpFooter
: process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true" : process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true"
? LoginFooter ? LoginFooter
: null : null
@ -135,7 +148,7 @@ export default function Login({
<EmailField <EmailField
id="email" id="email"
label={t("email_address")} label={t("email_address")}
defaultValue={router.query.email as string} defaultValue={totpEmail || (router.query.email as string)}
placeholder="john.doe@example.com" placeholder="john.doe@example.com"
required required
{...register("email")} {...register("email")}
@ -152,7 +165,7 @@ export default function Login({
<PasswordField <PasswordField
id="password" id="password"
autoComplete="off" autoComplete="off"
required required={!totpEmail}
className="mb-0" className="mb-0"
{...register("password")} {...register("password")}
/> />
@ -211,6 +224,40 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
const session = await getSession({ req }); const session = await getSession({ req });
const ssr = await ssrInit(context); 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) { if (session) {
return { return {
redirect: { redirect: {
@ -238,6 +285,7 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
isSAMLLoginEnabled, isSAMLLoginEnabled,
samlTenantID, samlTenantID,
samlProductID, samlProductID,
totpEmail,
}, },
}; };
}; };