我们大多数人至少经历过一次帐户恢复过程 - 当我们忘记密码时,需要创建一个新密码并重新获得系统访问权限。本文重点介绍使用 Node.js、Knex 和一些未公开的工具以及 Express 来处理路由并执行必要的操作来实现这样的流程。
我们将介绍路由器实现、处理 URL 参数、确定在只有电子邮件或电话号码作为证据时发送给用户的内容、管理电子邮件提交以及解决安全问题。
在深入编码之前,我想确保我们使用相同的代码库,您可以从我的公开访问该代码库
现在,看一下忘记密码流程的架构。
服务器将负责向用户邮箱发送包含用于密码重置的有效链接的电子邮件,并且还将验证令牌和用户是否存在。
要开始使用电子邮件服务并使用 Node.js 发送电子邮件,除了现有的依赖项之外,我们还需要安装以下软件包:
npm i --save nodemailer handlebars
Nodemailer :功能强大的模块,允许使用 SMTP 或其他传输机制轻松发送电子邮件。
Handlebars :Handlebars 是一种流行的 JavaScript 模板引擎。它将允许我们定义带有占位符的模板,这些占位符可以在渲染时填充数据。
现在,我们需要创建迁移,因此就我而言,我必须向users
表添加一个新列forgot_password_token
:
knex migrate:make add_field_forgot_password_token -x ts
在生成的文件中,我设置了代码:
import type { Knex } from 'knex'; export async function up(knex: Knex): Promise<void> { return knex.schema.alterTable('users', table => { table.string('forgot_password_token').unique(); }); } export async function down(knex: Knex): Promise<void> { return knex.schema.alterTable('users', table => { table.dropColumn('forgot_password_token'); }); }
用户表中忘记密码令牌的迁移
然后迁移最新的文件:
knex migrate:knex
现在我们可以将forgot_password_token
设置到users
表中
为了管理负责处理密码忘记和重置逻辑的控制器,我们必须建立两条路由。第一个路由启动忘记密码过程,而第二个路由处理重置过程,需要 URL 中的令牌参数进行验证。要实现此目的,请在src/routes/
目录中创建一个名为forgotPasswordRouter.ts
的文件,并插入以下代码:
import { Router } from 'express'; import { forgotPasswordController } from 'src/controllers/forgotPasswordController'; import { resetPasswordController } from 'src/controllers/resetPasswordController'; export const forgotPasswordRouter = Router(); forgotPasswordRouter.post('/', forgotPasswordController); forgotPasswordRouter.post('/reset/:token', resetPasswordController);
忘记路由器密码
两个控制器将管理发送电子邮件和重置密码的逻辑。
当客户端忘记密码时,他就没有会话,这意味着我们无法获取除电子邮件或任何其他安全标识符之外的用户数据。在我们的例子中,我们发送一封电子邮件来处理密码重置。我们将把这个逻辑设置到控制器中。
forgotPasswordRouter.post('/', forgotPasswordController);
还记得“忘记密码”吗?登录表单下方的链接通常位于登录表单中任何客户端的 UI 中?单击它会将我们引导到一个可以请求重置密码的视图。我们只需输入电子邮件,控制器就会处理所有必要的程序。让我们检查以下代码:
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import type { User } from 'src/@types'; import { TokenService } from 'src/services/TokenService'; import { EmailService } from 'src/services/EmailService'; export const forgotPasswordController = async (req: Request, res: Response) => { try { const { email, }: { email: string; } = req.body; const user = await UserModel.findByEmail(email); if (user) { const token = await TokenService.sign( { id: user.id, }, { expiresIn: '1 day', } ); await user.context.update({ forgot_password_token: token }); await EmailService.sendPasswordResetEmail(email, token); } return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } };
忘记密码控制器
从正文中,我们将收到一封电子邮件,然后我们将使用UserModel.findByEmail
找到用户。如果用户存在,我们使用TokenService.sign
创建一个 JWT 令牌,并将该令牌保存到用户forgot_password_token
令牌,有效期为 1 天。然后,我们将消息发送到电子邮件,其中包含正确的链接以及用户可以更改密码的令牌。
为了能够发送电子邮件,我们必须创建新的电子邮件地址作为发件人。
让我们转到 Google 创建一个新的电子邮件帐户,然后在创建帐户后,继续访问“管理您的 Google 帐户”链接。您可以通过点击头像在右上角找到它。然后,在左侧菜单上,单击“安全”项,然后按“两步验证” 。您将在下面找到应用程序密码部分,单击箭头:
输入需要使用的名称。就我而言,我设置Nodemailer
并按Create 。
复制生成的密码,并将其设置到您的.env
文件中。我们需要设置两个变量来归档:
MAIL_USER="[email protected]" MAIL_PASSWORD="vyew hzek avty iwst"
当然,要拥有像info@company_name.com
这样的正确电子邮件地址,您必须将 Google Workspace 或 AWS Amazon WorkMail 与 AWS SES 或任何其他服务一起设置。但在我们的例子中,我们免费使用一个简单的 Gmail 帐户。
准备好.env
文件后,我们就可以设置发送电子邮件的服务了。控制器将使用该服务以及生成的令牌和我们的消息的收件人电子邮件地址。
await EmailService.sendPasswordResetEmail(email, token);
让我们创建src/services/EmailService.ts
并定义服务的类:
export class EmailService {}
现在作为初始数据,我必须获得与nodemailer
一起使用的环境:
import process from 'process'; import * as nodemailer from 'nodemailer'; import * as dotenv from 'dotenv'; dotenv.config(); export class EmailService { private static transporter: nodemailer.Transporter; private static env = { USER: process.env.MAIL_USER, PASS: process.env.MAIL_PASSWORD, }; }
电子邮件服务
我们必须负责服务初始化。我之前在上一篇文章中写过
import { TokenService } from 'src/services/TokenService'; import { RedisService } from 'src/services/RedisService'; import { EmailService } from 'src/services/EmailService'; export const initialize = async () => { await RedisService.initialize(); TokenService.initialize(); EmailService.initialize(); };
初始化服务
现在,让我们继续在EmailService
类中创建初始化:
import process from 'process'; import * as nodemailer from 'nodemailer'; import * as dotenv from 'dotenv'; dotenv.config(); export class EmailService { private static transporter: nodemailer.Transporter; private static env = { USER: process.env.MAIL_USER, PASS: process.env.MAIL_PASSWORD, }; public static initialize() { try { EmailService.transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: this.env.USER, pass: this.env.PASS, }, }); } catch (error) { console.error('Error initializing email service'); throw error; } } }
电子邮件服务初始化
有初始化nodemailer.createTransport()
,这是nodemailer
库提供的方法。它创建一个将用于发送电子邮件的传输器对象。该方法接受选项对象作为参数,您可以在其中指定传输程序的配置详细信息。
我们使用的是 Google: service: 'gmail'
指定电子邮件服务提供商。 Nodemailer 为各种电子邮件服务提供商提供内置支持, gmail
表示传输器将配置为与 Gmail 的 SMTP 服务器配合使用。
对于身份验证auth
,需要设置访问电子邮件服务提供商的 SMTP 服务器所需的凭据。
对于user
应设置为我们要发送电子邮件的电子邮件地址,并且该密码已在 Google 帐户中从应用程序密码生成。
现在,让我们设置服务的最后一部分:
import process from 'process'; import * as nodemailer from 'nodemailer'; import * as dotenv from 'dotenv'; import { generateAttachments } from 'src/helpers/generateAttachments'; import { generateTemplate } from 'src/helpers/generateTemplate'; import { getHost } from 'src/helpers/getHost'; dotenv.config(); export class EmailService { // ...rest code public static async sendPasswordResetEmail(email: string, token: string) { try { const host = getHost(); const template = generateTemplate<{ token: string; host: string; }>('passwordResetTemplate', { token, host }); const attachments = generateAttachments([{ name: 'email_logo' }]); const info = await EmailService.transporter.sendMail({ from: this.env.USER, to: email, subject: 'Password Reset', html: template, attachments, }); console.log('Message sent: %s', info.messageId); } catch (error) { console.error('Error sending email: ', error); } } }
发送密码重置电子邮件
在继续之前,确定客户端收到电子邮件时的适当主机至关重要。在电子邮件正文中建立带有令牌的链接至关重要。
import * as dotenv from 'dotenv'; import process from 'process'; dotenv.config(); export const getHost = (): string => { const isProduction = process.env.NODE_ENV === 'production'; const protocol = isProduction ? 'https' : 'http'; const port = isProduction ? '' : `:${process.env.CLIENT_PORT}`; return `${protocol}://${process.env.WEB_HOST}${port}`; };
获取主机
对于模板,我使用handlebars
,为此,我们需要在src/temlates/passwordResetTemplate.hbs
中创建我们的第一个 HTML 模板:
<!-- passwordResetTemplate.hbs --> <html lang='en'> <head> <style> a { color: #372aff; } .token { font-weight: bold; } </style> <title>Forgot Password</title> </head> <body> <p>You requested a password reset. Please use the following link to reset your password:</p> <a class='token' href="{{ host }}/reset-password/{{ token }}">Reset Password</a> <p>If you did not request a password reset, please ignore this email.</p> <img src="cid:email_logo" alt="Email Logo"/> </body> </html>
密码重置模板
现在我们可以通过助手重用这个模板:
import path from 'path'; import fs from 'fs'; import handlebars from 'handlebars'; export const generateTemplate = <T>(name: string, props: T): string => { const templatePath = path.join(__dirname, '..', 'src/templates', `${name}.hbs`); const templateSource = fs.readFileSync(templatePath, 'utf8'); const template = handlebars.compile(templateSource); return template(props); };
生成模板助手
为了增强我们的电子邮件,我们甚至可以添加附件。为此,请将email_logo.png
文件添加到src/assets
文件夹中。然后,我们可以使用以下辅助函数在电子邮件中渲染此图像:
import path from 'path'; import { Extension } from 'src/@types/enums'; type AttachmentFile = { name: string; ext?: Extension; cid?: string; }; export const generateAttachments = (files: AttachmentFile[] = []) => files.map(file => { const ext = file.ext || Extension.png; const filename = `${file.name}.${ext}`; const imagePath = path.join(__dirname, '..', 'src/assets', filename); return { filename, path: imagePath, cid: file.cid || file.name, }; });
生成附件助手
收集所有这些助手后,我们必须能够使用以下方式发送电子邮件:
const info = await EmailService.transporter.sendMail({ from: this.env.USER, to: email, subject: 'Password Reset', html: template, attachments, });
这种方法提供了良好的可扩展性,使该服务能够采用各种方法来发送具有不同内容的电子邮件。
现在,让我们尝试使用路由器触发控制器并发送电子邮件。为此,我正在使用
控制台会告诉你消息已发送:
Message sent: <1k96ah55-c09t-p9k2–[email protected]>
检查收件箱中是否有新邮件:
重置密码的链接必须包含令牌和主机:
http://localhost:3000/reset-password/<token>
此处指定端口3000
,因为此消息与开发过程有关。这表明负责处理密码重置表单的客户端也将在开发环境中运行。
必须使用 TokenService 在控制器端验证令牌,从中我们可以获取发送该电子邮件的用户。让我们恢复使用令牌的路由器:
forgotPasswordRouter.post('/reset/:token', resetPasswordController);
仅当令牌有效且未过期(过期时间设置为一小时)时,控制器才会更新密码。要实现此功能,请导航到src/controllers/
文件夹并创建一个名为resetPasswordController.ts
的文件,其中包含以下代码:
import bcrypt from 'bcrypt'; import { Request, Response } from 'express'; import { TokenService } from 'src/services/TokenService'; import { UserModel } from 'src/models/UserModel'; import type { User } from 'src/@types'; export const resetPasswordController = async (req: Request, res: Response) => { try { const token = req.params.token; if (!token) { return res.sendStatus(400); } const userData = await TokenService.verify<{ id: number }>(token); const user = await UserModel.findOneById<User>(userData.id); if (!user) { return res.sendStatus(400); } const newPassword = req.body.password; if (!newPassword) { return res.sendStatus(400); } const hashedPassword = await bcrypt.hash(newPassword, 10); await UserModel.updateById(user.id, { password: hashedPassword, passwordResetToken: null }); return res.sendStatus(200); } catch (error) { const errors = ['jwt malformed', 'TokenExpiredError', 'invalid token']; if (errors.includes(error.message)) { return res.sendStatus(400); } return res.sendStatus(500); } };
重置密码控制器
该控制器将接收令牌,对其进行验证,从解密的数据中提取用户ID,检索相应的用户,获取客户端在请求正文中发送的新密码,然后继续更新数据库中的密码。最终,这使客户端能够使用新密码登录。
电子邮件服务的可扩展性通过各种方法得到证明,例如发送确认或成功消息,例如指示密码更新和启用后续登录的消息。然而,管理密码是一个巨大的挑战,特别是当必须增强应用程序安全性时。
有许多选项可用于增强安全性,包括在允许更改密码之前进行额外检查,例如令牌比较、电子邮件和密码验证。
另一种选择是实施 PIN 码系统,其中代码被发送到用户的电子邮件以在服务器端进行验证。这些措施中的每一项都需要利用电子邮件发送功能。
您可以在以下位置找到所有已实现的代码
请随意使用此版本进行任何实验,并分享您对该主题哪些方面的欣赏意见。太感谢了。
在这里,您可以找到我在本文中使用的几个参考资料:
也发布在这里