From f68ca100a12a1e161bafee2e95b25894e2870e1b Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Thu, 16 May 2024 15:02:22 -0400 Subject: [PATCH] refactored email verification --- .env.sample | 1 + .../EmailChangeVerificationModal.tsx | 67 +++ .../users/userId/updateUserById.ts | 63 +-- lib/api/sendChangeEmailVerificationRequest.ts | 54 +++ lib/api/sendVerificationRequest.ts | 77 +--- lib/api/transporter.ts | 8 + package.json | 1 + pages/api/v1/auth/verify-email.ts | 116 +++++ pages/auth/verify-email.tsx | 33 ++ pages/settings/account.tsx | 120 ++--- .../migration.sql | 2 + prisma/schema.prisma | 1 + templates/verifyEmail.html | 413 +++++++++++++++++ templates/verifyEmailChange.html | 424 ++++++++++++++++++ types/enviornment.d.ts | 3 +- yarn.lock | 37 ++ 16 files changed, 1285 insertions(+), 135 deletions(-) create mode 100644 components/ModalContent/EmailChangeVerificationModal.tsx create mode 100644 lib/api/sendChangeEmailVerificationRequest.ts create mode 100644 lib/api/transporter.ts create mode 100644 pages/api/v1/auth/verify-email.ts create mode 100644 pages/auth/verify-email.tsx create mode 100644 prisma/migrations/20240515084924_add_unverified_new_email_field_to_user_table/migration.sql create mode 100644 templates/verifyEmail.html create mode 100644 templates/verifyEmailChange.html diff --git a/.env.sample b/.env.sample index a579635..30bc044 100644 --- a/.env.sample +++ b/.env.sample @@ -36,6 +36,7 @@ SPACES_FORCE_PATH_STYLE= NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= EMAIL_SERVER= +BASE_URL= # Proxy settings PROXY= diff --git a/components/ModalContent/EmailChangeVerificationModal.tsx b/components/ModalContent/EmailChangeVerificationModal.tsx new file mode 100644 index 0000000..15560d9 --- /dev/null +++ b/components/ModalContent/EmailChangeVerificationModal.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import TextInput from "@/components/TextInput"; +import Modal from "../Modal"; + +type Props = { + onClose: Function; + onSubmit: Function; + oldEmail: string; + newEmail: string; +}; + +export default function EmailChangeVerificationModal({ + onClose, + onSubmit, + oldEmail, + newEmail, +}: Props) { + const [password, setPassword] = useState(""); + + return ( + +

Confirm Password

+ +
+ +
+

+ Please confirm your password before changing your email address.{" "} + {process.env.NEXT_PUBLIC_STRIPE === "true" + ? "Updating this field will change your billing email on Stripe as well." + : undefined} +

+ +
+

Old Email

+

{oldEmail}

+
+ +
+

New Email

+

{newEmail}

+
+ +
+

Password

+ setPassword(e.target.value)} + placeholder="••••••••••••••" + className="bg-base-200" + type="password" + autoFocus + /> +
+ +
+ +
+
+
+ ); +} diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 782cc3a..cbf8a15 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -3,8 +3,8 @@ import { AccountSettings } from "@/types/global"; import bcrypt from "bcrypt"; import removeFile from "@/lib/api/storage/removeFile"; import createFile from "@/lib/api/storage/createFile"; -import updateCustomerEmail from "@/lib/api/updateCustomerEmail"; import createFolder from "@/lib/api/storage/createFolder"; +import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -13,17 +13,6 @@ export default async function updateUserById( userId: number, data: AccountSettings ) { - const ssoUser = await prisma.account.findFirst({ - where: { - userId: userId, - }, - }); - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - if (emailEnabled && !data.email) return { response: "Email invalid.", @@ -39,6 +28,7 @@ export default async function updateUserById( response: "Password must be at least 8 characters.", status: 400, }; + // Check email (if enabled) const checkEmail = /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; @@ -126,11 +116,42 @@ export default async function updateUserById( removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); } - const previousEmail = ( - await prisma.user.findUnique({ where: { id: userId } }) - )?.email; + // Email Settings - // Other settings + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, password: true }, + }); + + if (user && user.email && data.email && data.email !== user.email) { + if (!data.password) { + return { + response: "Invalid password.", + status: 400, + }; + } + + // Verify password + if (!user.password) { + return { + response: "User has no password.", + status: 400, + }; + } + + const passwordMatch = bcrypt.compareSync(data.password, user.password); + + if (!passwordMatch) { + return { + response: "Password is incorrect.", + status: 400, + }; + } + + sendChangeEmailVerificationRequest(user.email, data.email, data.name); + } + + // Other settings / Apply changes const saltRounds = 10; const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds); @@ -142,7 +163,6 @@ export default async function updateUserById( data: { name: data.name, username: data.username?.toLowerCase().trim(), - email: data.email?.toLowerCase().trim(), isPrivate: data.isPrivate, image: data.image && data.image.startsWith("http") @@ -211,15 +231,6 @@ export default async function updateUserById( }); } - const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; - - if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email) - await updateCustomerEmail( - STRIPE_SECRET_KEY, - previousEmail as string, - data.email as string - ); - const response: Omit = { ...userInfo, whitelistedUsers: newWhitelistedUsernames, diff --git a/lib/api/sendChangeEmailVerificationRequest.ts b/lib/api/sendChangeEmailVerificationRequest.ts new file mode 100644 index 0000000..85b09c9 --- /dev/null +++ b/lib/api/sendChangeEmailVerificationRequest.ts @@ -0,0 +1,54 @@ +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 sendChangeEmailVerificationRequest( + oldEmail: string, + newEmail: string, + user: string +) { + const token = randomBytes(32).toString("hex"); + + await prisma.$transaction(async () => { + await prisma.verificationToken.create({ + data: { + identifier: oldEmail?.toLowerCase(), + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day + }, + }); + await prisma.user.update({ + where: { + email: oldEmail?.toLowerCase(), + }, + data: { + unverifiedNewEmail: newEmail?.toLowerCase(), + }, + }); + }); + + const emailsDir = path.resolve(process.cwd(), "templates"); + + const templateFile = readFileSync( + path.join(emailsDir, "verifyEmailChange.html"), + "utf8" + ); + + const emailTemplate = Handlebars.compile(templateFile); + + transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: newEmail, + subject: "Verify your new Linkwarden email address", + html: emailTemplate({ + user, + baseUrl: process.env.BASE_URL, + oldEmail, + newEmail, + verifyUrl: `${process.env.BASE_URL}/auth/verify-email?token=${token}`, + }), + }); +} diff --git a/lib/api/sendVerificationRequest.ts b/lib/api/sendVerificationRequest.ts index 5951e9f..730df46 100644 --- a/lib/api/sendVerificationRequest.ts +++ b/lib/api/sendVerificationRequest.ts @@ -1,19 +1,33 @@ -import { Theme } from "next-auth"; +import { readFileSync } from "fs"; import { SendVerificationRequestParams } from "next-auth/providers"; -import { createTransport } from "nodemailer"; +import path from "path"; +import Handlebars from "handlebars"; +import transporter from "./transporter"; export default async function sendVerificationRequest( params: SendVerificationRequestParams ) { - const { identifier, url, provider, theme } = params; + const emailsDir = path.resolve(process.cwd(), "templates"); + + const templateFile = readFileSync( + path.join(emailsDir, "verifyEmail.html"), + "utf8" + ); + + const emailTemplate = Handlebars.compile(templateFile); + + const { identifier, url, provider, token } = params; const { host } = new URL(url); - const transport = createTransport(provider.server); - const result = await transport.sendMail({ + const result = await transporter.sendMail({ to: identifier, from: provider.from, - subject: `Sign in to ${host}`, + subject: `Please verify your email address`, text: text({ url, host }), - html: html({ url, host, theme }), + html: emailTemplate({ + url: `${ + process.env.NEXTAUTH_URL + }/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`, + }), }); const failed = result.rejected.concat(result.pending).filter(Boolean); if (failed.length) { @@ -21,55 +35,6 @@ export default async function sendVerificationRequest( } } -function html(params: { url: string; host: string; theme: Theme }) { - const { url, host, theme } = params; - - const escapedHost = host.replace(/\./g, "​."); - - const brandColor = theme.brandColor || "#0029cf"; - const color = { - background: "#f9f9f9", - text: "#444", - mainBackground: "#fff", - buttonBackground: brandColor, - buttonBorder: brandColor, - buttonText: theme.buttonText || "#fff", - }; - - return ` - - - - - - - - - - - -
- Sign in to ${escapedHost} -
- - - - -
- - Sign in - -
-
- If you did not request this email you can safely ignore it. -
- -`; -} - /** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */ function text({ url, host }: { url: string; host: string }) { return `Sign in to ${host}\n${url}\n\n`; diff --git a/lib/api/transporter.ts b/lib/api/transporter.ts new file mode 100644 index 0000000..f07dd9c --- /dev/null +++ b/lib/api/transporter.ts @@ -0,0 +1,8 @@ +import { createTransport } from "nodemailer"; + +export default createTransport({ + url: process.env.EMAIL_SERVER, + auth: { + user: process.env.EMAIL_FROM, + }, +}); diff --git a/package.json b/package.json index a41987e..13d0fd9 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-config-next": "13.4.9", "formidable": "^3.5.1", "framer-motion": "^10.16.4", + "handlebars": "^4.7.8", "himalaya": "^1.1.0", "jimp": "^0.22.10", "jsdom": "^22.1.0", diff --git a/pages/api/v1/auth/verify-email.ts b/pages/api/v1/auth/verify-email.ts new file mode 100644 index 0000000..1205fe7 --- /dev/null +++ b/pages/api/v1/auth/verify-email.ts @@ -0,0 +1,116 @@ +import { prisma } from "@/lib/api/db"; +import updateCustomerEmail from "@/lib/api/updateCustomerEmail"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function verifyEmail( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const token = req.query.token; + + if (!token || typeof token !== "string") { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + // Check token in db + const verifyToken = await prisma.verificationToken.findFirst({ + where: { + token, + expires: { + gte: new Date(), + }, + }, + }); + + const oldEmail = verifyToken?.identifier; + + if (!oldEmail) { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + // Ensure email isn't in use + const findNewEmail = await prisma.user.findFirst({ + where: { + email: oldEmail, + }, + select: { + unverifiedNewEmail: true, + }, + }); + + const newEmail = findNewEmail?.unverifiedNewEmail; + + if (!newEmail) { + return res.status(400).json({ + response: "No unverified emails found.", + }); + } + + const emailInUse = await prisma.user.findFirst({ + where: { + email: newEmail, + }, + select: { + email: true, + }, + }); + + console.log(emailInUse); + + if (emailInUse) { + return res.status(400).json({ + response: "Email is already in use.", + }); + } + + // Remove SSO provider + await prisma.account.deleteMany({ + where: { + user: { + email: oldEmail, + }, + }, + }); + + // Update email in db + await prisma.user.update({ + where: { + email: oldEmail, + }, + data: { + email: newEmail.toLowerCase().trim(), + unverifiedNewEmail: null, + }, + }); + + // Apply to Stripe + const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + + if (STRIPE_SECRET_KEY) + await updateCustomerEmail(STRIPE_SECRET_KEY, oldEmail, newEmail); + + // Clean up existing tokens + await prisma.verificationToken.delete({ + where: { + token, + }, + }); + + await prisma.verificationToken.deleteMany({ + where: { + identifier: oldEmail, + }, + }); + + return res.status(200).json({ + response: token, + }); + } +} + +// http://localhost:3000/api/v1/auth/verify-email?token=67b3d33491be1ba7b9ab60a8cb8caec5f248b72c0e890aafec979c0d33899279 diff --git a/pages/auth/verify-email.tsx b/pages/auth/verify-email.tsx new file mode 100644 index 0000000..ce4d967 --- /dev/null +++ b/pages/auth/verify-email.tsx @@ -0,0 +1,33 @@ +import { useRouter } from "next/router"; +import { useEffect } from "react"; +import toast from "react-hot-toast"; + +const VerifyEmail = () => { + const router = useRouter(); + + useEffect(() => { + const token = router.query.token; + + if (!token || typeof token !== "string") { + router.push("/login"); + } + + // Verify token + + fetch(`/api/v1/auth/verify-email?token=${token}`, { + method: "POST", + }).then((res) => { + if (res.ok) { + toast.success("Email verified. You can now login."); + } else { + toast.error("Invalid token."); + } + }); + + console.log(token); + }, []); + + return
Verify email...
; +}; + +export default VerifyEmail; diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 8d3134e..05fe14c 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -12,9 +12,13 @@ import { MigrationFormat, MigrationRequest } from "@/types/global"; import Link from "next/link"; import Checkbox from "@/components/Checkbox"; import { dropdownTriggerer } from "@/lib/client/utils"; +import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal"; + +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; export default function Account() { - const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; + const [emailChangeVerificationModal, setEmailChangeVerificationModal] = + useState(false); const [submitLoader, setSubmitLoader] = useState(false); @@ -30,6 +34,7 @@ export default function Account() { username: "", email: "", emailVerified: null, + password: undefined, image: "", isPrivate: true, // @ts-ignore @@ -68,19 +73,29 @@ export default function Account() { } }; - const submit = async () => { + const submit = async (password?: string) => { setSubmitLoader(true); const load = toast.loading("Applying..."); const response = await updateAccount({ ...user, + // @ts-ignore + password: password ? password : undefined, }); toast.dismiss(load); if (response.ok) { - toast.success("Settings Applied!"); + const emailChanged = account.email !== user.email; + + if (emailChanged) { + toast.success("Settings Applied!"); + toast.success( + "Email change request sent. Please verify the new email address." + ); + setEmailChangeVerificationModal(false); + } else toast.success("Settings Applied!"); } else toast.error(response.data as string); setSubmitLoader(false); }; @@ -177,12 +192,6 @@ export default function Account() { {emailEnabled ? (

Email

- {user.email !== account.email && - process.env.NEXT_PUBLIC_STRIPE === "true" ? ( -

- Updating this field will change your billing email as well -

- ) : undefined}
+
+ setUser({ ...user, isPrivate: !user.isPrivate })} + /> + +

+ This will limit who can find and add you to new Collections. +

+ + {user.isPrivate && ( +
+

Whitelisted Users

+

+ Please provide the Username of the users you wish to grant + visibility to your profile. Separated by comma. +

+