2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { useSession } from "next-auth/react";
import type { FC } from "react";
import { Fragment } from "react";
import { UserPermissionRole } from "@calcom/prisma/enums";
type AdminRequiredProps = {
as?: keyof JSX.IntrinsicElements;
children?: React.ReactNode;
/**Not needed right now but will be useful if we ever expand our permission roles */
roleRequired?: UserPermissionRole;
};
export const PermissionContainer: FC<AdminRequiredProps> = ({
children,
as,
roleRequired = "ADMIN",
...rest
}) => {
const session = useSession();
// Admin can do everything
if (session.data?.user.role !== roleRequired && session.data?.user.role != UserPermissionRole.ADMIN)
return null;
const Component = as ?? Fragment;
return <Component {...rest}>{children}</Component>;
};

View File

@@ -0,0 +1 @@
# Auth-related code will live here

View File

@@ -0,0 +1,68 @@
import { signIn } from "next-auth/react";
import type { Dispatch, SetStateAction } from "react";
import { useFormContext } from "react-hook-form";
import z from "zod";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
interface Props {
samlTenantID: string;
samlProductID: string;
setErrorMessage: Dispatch<SetStateAction<string | null>>;
}
const schema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function SAMLLogin({ samlTenantID, samlProductID, setErrorMessage }: Props) {
const { t } = useLocale();
const methods = useFormContext();
const mutation = trpc.viewer.public.samlTenantProduct.useMutation({
onSuccess: async (data) => {
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
},
onError: (err) => {
setErrorMessage(t(err.message));
},
});
return (
<Button
StartIcon="lock"
color="secondary"
data-testid="saml"
className="flex w-full justify-center"
onClick={async (event) => {
event.preventDefault();
if (!HOSTED_CAL_FEATURES) {
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
return;
}
// Hosted solution, fetch tenant and product from the backend
const email = methods.getValues("email");
const parsed = schema.safeParse({ email });
if (!parsed.success) {
const {
fieldErrors: { email },
} = parsed.error.flatten();
setErrorMessage(email ? email[0] : null);
return;
}
mutation.mutate({
email,
});
}}>
{t("signin_with_saml_oidc")}
</Button>
);
}

View File

@@ -0,0 +1,20 @@
export enum ErrorCode {
IncorrectEmailPassword = "incorrect-email-password",
UserNotFound = "user-not-found",
IncorrectPassword = "incorrect-password",
UserMissingPassword = "missing-password",
TwoFactorDisabled = "two-factor-disabled",
TwoFactorAlreadyEnabled = "two-factor-already-enabled",
TwoFactorSetupRequired = "two-factor-setup-required",
SecondFactorRequired = "second-factor-required",
IncorrectTwoFactorCode = "incorrect-two-factor-code",
IncorrectBackupCode = "incorrect-backup-code",
MissingBackupCodes = "missing-backup-codes",
IncorrectEmailVerificationCode = "incorrect_email_verification_code",
InternalServerError = "internal-server-error",
NewPasswordMatchesOld = "new-password-matches-old",
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
RateLimitExceeded = "rate-limit-exceeded",
SocialIdentityProviderRequired = "social-identity-provider-required",
UserAccountLocked = "user-account-locked",
}

View File

@@ -0,0 +1,13 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { getSession } from "./getSession";
type CtxOrReq = { req: NextApiRequest; ctx?: never } | { ctx: { req: NextApiRequest }; req?: never };
export const ensureSession = async (ctxOrReq: CtxOrReq) => {
const session = await getSession(ctxOrReq);
if (!session?.user.id) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
return session;
};

View File

@@ -0,0 +1,61 @@
import { parse } from "accept-language-parser";
import { lookup } from "bcp-47-match";
import type { GetTokenParams } from "next-auth/jwt";
import { getToken } from "next-auth/jwt";
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
//@ts-expect-error no type definitions
import { i18n } from "@calcom/config/next-i18next.config";
/**
* This is a slimmed down version of the `getServerSession` function from
* `next-auth`.
*
* Instead of requiring the entire options object for NextAuth, we create
* a compatible session using information from the incoming token.
*
* The downside to this is that we won't refresh sessions if the users
* token has expired (30 days). This should be fine as we call `/auth/session`
* frequently enough on the client-side to keep the session alive.
*/
export const getLocale = async (
req:
| GetTokenParams["req"]
| {
cookies: ReadonlyRequestCookies;
headers: ReadonlyHeaders;
}
): Promise<string> => {
const token = await getToken({
req: req as GetTokenParams["req"],
});
const tokenLocale = token?.["locale"];
if (tokenLocale) {
return tokenLocale;
}
const acceptLanguage =
req.headers instanceof Headers ? req.headers.get("accept-language") : req.headers["accept-language"];
const languages = acceptLanguage ? parse(acceptLanguage) : [];
const code: string = languages[0]?.code ?? "";
const region: string = languages[0]?.region ?? "";
// the code should consist of 2 or 3 lowercase letters
// the regex underneath is more permissive
const testedCode = /^[a-zA-Z]+$/.test(code) ? code : "en";
// the code should consist of either 2 uppercase letters or 3 digits
// the regex underneath is more permissive
const testedRegion = /^[a-zA-Z0-9]+$/.test(region) ? region : "";
const requestedLocale = `${testedCode}${testedRegion !== "" ? "-" : ""}${testedRegion}`;
// use fallback to closest supported locale.
// for instance, es-419 will be transformed to es
return lookup(i18n.locales, requestedLocale) ?? requestedLocale;
};

View File

