Crearea fluxurilor de parole sigure cu NodeJS și MySQL
Publicat: 2022-03-10Dacă sunteți ca mine, v-ați uitat parola de mai multe ori, mai ales pe site-uri pe care nu le-ați mai vizitat de ceva vreme. Probabil că ați văzut și/sau ați fost mortificat de e-mailuri de resetare a parolei care conțin parola dvs. în text simplu.
Din păcate, fluxul de lucru pentru resetarea parolei devine scurt și o atenție limitată în timpul dezvoltării aplicației. Acest lucru nu numai că poate duce la o experiență frustrantă pentru utilizator, dar poate, de asemenea, lăsa aplicația dvs. cu găuri de securitate.
Vom acoperi cum să construim un flux de lucru sigur pentru resetarea parolei. Vom folosi NodeJS și MySQL ca componente de bază. Dacă scrieți folosind un alt limbaj, cadru sau bază de date, puteți beneficia în continuare de a urma „Sfaturile de securitate” generale prezentate în fiecare secțiune.
Un flux de resetare a parolei constă din următoarele componente:
- Un link pentru a trimite utilizatorul la începutul fluxului de lucru.
- Un formular care permite utilizatorului să-și trimită e-mailul.
- O căutare care validează e-mailul și trimite un e-mail la adresa.
- Un e-mail care conține simbolul de resetare cu o expirare care permite utilizatorului să își reseta parola.
- Un formular care permite utilizatorului să genereze o nouă parolă.
- Salvarea noii parole și lăsarea utilizatorului să se conecteze din nou cu noua parolă.
Pe lângă Node, Express și MySQL, vom folosi următoarele biblioteci:
- Sequelize ORM
- Nodemailer
Sequelize este un ORM de baze de date NodeJS care facilitează rularea migrărilor de baze de date, precum și interogările de creare de securitate. Nodemailer este o bibliotecă populară de e-mailuri NodeJS pe care o vom folosi pentru a trimite e-mailuri de resetare a parolei.
Sfat de securitate #1
Unele articole sugerează că fluxurile de parole securizate pot fi proiectate folosind JSON Web Tokens (JWT), care elimină necesitatea stocării bazei de date (și, prin urmare, sunt mai ușor de implementat). Nu folosim această abordare pe site-ul nostru, deoarece secretele tokenului JWT sunt de obicei stocate direct în cod. Dorim să evităm să avem „un singur secret” pentru a le guverna pe toate (din același motiv, nu puneți parole cu aceeași valoare) și, prin urmare, trebuie să mutăm aceste informații într-o bază de date.
Instalare
Mai întâi, instalați Sequelize, Nodemailer și alte biblioteci asociate:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
Pe traseul în care doriți să includeți fluxurile de lucru de resetare, adăugați modulele necesare. Dacă aveți nevoie de o actualizare despre Express și rute, consultați ghidul lor.
const nodemailer = require('nodemailer');
Și configurați-l cu acreditările SMTP de e-mail.
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 } });
Soluția de e-mail pe care o folosesc este Serviciul de e-mail simplu de la AWS, dar puteți folosi orice (Mailgun etc.).
Dacă este prima dată când configurați serviciul de trimitere de e-mailuri, va trebui să petreceți ceva timp pentru a configura cheile de domeniu corespunzătoare și a configura autorizațiile. Dacă utilizați Route 53 împreună cu SES, acest lucru este foarte simplu și se face practic automat, motiv pentru care l-am ales. AWS are câteva tutoriale despre cum funcționează SES cu Route53.
Sfat de securitate #2
Pentru a stoca acreditările departe de codul meu, folosesc dotenv, care îmi permite să creez un fișier .env local cu variabilele mele de mediu. În acest fel, atunci când implementez în producție, pot folosi diferite chei de producție care nu sunt vizibile în cod și, prin urmare, îmi permite să restricționez permisiunile configurației mele doar la anumiți membri ai echipei mele.
Configurarea bazei de date
Deoarece vom trimite token-uri de resetare utilizatorilor, trebuie să stocăm acele jetoane într-o bază de date.
Presupun că aveți un tabel de utilizatori funcțional în baza de date. Dacă utilizați deja Sequelize, grozav! Dacă nu, poate doriți să vă actualizați Sequelize și Sequelize CLI.
Dacă nu ați folosit încă Sequelize în aplicația dvs., o puteți configura executând comanda de mai jos în folderul rădăcină al aplicației:
$ sequelize init
Acest lucru va crea o serie de dosare noi în configurația dvs., inclusiv migrații și modele.
Acest lucru va crea, de asemenea, un fișier de configurare. În fișierul dvs. de configurare, actualizați blocul de development
cu acreditările la serverul local de baze de date mysql.
Să folosim instrumentul CLI al lui Sequelize pentru a genera tabelul bazei de date pentru noi.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Acest tabel are următoarele coloane:
- Adresa de e-mail a utilizatorului,
- Token care a fost generat,
- Expirarea acelui simbol,
- Indiferent dacă jetonul a fost folosit sau nu.
În fundal, sequelize-cli rulează următoarea interogare 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;
Verificați că acest lucru a funcționat corect folosind clientul SQL sau linia de comandă:
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)
Sfat de securitate #3
Dacă nu utilizați în prezent un ORM, ar trebui să vă gândiți să faceți acest lucru. Un ORM automatizează scrierea și evadarea corectă a interogărilor SQL, făcând codul mai ușor de citit și mai sigur în mod implicit. Ele vă vor ajuta să evitați atacurile prin injecție SQL scăpând în mod corespunzător de interogările dvs. SQL.
Configurați ruta de resetare a parolei
Creați ruta de obținere în user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Apoi creați ruta POST, care este ruta care este lovită când este postat formularul de resetare a parolei. În codul de mai jos, am inclus câteva caracteristici importante de securitate.
Sfaturi de securitate #4-6
- Chiar dacă nu găsim o adresă de e-mail, returnăm „ok” ca stare. Nu vrem ca roboții nefavorabili să descopere ce e-mailuri sunt reale și ce nu sunt reale în baza noastră de date.
- Cu cât folosiți mai mulți octeți aleatori într-un token, cu atât este mai puțin probabil ca acesta să fie piratat. Folosim 64 de octeți aleatori în generatorul nostru de jetoane (nu folosiți mai puțin de 8).
- Expirați jetonul în 1 oră. Acest lucru limitează intervalul de timp în care funcționează jetonul de resetare.
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'}); });
Veți vedea o variabilă de utilizator la care se face referire mai sus — ce este aceasta? În scopul acestui tutorial, presupunem că aveți un model de utilizator care se conectează la baza de date pentru a prelua valori. Codul de mai sus se bazează pe Sequelize, dar puteți modifica după cum este necesar dacă interogați direct baza de date (dar recomand Sequelize!).
Acum trebuie să generăm vizualizarea. Folosind Bootstrap CSS, jQuery și cadrul Pug încorporat în cadrul Node Express, vizualizarea arată astfel:
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(); }); });
Iată formularul de pe pagină:
În acest moment, ar trebui să puteți completa formularul cu o adresă de e-mail care se află în baza de date și apoi să primiți un e-mail de resetare a parolei la acea adresă. Făcând clic pe linkul de resetare nu va face nimic încă.
Configurați ruta „Resetare parolă”.
Acum să continuăm și să setăm restul fluxului de lucru.
Adăugați modulul Sequelize.Op pe traseu:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Acum să construim ruta GET pentru utilizatorii care au făcut clic pe acel link de resetare a parolei. După cum veți vedea mai jos, vrem să ne asigurăm că validăm indicativul de resetare în mod corespunzător.
Sfat de securitate #7:
Asigurați-vă că căutați doar jetoane de resetare care nu au expirat și nu au fost folosite.
În scopuri demonstrative, șterg și toate jetoanele expirate încărcate aici pentru a menține masa mică. Dacă aveți un site web mare, mutați-l într-un 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 }); });
Acum să creăm ruta POST, care este ceea ce este lovit odată ce utilizatorul completează noile detalii despre parolă.
Sfat de securitate de la 8 la 11:
- Asigurați-vă că parolele se potrivesc și îndeplinesc cerințele dvs. minime.
- Verificați din nou simbolul de resetare pentru a vă asigura că nu a fost folosit și nu a expirat. Trebuie să-l verificăm din nou, deoarece simbolul este trimis de către un utilizator prin intermediul formularului.
- Înainte de a reseta parola, marcați jetonul ca fiind utilizat. În acest fel, dacă se întâmplă ceva neprevăzut (crash server, de exemplu), parola nu va fi resetată cât timp tokenul este încă valabil.
- Utilizați o sare aleatorie sigură criptografic (în acest caz, folosim 64 de octeți aleatori).
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); } }); });
Iată cum ar trebui să arate:
Adăugați linkul la pagina dvs. de conectare
În cele din urmă, nu uitați să adăugați un link către acest flux din pagina dvs. de conectare! Odată ce faceți acest lucru, ar trebui să aveți un flux de resetare a parolei funcțional. Asigurați-vă că testați temeinic în fiecare etapă a procesului pentru a confirma că totul funcționează și token-urile dvs. au o expirare scurtă și sunt marcate cu starea corectă pe măsură ce fluxul de lucru progresează.
Pasii urmatori
Sperăm că acest lucru v-a ajutat pe drumul spre codificarea unei funcții de resetare a parolei sigure și ușor de utilizat.
- Dacă sunteți interesat să aflați mai multe despre securitatea criptografică, vă recomand rezumatul Wikipedia (atenție, este dens!).
- Dacă doriți să adăugați și mai multă securitate la autentificarea aplicației dvs., priviți 2FA. Există o mulțime de opțiuni diferite acolo.
- Dacă v-am speriat să vă construiți propriul flux de resetare a parolelor, vă puteți baza pe sisteme de conectare ale unor terțe părți precum Google și Facebook. PassportJS este un middleware pe care îl puteți folosi pentru NodeJS care implementează aceste strategii.