first commit
This commit is contained in:
27
calcom/packages/features/auth/PermissionContainer.tsx
Normal file
27
calcom/packages/features/auth/PermissionContainer.tsx
Normal 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>;
|
||||
};
|
||||
1
calcom/packages/features/auth/README.md
Normal file
1
calcom/packages/features/auth/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Auth-related code will live here
|
||||
68
calcom/packages/features/auth/SAMLLogin.tsx
Normal file
68
calcom/packages/features/auth/SAMLLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
calcom/packages/features/auth/lib/ErrorCode.ts
Normal file
20
calcom/packages/features/auth/lib/ErrorCode.ts
Normal 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",
|
||||
}
|
||||
13
calcom/packages/features/auth/lib/ensureSession.ts
Normal file
13
calcom/packages/features/auth/lib/ensureSession.ts
Normal 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;
|
||||
};
|
||||
61
calcom/packages/features/auth/lib/getLocale.ts
Normal file
61
calcom/packages/features/auth/lib/getLocale.ts
Normal 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;
|
||||
};
|
||||
131
calcom/packages/features/auth/lib/getServerSession.ts
Normal file
131
calcom/packages/features/auth/lib/getServerSession.ts
Normal 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;
|
||||
}
|
||||
10
calcom/packages/features/auth/lib/getSession.ts
Normal file
10
calcom/packages/features/auth/lib/getSession.ts
Normal 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;
|
||||
}
|
||||
6
calcom/packages/features/auth/lib/hashPassword.ts
Normal file
6
calcom/packages/features/auth/lib/hashPassword.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
return hashedPassword;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
26
calcom/packages/features/auth/lib/isPasswordValid.ts
Normal file
26
calcom/packages/features/auth/lib/isPasswordValid.ts
Normal 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;
|
||||
}
|
||||
@@ -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 } }),
|
||||
};
|
||||
}
|
||||
947
calcom/packages/features/auth/lib/next-auth-options.ts
Normal file
947
calcom/packages/features/auth/lib/next-auth-options.ts
Normal 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];
|
||||
};
|
||||
55
calcom/packages/features/auth/lib/oAuthAuthorization.ts
Normal file
55
calcom/packages/features/auth/lib/oAuthAuthorization.ts
Normal 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;
|
||||
}
|
||||
51
calcom/packages/features/auth/lib/passwordResetRequest.ts
Normal file
51
calcom/packages/features/auth/lib/passwordResetRequest.ts
Normal 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 };
|
||||
39
calcom/packages/features/auth/lib/sendVerificationRequest.ts
Normal file
39
calcom/packages/features/auth/lib/sendVerificationRequest.ts
Normal 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;
|
||||
17
calcom/packages/features/auth/lib/signJwt.ts
Normal file
17
calcom/packages/features/auth/lib/signJwt.ts
Normal 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;
|
||||
9
calcom/packages/features/auth/lib/validPassword.ts
Normal file
9
calcom/packages/features/auth/lib/validPassword.ts
Normal 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;
|
||||
}
|
||||
153
calcom/packages/features/auth/lib/verifyEmail.ts
Normal file
153
calcom/packages/features/auth/lib/verifyEmail.ts
Normal 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 };
|
||||
};
|
||||
6
calcom/packages/features/auth/lib/verifyPassword.ts
Normal file
6
calcom/packages/features/auth/lib/verifyPassword.ts
Normal 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;
|
||||
}
|
||||
23
calcom/packages/features/auth/package.json
Normal file
23
calcom/packages/features/auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
228
calcom/packages/features/auth/signup/handlers/calcomHandler.ts
Normal file
228
calcom/packages/features/auth/signup/handlers/calcomHandler.ts
Normal 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);
|
||||
@@ -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" });
|
||||
}
|
||||
128
calcom/packages/features/auth/signup/username.ts
Normal file
128
calcom/packages/features/auth/signup/username.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
84
calcom/packages/features/auth/signup/utils/organization.ts
Normal file
84
calcom/packages/features/auth/signup/utils/organization.ts
Normal 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);
|
||||
}
|
||||
83
calcom/packages/features/auth/signup/utils/prefillAvatar.ts
Normal file
83
calcom/packages/features/auth/signup/utils/prefillAvatar.ts
Normal 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;
|
||||
};
|
||||
65
calcom/packages/features/auth/signup/utils/token.ts
Normal file
65
calcom/packages/features/auth/signup/utils/token.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user