@@ -0,0 +1,131 @@
import { LRUCache } from "lru-cache";
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
import type { AuthOptions, Session } from "next-auth";
import { getToken } from "next-auth/jwt";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["getServerSession"] });
/**
* Stores the session in memory using the stringified token as the key.
*
*/
const CACHE = new LRUCache<string, Session>({ max: 1000 });
/**
* This is a slimmed down version of the `getServerSession` function from
* `next-auth`.
*
* Instead of requiring the entire options object for NextAuth, we create
* a compatible session using information from the incoming token.
*
* The downside to this is that we won't refresh sessions if the users
* token has expired (30 days). This should be fine as we call `/auth/session`
* frequently enough on the client-side to keep the session alive.
*/
export async function getServerSession(options: {
req: NextApiRequest | GetServerSidePropsContext["req"];
res?: NextApiResponse | GetServerSidePropsContext["res"];
authOptions?: AuthOptions;
}) {
const { req, authOptions: { secret } = {} } = options;
const token = await getToken({
req,
secret,
});
log.debug("Getting server session", safeStringify({ token }));
if (!token || !token.email || !token.sub) {
log.debug("Couldnt get token");
return null;
}
const cachedSession = CACHE.get(JSON.stringify(token));
if (cachedSession) {
log.debug("Returning cached session", safeStringify(cachedSession));
return cachedSession;
}
const userFromDb = await prisma.user.findUnique({
where: {
email: token.email.toLowerCase(),
},
});
if (!userFromDb) {
log.debug("No user found");
return null;
}
const hasValidLicense = await checkLicense(prisma);
let upId = token.upId;
if (!upId) {
upId = `usr-${userFromDb.id}`;
}
if (!upId) {
log.error("No upId found for session", { userId: userFromDb.id });
return null;
}
const user = await UserRepository.enrichUserWithTheProfile({
user: userFromDb,
upId,
});
const session: Session = {
hasValidLicense,
expires: new Date(typeof token.exp === "number" ? token.exp * 1000 : Date.now()).toISOString(),
user: {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
emailVerified: user.emailVerified,
email_verified: user.emailVerified !== null,
role: user.role,
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
belongsToActiveTeam: token.belongsToActiveTeam,
org: token.org,
locale: user.locale ?? undefined,
profile: user.profile,
},
profileId: token.profileId,
upId,
};
if (token?.impersonatedBy?.id) {
const impersonatedByUser = await prisma.user.findUnique({
where: {
id: token.impersonatedBy.id,
},
select: {
id: true,
role: true,
},
});
if (impersonatedByUser) {
session.user.impersonatedBy = {
id: impersonatedByUser?.id,
role: impersonatedByUser.role,
};
}
}
CACHE.set(JSON.stringify(token), session);
log.debug("Returned session", safeStringify(session));
return session;
}

View File

@@ -0,0 +1,10 @@
import type { Session } from "next-auth";
import type { GetSessionParams } from "next-auth/react";
import { getSession as getSessionInner } from "next-auth/react";
export async function getSession(options: GetSessionParams): Promise<Session | null> {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
return session as Session | null;
}

View File

@@ -0,0 +1,6 @@
import { hash } from "bcryptjs";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}

View File

@@ -0,0 +1,7 @@
import { IdentityProvider } from "@calcom/prisma/enums";
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
[IdentityProvider.CAL]: "Cal",
[IdentityProvider.GOOGLE]: "Google",
[IdentityProvider.SAML]: "SAML",
};

View File

@@ -0,0 +1,26 @@
export function isPasswordValid(password: string): boolean;
export function isPasswordValid(
password: string,
breakdown: boolean,
strict?: boolean
): { caplow: boolean; num: boolean; min: boolean; admin_min: boolean };
export function isPasswordValid(password: string, breakdown?: boolean, strict?: boolean) {
let cap = false, // Has uppercase characters
low = false, // Has lowercase characters
num = false, // At least one number
min = false, // Eight characters, or fifteen in strict mode.
admin_min = false;
if (password.length >= 7 && (!strict || password.length > 14)) min = true;
if (strict && password.length > 14) admin_min = true;
if (password.match(/\d/)) num = true;
if (password.match(/[a-z]/)) low = true;
if (password.match(/[A-Z]/)) cap = true;
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);
let errors: Record<string, boolean> = { caplow: cap && low, num, min };
// Only return the admin key if strict mode is enabled.
if (strict) errors = { ...errors, admin_min };
return errors;
}

View File

@@ -0,0 +1,78 @@
import type { Account, IdentityProvider, Prisma, User, VerificationToken } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { PrismaClient } from "@calcom/prisma";
import { identityProviderNameMap } from "./identityProviderNameMap";
/** @return { import("next-auth/adapters").Adapter } */
export default function CalComAdapter(prismaClient: PrismaClient) {
return {
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
getUser: (id: string | number) =>
prismaClient.user.findUnique({ where: { id: typeof id === "string" ? parseInt(id) : id } }),
getUserByEmail: (email: User["email"]) => prismaClient.user.findUnique({ where: { email } }),
async getUserByAccount(provider_providerAccountId: {
providerAccountId: Account["providerAccountId"];
provider: User["identityProvider"];
}) {
let _account;
const account = await prismaClient.account.findUnique({
where: {
provider_providerAccountId,
},
select: { user: true },
});
if (account) {
return (_account = account === null || account === void 0 ? void 0 : account.user) !== null &&
_account !== void 0
? _account
: null;
}
// NOTE: this code it's our fallback to users without Account but credentials in User Table
// We should remove this code after all googles tokens have expired
const provider = provider_providerAccountId?.provider.toUpperCase() as IdentityProvider;
if (["GOOGLE", "SAML"].indexOf(provider) < 0) {
return null;
}
const obtainProvider = identityProviderNameMap[provider].toUpperCase() as IdentityProvider;
const user = await prismaClient.user.findFirst({
where: {
identityProviderId: provider_providerAccountId?.providerAccountId,
identityProvider: obtainProvider,
},
});
return user || null;
},
updateUser: ({ id, ...data }: Prisma.UserUncheckedCreateInput) =>
prismaClient.user.update({ where: { id }, data }),
deleteUser: (id: User["id"]) => prismaClient.user.delete({ where: { id } }),
async createVerificationToken(data: VerificationToken) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id: _, ...verificationToken } = await prismaClient.verificationToken.create({
data,
});
return verificationToken;
},
async useVerificationToken(identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput) {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id: _, ...verificationToken } = await prismaClient.verificationToken.delete({
where: { identifier_token },
});
return verificationToken;
} catch (error) {
// If token already used/deleted, just return null
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2025") return null;
}
throw error;
}
},
linkAccount: (data: Prisma.AccountCreateInput) => prismaClient.account.create({ data }),
unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) =>
prismaClient.account.delete({ where: { provider_providerAccountId } }),
};
}

View File

