diff --git a/packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql b/packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql new file mode 100644 index 0000000..3ce8efc --- /dev/null +++ b/packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "PasswordResetReq" ( + "passwordResetId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PasswordResetReq_pkey" PRIMARY KEY ("passwordResetId") +); + +-- AddForeignKey +ALTER TABLE "PasswordResetReq" ADD CONSTRAINT "PasswordResetReq_passwordResetId_fkey" FOREIGN KEY ("passwordResetId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6a3d7ca..82f33f1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -23,21 +23,22 @@ enum Role { } model User { - userId String @id @default(uuid()) @map("id") - email String @unique - hashedPassword String @map("password") + userId String @id @default(uuid()) @map("id") + email String @unique + hashedPassword String @map("password") firstName String lastName String stageName String? role Role - isSongWriter Boolean @default(false) - isAscapAffiliated Boolean @default(false) - isBmiAffiliated Boolean @default(false) + isSongWriter Boolean @default(false) + isAscapAffiliated Boolean @default(false) + isBmiAffiliated Boolean @default(false) groups Group[] + passwordResetReq PasswordResetReq? sessions Session[] sentInvites GroupInvite[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Session { @@ -84,3 +85,10 @@ model EmailVerificationCode { updatedAt DateTime @updatedAt expiresAt DateTime } + +model PasswordResetReq { + passwordResetId String @id @default(cuid()) + user User @relation(fields: [passwordResetId], references: [userId], onDelete: Cascade) + createdAt DateTime @default(now()) + expiresAt DateTime +} diff --git a/packages/email/package.json b/packages/email/package.json index 2fc7d1a..565f7ff 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { "./verification-email": "./src/verification-email.ts", - "./email-service": "./src/email-service.ts" + "./email-service": "./src/email-service.ts", + "./password-reset-email": "./src/password-reset-email.ts" }, "license": "MIT", "scripts": { diff --git a/packages/email/src/password-reset-email.ts b/packages/email/src/password-reset-email.ts new file mode 100644 index 0000000..1cc7f93 --- /dev/null +++ b/packages/email/src/password-reset-email.ts @@ -0,0 +1,19 @@ +import { send, setApiKey } from "@good-dog/email/email-service"; +import { env } from "@good-dog/env"; + +export async function sendPasswordResetEmail(toEmail: string, cuid: string) { + setApiKey(env.SENDGRID_API_KEY ?? ""); + + let baseURL = "http://localhost:3000"; + if (env.VERCEL_URL) { + baseURL = `https://${env.VERCEL_URL}`; + } + + const msg = { + to: toEmail, + subject: "Reset Your Password - Good Dog Licensing", + html: `

Follow this link to reset your password.`, + }; + + return await send(msg, env.VERIFICATION_FROM_EMAIL ?? ""); +} diff --git a/packages/trpc/src/internal/router.ts b/packages/trpc/src/internal/router.ts index 12c4e8a..7cc8937 100644 --- a/packages/trpc/src/internal/router.ts +++ b/packages/trpc/src/internal/router.ts @@ -8,6 +8,10 @@ import { confirmEmailProcedure, sendEmailVerificationProcedure, } from "../procedures/email-verification"; +import { + confirmPasswordResetProcedure, + sendForgotPasswordEmailProcedure, +} from "../procedures/forgot-password"; import { onboardingProcedure } from "../procedures/onboarding"; import { getAuthenticatedUserProcedure, @@ -25,6 +29,8 @@ export const appRouter = createTRPCRouter({ deleteAccount: deleteAccountProcedure, authenticatedUser: getAuthenticatedUserProcedure, user: getUserProcedure, + sendForgotPasswordEmail: sendForgotPasswordEmailProcedure, + confirmPasswordReset: confirmPasswordResetProcedure, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts new file mode 100644 index 0000000..e1eb875 --- /dev/null +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -0,0 +1,132 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { hashPassword } from "@good-dog/auth/password"; +import { sendPasswordResetEmail } from "@good-dog/email/password-reset-email"; +import { env } from "@good-dog/env"; + +import { baseProcedureBuilder } from "../internal/init"; + +// click forgot passwd -> enter email -> receive unique link -> enter new passwd at given link + +const getNewPasswordResetExpirationDate = () => + new Date(Date.now() + 60_000 * 15); + +export const sendForgotPasswordEmailProcedure = baseProcedureBuilder + .input( + z.object({ + email: z.string().email(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Find user with the given email + const user = await ctx.prisma.user.findUnique({ + where: { + email: input.email, + }, + }); + + // If user with that email doesn't exist, return. + if (!user) { + return { + message: `If a user exists for ${input.email}, a password reset link was sent to the email.`, + }; + } + + const [_, pwdResetReq] = await ctx.prisma.$transaction([ + // Delete any existing password reset requests for the user + ctx.prisma.passwordResetReq.deleteMany({ + where: { + user: user, + }, + }), + // Create new password reset request with updated expired at time + ctx.prisma.passwordResetReq.create({ + data: { + user: { + connect: { + userId: user.userId, + }, + }, + expiresAt: getNewPasswordResetExpirationDate(), + }, + }), + ]); + + // Send the password reset email + try { + await sendPasswordResetEmail(input.email, pwdResetReq.passwordResetId); + } catch (error) { + if (env.NODE_ENV === "development") { + console.error(error); + } else { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Password reset email to ${input.email} failed to send.`, + cause: error, + }); + } + } + + return { + message: `If a user exists for ${input.email}, a password reset link was sent to the email.`, + }; + }); + +export const confirmPasswordResetProcedure = baseProcedureBuilder + .input( + z.object({ + passwordResetId: z.string(), + newPassword: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Find password reset request for given passwordResetId + const passwordResetReq = await ctx.prisma.passwordResetReq.findUnique({ + where: { + passwordResetId: input.passwordResetId, + }, + include: { + user: true, + }, + }); + + // If password reset request doesn't exist, throw error + if (!passwordResetReq) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No password reset request found for given id.`, + }); + } + + // If password reset request is expired, throw error + if (passwordResetReq.expiresAt < new Date()) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `Password reset request is expired.`, + }); + } + + await ctx.prisma.$transaction([ + // Update user's password + ctx.prisma.user.update({ + where: { + email: passwordResetReq.user.email, + }, + data: { + hashedPassword: await hashPassword(input.newPassword), + }, + }), + + // Delete the password reset request + ctx.prisma.passwordResetReq.delete({ + where: { + passwordResetId: passwordResetReq.passwordResetId, + }, + }), + ]); + + return { + message: "Password reset.", + }; + }); diff --git a/tests/api/forgot-password.test.ts b/tests/api/forgot-password.test.ts new file mode 100644 index 0000000..c9e378f --- /dev/null +++ b/tests/api/forgot-password.test.ts @@ -0,0 +1,266 @@ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; + +import { comparePassword, hashPassword } from "@good-dog/auth/password"; +import { prisma } from "@good-dog/db"; +import { $trpcCaller } from "@good-dog/trpc/server"; + +import { MockEmailService } from "../mocks/MockEmailService"; +import { MockNextCookies } from "../mocks/MockNextCookies"; + +describe("forgot-password", () => { + const mockCookies = new MockNextCookies(); + const mockEmails = new MockEmailService(); + + const createAccount = async (email: string) => + prisma.user.upsert({ + create: { + firstName: "Walter", + lastName: "White", + role: "MEDIA_MAKER", + email: email, + hashedPassword: await hashPassword("password123"), + }, + update: { + email: email, + hashedPassword: await hashPassword("password123"), + }, + where: { + email: email, + }, + }); + + const cleanupAccount = async (email: string) => { + try { + await prisma.user.delete({ + where: { + email: email, + }, + }); + } catch (error) { + void error; + } + }; + + const createPasswordResetRequest = async ( + userEmail: string, + expiresAt: Date, + ) => + prisma.passwordResetReq.create({ + data: { + expiresAt: expiresAt, + user: { + connect: { + email: userEmail, + }, + }, + }, + }); + + const cleanupPasswordResetRequest = async (userEmail: string) => { + try { + await prisma.passwordResetReq.deleteMany({ + where: { + user: { + email: userEmail, + }, + }, + }); + } catch (error) { + void error; + } + }; + + beforeAll(async () => { + await Promise.all([mockCookies.apply(), mockEmails.apply()]); + }); + + beforeEach(async () => { + await Promise.all([ + cleanupPasswordResetRequest("walter@gmail.com"), + createAccount("walter@gmail.com"), + ]); + }); + + afterEach(async () => { + mockCookies.clear(); + mockEmails.clear(); + await Promise.all([ + cleanupPasswordResetRequest("walter@gmail.com"), + cleanupAccount("walter@gmail.com"), + ]); + }); + + describe("forgot-password/sendForgotPasswordEmail", () => { + test("No user with given email", async () => { + await cleanupAccount("walter@gmail.com"); + + const response = await $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }); + + expect(response.message).toBe( + "If a user exists for walter@gmail.com, a password reset link was sent to the email.", + ); + + expect(mockEmails.setApiKey).not.toBeCalled(); + expect(mockEmails.send).not.toBeCalled(); + }); + + test("Valid user. No pending password reset request.", async () => { + const response = await $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }); + + expect(mockEmails.setApiKey).toBeCalled(); + expect(mockEmails.send).toBeCalled(); + + expect(response.message).toBe( + "If a user exists for walter@gmail.com, a password reset link was sent to the email.", + ); + + const user = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + include: { + passwordResetReq: true, + }, + }); + const passwordResetReq = await prisma.passwordResetReq.findMany({ + where: { + user: user ?? {}, + }, + }); + + expect(passwordResetReq.length).toBe(1); + expect( + (passwordResetReq[0]?.expiresAt ?? new Date(Date.now() - 10000000)) > + new Date(), + ).toBe(true); + expect(user?.passwordResetReq?.passwordResetId).toBe( + passwordResetReq[0]?.passwordResetId ?? "", + ); + }); + + test("Valid user. Pending password reset request.", async () => { + await createPasswordResetRequest( + "walter@gmail.com", + new Date(Date.now() + 60_000 * 100000), + ); + + const user = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + }); + let passwordResetReqs = await prisma.passwordResetReq.findMany({ + where: { + user: user ?? {}, + }, + }); + expect(passwordResetReqs.length).toBe(1); + + const response = await $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }); + + expect(mockEmails.setApiKey).toBeCalled(); + expect(mockEmails.send).toBeCalled(); + + expect(response.message).toBe( + "If a user exists for walter@gmail.com, a password reset link was sent to the email.", + ); + + const userUpdated = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + include: { + passwordResetReq: true, + }, + }); + passwordResetReqs = await prisma.passwordResetReq.findMany({ + where: { + user: userUpdated ?? {}, + }, + }); + + expect(passwordResetReqs.length).toBe(1); + expect( + (passwordResetReqs[0]?.expiresAt ?? new Date(Date.now() - 100000)) > + new Date(), + ).toBe(true); + expect(userUpdated?.passwordResetReq?.passwordResetId).toBe( + passwordResetReqs[0]?.passwordResetId ?? "", + ); + }); + }); + + describe("forgot-password/confirmPasswordReset", () => { + test("Given cuid doesn't exist", () => { + expect( + $trpcCaller.confirmPasswordReset({ + passwordResetId: "12345", + newPassword: "password", + }), + ).rejects.toThrow("No password reset request found for given id."); + }); + + test("Password reset request is expired", async () => { + const passwordResetReq = await createPasswordResetRequest( + "walter@gmail.com", + new Date(Date.now() - 10000), + ); + + expect( + $trpcCaller.confirmPasswordReset({ + passwordResetId: passwordResetReq.passwordResetId, + newPassword: "password", + }), + ).rejects.toThrow("Password reset request is expired."); + }); + + test("Password reset request is valid", async () => { + const passwordResetReq = await createPasswordResetRequest( + "walter@gmail.com", + new Date(Date.now() + 60_000 * 100000), + ); + + const response = await $trpcCaller.confirmPasswordReset({ + passwordResetId: passwordResetReq.passwordResetId, + newPassword: "newPassword", + }); + + expect(response.message).toBe("Password reset."); + + const user = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + include: { + passwordResetReq: true, + }, + }); + const passwordUpdated = await comparePassword( + "newPassword", + user?.hashedPassword ?? "", + ); + expect(passwordUpdated).toBe(true); + expect(user?.passwordResetReq).toBe(null); + + const oldPasswordResetReq = await prisma.passwordResetReq.findUnique({ + where: { + passwordResetId: passwordResetReq.passwordResetId, + }, + }); + expect(oldPasswordResetReq).toBe(null); + }); + }); +});