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",
"configurations": [
{
"name": "Next.js: Server",
"type": "node-terminal",
"type": "node",
"request": "launch",
"command": "npm run dev",
"skipFiles": ["<node_internals>/**"],
"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}/*"
}
}
]
}
}

View File

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

View File

@ -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<TransportOptions>({
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;

View File

@ -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<typeof _getServerSideProps> & WithNonceProps) {
const { t } = useLocale();
const router = useRouter();
const methods = useForm<LoginValues>();
const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const errorMessages: { [key: string]: string } = {
@ -94,6 +95,16 @@ export default function Login({
</Button>
);
const ExternalTotpFooter = (
<Button
onClick={() => {
window.location.replace("/");
}}
color="minimal">
{t("cancel")}
</Button>
);
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({
<EmailField
id="email"
label={t("email_address")}
defaultValue={router.query.email as string}
defaultValue={totpEmail || (router.query.email as string)}
placeholder="john.doe@example.com"
required
{...register("email")}
@ -152,7 +165,7 @@ export default function Login({
<PasswordField
id="password"
autoComplete="off"
required
required={!totpEmail}
className="mb-0"
{...register("password")}
/>
@ -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,
},
};
};