refactor: migrates user password to it's own table (#13628)

This commit is contained in:
Omar López
2024-02-12 14:13:21 -07:00
committed by GitHub
parent 7702ded85e
commit 7b0d5959cb
25 changed files with 189 additions and 89 deletions
+4 -4
View File
@@ -38,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const currentPassword = user.password;
const currentPassword = user.password?.hash;
if (!currentPassword) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
@@ -53,12 +53,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
await prisma.userPassword.update({
where: {
id: user.id,
userId: user.id,
},
data: {
password: hashedPassword,
hash: hashedPassword,
},
});
+5 -1
View File
@@ -41,7 +41,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
email: maybeRequest.email,
},
data: {
password: hashedPassword,
password: {
update: {
hash: hashedPassword,
},
},
emailVerified: new Date(),
},
});
+1 -1
View File
@@ -41,7 +41,7 @@ async function handler(req: NextApiRequest) {
data: {
username,
email: userEmail,
password: hashedPassword,
password: { create: { hash: hashedPassword } },
role: "ADMIN",
name: parsedQuery.data.full_name,
emailVerified: new Date(),
@@ -23,13 +23,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (!user.password && user.identityProvider === IdentityProvider.CAL) {
if (!user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
@@ -37,8 +37,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.json({ message: "Two factor disabled" });
}
if (user.password && user.identityProvider === IdentityProvider.CAL) {
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
if (user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
@@ -25,7 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
@@ -35,7 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
}
if (!user.password) {
if (!user.password?.hash) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
@@ -48,7 +48,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const isCorrectPassword = await verifyPassword(req.body.password, user.password);
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
@@ -86,7 +86,8 @@ testBothFutureAndLegacyRoutes.describe("Forgot password", async () => {
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
const updatedPassword = updatedUser.password!.hash;
expect(await verifyPassword(newPassword, updatedPassword)).toBeTruthy();
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
await page.goto(`/auth/forgot-password/${id}`);
+8 -4
View File
@@ -652,7 +652,6 @@ type SupportedTestWorkflows = PrismaType.WorkflowCreateInput;
type CustomUserOptsKeys =
| "username"
| "password"
| "completedOnboarding"
| "locale"
| "name"
@@ -669,6 +668,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
useExactUsername?: boolean;
roleInOrganization?: MembershipRole;
schedule?: Schedule;
password?: string | null;
};
// creates the actual user in the db.
@@ -690,7 +690,11 @@ const createUser = (
username: uname,
name: opts?.name,
email: opts?.email ?? `${uname}@example.com`,
password: hashPassword(uname),
password: {
create: {
hash: hashPassword(uname),
},
},
emailVerified: new Date(),
completedOnboarding: opts?.completedOnboarding ?? true,
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
@@ -791,7 +795,7 @@ async function confirmPendingPayment(page: Page) {
// login using a replay of an E2E routine.
export async function login(
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "password" | "email">>,
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password?: string | null },
page: Page
) {
// get locators
@@ -812,7 +816,7 @@ export async function login(
}
export async function apiLogin(
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "password" | "email">>,
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password: string | null },
page: Page
) {
const csrfToken = await page
+20 -15
View File
@@ -42,8 +42,8 @@ const ORGANIZATIONS_AUTOLINK =
const usernameSlug = (username: string) => `${slugify(username)}-${randomString(6).toLowerCase()}`;
const loginWithTotp = async (user: { email: string }) =>
`/auth/login?totp=${await (await import("./signJwt")).default({ email: user.email })}`;
const loginWithTotp = async (email: string) =>
`/auth/login?totp=${await (await import("./signJwt")).default({ email })}`;
type UserTeams = {
teams: (Membership & {
@@ -126,18 +126,18 @@ const providers: Provider[] = [
if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password && user.identityProvider == IdentityProvider.CAL) {
if (!user.password?.hash && user.identityProvider == IdentityProvider.CAL) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (user.password && !credentials.totpCode) {
if (!user.password) {
if (user.password?.hash && !credentials.totpCode) {
if (!user.password?.hash) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
const isCorrectPassword = await verifyPassword(credentials.password, user.password.hash);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
@@ -717,7 +717,7 @@ export const AUTH_OPTIONS: AuthOptions = {
}
}
if (existingUser.twoFactorEnabled && existingUser.identityProvider === idP) {
return loginWithTotp(existingUser);
return loginWithTotp(existingUser.email);
} else {
return true;
}
@@ -733,7 +733,7 @@ export const AUTH_OPTIONS: AuthOptions = {
if (!userWithNewEmail) {
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
if (existingUser.twoFactorEnabled) {
return loginWithTotp(existingUser);
return loginWithTotp(existingUser.email);
} else {
return true;
}
@@ -752,6 +752,9 @@ export const AUTH_OPTIONS: AuthOptions = {
mode: "insensitive",
},
},
include: {
password: true,
},
});
if (existingUserWithEmail) {
@@ -762,7 +765,7 @@ export const AUTH_OPTIONS: AuthOptions = {
existingUserWithEmail.identityProvider !== IdentityProvider.CAL
) {
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail);
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
@@ -770,7 +773,7 @@ export const AUTH_OPTIONS: AuthOptions = {
// check if user was invited
if (
!existingUserWithEmail.password &&
!existingUserWithEmail.password?.hash &&
!existingUserWithEmail.emailVerified &&
!existingUserWithEmail.username
) {
@@ -792,7 +795,7 @@ export const AUTH_OPTIONS: AuthOptions = {
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail);
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
@@ -807,14 +810,16 @@ export const AUTH_OPTIONS: AuthOptions = {
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
password: null,
password: {
delete: true,
},
email: user.email,
identityProvider: idP,
identityProviderId: account.providerAccountId,
},
});
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail);
return loginWithTotp(existingUserWithEmail.email);
} else {
return true;
}
@@ -853,7 +858,7 @@ export const AUTH_OPTIONS: AuthOptions = {
await calcomAdapter.linkAccount(linkAccountNewUserData);
if (account.twoFactorEnabled) {
return loginWithTotp(newUser);
return loginWithTotp(newUser.email);
} else {
return true;
}
@@ -138,18 +138,22 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
where: { email },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
},
create: {
username,
email,
password: hashedPassword,
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({
teamMetadata,
@@ -180,7 +184,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
data: {
username,
email,
password: hashedPassword,
password: { create: { hash: hashedPassword } },
metadata: {
stripeCustomerId: customer.id,
checkoutSessionId,
@@ -79,14 +79,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
where: { email: userEmail },
update: {
username: correctedUsername,
password: hashedPassword,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username: correctedUsername,
email: userEmail,
password: hashedPassword,
password: { create: { hash: hashedPassword } },
identityProvider: IdentityProvider.CAL,
},
});
@@ -128,14 +133,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
where: { email: userEmail },
update: {
username: correctedUsername,
password: hashedPassword,
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username: correctedUsername,
email: userEmail,
password: hashedPassword,
password: { create: { hash: hashedPassword } },
identityProvider: IdentityProvider.CAL,
},
});
@@ -67,9 +67,7 @@ const authedAdminProcedureWithRequestedUser = authedAdminProcedure.use(async ({
return next({
ctx: {
user: ctx.user,
requestedUser:
/** Don't leak the password */
exclude(user, ["password"]),
requestedUser: user,
},
});
});
@@ -84,8 +82,7 @@ export const userAdminRouter = router({
// TODO: Add search, pagination, etc.
const users = await prisma.user.findMany();
return users.map((user) => ({
/** Don't leak the password */
...exclude(user, ["password"]),
...user,
/**
* FIXME: This should be either a prisma extension or middleware
* @see https://www.prisma.io/docs/concepts/components/prisma-client/middleware
-1
View File
@@ -224,7 +224,6 @@ export const buildUser = <T extends Partial<UserPayload>>(
invitedTo: null,
locale: "en",
metadata: null,
password: null,
role: "USER",
schedules: [],
selectedCalendars: [],
+1 -3
View File
@@ -56,9 +56,7 @@ export const validateAndGetCorrectedUsernameAndEmail = async ({
{
OR: [
{ emailVerified: { not: null } },
{
AND: [{ password: { not: null } }, { username: { not: null } }],
},
{ AND: [{ password: { isNot: null } }, { username: { not: null } }] },
],
},
],
@@ -0,0 +1,31 @@
/*
Warnings:
- You are about to drop the column `password` on the `users` table. All the data in the column will be lost.
*/
-- CreateTable
CREATE TABLE
"UserPassword" (
"hash" TEXT NOT NULL,
"userId" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "UserPassword_userId_key" ON "UserPassword" ("userId");
-- AddForeignKey
ALTER TABLE "UserPassword" ADD CONSTRAINT "UserPassword_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Inserts current user password into new table
INSERT INTO
"UserPassword" ("hash", "userId")
SELECT
u."password",
u.id
FROM
users u;
-- We rename instead of dropping the column to avoid possible data loss, will be dropped in the next migration.
ALTER TABLE "users"
RENAME COLUMN "password" TO "password_deprecated";
+8 -1
View File
@@ -191,6 +191,13 @@ enum UserPermissionRole {
ADMIN
}
// It holds the password of a User, separate from the User model to avoid leaking the password hash
model UserPassword {
hash String
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// It holds Personal Profiles of a User plus it has email, password and other core things
model User {
id Int @id @default(autoincrement())
@@ -199,7 +206,7 @@ model User {
/// @zod.email()
email String
emailVerified DateTime?
password String?
password UserPassword?
bio String?
avatar String?
avatarUrl String?
+15 -3
View File
@@ -78,7 +78,11 @@ async function main() {
insightsAdmin = await prisma.user.create({
data: {
email: "insights@example.com",
password: await hashPassword("insightsinsightsinsights...!"),
password: {
create: {
hash: await hashPassword("insightsinsightsinsights...!"),
},
},
name: "Insights Admin",
role: "ADMIN",
username: "insights-admin",
@@ -102,7 +106,11 @@ async function main() {
insightsUser = await prisma.user.create({
data: {
email: "insightsuser@example.com",
password: await hashPassword("insightsuser"),
password: {
create: {
hash: await hashPassword("insightsuser"),
},
},
name: "Insights User",
role: "USER",
username: "insights-user",
@@ -415,7 +423,11 @@ async function createPerformanceData() {
const email = `insightsuser${timestamp}@example.com`;
const insightsUser = {
email,
password: await hashPassword("insightsuser"),
password: {
create: {
hash: await hashPassword("insightsuser"),
},
},
name: `Insights User ${timestamp}`,
username: `insights-user-${timestamp}`,
completedOnboarding: true,
+5 -1
View File
@@ -40,7 +40,11 @@ export async function createUserAndEventType({
}) {
const userData = {
...user,
password: await hashPassword(user.password),
password: {
create: {
hash: await hashPassword(user.password),
},
},
emailVerified: new Date(),
completedOnboarding: user.completedOnboarding ?? true,
locale: "en",
+35 -7
View File
@@ -122,7 +122,11 @@ async function createOrganizationAndAddMembersAndTeams({
data: {
...member.memberData,
emailVerified: new Date(),
password: await hashPassword(member.memberData.password),
password: {
create: {
hash: await hashPassword(member.memberData.password.create?.hash || ""),
},
},
},
})),
inTeams: member.inTeams,
@@ -149,7 +153,11 @@ async function createOrganizationAndAddMembersAndTeams({
name: user.name,
email: user.email,
emailVerified: new Date(),
password: await hashPassword(user.username),
password: {
create: {
hash: await hashPassword(user.username),
},
},
},
});
}),
@@ -255,7 +263,11 @@ async function createOrganizationAndAddMembersAndTeams({
await prisma.user.create({
data: {
...nonOrgMember,
password: await hashPassword(nonOrgMember.password),
password: {
create: {
hash: await hashPassword(nonOrgMember.username),
},
},
emailVerified: new Date(),
},
})
@@ -807,7 +819,11 @@ async function main() {
{
memberData: {
email: "owner1-acme@example.com",
password: "owner1-acme",
password: {
create: {
hash: "owner1-acme",
},
},
username: "owner1-acme",
name: "Owner 1",
},
@@ -836,7 +852,11 @@ async function main() {
nonOrgMembers: [
{
email: "non-acme-member-1@example.com",
password: "non-acme-member-1",
password: {
create: {
hash: "non-acme-member-1",
},
},
username: "non-acme-member-1",
name: "NonAcme Member1",
},
@@ -866,7 +886,11 @@ async function main() {
{
memberData: {
email: "owner1-dunder@example.com",
password: "owner1-dunder",
password: {
create: {
hash: "owner1-dunder",
},
},
username: "owner1-dunder",
name: "Owner 1",
},
@@ -895,7 +919,11 @@ async function main() {
nonOrgMembers: [
{
email: "non-dunder-member-1@example.com",
password: "non-dunder-member-1",
password: {
create: {
hash: "non-dunder-member-1",
},
},
username: "non-dunder-member-1",
name: "NonDunder Member1",
},
@@ -43,11 +43,11 @@ export const deleteMeHandler = async ({ ctx, input }: DeleteMeOptions) => {
throw new HttpError({ statusCode: 400, message: ErrorCode.ThirdPartyIdentityProviderEnabled });
}
if (!user.password) {
if (!user.password?.hash) {
throw new HttpError({ statusCode: 400, message: ErrorCode.UserMissingPassword });
}
const isCorrectPassword = await verifyPassword(input.password, user.password);
const isCorrectPassword = await verifyPassword(input.password, user.password.hash);
if (!isCorrectPassword) {
throw new HttpError({ statusCode: 403, message: ErrorCode.IncorrectPassword });
}
@@ -25,16 +25,11 @@ export const changePasswordHandler = async ({ input, ctx }: ChangePasswordOption
throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" });
}
const currentPasswordQuery = await prisma.user.findFirst({
where: {
id: user.id,
},
select: {
password: true,
},
const currentPasswordQuery = await prisma.userPassword.findFirst({
where: { userId: user.id },
});
const currentPassword = currentPasswordQuery?.password;
const currentPassword = currentPasswordQuery?.hash;
if (!currentPassword) {
throw new TRPCError({ code: "NOT_FOUND", message: "MISSING_PASSWORD" });
@@ -54,12 +49,12 @@ export const changePasswordHandler = async ({ input, ctx }: ChangePasswordOption
}
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
await prisma.userPassword.update({
where: {
id: user.id,
userId: user.id,
},
data: {
password: hashedPassword,
hash: hashedPassword,
},
});
};
@@ -14,17 +14,17 @@ type VerifyPasswordOptions = {
};
export const verifyPasswordHandler = async ({ input, ctx }: VerifyPasswordOptions) => {
const user = await prisma.user.findUnique({
const userPassword = await prisma.userPassword.findUnique({
where: {
id: ctx.user.id,
userId: ctx.user.id,
},
});
if (!user?.password) {
if (!userPassword?.hash) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const passwordsMatch = await verifyPassword(input.passwordInput, user.password);
const passwordsMatch = await verifyPassword(input.passwordInput, userPassword.hash);
if (!passwordsMatch) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -119,7 +119,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
username: slugify(adminUsername),
email: adminEmail,
emailVerified: new Date(),
password: hashedPassword,
password: { create: { hash: hashedPassword } },
organizationId: organization.id,
// Default schedule
schedules: {
@@ -30,12 +30,13 @@ export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => {
});
if (!user) throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
if (!user.password) throw new TRPCError({ code: "BAD_REQUEST", message: "Password not set by default" });
if (!user.password?.hash)
throw new TRPCError({ code: "BAD_REQUEST", message: "Password not set by default" });
const generatedPassword = createHash("md5")
.update(`${user?.email ?? ""}${process.env.CALENDSO_ENCRYPTION_KEY}`)
.digest("hex");
const isCorrectPassword = await verifyPassword(generatedPassword, user?.password);
const isCorrectPassword = await verifyPassword(generatedPassword, user.password.hash);
if (!isCorrectPassword)
throw new TRPCError({
@@ -44,12 +45,12 @@ export const setPasswordHandler = async ({ ctx, input }: UpdateOptions) => {
});
const hashedPassword = await hashPassword(newPassword);
await prisma.user.update({
await prisma.userPassword.update({
where: {
id: ctx.user.id,
userId: ctx.user.id,
},
data: {
password: hashedPassword,
hash: hashedPassword,
},
});
@@ -13,7 +13,7 @@ import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Membership, Team } from "@calcom/prisma/client";
import { Prisma, type User as UserType } from "@calcom/prisma/client";
import { Prisma, type User as UserType, type UserPassword } from "@calcom/prisma/client";
import type { Profile as ProfileType } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -27,12 +27,13 @@ import type { InviteMemberOptions, TeamWithParent } from "./types";
const log = logger.getSubLogger({ prefix: ["inviteMember.utils"] });
export type Invitee = Pick<
UserType,
"id" | "email" | "username" | "password" | "identityProvider" | "completedOnboarding"
"id" | "email" | "username" | "identityProvider" | "completedOnboarding"
>;
export type UserWithMembership = Invitee & {
teams?: Pick<Membership, "userId" | "teamId" | "accepted" | "role">[];
profiles: ProfileType[];
password: UserPassword | null;
};
export async function checkPermissions({
@@ -570,7 +571,7 @@ export const sendExistingUserTeamInviteEmails = async ({
* Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template.
* This only changes if the user is a CAL user and has not completed onboarding and has no password
*/
if (!user.completedOnboarding && !user.password && user.identityProvider === "CAL") {
if (!user.completedOnboarding && !user.password?.hash && user.identityProvider === "CAL") {
const token = randomBytes(32).toString("hex");
await prisma.verificationToken.create({
data: {
@@ -73,7 +73,6 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
id: true,
movedToProfileId: true,
email: true,
password: true,
username: true,
completedOnboarding: true,
},