การสร้างกระแสรหัสผ่านที่ปลอดภัยด้วย NodeJS และ MySQL
เผยแพร่แล้ว: 2022-03-10หากคุณเป็นเหมือนฉัน คุณลืมรหัสผ่านมากกว่าหนึ่งครั้ง โดยเฉพาะอย่างยิ่งในเว็บไซต์ที่คุณไม่ได้เข้าชมมาระยะหนึ่งแล้ว คุณอาจเคยเห็นและ/หรือเสียใจกับการรีเซ็ตอีเมลรหัสผ่านที่มีรหัสผ่านของคุณเป็นข้อความธรรมดา
น่าเสียดายที่เวิร์กโฟลว์การรีเซ็ตรหัสผ่านนั้นสั้นและได้รับความสนใจอย่างจำกัดในระหว่างการพัฒนาแอปพลิเคชัน สิ่งนี้ไม่เพียงแต่จะนำไปสู่ประสบการณ์ผู้ใช้ที่น่าผิดหวังเท่านั้น แต่ยังทำให้แอปพลิเคชันของคุณมีช่องโหว่ด้านความปลอดภัยที่อ้าปากค้างอีกด้วย
เราจะพูดถึงวิธีสร้างเวิร์กโฟลว์การรีเซ็ตรหัสผ่านที่ปลอดภัย เราจะใช้ NodeJS และ MySQL เป็นส่วนประกอบพื้นฐานของเรา หากคุณกำลังเขียนโดยใช้ภาษา เฟรมเวิร์ก หรือฐานข้อมูลอื่น คุณยังคงได้รับประโยชน์จากการปฏิบัติตาม "คำแนะนำด้านความปลอดภัย" ทั่วไปที่ร่างไว้ในแต่ละส่วน
ขั้นตอนการรีเซ็ตรหัสผ่านประกอบด้วยส่วนประกอบต่อไปนี้:
- ลิงก์สำหรับส่งผู้ใช้ไปยังจุดเริ่มต้นของเวิร์กโฟลว์
- แบบฟอร์มที่อนุญาตให้ผู้ใช้ส่งอีเมล
- การค้นหาที่ตรวจสอบความถูกต้องของอีเมลและส่งอีเมลไปยังที่อยู่
- อีเมลที่มีโทเค็นการรีเซ็ตพร้อมวันหมดอายุที่อนุญาตให้ผู้ใช้รีเซ็ตรหัสผ่านได้
- แบบฟอร์มที่ให้ผู้ใช้สร้างรหัสผ่านใหม่
- บันทึกรหัสผ่านใหม่และให้ผู้ใช้เข้าสู่ระบบอีกครั้งด้วยรหัสผ่านใหม่
นอกจาก Node, Express & MySQL เราจะใช้ไลบรารีต่อไปนี้:
- ภาคต่อ ORM
- Nodemailer
Sequelize เป็น ORM ของฐานข้อมูล NodeJS ที่ช่วยให้เรียกใช้การย้ายฐานข้อมูลได้ง่ายขึ้น เช่นเดียวกับการสร้างการสืบค้นข้อมูลด้านความปลอดภัย Nodemailer เป็นไลบรารีอีเมล NodeJS ยอดนิยมที่เราจะใช้เพื่อส่งอีเมลรีเซ็ตรหัสผ่าน
เคล็ดลับความปลอดภัย #1
บางบทความแนะนำว่าสามารถออกแบบโฟลว์รหัสผ่านที่ปลอดภัยได้โดยใช้ JSON Web Token (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 } });
โซลูชันอีเมลที่ฉันใช้คือ Simple Email Service ของ AWS แต่คุณสามารถใช้อะไรก็ได้ (Mailgun ฯลฯ)
หากนี่เป็นครั้งแรกที่คุณตั้งค่าบริการส่งอีเมล คุณจะต้องใช้เวลาในการกำหนดค่าคีย์โดเมนที่เหมาะสมและตั้งค่าการอนุญาต หากคุณใช้ Route 53 ร่วมกับ SES สิ่งนี้ง่ายมากและทำงานโดยอัตโนมัติแบบเสมือน นั่นคือเหตุผลที่ฉันเลือกมัน AWS มีบทช่วยสอนเกี่ยวกับวิธีการทำงานของ SES กับ Route53
เคล็ดลับความปลอดภัย #2
ในการจัดเก็บข้อมูลประจำตัวให้ห่างจากโค้ดของฉัน ฉันใช้ dotenv ซึ่งช่วยให้ฉันสร้างไฟล์ .env ในเครื่องพร้อมตัวแปรสภาพแวดล้อมของฉันได้ ด้วยวิธีนี้ เมื่อฉันปรับใช้กับการใช้งานจริง ฉันสามารถใช้คีย์การผลิตที่แตกต่างกันซึ่งไม่ปรากฏในโค้ด ดังนั้นจึงอนุญาตให้ฉันจำกัดสิทธิ์ในการกำหนดค่าของฉันให้เหลือเฉพาะสมาชิกบางคนในทีมของฉันเท่านั้น
ตั้งค่าฐานข้อมูล
เนื่องจากเราจะส่งโทเค็นการรีเซ็ตให้กับผู้ใช้ เราจึงต้องจัดเก็บโทเค็นเหล่านั้นไว้ในฐานข้อมูล
ฉันถือว่าคุณมีตารางผู้ใช้ที่ใช้งานได้ในฐานข้อมูลของคุณ หากคุณกำลังใช้ Sequelize อยู่แล้ว เยี่ยมไปเลย! ถ้าไม่เช่นนั้น คุณอาจต้องการทำความเข้าใจเกี่ยวกับ Sequelize และ Sequelize CLI
หากคุณยังไม่ได้ใช้ Sequelize ในแอปของคุณ คุณสามารถตั้งค่าได้โดยเรียกใช้คำสั่งด้านล่างในโฟลเดอร์รูทของแอป:
$ sequelize init
สิ่งนี้จะสร้างโฟลเดอร์ใหม่จำนวนหนึ่งในการตั้งค่าของคุณ รวมถึงการโยกย้ายและรุ่นต่างๆ
สิ่งนี้จะสร้างไฟล์กำหนดค่าด้วย ในไฟล์กำหนดค่าของคุณ ให้อัปเดตบล็อก development
ด้วยข้อมูลประจำตัวไปยังเซิร์ฟเวอร์ฐานข้อมูล mysql ในเครื่องของคุณ
มาใช้เครื่องมือ CLI ของ Sequelize เพื่อสร้างตารางฐานข้อมูลให้เรา
$ 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 :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
จากนั้นสร้างเส้นทาง POST ซึ่งเป็นเส้นทางที่โดนเมื่อมีการโพสต์แบบฟอร์มรีเซ็ตรหัสผ่าน ในโค้ดด้านล่าง ฉันได้รวมคุณลักษณะด้านความปลอดภัยที่สำคัญสองสามอย่าง
เคล็ดลับความปลอดภัย #4-6
- แม้ว่าเราจะไม่พบที่อยู่อีเมล เราก็ส่งคืนสถานะของเราว่า 'ตกลง' เราไม่ต้องการให้บอทที่ไม่ดีรู้ว่าอีเมลใดเป็นของจริงและไม่ใช่ของจริงในฐานข้อมูลของเรา
- ยิ่งคุณใช้ไบต์สุ่มในโทเค็นมากเท่าใด โอกาสที่จะถูกแฮ็กก็จะยิ่งน้อยลงเท่านั้น เราใช้ 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'}); });
คุณจะเห็นตัวแปร User ที่อ้างอิงด้านบน นี่คืออะไร? สำหรับจุดประสงค์ของบทช่วยสอนนี้ เราถือว่าคุณมีโมเดลผู้ใช้ที่เชื่อมต่อกับฐานข้อมูลของคุณเพื่อดึงค่า โค้ดด้านบนอิงตาม Sequelize แต่คุณสามารถแก้ไขได้ตามต้องการ หากคุณสอบถามฐานข้อมูลโดยตรง (แต่ฉันแนะนำ Sequelize!)
ตอนนี้เราต้องสร้างมุมมอง การใช้ Bootstrap CSS, jQuery และ pug framework ที่สร้างไว้ในเฟรมเวิร์ก Node Express มุมมองจะมีลักษณะดังนี้:
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); } }); });
นี่คือสิ่งที่ควรมีลักษณะดังนี้:
เพิ่มลิงค์ไปยังหน้าเข้าสู่ระบบของคุณ
สุดท้ายนี้ อย่าลืมเพิ่มลิงก์ไปยังขั้นตอนนี้จากหน้าเข้าสู่ระบบของคุณ! เมื่อคุณทำเช่นนี้ คุณควรมีโฟลว์การรีเซ็ตรหัสผ่านที่ใช้งานได้ อย่าลืมทดสอบอย่างละเอียดในแต่ละขั้นตอนของกระบวนการเพื่อยืนยันว่าทุกอย่างใช้งานได้ และโทเค็นของคุณมีการหมดอายุสั้น ๆ และถูกทำเครื่องหมายด้วยสถานะที่ถูกต้องเมื่อเวิร์กโฟลว์ดำเนินไป
ขั้นตอนถัดไป
หวังว่านี่จะช่วยคุณในการเข้ารหัสคุณลักษณะการรีเซ็ตรหัสผ่านที่ปลอดภัยและเป็นมิตรกับผู้ใช้
- หากคุณสนใจที่จะเรียนรู้เพิ่มเติมเกี่ยวกับความปลอดภัยการเข้ารหัส ฉันขอแนะนำบทสรุปของ Wikipedia (คำเตือน มันหนาแน่น!)
- หากคุณต้องการเพิ่มความปลอดภัยให้กับการตรวจสอบสิทธิ์ของแอปของคุณ ให้ดูที่ 2FA มีตัวเลือกที่แตกต่างกันมากมาย
- ถ้าฉันทำให้คุณกลัวที่จะสร้างโฟลว์การรีเซ็ตรหัสผ่านของคุณเอง คุณสามารถพึ่งพาระบบการเข้าสู่ระบบของบริษัทอื่น เช่น Google และ Facebook ได้ PassportJS เป็นมิดเดิลแวร์ที่คุณสามารถใช้สำหรับ NodeJS ที่ใช้กลยุทธ์เหล่านี้