-
Notifications
You must be signed in to change notification settings - Fork 7.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implementing CAL-1173 #7509
Implementing CAL-1173 #7509
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 })}`; | ||
emrysal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to not fail the authorization when an identity provider exists for the user, we want to let the authorization go through to check totp stuff |
||
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); | ||
} | ||
|
||
if (!user.password) { | ||
if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same thing here, when a TOTP code is used in the login form, a password is not entered as there is no password because it's an external provider, so unless there is a totp code sent and we are in the presence of an external provider, we let the flow continue not throwing an error to be able to check the totp code |
||
throw new Error(ErrorCode.IncorrectUsernamePassword); | ||
} | ||
|
||
const isCorrectPassword = await verifyPassword(credentials.password, user.password); | ||
if (!isCorrectPassword) { | ||
throw new Error(ErrorCode.IncorrectUsernamePassword); | ||
if (user.password) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should only check the password validity when it is present. |
||
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 } }) => { | ||
emrysal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
leog marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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("/"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The only way we could reuse the two factor auth required flag is that when the user wants to cancel, we reload, not closing the modal as the "Go Back" button does when Cal credentials are used. |
||
}} | ||
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)} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a totp code is needed in an external provider login use case, we still need the email to be filled to be sent in the form. |
||
placeholder="john.doe@example.com" | ||
required | ||
{...register("email")} | ||
|
@@ -152,7 +165,7 @@ export default function Login({ | |
<PasswordField | ||
id="password" | ||
autoComplete="off" | ||
required | ||
required={!totpEmail} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a totp code is needed in an external provider login use case, there is no password involved, so not requiring it to let the form go through when submitting a totp code. |
||
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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we are in the presence of a totp JWT token and we don't get a payload, we will redirect the user to an auth error "JWT Invalid Payload" |
||
permanent: false, | ||
}, | ||
}; | ||
} | ||
} catch (e) { | ||
return { | ||
redirect: { | ||
destination: "/auth/error?error=Invalid%20JWT%3A%20Please%20try%20again", | ||
emrysal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
permanent: false, | ||
}, | ||
}; | ||
} | ||
} | ||
|
||
if (session) { | ||
return { | ||
redirect: { | ||
|
@@ -238,6 +285,7 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer | |
isSAMLLoginEnabled, | ||
samlTenantID, | ||
samlProductID, | ||
totpEmail, | ||
}, | ||
}; | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this file meant to be changed in this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh you are right, although we can leave it, couldn't get the debugger work without these changes.