From 082af73c2cb8318959eba10fb2c6629312b7d04a Mon Sep 17 00:00:00 2001 From: Paul Kim <45007945+paulkim26@users.noreply.github.com> Date: Sat, 4 Nov 2023 18:57:34 -0400 Subject: [PATCH] Add password reset page (#29) * feat: added reset password page * feat: added reset token db model * feat: added reset password page server * feat: finished reset password flow * fix: return success regardless of user existing or not * fix: return fail if MAIL_ENABLED option is false * fix: only check for user if MAIL_ENABLED --- src/lib/server/auth.ts | 29 +++++++ src/lib/server/email/reset-password.ts | 26 +++++++ .../migration.sql | 19 +++++ src/lib/server/prisma/schema.prisma | 28 ++++--- src/lib/server/validate.ts | 36 +++++++++ .../(auth)/forgot-password/+page.server.ts | 39 ++++++++++ .../(auth)/forgot-password/+page.svelte | 34 ++++++++ src/routes/(auth)/register/+page.server.ts | 36 ++++++--- .../(auth)/reset-password/+page.server.ts | 77 +++++++++++++++++++ src/routes/(auth)/reset-password/+page.svelte | 47 +++++++++++ 10 files changed, 350 insertions(+), 21 deletions(-) create mode 100644 src/lib/server/email/reset-password.ts create mode 100644 src/lib/server/prisma/migrations/20231031235131_add_resettoken/migration.sql create mode 100644 src/lib/server/validate.ts create mode 100644 src/routes/(auth)/forgot-password/+page.server.ts create mode 100644 src/routes/(auth)/forgot-password/+page.svelte create mode 100644 src/routes/(auth)/reset-password/+page.server.ts create mode 100644 src/routes/(auth)/reset-password/+page.svelte diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 146c8ef..fbbacd5 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private'; import { hashPassword } from '$lib/crypto'; import prisma from '@db'; import type { Cookies } from '@sveltejs/kit'; +import { nanoid } from 'nanoid'; export const getUserIdFromCookie = async (cookies: Cookies) => { const token = cookies.get('token'); @@ -38,3 +39,31 @@ export const validateVerificationHash = async (userId: string, hash: string) => await prisma.user.update({ where: { id: userId }, data: { verified: true } }); return true; }; + +export const generatePasswordResetToken = async (userId: string) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return false; + + const resetToken = await prisma.resetToken.upsert({ + where: { + userId: user.id + }, + update: { + createdAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days + token: nanoid(32) + }, + create: { + user: { + connect: { + id: user.id + } + }, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days + token: nanoid(32) + } + }); + + return resetToken.token; +}; \ No newline at end of file diff --git a/src/lib/server/email/reset-password.ts b/src/lib/server/email/reset-password.ts new file mode 100644 index 0000000..9457eba --- /dev/null +++ b/src/lib/server/email/reset-password.ts @@ -0,0 +1,26 @@ +import { env } from '$env/dynamic/public'; +import prisma from '@db'; +import { generatePasswordResetToken } from '../auth'; +import { sendEmail } from './base'; + +export const sendResetEmail = async (userId: string) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return false; + + const resetToken = await generatePasswordResetToken(userId); + + const verifyUrl = `${env.PUBLIC_URL}/reset-password?token=${encodeURIComponent( + resetToken + )}&userId=${encodeURIComponent(userId)}`; + + const content = `To reset your password, please click the following link: ${verifyUrl} + + If you did not make this request, please disgregard this email.`; + + const subject = 'YABin: Password reset request'; + + const sent = await sendEmail(user.email, subject, content); + if (!sent) return false; + + return true; +}; diff --git a/src/lib/server/prisma/migrations/20231031235131_add_resettoken/migration.sql b/src/lib/server/prisma/migrations/20231031235131_add_resettoken/migration.sql new file mode 100644 index 0000000..f15ccdb --- /dev/null +++ b/src/lib/server/prisma/migrations/20231031235131_add_resettoken/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "ResetToken" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "ResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ResetToken_token_key" ON "ResetToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "ResetToken_userId_key" ON "ResetToken"("userId"); + +-- AddForeignKey +ALTER TABLE "ResetToken" ADD CONSTRAINT "ResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/lib/server/prisma/schema.prisma b/src/lib/server/prisma/schema.prisma index c99f935..08a54bf 100644 --- a/src/lib/server/prisma/schema.prisma +++ b/src/lib/server/prisma/schema.prisma @@ -11,15 +11,16 @@ datasource db { } model User { - id String @id @default(nanoid(16)) - username String @unique - email String @unique - password String - name String - verified Boolean @default(false) - settings Json @default("{}") - pastes Paste[] - AuthToken AuthToken[] + id String @id @default(nanoid(16)) + username String @unique + email String @unique + password String + name String + verified Boolean @default(false) + settings Json @default("{}") + pastes Paste[] + AuthToken AuthToken[] + ResetToken ResetToken? } model AuthToken { @@ -31,6 +32,15 @@ model AuthToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model ResetToken { + id String @id @default(nanoid(16)) + createdAt DateTime @default(now()) + expiresAt DateTime + token String @unique + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Paste { id BigInt @id @default(autoincrement()) createdAt DateTime @default(now()) diff --git a/src/lib/server/validate.ts b/src/lib/server/validate.ts new file mode 100644 index 0000000..a5d9c9c --- /dev/null +++ b/src/lib/server/validate.ts @@ -0,0 +1,36 @@ +const emailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g; +const passwordLength = 8; +const nameLength = 50; +const usernameLength = 50; + +export function validateEmail(email: FormDataEntryValue) { + if (emailRegex.test(email?.toString())) { + return true; + } else { + throw new Error('Invalid email address'); + } +} + +export function validatePassword(password: FormDataEntryValue) { + if (password.toString().length >= passwordLength) { + return true; + } else { + throw new Error(`Password must be at least ${passwordLength} characters long`); + } +} + +export function validateName(name: FormDataEntryValue) { + if (name.toString().length <= nameLength) { + return true; + } else { + throw new Error(`Name is too long`); + } +} + +export function validateUsername(username: FormDataEntryValue) { + if (username.toString().length <= usernameLength) { + return true; + } else { + throw new Error(`Username is too long`); + } +} diff --git a/src/routes/(auth)/forgot-password/+page.server.ts b/src/routes/(auth)/forgot-password/+page.server.ts new file mode 100644 index 0000000..479cebb --- /dev/null +++ b/src/routes/(auth)/forgot-password/+page.server.ts @@ -0,0 +1,39 @@ +import type { Actions, PageServerLoad } from './$types'; +import { redirect, fail } from '@sveltejs/kit'; +import prisma from '@db'; +import { env } from '$env/dynamic/private'; +import { sendResetEmail } from '$lib/server/email/reset-password'; + +export const load: PageServerLoad = async () => { + if (env.MAIL_ENABLED === 'false') { + throw redirect(303, '/'); + } +}; + +export const actions: Actions = { + default: async ({ request }) => { + const data = await request.formData(); + + const usernameOrEmail = data.get('username-email'); + + if (!usernameOrEmail) { + return fail(400, { success: false, errors: ['All fields are required'] }); + } + + if (env.MAIL_ENABLED === 'true') { + const user = await prisma.user.findFirst({ + where: { + OR: [{ username: usernameOrEmail.toString() }, { email: usernameOrEmail.toString() }] + } + }); + + if (user) { + sendResetEmail(user.id); + } + // Return success regardless of whether username/email is found or not + return { success: true, message: 'Please check e-mail for a password reset link' }; + } else { + return fail(400, { success: false, errors: ['E-mail is disabled'] }); + } + } +}; diff --git a/src/routes/(auth)/forgot-password/+page.svelte b/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 0000000..0565253 --- /dev/null +++ b/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,34 @@ + + +
+

Reset Password

+
+ {#if form?.errors} + + {/if} + {#if form?.success} +
{form?.message}
+ {/if} + +
+
+ + +
+ +
+ +
+ +
+
+
diff --git a/src/routes/(auth)/register/+page.server.ts b/src/routes/(auth)/register/+page.server.ts index f714d29..03ff3a6 100644 --- a/src/routes/(auth)/register/+page.server.ts +++ b/src/routes/(auth)/register/+page.server.ts @@ -6,8 +6,12 @@ import { nanoid } from 'nanoid'; import { env } from '$env/dynamic/private'; import { env as envPublic } from '$env/dynamic/public'; import { sendVerificationEmail } from '$lib/server/email/verify'; - -const emailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g; +import { + validateEmail, + validatePassword, + validateName, + validateUsername +} from '$lib/server/validate'; export const actions: Actions = { default: async ({ cookies, request }) => { @@ -29,24 +33,32 @@ export const actions: Actions = { errors.push('All fields are required'); } - if (email && !emailRegex.test(email?.toString())) { - errors.push('Invalid email address'); + try { + if (email) validateEmail(email); + } catch (e: any) { + errors.push(e.message); } - if (password && password.toString().length < 8) { - errors.push('Password must be at least 8 characters long'); + try { + if (password) validatePassword(password); + } catch (e: any) { + errors.push(e.message); } - if (password && password !== cnfPassword) { - errors.push('Passwords do not match'); + try { + if (name) validateName(name); + } catch (e: any) { + errors.push(e.message); } - if (name && name.toString().length > 50) { - errors.push('Name is too long'); + try { + if (username) validateUsername(username); + } catch (e: any) { + errors.push(e.message); } - if (username && username.toString().length > 50) { - errors.push('Username is too long'); + if (password && password !== cnfPassword) { + errors.push('Passwords do not match'); } if (username && email) { diff --git a/src/routes/(auth)/reset-password/+page.server.ts b/src/routes/(auth)/reset-password/+page.server.ts new file mode 100644 index 0000000..7ba4ec7 --- /dev/null +++ b/src/routes/(auth)/reset-password/+page.server.ts @@ -0,0 +1,77 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { fail } from '@sveltejs/kit'; +import prisma from '@db'; +import { hashPassword } from '$lib/crypto'; +import { env } from '$env/dynamic/private'; +import { validatePassword } from '$lib/server/validate'; + +export const load: PageServerLoad = async ({ url }) => { + const userId = url.searchParams.get('userId'); + const token = url.searchParams.get('token'); + + if (!userId || !token) { + throw error(404, 'Not found'); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + const resetToken = await prisma.resetToken.findUnique({ where: { token: token } }); + + if (!user || !resetToken) { + throw error(404, 'Not found'); + } + + if (resetToken.expiresAt <= new Date()) { + throw error(404, 'Expired link'); + } +}; + +export const actions: Actions = { + default: async ({ url, request }) => { + const userId = url.searchParams.get('userId'); + if (!userId) throw error(404, 'Not found'); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw error(404, 'Not found'); + + const data = await request.formData(); + const newPassword = data.get('new-password'); + const cnfPassword = data.get('confirm-password'); + const errors: string[] = []; + + if (!newPassword || !cnfPassword) { + errors.push('All fields are required'); + } + + if (newPassword !== cnfPassword) { + errors.push('Passwords need to match'); + } + + try { + if (newPassword) validatePassword(newPassword); + } catch (e: any) { + errors.push(e.message); + } + + if (newPassword) { + const oldPasswordHash = user.password; + const newPasswordHash = await hashPassword(newPassword.toString(), env.SALT); + if (oldPasswordHash === newPasswordHash) { + errors.push('Cannot use existing password'); + } + } + + if (errors.length > 0) { + return fail(400, { success: false, errors }); + } + + if (newPassword && cnfPassword) { + const newPasswordHash = await hashPassword(newPassword.toString(), env.SALT); + await prisma.user.update({ where: { id: userId }, data: { password: newPasswordHash } }); + await prisma.resetToken.delete({ where: { userId: userId } }); + throw redirect(303, '/login'); + } + + return { success: false, errors: ['Unknown error'] }; + } +}; diff --git a/src/routes/(auth)/reset-password/+page.svelte b/src/routes/(auth)/reset-password/+page.svelte new file mode 100644 index 0000000..18b1d13 --- /dev/null +++ b/src/routes/(auth)/reset-password/+page.svelte @@ -0,0 +1,47 @@ + + +
+

Change Password

+
+ {#if form?.errors} + + {/if} + +
+
+ + +
+ +
+ + +
+ +
+ +
+
+
+