@@ -0,0 +1,947 @@
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import type { AuthOptions, Session } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
import type { Provider } from "next-auth/providers";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
import { isENVDev } from "@calcom/lib/env";
import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { ErrorCode } from "./ErrorCode";
import { isPasswordValid } from "./isPasswordValid";
import CalComAdapter from "./next-auth-custom-adapter";
import { verifyPassword } from "./verifyPassword";
const log = logger.getSubLogger({ prefix: ["next-auth-options"] });
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}";
const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } =
JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {};
const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true";
const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED);
const ORGANIZATIONS_AUTOLINK =
process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true";
const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`;
const getDomainFromEmail = (email: string): string => email.split("@")[1];
const getVerifiedOrganizationByAutoAcceptEmailDomain = async (domain: string) => {
const existingOrg = await prisma.team.findFirst({
where: {
organizationSettings: {
isOrganizationVerified: true,
orgAutoAcceptEmail: domain,
},
},
select: {
id: true,
},
});
return existingOrg?.id;
};
const loginWithTotp = async (email: string) =>
`/auth/login?totp=${await (await import("./signJwt")).default({ email })}`;
type UserTeams = {
teams: (Membership & {
team: Pick<Team, "metadata">;
})[];
};
export const checkIfUserBelongsToActiveTeam = <T extends UserTeams>(user: T) =>
user.teams.some((m: { team: { metadata: unknown } }) => {
if (!IS_TEAM_BILLING_ENABLED) {
return true;
}
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
return metadata.success && metadata.data?.subscriptionId;
});
const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string) => {
const [orgUsername, apexDomain] = email.split("@");
if (!ORGANIZATIONS_AUTOLINK || idP !== "GOOGLE") return { orgUsername, orgId: undefined };
const existingOrg = await prisma.team.findFirst({
where: {
organizationSettings: {
isOrganizationVerified: true,
orgAutoAcceptEmail: apexDomain,
},
},
select: {
id: true,
},
});
return { orgUsername, orgId: existingOrg?.id };
};
const providers: Provider[] = [
CredentialsProvider({
id: "credentials",
name: "Cal.com",
type: "credentials",
credentials: {
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" },
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials) {
if (!credentials) {
console.error(`For some reason credentials are missing`);
throw new Error(ErrorCode.InternalServerError);
}
const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
email: credentials.email,
});
// Don't leak information about it being username or password that is invalid
if (!user) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
// Locked users cannot login
if (user.locked) {
throw new Error(ErrorCode.UserAccountLocked);
}
await checkRateLimitAndThrowError({
identifier: user.email,
});
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (!user.password?.hash && user.identityProvider == IdentityProvider.CAL) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (user.password?.hash && !credentials.totpCode) {
if (!user.password?.hash) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
const isCorrectPassword = await verifyPassword(credentials.password, user.password.hash);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
}
if (user.twoFactorEnabled && credentials.backupCode) {
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with backup code login.");
throw new Error(ErrorCode.InternalServerError);
}
if (!user.backupCodes) throw new Error(ErrorCode.MissingBackupCodes);
const backupCodes = JSON.parse(
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
);
// check if user-supplied code matches one
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
if (index === -1) throw new Error(ErrorCode.IncorrectBackupCode);
// delete verified backup code and re-encrypt remaining
backupCodes[index] = null;
await prisma.user.update({
where: {
id: user.id,
},
data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
},
});
} else if (user.twoFactorEnabled) {
if (!credentials.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = (await import("@calcom/lib/totp")).totpAuthenticatorCheck(
credentials.totpCode,
secret
);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
// Check if the user you are logging into has any active teams
const hasActiveTeams = checkIfUserBelongsToActiveTeam(user);
// authentication success- but does it meet the minimum password requirements?
const validateRole = (role: UserPermissionRole) => {
// User's role is not "ADMIN"
if (role !== "ADMIN") return role;
// User's identity provider is not "CAL"
if (user.identityProvider !== IdentityProvider.CAL) return role;
if (process.env.NEXT_PUBLIC_IS_E2E) {
console.warn("E2E testing is enabled, skipping password and 2FA requirements for Admin");
return role;
}
// User's password is valid and two-factor authentication is enabled
if (isPasswordValid(credentials.password, false, true) && user.twoFactorEnabled) return role;
// Code is running in a development environment
if (isENVDev) return role;
// By this point it is an ADMIN without valid security conditions
return "INACTIVE_ADMIN";
};
return {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: validateRole(user.role),
belongsToActiveTeam: hasActiveTeams,
locale: user.locale,
profile: user.allProfiles[0],
};
},
}),
ImpersonationProvider,
];
if (IS_GOOGLE_LOGIN_ENABLED) {
providers.push(
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
})
);
}
if (isSAMLLoginEnabled) {
providers.push({
id: "saml",
name: "BoxyHQ",
type: "oauth",
version: "2.0",
checks: ["pkce", "state"],
authorization: {
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
params: {
scope: "",
response_type: "code",
provider: "saml",
},
},
token: {
url: `${WEBAPP_URL}/api/auth/saml/token`,
params: { grant_type: "authorization_code" },
},
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
profile: async (profile: {
id?: number;
firstName?: string;
lastName?: string;
email?: string;
locale?: string;
}) => {
const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
email: profile.email || "",
});
return {
id: profile.id || 0,
firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
name: `${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
email_verified: true,
locale: profile.locale,
...(user ? { profile: user.allProfiles[0] } : {}),
};
},
options: {
clientId: "dummy",
clientSecret: clientSecretVerifier,
},
allowDangerousEmailAccountLinking: true,
});
// Idp initiated login
providers.push(
CredentialsProvider({
id: "saml-idp",
name: "IdP Login",
credentials: {
code: {},
},
async authorize(credentials) {
if (!credentials) {
return null;
}
const { code } = credentials;
if (!code) {
return null;
}
const { oauthController } = await (await import("@calcom/features/ee/sso/lib/jackson")).default();
// Fetch access token
const { access_token } = await oauthController.token({
code,
grant_type: "authorization_code",
redirect_uri: `${process.env.NEXTAUTH_URL}`,
client_id: "dummy",
client_secret: clientSecretVerifier,
});
if (!access_token) {
return null;
}
// Fetch user info
const userInfo = await oauthController.userInfo(access_token);
if (!userInfo) {
return null;
}
const { id, firstName, lastName } = userInfo;
const email = userInfo.email.toLowerCase();
let user = !email
? undefined
: await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email });
if (!user) {
const hostedCal = Boolean(HOSTED_CAL_FEATURES);
if (hostedCal && email) {
const domain = getDomainFromEmail(email);
const organizationId = await getVerifiedOrganizationByAutoAcceptEmailDomain(domain);
if (organizationId) {
const createUsersAndConnectToOrgProps = {
emailsToCreate: [email],
organizationId,
identityProvider: IdentityProvider.SAML,
identityProviderId: email,
};
await createUsersAndConnectToOrg(createUsersAndConnectToOrgProps);
user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
email: email,
});
}
}
if (!user) throw new Error(ErrorCode.UserNotFound);
}
const [userProfile] = user?.allProfiles;
return {
id: id as unknown as number,
firstName,
lastName,
email,
name: `${firstName} ${lastName}`.trim(),
email_verified: true,
profile: userProfile,
};
},
})
);
}
providers.push(
EmailProvider({
type: "email",
maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
// Here we setup the sendVerificationRequest that calls the email template with the identifier (email) and token to verify.
sendVerificationRequest: async (props) => (await import("./sendVerificationRequest")).default(props),
})
);
function isNumber(n: string) {
return !isNaN(parseFloat(n)) && !isNaN(+n);
}
const calcomAdapter = CalComAdapter(prisma);
const mapIdentityProvider = (providerName: string) => {
switch (providerName) {
case "saml-idp":
case "saml":
return IdentityProvider.SAML;
default:
return IdentityProvider.GOOGLE;
}
};
export const AUTH_OPTIONS: AuthOptions = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
adapter: calcomAdapter,
session: {
strategy: "jwt",
},
jwt: {
// decorate the native JWT encode function
// Impl. detail: We don't pass through as this function is called with encode/decode functions.
encode: async ({ token, maxAge, secret }) => {
if (token?.sub && isNumber(token.sub)) {
const user = await prisma.user.findFirst({
where: { id: Number(token.sub) },
select: { metadata: true },
});
// if no user is found, we still don't want to crash here.
if (user) {
const metadata = userMetadata.parse(user.metadata);
if (metadata?.sessionTimeout) {
maxAge = metadata.sessionTimeout * 60;
}
}
}
return encode({ secret, token, maxAge });
},
},
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/error", // Error code passed in query string as ?error=
verifyRequest: "/auth/verify",
// newUser: "/auth/new", // New users will be directed here on first sign in (leave the property out if not of interest)
},
providers,
callbacks: {
async jwt({
// Always available but with a little difference in value
token,
// Available only in case of signIn, signUp or useSession().update call.
trigger,
// Available when useSession().update is called. The value will be the POST data
session,
// Available only in the first call once the user signs in. Not available in subsequent calls
user,
// Available only in the first call once the user signs in. Not available in subsequent calls
account,
}) {
log.debug("callbacks:jwt", safeStringify({ token, user, account, trigger, session }));
// The data available in 'session' depends on what data was supplied in update method call of session
if (trigger === "update") {
return {
...token,
profileId: session?.profileId ?? token.profileId ?? null,
upId: session?.upId ?? token.upId ?? null,
locale: session?.locale ?? token.locale ?? "en",
name: session?.name ?? token.name,
username: session?.username ?? token.username,
email: session?.email ?? token.email,
} as JWT;
}
const autoMergeIdentities = async () => {
const existingUser = await prisma.user.findFirst({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
where: { email: token.email! },
select: {
id: true,
username: true,
avatarUrl: true,
name: true,
email: true,
role: true,
locale: true,
movedToProfileId: true,
teams: {
include: {
team: true,
},
},
},
});
if (!existingUser) {
return token;
}
// Check if the existingUser has any active teams
const belongsToActiveTeam = checkIfUserBelongsToActiveTeam(existingUser);
const { teams: _teams, ...existingUserWithoutTeamsField } = existingUser;
const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(existingUser);
log.debug(
"callbacks:jwt:autoMergeIdentities",
safeStringify({
allProfiles,
})
);
const { upId } = determineProfile({ profiles: allProfiles, token });
const profile = await ProfileRepository.findByUpId(upId);
if (!profile) {
throw new Error("Profile not found");
}
const profileOrg = profile?.organization;
let orgRole: MembershipRole | undefined;
// Get users role of org
if (profileOrg) {
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
teamId: profileOrg.id,
userId: existingUser.id,
},
},
});
orgRole = membership?.role;
}
return {
...existingUserWithoutTeamsField,
...token,
profileId: profile.id,
upId,
belongsToActiveTeam,
// All organizations in the token would be too big to store. It breaks the sessions request.
// So, we just set the currently switched organization only here.
org: profileOrg
? {
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
logoUrl: profileOrg.logoUrl,
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
domainSuffix: subdomainSuffix(),
role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg
}
: null,
} as JWT;
};
if (!user) {
return await autoMergeIdentities();
}
if (!account) {
return token;
}
if (account.type === "credentials") {
// return token if credentials,saml-idp
if (account.provider === "saml-idp") {
return token;
}
// any other credentials, add user info
return {
...token,
id: user.id,
name: user.name,
username: user.username,
email: user.email,
role: user.role,
impersonatedBy: user.impersonatedBy,
belongsToActiveTeam: user?.belongsToActiveTeam,
org: user?.org,
locale: user?.locale,
profileId: user.profile?.id ?? token.profileId ?? null,
upId: user.profile?.upId ?? token.upId ?? null,
} as JWT;
}
// The arguments above are from the provider so we need to look up the
// user based on those values in order to construct a JWT.
if (account.type === "oauth") {
if (!account.provider || !account.providerAccountId) {
return token;
}
const idP = account.provider === "saml" ? IdentityProvider.SAML : IdentityProvider.GOOGLE;
const existingUser = await prisma.user.findFirst({
where: {
AND: [
{
identityProvider: idP,
},
{
identityProviderId: account.providerAccountId,
},
],
},
});
if (!existingUser) {
return await autoMergeIdentities();
}
return {
...token,
id: existingUser.id,
name: existingUser.name,
username: existingUser.username,
email: existingUser.email,
role: existingUser.role,
impersonatedBy: token.impersonatedBy,
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: existingUser.locale,
} as JWT;
}
if (account.type === "email") {
return await autoMergeIdentities();
}
return token;
},
async session({ session, token, user }) {
log.debug("callbacks:session - Session callback called", safeStringify({ session, token, user }));
const hasValidLicense = await checkLicense(prisma);
const profileId = token.profileId;
const calendsoSession: Session = {
...session,
profileId,
upId: token.upId || session.upId,
hasValidLicense,
user: {
...session.user,
id: token.id as number,
name: token.name,
username: token.username as string,
role: token.role as UserPermissionRole,
impersonatedBy: token.impersonatedBy,
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: token.locale,
},
};
return calendsoSession;
},
async signIn(params) {
const {
/**
* Available when Credentials provider is used - Has the value returned by authorize callback
*/
user,
/**
* Available when Credentials provider is used - Has the value submitted as the body of the HTTP POST submission
*/
profile,
account,
} = params;
log.debug("callbacks:signin", safeStringify(params));
if (account?.provider === "email") {
return true;
}
// In this case we've already verified the credentials in the authorize
// callback so we can sign the user in.
// Only if provider is not saml-idp
if (account?.provider !== "saml-idp") {
if (account?.type === "credentials") {
return true;
}
if (account?.type !== "oauth") {
return false;
}
}
if (!user.email) {
return false;
}
if (!user.name) {
return false;
}
if (account?.provider) {
const idP: IdentityProvider = mapIdentityProvider(account.provider);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error TODO validate email_verified key on profile
user.email_verified = user.email_verified || !!user.emailVerified || profile.email_verified;
if (!user.email_verified) {
return "/auth/error?error=unverified-email";
}
let existingUser = await prisma.user.findFirst({
include: {
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
/* --- START FIX LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (!existingUser) {
existingUser = await prisma.user.findFirst({
include: {
accounts: {
where: {
provider: account.provider,
},
},
},
where: {
identityProvider: idP,
identityProviderId: String(user.id),
},
});
if (existingUser) {
await prisma.user.update({
where: {
id: existingUser?.id,
},
data: {
identityProviderId: account.providerAccountId,
},
});
}
}
/* --- END FIXES LEGACY ISSUE WHERE 'identityProviderId' was accidentally set to userId --- */
if (existingUser) {
// In this case there's an existing user and their email address
// hasn't changed since they last logged in.
if (existingUser.email === user.email) {
try {
// If old user without Account entry we link their google account
if (existingUser.accounts.length === 0) {
const linkAccountWithUserData = {
...account,
userId: existingUser.id,
providerEmail: user.email,
};
await calcomAdapter.linkAccount(linkAccountWithUserData);
}
} catch (error) {
if (error instanceof Error) {
console.error("Error while linking account of already existing user");
}
}
if (existingUser.twoFactorEnabled && existingUser.identityProvider === idP) {
return loginWithTotp(existingUser.email);
} else {
return true;
}
}
// 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
// not, update the email of their account and log them in.
const userWithNewEmail = await prisma.user.findFirst({
where: { email: user.email },
});
if (!userWithNewEmail) {
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
if (existingUser.twoFactorEnabled) {
return loginWithTotp(existingUser.email);
} else {
return true;
}
} else {
return "/auth/error?error=new-email-conflict";
}
}
// If there's no existing user for this identity provider and id, create
// a new account. If an account already exists with the incoming email
// address return an error for now.
const existingUserWithEmail = await prisma.user.findFirst({
where: {
email: {
equals: user.email,
mode: "insensitive",
},
},
include: {
password: true,
},
});
if (existingUserWithEmail) {
// if self-hosted then we can allow auto-merge of identity providers if email is verified
if (
!hostedCal &&
existingUserWithEmail.emailVerified &&
existingUserWithEmail.identityProvider !== IdentityProvider.CAL
) {
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
}
// check if user was invited
if (
!existingUserWithEmail.password?.hash &&
!existingUserWithEmail.emailVerified &&
!existingUserWithEmail.username
) {
await prisma.user.update({
where: {
email: existingUserWithEmail.email,
},
data: {
// update the email to the IdP email
email: user.email,
// Slugify the incoming name and append a few random characters to
// prevent conflicts for users with the same name.
username: usernameSlug(user.name),
emailVerified: new Date(Date.now()),
name: user.name,
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
}
// User signs up with email/password and then tries to login with Google/SAML using the same email
if (
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
) {
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
return "/auth/error?error=use-password-login";
} else if (
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
idP === IdentityProvider.SAML
) {
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
email: user.email.toLowerCase(),
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
}
return "/auth/error?error=use-identity-login";
}
// Associate with organization if enabled by flag and idP is Google (for now)
const { orgUsername, orgId } = await checkIfUserShouldBelongToOrg(idP, user.email);
const newUser = await prisma.user.create({
data: {
// Slugify the incoming name and append a few random characters to
// prevent conflicts for users with the same name.
username: orgId ? slugify(orgUsername) : usernameSlug(user.name),
emailVerified: new Date(Date.now()),
name: user.name,
...(user.image && { avatarUrl: user.image }),
email: user.email,
identityProvider: idP,
identityProviderId: account.providerAccountId,
...(orgId && {
verified: true,
organization: { connect: { id: orgId } },
teams: {
create: { role: MembershipRole.MEMBER, accepted: true, team: { connect: { id: orgId } } },
},
}),
},
});
const linkAccountNewUserData = { ...account, userId: newUser.id, providerEmail: user.email };
await calcomAdapter.linkAccount(linkAccountNewUserData);
if (account.twoFactorEnabled) {
return loginWithTotp(newUser.email);
} else {
return true;
}
}
return false;
},
/**
* Used to handle the navigation right after successful login or logout
*/
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same domain
else if (new URL(url).hostname === new URL(WEBAPP_URL).hostname) return url;
return baseUrl;
},
},
};
/**
* Identifies the profile the user should be logged into.
*/
const determineProfile = ({
token,
profiles,
}: {
token: JWT;
profiles: { id: number | null; upId: string }[];
}) => {
// If profile switcher is disabled, we can only show the first profile.
if (!ENABLE_PROFILE_SWITCHER) {
return profiles[0];
}
if (token.upId) {
// Otherwise use what's in the token
return { profileId: token.profileId, upId: token.upId as string };
}
// If there is just one profile it has to be the one we want to log into.
return profiles[0];
};

View File

@@ -0,0 +1,55 @@
import jwt from "jsonwebtoken";
import type { NextApiRequest } from "next";
import prisma from "@calcom/prisma";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
export default async function isAuthorized(req: NextApiRequest, requiredScopes: string[] = []) {
const token = req.headers.authorization?.split(" ")[1] || "";
let decodedToken: OAuthTokenPayload;
try {
decodedToken = jwt.verify(token, process.env.CALENDSO_ENCRYPTION_KEY || "") as OAuthTokenPayload;
} catch {
return null;
}
if (!decodedToken) return null;
const hasAllRequiredScopes = requiredScopes.every((scope) => decodedToken.scope.includes(scope));
if (!hasAllRequiredScopes || decodedToken.token_type !== "Access Token") {
return null;
}
if (decodedToken.userId) {
const user = await prisma.user.findFirst({
where: {
id: decodedToken.userId,
},
select: {
id: true,
username: true,
},
});
if (!user) return null;
return { id: user.id, name: user.username, isTeam: false };
}
if (decodedToken.teamId) {
const team = await prisma.team.findFirst({
where: {
id: decodedToken.teamId,
},
select: {
id: true,
name: true,
},
});
if (!team) return null;
return { ...team, isTeam: true };
}
return null;
}

View File

@@ -0,0 +1,51 @@
import type { User } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
const RECENT_MAX_ATTEMPTS = 3;
const RECENT_PERIOD_IN_MINUTES = 5;
const createPasswordReset = async (email: string): Promise<string> => {
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
data: {
email,
expires: expiry,
},
});
return `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${createdResetPasswordRequest.id}`;
};
const guardAgainstTooManyPasswordResets = async (email: string) => {
const recentPasswordRequestsCount = await prisma.resetPasswordRequest.count({
where: {
email,
createdAt: {
gt: dayjs().subtract(RECENT_PERIOD_IN_MINUTES, "minutes").toDate(),
},
},
});
if (recentPasswordRequestsCount >= RECENT_MAX_ATTEMPTS) {
throw new Error("Too many password reset attempts. Please try again later.");
}
};
const passwordResetRequest = async (user: Pick<User, "email" | "name" | "locale">) => {
const { email } = user;
const t = await getTranslation(user.locale ?? "en", "common");
await guardAgainstTooManyPasswordResets(email);
const resetLink = await createPasswordReset(email);
// send email in user language
await sendPasswordResetEmail({
language: t,
user,
resetLink,
});
};
export { passwordResetRequest };

View File

@@ -0,0 +1,39 @@
import { readFileSync } from "fs";
import Handlebars from "handlebars";
import type { SendVerificationRequestParams } from "next-auth/providers/email";
import type { TransportOptions } from "nodemailer";
import nodemailer from "nodemailer";
import path from "path";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { serverConfig } from "@calcom/lib/serverConfig";
const transporter = nodemailer.createTransport<TransportOptions>({
...(serverConfig.transport as TransportOptions),
} as TransportOptions);
const sendVerificationRequest = async ({ identifier, url }: SendVerificationRequestParams) => {
const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates");
const originalUrl = new URL(url);
const webappUrl = new URL(process.env.NEXTAUTH_URL || WEBAPP_URL);
if (originalUrl.origin !== webappUrl.origin) {
url = url.replace(originalUrl.origin, webappUrl.origin);
}
const emailFile = readFileSync(path.join(emailsDir, "confirm-email.html"), {
encoding: "utf8",
});
const emailTemplate = Handlebars.compile(emailFile);
// async transporter
transporter.sendMail({
from: `${process.env.EMAIL_FROM}` || APP_NAME,
to: identifier,
subject: `Your sign-in link for ${APP_NAME}`,
html: emailTemplate({
base_url: WEBAPP_URL,
signin_url: url,
email: identifier,
}),
});
};
export default sendVerificationRequest;

View File

@@ -0,0 +1,17 @@
import { SignJWT } from "jose";
import { WEBSITE_URL } from "@calcom/lib/constants";
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);
};
export default signJwt;

