使用 NodeJS 和 MySQL 創建安全密碼流
已發表: 2022-03-10如果您像我一樣,您已經不止一次忘記了密碼,尤其是在您有一段時間沒有訪問過的網站上。 您可能還看到過,和/或被包含純文本密碼的重置密碼電子郵件所困擾。
不幸的是,在應用程序開發過程中,重置密碼工作流程受到冷落和有限的關注。 這不僅會導致令人沮喪的用戶體驗,還會給您的應用程序留下巨大的安全漏洞。
我們將介紹如何構建安全的重置密碼工作流程。 我們將使用 NodeJS 和 MySQL 作為我們的基礎組件。 如果您使用不同的語言、框架或數據庫進行編寫,您仍然可以從遵循每個部分中概述的一般“安全提示”中受益。
重置密碼流程由以下組件組成:
- 將用戶引導至工作流開始的鏈接。
- 允許用戶提交電子郵件的表單。
- 驗證電子郵件並向地址發送電子郵件的查找。
- 一封包含重置令牌的電子郵件,其有效期允許用戶重置其密碼。
- 讓用戶生成新密碼的表單。
- 保存新密碼並讓用戶使用新密碼再次登錄。
除了 Node、Express 和 MySQL,我們將使用以下庫:
- 續集 ORM
- 節點郵件程序
Sequelize 是一個 NodeJS 數據庫 ORM,它可以更輕鬆地運行數據庫遷移以及安全創建查詢。 Nodemailer 是一個流行的 NodeJS 電子郵件庫,我們將使用它來發送密碼重置電子郵件。
安全提示 #1
一些文章建議可以使用 JSON Web 令牌 (JWT) 設計安全密碼流,從而消除對數據庫存儲的需求(因此更容易實現)。 我們不會在我們的網站上使用這種方法,因為 JWT 令牌秘密通常存儲在代碼中。 我們希望避免使用“一個秘密”來統治所有這些(出於同樣的原因,您不會使用相同的值對密碼進行加鹽),因此需要將此信息移動到數據庫中。
安裝
首先,安裝 Sequelize、Nodemailer 和其他相關庫:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
在要包含重置工作流的路徑中,添加所需的模塊。 如果您需要復習 Express 和路線,請查看他們的指南。
const nodemailer = require('nodemailer');
並使用您的電子郵件 SMTP 憑據對其進行配置。
const transport = nodemailer.createTransport({ host: process.env.EMAIL_HOST, port: process.env.EMAIL_PORT, secure: true, auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS } });
我使用的電子郵件解決方案是 AWS 的簡單電子郵件服務,但您可以使用任何東西(Mailgun 等)。
如果這是您第一次設置電子郵件發送服務,則需要花一些時間配置適當的域密鑰並設置授權。 如果您將 Route 53 與 SES 一起使用,這非常簡單並且幾乎是自動完成的,這就是我選擇它的原因。 AWS 有一些關於 SES 如何與 Route53 配合使用的教程。
安全提示 #2
為了將憑據存儲在我的代碼之外,我使用了 dotenv,它允許我使用我的環境變量創建一個本地 .env 文件。 這樣,當我部署到生產環境時,我可以使用在代碼中不可見的不同生產密鑰,因此我可以將我的配置權限限制在我的團隊中的某些成員。
數據庫設置
由於我們將向用戶發送重置令牌,因此我們需要將這些令牌存儲在數據庫中。
我假設您的數據庫中有一個正常工作的用戶表。 如果您已經在使用 Sequelize,那就太好了! 如果沒有,您可能需要復習 Sequelize 和 Sequelize CLI。
如果您還沒有在您的應用程序中使用 Sequelize,您可以通過在應用程序的根文件夾中運行以下命令來設置它:
$ sequelize init
這將在您的設置中創建許多新文件夾,包括遷移和模型。
這也將創建一個配置文件。 在您的配置文件中,使用本地 mysql 數據庫服務器的憑據更新development
塊。
讓我們使用 Sequelize 的 CLI 工具為我們生成數據庫表。
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
此表具有以下列:
- 用戶的電子郵件地址,
- 已生成的令牌,
- 該令牌到期,
- 令牌是否已被使用。
在後台,sequelize-cli 正在運行以下 SQL 查詢:
CREATE TABLE `ResetTokens` ( `id` int(11) NOT NULL AUTO_INCREMENT, `email` varchar(255) DEFAULT NULL, `token` varchar(255) DEFAULT NULL, `expiration` datetime DEFAULT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, `used` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
使用您的 SQL 客戶端或命令行驗證此操作是否正常工作:
mysql> describe ResetTokens; +------------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------+--------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | email | varchar(255) | YES | | NULL | | | token | varchar(255) | YES | | NULL | | | expiration | datetime | YES | | NULL | | | createdAt | datetime | NO | | NULL | | | updatedAt | datetime | NO | | NULL | | | used | int(11) | NO | | 0 | | +------------+--------------+------+-----+---------+----------------+ 7 rows in set (0.00 sec)
安全提示 #3
如果你當前沒有使用 ORM,你應該考慮這樣做。 ORM 自動編寫和正確轉義 SQL 查詢,默認情況下使您的代碼更具可讀性和安全性。 它們將通過正確轉義 SQL 查詢來幫助您避免 SQL 注入攻擊。
設置重置密碼路由
在user.js中創建 get 路由:
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
然後創建 POST 路由,這是發布重置密碼表單時命中的路由。 在下面的代碼中,我包含了幾個重要的安全功能。
安全提示 #4-6
- 即使我們沒有找到電子郵件地址,我們也會返回“ok”作為我們的狀態。 我們不希望不幸的機器人在我們的數據庫中找出哪些電子郵件是真實的,哪些不是真實的。
- 您在令牌中使用的隨機字節越多,它被黑客入侵的可能性就越小。 我們在令牌生成器中使用了 64 個隨機字節(不要少於 8 個)。
- 令牌在 1 小時內過期。 這限制了重置令牌工作的時間窗口。
router.post('/forgot-password', async function(req, res, next) { //ensure that you have a user with this email var email = await User.findOne({where: { email: req.body.email }}); if (email == null) { /** * we don't want to tell attackers that an * email doesn't exist, because that will let * them use this form to find ones that do * exist. **/ return res.json({status: 'ok'}); } /** * Expire any tokens that were previously * set for this user. That prevents old tokens * from being used. **/ await ResetToken.update({ used: 1 }, { where: { email: req.body.email } }); //Create a random reset token var fpSalt = crypto.randomBytes(64).toString('base64'); //token expires after one hour var expireDate = new Date(new Date().getTime() + (60 * 60 * 1000)) //insert token data into DB await ResetToken.create({ email: req.body.email, expiration: expireDate, token: fpSalt, used: 0 }); //create email const message = { from: process.env.SENDER_ADDRESS, to: req.body.email, replyTo: process.env.REPLYTO_ADDRESS, subject: process.env.FORGOT_PASS_SUBJECT_LINE, text: 'To reset your password, please click the link below.\n\nhttps://'+process.env.DOMAIN+'/user/reset-password?token='+encodeURIComponent(token)+'&email='+req.body.email }; //send email transport.sendMail(message, function (err, info) { if(err) { console.log(err)} else { console.log(info); } }); return res.json({status: 'ok'}); });
您將看到上面引用的用戶變量——這是什麼? 出於本教程的目的,我們假設您有一個連接到數據庫以檢索值的用戶模型。 上面的代碼是基於Sequelize的,但是如果直接查詢數據庫可以根據需要修改(不過我推薦Sequelize!)。
我們現在需要生成視圖。 使用 Bootstrap CSS、jQuery 和 Node Express 框架中內置的 pug 框架,視圖如下所示:
extends ../layout block content div.container div.row div.col h1 Forgot password p Enter your email address below. If we have it on file, we will send you a reset email. div.forgot-message.alert.alert-success() Email address received. If you have an email on file we will send you a reset email. Please wait a few minutes and check your spam folder if you don't see it. form#forgotPasswordForm.form-inline(onsubmit="return false;") div.form-group label.sr-only(for="email") Email address: input.form-control.mr-2#emailFp(type='email', name='email', placeholder="Email address") div.form-group.mt-1.text-center button#fpButton.btn.btn-success.mb-2(type='submit') Send email script. $('#fpButton').on('click', function() { $.post('/user/forgot-password', { email: $('#emailFp').val(), }, function(resp) { $('.forgot-message').show(); $('#forgotPasswordForm').remove(); }); });
這是頁面上的表格:
此時,您應該能夠使用數據庫中的電子郵件地址填寫表格,然後在該地址收到重置密碼電子郵件。 單擊重置鏈接將不會執行任何操作。
設置“重置密碼”路線
現在讓我們繼續設置工作流程的其餘部分。
將 Sequelize.Op 模塊添加到您的路線:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
現在讓我們為點擊了重置密碼鏈接的用戶構建 GET 路由。 正如您將在下面看到的,我們希望確保我們正確地驗證了重置令牌。
安全提示 #7:
確保您只查找尚未過期且未使用的重置令牌。
出於演示目的,我還在此處清除加載時所有過期的令牌以保持表較小。 如果您有一個大型網站,請將其移至 cronjob。
router.get('/reset-password', async function(req, res, next) { /** * This code clears all expired tokens. You * should move this to a cronjob if you have a * big site. We just include this in here as a * demonstration. **/ await ResetToken.destroy({ where: { expiration: { [Op.lt]: Sequelize.fn('CURDATE')}, } }); //find the token var record = await ResetToken.findOne({ where: { email: req.query.email, expiration: { [Op.gt]: Sequelize.fn('CURDATE')}, token: req.query.token, used: 0 } }); if (record == null) { return res.render('user/reset-password', { message: 'Token has expired. Please try password reset again.', showForm: false }); } res.render('user/reset-password', { showForm: true, record: record }); });
現在讓我們創建 POST 路由,一旦用戶填寫了他們的新密碼詳細信息,就會觸發該路由。
安全提示 #8 到 11:
- 確保密碼匹配並滿足您的最低要求。
- 再次檢查重置令牌以確保它未被使用且未過期。 我們需要再次檢查它,因為令牌是由用戶通過表單發送的。
- 在重置密碼之前,將令牌標記為已使用。 這樣,如果發生不可預見的事情(例如服務器崩潰),在令牌仍然有效時不會重置密碼。
- 使用加密安全的隨機鹽(在這種情況下,我們使用 64 個隨機字節)。
router.post('/reset-password', async function(req, res, next) { //compare passwords if (req.body.password1 !== req.body.password2) { return res.json({status: 'error', message: 'Passwords do not match. Please try again.'}); } /** * Ensure password is valid (isValidPassword * function checks if password is >= 8 chars, alphanumeric, * has special chars, etc) **/ if (!isValidPassword(req.body.password1)) { return res.json({status: 'error', message: 'Password does not meet minimum requirements. Please try again.'}); } var record = await ResetToken.findOne({ where: { email: req.body.email, expiration: { [Op.gt]: Sequelize.fn('CURDATE')}, token: req.body.token, used: 0 } }); if (record == null) { return res.json({status: 'error', message: 'Token not found. Please try the reset password process again.'}); } var upd = await ResetToken.update({ used: 1 }, { where: { email: req.body.email } }); var newSalt = crypto.randomBytes(64).toString('hex'); var newPassword = crypto.pbkdf2Sync(req.body.password1, newSalt, 10000, 64, 'sha512').toString('base64'); await User.update({ password: newPassword, salt: newSalt }, { where: { email: req.body.email } }); return res.json({status: 'ok', message: 'Password reset. Please login with your new password.'}); }); And again, the view: extends ../layout block content div.container div.row div.col h1 Reset password p Enter your new password below. if message div.reset-message.alert.alert-warning #{message} else div.reset-message.alert() if showForm form#resetPasswordForm(onsubmit="return false;") div.form-group label(for="password1") New password: input.form-control#password1(type='password', name='password1') small.form-text.text-muted Password must be 8 characters or more. div.form-group label(for="password2") Confirm new password input.form-control#password2(type='password', name='password2') small.form-text.text-muted Both passwords must match. input#emailRp(type='hidden', name='email', value=record.email) input#tokenRp(type='hidden', name='token', value=record.token) div.form-group button#rpButton.btn.btn-success(type='submit') Reset password script. $('#rpButton').on('click', function() { $.post('/user/reset-password', { password1: $('#password1').val(), password2: $('#password2').val(), email: $('#emailRp').val(), token: $('#tokenRp').val() }, function(resp) { if (resp.status == 'ok') { $('.reset-message').removeClass('alert-danger').addClass('alert-success').show().text(resp.message); $('#resetPasswordForm').remove(); } else { $('.reset-message').removeClass('alert-success').addClass('alert-danger').show().text(resp.message); } }); });
這應該是這樣的:
將鏈接添加到您的登錄頁面
最後,不要忘記從您的登錄頁面添加指向此流程的鏈接! 完成此操作後,您應該有一個有效的重置密碼流程。 請務必在流程的每個階段進行徹底測試,以確認一切正常,並且您的令牌的有效期很短,並且隨著工作流程的進行標記為正確的狀態。
下一步
希望這有助於您編寫安全、用戶友好的重置密碼功能。
- 如果您有興趣了解有關密碼安全的更多信息,我推薦維基百科的摘要(警告,它很密集!)。
- 如果您想為應用的身份驗證添加更多安全性,請查看 2FA。 有很多不同的選擇。
- 如果我讓您不敢建立自己的重置密碼流程,您可以依賴第三方登錄系統,例如 Google 和 Facebook。 PassportJS 是一個可用於實現這些策略的 NodeJS 的中間件。