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 @@ + + +