Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add password reset page #29

Merged
merged 7 commits into from
Nov 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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?
Yureien marked this conversation as resolved.
Show resolved Hide resolved
}

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'] });
}
Yureien marked this conversation as resolved.
Show resolved Hide resolved
}
};
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);
Yureien marked this conversation as resolved.
Show resolved Hide resolved
} 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');
Yureien marked this conversation as resolved.
Show resolved Hide resolved
}

return { success: false, errors: ['Unknown error'] };
}
};