Skip to content

Commit

Permalink
Add password reset page (#29)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
paulkim26 committed Nov 4, 2023
1 parent 5bbfe25 commit 082af73
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 21 deletions.
29 changes: 29 additions & 0 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
};
26 changes: 26 additions & 0 deletions src/lib/server/email/reset-password.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
28 changes: 19 additions & 9 deletions src/lib/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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())
Expand Down
36 changes: 36 additions & 0 deletions src/lib/server/validate.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
39 changes: 39 additions & 0 deletions src/routes/(auth)/forgot-password/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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'] });
}
}
};
34 changes: 34 additions & 0 deletions src/routes/(auth)/forgot-password/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
export let form: ActionData;
</script>

<div class="flex flex-col justify-center items-center">
<h1 class="text-4xl">Reset Password</h1>
<div class="mt-6">
{#if form?.errors}
<ul class="text-red-500 text-center">
{#each form.errors as error}
<li>{error}</li>
{/each}
</ul>
{/if}
{#if form?.success}
<div class="text-green-500 text-center">{form?.message}</div>
{/if}

<form method="post" class="mt-2 flex flex-col gap-4" use:enhance>
<div>
<label for="username-email" class="px-2 py-2">Username or E-mail</label>
<input class="bg-dark px-2 py-1 w-full" type="text" id="username-email" name="username-email" placeholder="Username or E-mail" />
</div>

<div class="flex flex-row items-center gap-4 mt-2">
<button class="bg-amber-500 text-black text-lg px-4 py-1">Reset Password</button>
</div>

</form>
</div>
</div>
36 changes: 24 additions & 12 deletions src/routes/(auth)/register/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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) {
Expand Down
77 changes: 77 additions & 0 deletions src/routes/(auth)/reset-password/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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'] };
}
};
Loading

0 comments on commit 082af73

Please sign in to comment.