Implementing CAL-1173 (#7509)
* Implementation * Added check when no pass is provided * Refactoring login url to function7508-booking-page-ui-issues
parent
262c8cf37c
commit
cc1d606ba8
|
@ -2,37 +2,19 @@
|
||||||
"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/**"
|
|
||||||
],
|
|
||||||
"sourceMaps": true,
|
|
||||||
"resolveSourceMapLocations": [
|
|
||||||
"${workspaceFolder}/**",
|
|
||||||
"!**/node_modules/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
"cwd": "${workspaceFolder}/apps/web",
|
||||||
"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}/*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,18 +98,20 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.password) {
|
||||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
||||||
if (!isCorrectPassword) {
|
if (!isCorrectPassword) {
|
||||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (user.twoFactorEnabled) {
|
if (user.twoFactorEnabled) {
|
||||||
if (!credentials.totpCode) {
|
if (!credentials.totpCode) {
|
||||||
|
@ -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,8 +467,12 @@ export default NextAuth({
|
||||||
console.error("Error while linking account of already existing user");
|
console.error("Error while linking account of already existing user");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (existingUser.twoFactorEnabled) {
|
||||||
|
return loginWithTotp(existingUser);
|
||||||
|
} else {
|
||||||
return true;
|
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
|
||||||
// with the new email address. If it does, for now we return an error. If
|
// with the new email address. If it does, for now we return an error. If
|
||||||
|
@ -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 } });
|
||||||
|
if (existingUser.twoFactorEnabled) {
|
||||||
|
return loginWithTotp(existingUser);
|
||||||
|
} else {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return "/auth/error?error=new-email-conflict";
|
return "/auth/error?error=new-email-conflict";
|
||||||
}
|
}
|
||||||
|
@ -477,8 +503,12 @@ 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) {
|
||||||
|
if (existingUserWithEmail.twoFactorEnabled) {
|
||||||
|
return loginWithTotp(existingUserWithEmail);
|
||||||
|
} else {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// check if user was invited
|
// check if user was invited
|
||||||
if (
|
if (
|
||||||
|
@ -499,8 +529,12 @@ export default NextAuth({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (existingUserWithEmail.twoFactorEnabled) {
|
||||||
|
return loginWithTotp(existingUserWithEmail);
|
||||||
|
} else {
|
||||||
return true;
|
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
|
||||||
if (
|
if (
|
||||||
|
@ -511,7 +545,11 @@ export default NextAuth({
|
||||||
where: { email: existingUserWithEmail.email },
|
where: { email: existingUserWithEmail.email },
|
||||||
data: { password: null },
|
data: { password: null },
|
||||||
});
|
});
|
||||||
|
if (existingUserWithEmail.twoFactorEnabled) {
|
||||||
|
return loginWithTotp(existingUserWithEmail);
|
||||||
|
} else {
|
||||||
return true;
|
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,8 +572,12 @@ export default NextAuth({
|
||||||
const linkAccountNewUserData = { ...account, userId: newUser.id };
|
const linkAccountNewUserData = { ...account, userId: newUser.id };
|
||||||
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
||||||
|
|
||||||
|
if (account.twoFactorEnabled) {
|
||||||
|
return loginWithTotp(newUser);
|
||||||
|
} else {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
? !totpEmail
|
||||||
? TwoFactorFooter
|
? 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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue