refactored email verification

This commit is contained in:
daniel31x13
2024-05-16 15:02:22 -04:00
parent db446d450f
commit f68ca100a1
16 changed files with 1285 additions and 135 deletions
@@ -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<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
@@ -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}`,
}),
});
}
+21 -56
View File
@@ -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, "&#8203;.");
const brandColor = theme.brandColor || "#0029cf";
const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText: theme.buttonText || "#fff",
};
return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}">
<a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">
Sign in
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`;
}
/** 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`;
+8
View File
@@ -0,0 +1,8 @@
import { createTransport } from "nodemailer";
export default createTransport({
url: process.env.EMAIL_SERVER,
auth: {
user: process.env.EMAIL_FROM,
},
});