add tests (#25566)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import type { User } from "next-auth";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { ErrorCode } from "./ErrorCode";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
default: {
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFindByEmailAndIncludeProfilesAndPassword = vi.fn();
|
||||
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
|
||||
return {
|
||||
UserRepository: vi.fn().mockImplementation(() => ({
|
||||
findByEmailAndIncludeProfilesAndPassword: mockFindByEmailAndIncludeProfilesAndPassword,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./verifyPassword", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/PiiHasher", () => ({
|
||||
hashEmail: vi.fn((email: string) => `hashed_${email}`),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/totp", () => ({
|
||||
totpAuthenticatorCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
symmetricEncrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/auth/isPasswordValid", () => ({
|
||||
isPasswordValid: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/env", () => ({
|
||||
isENVDev: false,
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: false,
|
||||
ENABLE_PROFILE_SWITCHER: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/safeStringify", () => ({
|
||||
safeStringify: vi.fn((obj) => JSON.stringify(obj)),
|
||||
}));
|
||||
|
||||
vi.mock("./next-auth-custom-adapter", () => ({
|
||||
default: vi.fn(() => ({
|
||||
linkAccount: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
|
||||
ProfileRepository: {
|
||||
findAllProfilesForUserIncludingMovedUser: vi.fn(),
|
||||
findByUpIdWithAuth: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CredentialsProvider authorize", () => {
|
||||
let authorizeCredentials: typeof import("./next-auth-options").authorizeCredentials;
|
||||
let verifyPassword: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockReset();
|
||||
|
||||
const verifyPasswordModule = await import("./verifyPassword");
|
||||
verifyPassword = verifyPasswordModule.verifyPassword;
|
||||
|
||||
// Import the exported authorize function directly
|
||||
const authModule = await import("./next-auth-options");
|
||||
authorizeCredentials = authModule.authorizeCredentials;
|
||||
});
|
||||
|
||||
const createMockUser = (overrides: Partial<any> = {}) => ({
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
username: "testuser",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: {
|
||||
hash: "$2a$10$hashedpassword",
|
||||
},
|
||||
allProfiles: [
|
||||
{
|
||||
id: 1,
|
||||
upId: "usr_123",
|
||||
username: "testuser",
|
||||
},
|
||||
],
|
||||
teams: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Password validation", () => {
|
||||
it("should throw error when user has no password hash with CAL identity provider", async () => {
|
||||
const mockUser = createMockUser({
|
||||
password: null,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
});
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
authorizeCredentials({
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
} as any)
|
||||
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
|
||||
});
|
||||
|
||||
it("should throw error when user has no password hash with Google identity provider", async () => {
|
||||
const mockUser = createMockUser({
|
||||
password: null,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
});
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
authorizeCredentials({
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
} as any)
|
||||
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
|
||||
});
|
||||
|
||||
it("should throw error when user has no password hash with SAML identity provider", async () => {
|
||||
const mockUser = createMockUser({
|
||||
password: null,
|
||||
identityProvider: IdentityProvider.SAML,
|
||||
});
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
authorizeCredentials({
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
} as any)
|
||||
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
|
||||
});
|
||||
|
||||
it("should throw error when user has no password hash even with TOTP code provided", async () => {
|
||||
const mockUser = createMockUser({
|
||||
password: null,
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
});
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
authorizeCredentials({
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
totpCode: "123456",
|
||||
} as any)
|
||||
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
|
||||
});
|
||||
|
||||
it("should throw error when user has no password hash with Google identity provider and TOTP code", async () => {
|
||||
const mockUser = createMockUser({
|
||||
password: null,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
});
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
authorizeCredentials({
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
totpCode: "123456",
|
||||
} as any)
|
||||
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
|
||||
});
|
||||
|
||||
it("should throw error when user has no password hash with SAML identity provider and TOTP code", async () => {
|
||||
const mockUser = createMockUser({
|
||||
password: null,
|
||||
identityProvider: IdentityProvider.SAML,
|
||||
});
|
||||
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
authorizeCredentials({
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
totpCode: "123456",
|
||||
} as any)
|
||||
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,139 +133,144 @@ const checkIfUserShouldBelongToOrg = async (idP: IdentityProvider, email: string
|
||||
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): Promise<User | null> {
|
||||
log.debug("CredentialsProvider:credentials:authorize", safeStringify({ credentials }));
|
||||
if (!credentials) {
|
||||
console.error(`For some reason credentials are missing`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
/**
|
||||
* Authorize function for credentials provider
|
||||
* Extracted for testability
|
||||
*/
|
||||
export async function authorizeCredentials(
|
||||
credentials: Record<"email" | "password" | "totpCode" | "backupCode", string> | undefined
|
||||
): Promise<User | null> {
|
||||
log.debug("CredentialsProvider:credentials:authorize", safeStringify({ credentials }));
|
||||
if (!credentials) {
|
||||
console.error(`For some reason credentials are missing`);
|
||||
throw new Error(ErrorCode.InternalServerError);
|
||||
}
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
|
||||
email: credentials.email,
|
||||
});
|
||||
// Don't leak information about it being username or password that is invalid
|
||||
if (!user) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.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);
|
||||
}
|
||||
// Locked users cannot login
|
||||
if (user.locked) {
|
||||
throw new Error(ErrorCode.UserAccountLocked);
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: hashEmail(user.email),
|
||||
});
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: hashEmail(user.email),
|
||||
});
|
||||
|
||||
// Users without a password must use their identity provider (Google/SAML) to login
|
||||
if (!user.password?.hash) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
// Users without a password must use their identity provider (Google/SAML) to login
|
||||
if (!user.password?.hash) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
|
||||
// Always verify password for users who have one
|
||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password.hash);
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.IncorrectEmailPassword);
|
||||
}
|
||||
// Always verify password for users who have one
|
||||
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.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);
|
||||
if (!user.backupCodes) throw new Error(ErrorCode.MissingBackupCodes);
|
||||
|
||||
const backupCodes = JSON.parse(
|
||||
symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
|
||||
);
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
// 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 (!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);
|
||||
}
|
||||
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 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);
|
||||
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 !== UserPermissionRole.ADMIN) return role;
|
||||
// User's identity provider is not "CAL"
|
||||
if (user.identityProvider !== IdentityProvider.CAL) return role;
|
||||
// authentication success- but does it meet the minimum password requirements?
|
||||
const validateRole = (role: UserPermissionRole) => {
|
||||
// User's role is not "ADMIN"
|
||||
if (role !== UserPermissionRole.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;
|
||||
}
|
||||
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";
|
||||
};
|
||||
// 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";
|
||||
};
|
||||
|
||||
// Create a NextAuth compatible user object using our presenter
|
||||
return AdapterUserPresenter.fromCalUser(user, validateRole(user.role), hasActiveTeams);
|
||||
},
|
||||
}),
|
||||
ImpersonationProvider,
|
||||
];
|
||||
// Create a NextAuth compatible user object using our presenter
|
||||
return AdapterUserPresenter.fromCalUser(user, validateRole(user.role), hasActiveTeams);
|
||||
}
|
||||
|
||||
export const CalComCredentialsProvider = 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" },
|
||||
},
|
||||
authorize: authorizeCredentials,
|
||||
});
|
||||
|
||||
const providers: Provider[] = [CalComCredentialsProvider, ImpersonationProvider];
|
||||
|
||||
if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
providers.push(
|
||||
|
||||
Reference in New Issue
Block a user