View File

@@ -0,0 +1,9 @@
export function validPassword(password: string) {
if (password.length < 7) return false;
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) return false;
if (!/\d+/.test(password)) return false;
return true;
}

View File

@@ -0,0 +1,153 @@
import { randomBytes, createHash } from "crypto";
import { totp } from "otplib";
import {
sendEmailVerificationCode,
sendEmailVerificationLink,
sendChangeOfEmailVerificationLink,
} from "@calcom/emails/email-manager";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
interface VerifyEmailType {
username?: string;
email: string;
language?: string;
secondaryEmailId?: number;
isVerifyingEmail?: boolean;
isPlatform?: boolean;
}
export const sendEmailVerification = async ({
email,
language,
username,
secondaryEmailId,
isPlatform = false,
}: VerifyEmailType) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const emailVerification = await getFeatureFlag(prisma, "email-verification");
if (!emailVerification) {
log.warn("Email verification is disabled - Skipping");
return { ok: true, skipped: true };
}
if (isPlatform) {
log.warn("Skipping Email verification");
return { ok: true, skipped: true };
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: email,
});
await prisma.verificationToken.create({
data: {
identifier: email,
token,
expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
secondaryEmailId: secondaryEmailId || null,
},
});
const params = new URLSearchParams({
token,
});
await sendEmailVerificationLink({
language: translation,
verificationEmailLink: `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`,
user: {
email,
name: username,
},
isSecondaryEmailVerification: !!secondaryEmailId,
});
return { ok: true, skipped: false };
};
export const sendEmailVerificationByCode = async ({
email,
language,
username,
isVerifyingEmail,
}: VerifyEmailType) => {
const translation = await getTranslation(language ?? "en", "common");
const secret = createHash("md5")
.update(email + process.env.CALENDSO_ENCRYPTION_KEY)
.digest("hex");
totp.options = { step: 900 };
const code = totp.generate(secret);
await sendEmailVerificationCode({
language: translation,
verificationEmailCode: code,
user: {
email,
name: username,
},
isVerifyingEmail,
});
return { ok: true, skipped: false };
};
interface ChangeOfEmail {
user: {
username: string;
emailFrom: string;
emailTo: string;
};
language?: string;
}
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const emailVerification = await getFeatureFlag(prisma, "email-verification");
if (!emailVerification) {
log.warn("Email verification is disabled - Skipping");
return { ok: true, skipped: true };
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: user.emailFrom,
});
await prisma.verificationToken.create({
data: {
identifier: user.emailFrom, // We use from as this is the email use to get the metadata from
token,
expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
},
});
const params = new URLSearchParams({
token,
});
await sendChangeOfEmailVerificationLink({
language: translation,
verificationEmailLink: `${WEBAPP_URL}/auth/verify-email-change?${params.toString()}`,
user: {
emailFrom: user.emailFrom,
emailTo: user.emailTo,
name: user.username,
},
});
return { ok: true, skipped: false };
};

