diff --git a/lib/api/sendPasswordResetRequest.ts b/lib/api/sendPasswordResetRequest.ts new file mode 100644 index 00000000..ce29e1ba --- /dev/null +++ b/lib/api/sendPasswordResetRequest.ts @@ -0,0 +1,44 @@ +import { randomBytes } from "crypto"; +import { prisma } from "./db"; +import transporter from "./transporter"; +import Handlebars from "handlebars"; +import { readFileSync } from "fs"; +import path from "path"; + +export default async function sendPasswordResetRequest( + email: string, + user: string +) { + const token = randomBytes(32).toString("hex"); + + await prisma.passwordResetToken.create({ + data: { + identifier: email?.toLowerCase(), + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day + }, + }); + + const emailsDir = path.resolve(process.cwd(), "templates"); + + const templateFile = readFileSync( + path.join(emailsDir, "passwordReset.html"), + "utf8" + ); + + const emailTemplate = Handlebars.compile(templateFile); + + transporter.sendMail({ + from: { + name: "Linkwarden", + address: process.env.EMAIL_FROM as string, + }, + to: email, + subject: "Verify your new Linkwarden email address", + html: emailTemplate({ + user, + baseUrl: process.env.BASE_URL, + url: `${process.env.BASE_URL}/auth/password-reset?token=${token}`, + }), + }); +} diff --git a/pages/api/v1/auth/forgot-password.ts b/pages/api/v1/auth/forgot-password.ts new file mode 100644 index 00000000..0672914c --- /dev/null +++ b/pages/api/v1/auth/forgot-password.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/api/db"; +import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function forgotPassword( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const email = req.body.email; + + if (!email) { + return res.status(400).json({ + response: "Invalid email.", + }); + } + + const recentPasswordRequestsCount = await prisma.passwordResetToken.count({ + where: { + identifier: email, + createdAt: { + gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes + }, + }, + }); + + // Rate limit password reset requests + if (recentPasswordRequestsCount >= 3) { + return res.status(400).json({ + response: "Too many requests. Please try again later.", + }); + } + + const user = await prisma.user.findFirst({ + where: { + email, + }, + }); + + if (!user || !user.email) { + return res.status(400).json({ + response: "Invalid email.", + }); + } + + sendPasswordResetRequest(user.email, user.name); + + return res.status(200).json({ + response: "Password reset email sent.", + }); + } +} diff --git a/pages/api/v1/auth/reset-password.ts b/pages/api/v1/auth/reset-password.ts new file mode 100644 index 00000000..06ebb799 --- /dev/null +++ b/pages/api/v1/auth/reset-password.ts @@ -0,0 +1,80 @@ +import { prisma } from "@/lib/api/db"; +import type { NextApiRequest, NextApiResponse } from "next"; +import bcrypt from "bcrypt"; + +export default async function resetPassword( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const token = req.body.token; + const password = req.body.password; + + if (!password || password.length < 8) { + return res.status(400).json({ + response: "Password must be at least 8 characters.", + }); + } + + if (!token || typeof token !== "string") { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + // Hashed password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Check token in db + const verifyToken = await prisma.passwordResetToken.findFirst({ + where: { + token, + expires: { + gt: new Date(), + }, + }, + }); + + if (!verifyToken) { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + const email = verifyToken.identifier; + + // Update password + await prisma.user.update({ + where: { + email, + }, + data: { + password: hashedPassword, + }, + }); + + await prisma.passwordResetToken.update({ + where: { + token, + }, + data: { + expires: new Date(), + }, + }); + + // Delete tokens older than 5 minutes + await prisma.passwordResetToken.deleteMany({ + where: { + identifier: email, + createdAt: { + lt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes + }, + }, + }); + + return res.status(200).json({ + response: "Password reset successfully.", + }); + } +} diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx new file mode 100644 index 00000000..4615ecd9 --- /dev/null +++ b/pages/auth/reset-password.tsx @@ -0,0 +1,116 @@ +import AccentSubmitButton from "@/components/AccentSubmitButton"; +import TextInput from "@/components/TextInput"; +import CenteredForm from "@/layouts/CenteredForm"; +import Link from "next/link"; +import { FormEvent, useState } from "react"; +import { toast } from "react-hot-toast"; + +interface FormData { + password: string; + passwordConfirmation: string; +} + +export default function ResetPassword() { + const [submitLoader, setSubmitLoader] = useState(false); + + const [form, setForm] = useState({ + password: "", + passwordConfirmation: "", + }); + + const [isEmailSent, setIsEmailSent] = useState(false); + + async function submitRequest() { + const response = await fetch("/api/v1/auth/forgot-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success(data.response); + setIsEmailSent(true); + } else { + toast.error(data.response); + } + } + + async function sendConfirmation(event: FormEvent) { + event.preventDefault(); + + if (form.password !== "") { + setSubmitLoader(true); + + const load = toast.loading("Sending password recovery link..."); + + await submitRequest(); + + toast.dismiss(load); + + setSubmitLoader(false); + } else { + toast.error("Please fill out all the fields."); + } + } + + return ( + +
+
+

+ {isEmailSent ? "Email Sent!" : "Forgot Password?"} +

+ +
+ + {!isEmailSent ? ( + <> +
+

+ Enter your email so we can send you a link to create a new + password. +

+
+
+

Email

+ + + setForm({ ...form, password: e.target.value }) + } + /> +
+ + + + ) : ( +

+ Check your email for a link to reset your password. If it doesn’t + appear within a few minutes, check your spam folder. +

+ )} + +
+ + Go back + +
+
+
+
+ ); +} diff --git a/pages/forgot.tsx b/pages/forgot.tsx index d2773d94..f216776d 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -1,7 +1,6 @@ import AccentSubmitButton from "@/components/AccentSubmitButton"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; -import { signIn } from "next-auth/react"; import Link from "next/link"; import { FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; @@ -17,24 +16,40 @@ export default function Forgot() { email: "", }); + const [isEmailSent, setIsEmailSent] = useState(false); + + async function submitRequest() { + const response = await fetch("/api/v1/auth/forgot-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success(data.response); + setIsEmailSent(true); + } else { + toast.error(data.response); + } + } + async function sendConfirmation(event: FormEvent) { event.preventDefault(); if (form.email !== "") { setSubmitLoader(true); - const load = toast.loading("Sending login link..."); + const load = toast.loading("Sending password recovery link..."); - await signIn("email", { - email: form.email, - callbackUrl: "/", - }); + await submitRequest(); toast.dismiss(load); setSubmitLoader(false); - - toast.success("Login link sent."); } else { toast.error("Please fill out all the fields."); } @@ -45,40 +60,46 @@ export default function Forgot() {

- Password Recovery + {isEmailSent ? "Email Sent!" : "Forgot Password?"}

-
-

- Enter your email so we can send you a link to recover your - account. Make sure to change your password in the profile settings - afterwards. -

-

- You wont get logged in if you haven't created an account yet. -

-
-
-

Email

+ {!isEmailSent ? ( + <> +
+

+ Enter your email so we can send you a link to create a new + password. +

+
+
+

Email

- setForm({ ...form, email: e.target.value })} - /> -
+ setForm({ ...form, email: e.target.value })} + /> +
+ + + + ) : ( +

+ Check your email for a link to reset your password. If it doesn’t + appear within a few minutes, check your spam folder. +

+ )} -
Go back diff --git a/templates/passwordReset.html b/templates/passwordReset.html new file mode 100644 index 00000000..d3b6f5e1 --- /dev/null +++ b/templates/passwordReset.html @@ -0,0 +1,388 @@ + + + + + + Email + + + + + + + + + + + +