View File

@@ -0,0 +1,6 @@
import { compare } from "bcryptjs";
export async function verifyPassword(password: string, hashedPassword: string) {
const isValid = await compare(password, hashedPassword);
return isValid;
}

View File

@@ -0,0 +1,23 @@
{
"name": "@calcom/feature-auth",
"sideEffects": false,
"private": true,
"description": "Cal.com's main auth code",
"authors": "Cal.com, Inc.",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"@calcom/dayjs": "*",
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@calcom/trpc": "*",
"@calcom/ui": "*",
"bcryptjs": "^2.4.3",
"handlebars": "^4.7.7",
"jose": "^4.13.1",
"lru-cache": "^9.0.3",
"next-auth": "^4.22.1",
"nodemailer": "^6.7.8",
"otplib": "^12.0.1"
}
}

View File

@@ -0,0 +1,228 @@
import type { NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username";
import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "../utils/token";
const log = logger.getSubLogger({ prefix: ["signupCalcomHandler"] });
async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
const {
email: _email,
password,
token,
} = signupSchema
.pick({
email: true,
password: true,
token: true,
})
.parse(req.body);
log.debug("handler", { email: _email });
let username: string | null = req.usernameStatus.requestedUserName;
let checkoutSessionId: string | null = null;
// Check for premium username
if (req.usernameStatus.statusCode === 418) {
return res.status(req.usernameStatus.statusCode).json(req.usernameStatus.json);
}
// Validate the user
if (!username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
const email = _email.toLowerCase();
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
username = await validateAndGetCorrectedUsernameForTeam({
username,
email,
teamId: foundToken?.teamId ?? null,
isSignup: true,
});
} else {
const usernameAndEmailValidation = await validateAndGetCorrectedUsernameAndEmail({
username,
email,
isSignup: true,
});
if (!usernameAndEmailValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
if (!usernameAndEmailValidation.username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
username = usernameAndEmailValidation.username;
}
// Create the customer in Stripe
const customer = await stripe.customers.create({
email,
metadata: {
email /* Stripe customer email can be changed, so we add this to keep track of which email was used to signup */,
username,
},
});
const returnUrl = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=/auth/verify?sessionId={CHECKOUT_SESSION_ID}`;
// Pro username, must be purchased
if (req.usernameStatus.statusCode === 402) {
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer: customer.id,
line_items: [
{
price: getPremiumMonthlyPlanPriceId(),
quantity: 1,
},
],
success_url: returnUrl,
cancel_url: returnUrl,
allow_promotion_codes: true,
});
/** We create a username-less user until he pays */
checkoutSessionId = checkoutSession.id;
username = null;
}
// Hash the password
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
include: {
parent: {
select: {
id: true,
slug: true,
organizationSettings: true,
},
},
organizationSettings: true,
},
});
if (team) {
const user = await prisma.user.upsert({
where: { email },
update: {
username,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
},
create: {
username,
email,
identityProvider: IdentityProvider.CAL,
password: { create: { hash: hashedPassword } },
},
});
// Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies
const { membership } = await createOrUpdateMemberships({
user,
team,
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parent) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
org: team.parent,
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
// Create the user
const user = await prisma.user.create({
data: {
username,
email,
password: { create: { hash: hashedPassword } },
metadata: {
stripeCustomerId: customer.id,
checkoutSessionId,
},
},
});
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
await prefillAvatar({ email });
}
sendEmailVerification({
email,
language: await getLocaleFromRequest(req),
username: username || "",
});
// Sync Services
await syncServicesCreateWebUser(user);
}
if (checkoutSessionId) {
console.log("Created user but missing payment", checkoutSessionId);
return res.status(402).json({
message: "Created user but missing payment",
checkoutSessionId,
});
}
return res.status(201).json({ message: "Created user", stripeCustomerId: customer.id });
}
export default usernameHandler(handler);

View File

@@ -0,0 +1,186 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { prefillAvatar } from "../utils/prefillAvatar";
import {
findTokenByToken,
throwIfTokenExpired,
validateAndGetCorrectedUsernameForTeam,
} from "../utils/token";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const data = req.body;
const { email, password, language, token } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
let correctedUsername = username;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
correctedUsername = await validateAndGetCorrectedUsernameForTeam({
username,
email: userEmail,
teamId: foundToken?.teamId,
isSignup: true,
});
} else {
const userValidation = await validateAndGetCorrectedUsernameAndEmail({
username,
email: userEmail,
isSignup: true,
});
if (!userValidation.isValid) {
logger.error("User validation failed", { userValidation });
return res.status(409).json({ message: "Username or email is already taken" });
}
if (!userValidation.username) {
return res.status(422).json({ message: "Invalid username" });
}
correctedUsername = userValidation.username;
}
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
include: {
parent: {
select: {
id: true,
slug: true,
organizationSettings: true,
},
},
organizationSettings: true,
},
});
if (team) {
const isInviteForATeamInOrganization = !!team.parent;
const isCheckingUsernameInGlobalNamespace = !team.isOrganization && !isInviteForATeamInOrganization;
if (isCheckingUsernameInGlobalNamespace) {
const isUsernameAvailable = !(await isUsernameReservedDueToMigration(correctedUsername));
if (!isUsernameAvailable) {
res.status(409).json({ message: "A user exists with that username" });
return;
}
}
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username: correctedUsername,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username: correctedUsername,
email: userEmail,
password: { create: { hash: hashedPassword } },
identityProvider: IdentityProvider.CAL,
},
});
const { membership } = await createOrUpdateMemberships({
user,
team,
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parent) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
org: team.parent,
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
const isUsernameAvailable = !(await isUsernameReservedDueToMigration(correctedUsername));
if (!isUsernameAvailable) {
res.status(409).json({ message: "A user exists with that username" });
return;
}
if (IS_PREMIUM_USERNAME_ENABLED) {
const checkUsername = await checkPremiumUsername(correctedUsername);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
});
return;
}
}
await prisma.user.upsert({
where: { email: userEmail },
update: {
username: correctedUsername,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username: correctedUsername,
email: userEmail,
password: { create: { hash: hashedPassword } },
identityProvider: IdentityProvider.CAL,
},
});
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
await prefillAvatar({ email: userEmail });
}
await sendEmailVerification({
email: userEmail,
username: correctedUsername,
language,
});
}
res.status(201).json({ message: "Created user" });
}

View File

@@ -0,0 +1,128 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import notEmpty from "@calcom/lib/notEmpty";
import { isPremiumUserName, generateUsernameSuggestion } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
export type RequestWithUsernameStatus = NextApiRequest & {
usernameStatus: {
/**
* ```text
* 200: Username is available
* 402: Pro username, must be purchased
* 418: A user exists with that username
* ```
*/
statusCode: 200 | 402 | 418;
requestedUserName: string;
json: {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
};
};
export const usernameStatusSchema = z.object({
statusCode: z.union([z.literal(200), z.literal(402), z.literal(418)]),
requestedUserName: z.string(),
json: z.object({
available: z.boolean(),
premium: z.boolean(),
message: z.string().optional(),
suggestion: z.string().optional(),
}),
});
type CustomNextApiHandler<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
const usernameHandler =
(handler: CustomNextApiHandler) =>
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
const username = slugify(req.body.username);
const check = await usernameCheck(username);
req.usernameStatus = {
statusCode: 200,
requestedUserName: username,
json: {
available: true,
premium: false,
message: "Username is available",
},
};
if (check.premium) {
req.usernameStatus.statusCode = 402;
req.usernameStatus.json.premium = true;
req.usernameStatus.json.message = "This is a premium username.";
}
if (!check.available) {
req.usernameStatus.statusCode = 418;
req.usernameStatus.json.available = false;
req.usernameStatus.json.message = "A user exists with that username";
}
req.usernameStatus.json.suggestion = check.suggestedUsername;
return handler(req, res);
};
const usernameCheck = async (usernameRaw: string) => {
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: {
username,
// Simply remove it when we drop organizationId column
organizationId: null,
},
select: {
username: true,
},
});
if (user) {
response.available = false;
}
if (await isPremiumUserName(username)) {
response.premium = true;
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
export { usernameHandler, usernameCheck };

View File

@@ -0,0 +1,90 @@
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { prisma } from "@calcom/prisma";
import type { Team, User, OrganizationSettings } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { getOrgUsernameFromEmail } from "./getOrgUsernameFromEmail";
export const createOrUpdateMemberships = async ({
user,
team,
}: {
user: Pick<User, "id">;
team: Pick<Team, "id" | "parentId" | "isOrganization"> & {
organizationSettings: OrganizationSettings | null;
};
}) => {
return await prisma.$transaction(async (tx) => {
if (team.isOrganization) {
const dbUser = await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
select: {
username: true,
email: true,
},
});
// Ideally dbUser.username should never be null, but just in case.
// This method being called only during signup means that dbUser.username should be the correct org username
const orgUsername =
dbUser.username ||
getOrgUsernameFromEmail(dbUser.email, team.organizationSettings?.orgAutoAcceptEmail ?? null);
await tx.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
userId: user.id,
organizationId: team.id,
username: orgUsername,
},
update: {
username: orgUsername,
},
where: {
userId_organizationId: {
userId: user.id,
organizationId: team.id,
},
},
});
}
const membership = await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
const orgMembership = null;
if (team.parentId) {
await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.parentId },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.parentId,
role: MembershipRole.MEMBER,
accepted: true,
},
});
}
await updateNewTeamMemberEventTypes(user.id, team.id);
return { membership, orgMembership };
});
};

View File

@@ -0,0 +1,11 @@
import slugify from "@calcom/lib/slugify";
export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string | null) => {
const [emailUser, emailDomain = ""] = email.split("@");
const username =
emailDomain === autoAcceptEmailDomain
? slugify(emailUser)
: slugify(`${emailUser}-${emailDomain.split(".")[0]}`);
return username;
};

View File

@@ -0,0 +1,84 @@
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { prisma } from "@calcom/prisma";
import type { Team, OrganizationSettings } from "@calcom/prisma/client";
import { getOrgUsernameFromEmail } from "./getOrgUsernameFromEmail";
export async function joinAnyChildTeamOnOrgInvite({
userId,
org,
}: {
userId: number;
org: Pick<Team, "id"> & {
organizationSettings: OrganizationSettings | null;
};
}) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error("User not found");
}
const orgUsername =
user.username ||
getOrgUsernameFromEmail(user.email, org.organizationSettings?.orgAutoAcceptEmail ?? null);
await prisma.$transaction([
// Simply remove this update when we remove the `organizationId` field from the user table
prisma.user.update({
where: {
id: userId,
},
data: {
organizationId: org.id,
},
}),
prisma.profile.upsert({
create: {
uid: ProfileRepository.generateProfileUid(),
userId: userId,
organizationId: org.id,
username: orgUsername,
},
update: {
username: orgUsername,
},
where: {
userId_organizationId: {
userId: user.id,
organizationId: org.id,
},
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
id: org.id,
},
accepted: false,
},
data: {
accepted: true,
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
parentId: org.id,
},
accepted: false,
},
data: {
accepted: true,
},
}),
]);
await updateNewTeamMemberEventTypes(userId, org.id);
}

View File

@@ -0,0 +1,83 @@
import type { Prisma } from "@prisma/client";
import fetch from "node-fetch";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
interface IPrefillAvatar {
email: string;
}
async function downloadImageDataFromUrl(url: string) {
try {
const response = await fetch(url);
if (!response.ok) {
console.log("Error fetching image from: ", url);
return null;
}
const imageBuffer = await response.buffer();
const base64Image = `data:image/png;base64,${imageBuffer.toString("base64")}`;
return base64Image;
} catch (error) {
console.log(error);
return null;
}
}
export const prefillAvatar = async ({ email }: IPrefillAvatar) => {
const imageUrl = await getImageUrlAvatarAPI(email);
if (!imageUrl) return;
const base64Image = await downloadImageDataFromUrl(imageUrl);
if (!base64Image) return;
const avatar = await resizeBase64Image(base64Image);
const user = await prisma.user.findFirst({
where: { email: email },
});
if (!user) {
return;
}
const avatarUrl = await uploadAvatar({ userId: user.id, avatar });
const data: Prisma.UserUpdateInput = {};
data.avatarUrl = avatarUrl;
await prisma.user.update({
where: { email: email },
data,
});
};
const getImageUrlAvatarAPI = async (email: string) => {
if (!process.env.AVATARAPI_USERNAME || !process.env.AVATARAPI_PASSWORD) {
console.info("No avatar api credentials found");
return null;
}
const response = await fetch("https://avatarapi.com/v2/api.aspx", {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: JSON.stringify({
username: process.env.AVATARAPI_USERNAME,
password: process.env.AVATARAPI_PASSWORD,
email: email,
}),
});
const info = await response.json();
if (!info.Success) {
console.log("Error from avatar api: ", info.Error);
return null;
}
return info.Image as string;
};

View File

@@ -0,0 +1,65 @@
import dayjs from "@calcom/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
select: {
id: true,
expires: true,
teamId: true,
},
});
if (!foundToken) {
throw new HttpError({
statusCode: 401,
message: "Invalid Token",
});
}
return foundToken;
}
export function throwIfTokenExpired(expires?: Date) {
if (!expires) return;
if (dayjs(expires).isBefore(dayjs())) {
throw new HttpError({
statusCode: 401,
message: "Token expired",
});
}
}
export async function validateAndGetCorrectedUsernameForTeam({
username,
email,
teamId,
isSignup,
}: {
username: string;
email: string;
teamId: number | null;
isSignup: boolean;
}) {
if (!teamId) return username;
const teamUserValidation = await validateAndGetCorrectedUsernameInTeam(username, email, teamId, isSignup);
if (!teamUserValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
if (!teamUserValidation.username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
return teamUserValidation.username;
}