Creazione di flussi di password sicuri con NodeJS e MySQL
Pubblicato: 2022-03-10Se sei come me, hai dimenticato la password più di una volta, soprattutto su siti che non visiti da un po'. Probabilmente hai anche visto, e/o mortificato, e-mail di reimpostazione della password che contengono la tua password in testo normale.
Sfortunatamente, il flusso di lavoro di reimpostazione della password riceve poca attenzione e un'attenzione limitata durante lo sviluppo dell'applicazione. Questo non solo può portare a un'esperienza utente frustrante, ma può anche lasciare la tua applicazione con enormi falle di sicurezza.
Tratteremo come creare un flusso di lavoro sicuro per la reimpostazione della password. Useremo NodeJS e MySQL come componenti di base. Se stai scrivendo utilizzando una lingua, un framework o un database diversi, puoi comunque trarre vantaggio dal seguire i "Suggerimenti per la sicurezza" generali descritti in ciascuna sezione.
Un flusso di reimpostazione della password è costituito dai seguenti componenti:
- Un collegamento per inviare l'utente all'inizio del flusso di lavoro.
- Un modulo che consente all'utente di inviare la propria e-mail.
- Una ricerca che convalida l'e-mail e invia un'e-mail all'indirizzo.
- Un'e-mail che contiene il token di ripristino con una scadenza che consente all'utente di reimpostare la propria password.
- Un modulo che consente all'utente di generare una nuova password.
- Salvare la nuova password e consentire all'utente di accedere nuovamente con la nuova password.
Oltre a Node, Express e MySQL, utilizzeremo le seguenti librerie:
- Sequenza ORM
- Nodemailer
Sequelize è un ORM di database NodeJS che semplifica l'esecuzione delle migrazioni di database e la creazione di query di sicurezza. Nodemailer è una popolare libreria di posta elettronica NodeJS che utilizzeremo per inviare e-mail di reimpostazione della password.
Consiglio di sicurezza n. 1
Alcuni articoli suggeriscono che i flussi di password sicuri possono essere progettati utilizzando JSON Web Tokens (JWT), che eliminano la necessità di archiviazione del database (e quindi sono più facili da implementare). Non utilizziamo questo approccio sul nostro sito, perché i segreti dei token JWT sono generalmente archiviati direttamente nel codice. Vogliamo evitare di avere "un segreto" per controllarli tutti (per lo stesso motivo per cui non si salgono password con lo stesso valore), e quindi è necessario spostare queste informazioni in un database.
Installazione
Innanzitutto, installa Sequelize, Nodemailer e altre librerie associate:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
Nel percorso in cui desideri includere i flussi di lavoro di ripristino, aggiungi i moduli richiesti. Se hai bisogno di un aggiornamento su Express e rotte, dai un'occhiata alla loro guida.
const nodemailer = require('nodemailer');
E configuralo con le tue credenziali SMTP di posta elettronica.
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 } });
La soluzione di posta elettronica che sto utilizzando è il servizio di posta elettronica semplice di AWS, ma puoi utilizzare qualsiasi cosa (Mailgun, ecc.).
Se è la prima volta che configuri il tuo servizio di invio e-mail, dovrai dedicare un po' di tempo alla configurazione delle chiavi di dominio appropriate e alla configurazione delle autorizzazioni. Se usi Route 53 insieme a SES, questo è semplicissimo e fatto praticamente automaticamente, motivo per cui l'ho scelto. AWS ha alcuni tutorial su come funziona SES con Route53.
Consiglio di sicurezza n. 2
Per archiviare le credenziali lontano dal mio codice, utilizzo dotenv, che mi consente di creare un file .env locale con le mie variabili di ambiente. In questo modo, quando eseguo la distribuzione in produzione, posso utilizzare chiavi di produzione diverse che non sono visibili nel codice e quindi mi consente di limitare le autorizzazioni della mia configurazione solo a determinati membri del mio team.
Configurazione del database
Poiché invieremo token di ripristino agli utenti, è necessario archiviare tali token in un database.
Presumo che tu abbia una tabella utenti funzionante nel tuo database. Se stai già usando Sequelize, fantastico! In caso contrario, potresti voler rispolverare Sequelize e Sequelize CLI.
Se non hai ancora utilizzato Sequelize nella tua app, puoi configurarla eseguendo il comando seguente nella cartella principale della tua app:
$ sequelize init
Questo creerà una serie di nuove cartelle nella tua configurazione, incluse migrazioni e modelli.
Questo creerà anche un file di configurazione. Nel tuo file di configurazione, aggiorna il blocco di development
con le credenziali del tuo server di database mysql locale.
Usiamo lo strumento CLI di Sequelize per generare la tabella del database per noi.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Questa tabella ha le seguenti colonne:
- Indirizzo email dell'utente,
- Token che è stato generato,
- Scadenza di quel token,
- Se il token è stato utilizzato o meno.
In background, sequelize-cli esegue la seguente query 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 che abbia funzionato correttamente usando il tuo client SQL o la riga di comando:
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)
Consiglio di sicurezza n. 3
Se attualmente non stai utilizzando un ORM, dovresti considerare di farlo. Un ORM automatizza la scrittura e la corretta evasione delle query SQL, rendendo il codice più leggibile e più sicuro per impostazione predefinita. Ti aiuteranno a evitare attacchi di SQL injection evadendo correttamente le tue query SQL.
Imposta il percorso di reimpostazione della password
Crea il percorso get in user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Quindi crea il percorso POST, che è il percorso che viene raggiunto quando viene inviato il modulo di reimpostazione della password. Nel codice seguente, ho incluso un paio di importanti funzionalità di sicurezza.
Suggerimenti per la sicurezza n. 4-6
- Anche se non troviamo un indirizzo e-mail, restituiamo "ok" come stato. Non vogliamo che i robot sgraditi capiscano quali e-mail sono reali e non reali nel nostro database.
- Più byte casuali usi in un token, meno è probabile che possa essere violato. Stiamo usando 64 byte casuali nel nostro generatore di token (non usarne meno di 8).
- Scade il token tra 1 ora. Ciò limita la finestra di tempo in cui il token di ripristino funziona.
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'}); });
Vedrai una variabile utente a cui si fa riferimento sopra: che cos'è? Ai fini di questo tutorial, presupponiamo che tu abbia un modello utente che si connette al tuo database per recuperare i valori. Il codice sopra è basato su Sequelize, ma è possibile modificare secondo necessità se si interroga direttamente il database (ma io consiglio Sequelize!).

Ora dobbiamo generare la vista. Usando Bootstrap CSS, jQuery e il framework pug integrato nel framework Node Express, la vista è simile alla seguente:
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(); }); });
Ecco il modulo nella pagina:

A questo punto, dovresti essere in grado di compilare il modulo con un indirizzo e-mail presente nel tuo database, quindi ricevere un'e-mail di reimpostazione della password a quell'indirizzo. Fare clic sul collegamento di ripristino non farà ancora nulla.
Imposta il percorso "Reimposta password".
Ora andiamo avanti e impostiamo il resto del flusso di lavoro.
Aggiungi il modulo Sequelize.Op al tuo percorso:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Ora costruiamo il percorso GET per gli utenti che hanno fatto clic sul collegamento di reimpostazione della password. Come vedrai di seguito, vogliamo assicurarci di convalidare il token di ripristino in modo appropriato.
Consiglio di sicurezza n. 7:
Assicurati di cercare solo i token di ripristino che non sono scaduti e non sono stati utilizzati.
A scopo dimostrativo, azzero anche tutti i token scaduti caricati qui per mantenere il tavolo piccolo. Se hai un sito Web di grandi dimensioni, spostalo in 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 }); });
Ora creiamo il percorso POST che è ciò che viene colpito una volta che l'utente ha compilato i dettagli della nuova password.
Suggerimento per la sicurezza da 8 a 11:
- Assicurati che le password corrispondano e soddisfino i tuoi requisiti minimi.
- Controllare di nuovo il token di ripristino per assicurarsi che non sia stato utilizzato e non sia scaduto. Dobbiamo ricontrollarlo perché il token viene inviato da un utente tramite il modulo.
- Prima di reimpostare la password, contrassegnare il token come utilizzato. In questo modo, se accade qualcosa di imprevisto (arresto anomalo del server, ad esempio), la password non verrà reimpostata mentre il token è ancora valido.
- Utilizzare un salt casuale crittograficamente sicuro (in questo caso, utilizziamo 64 byte casuali).
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); } }); });
Ecco come dovrebbe essere:

Aggiungi il link alla tua pagina di accesso
Infine, non dimenticare di aggiungere un collegamento a questo flusso dalla tua pagina di accesso! Una volta eseguita questa operazione, dovresti avere un flusso di reimpostazione della password funzionante. Assicurati di testare accuratamente in ogni fase del processo per confermare che tutto funzioni e che i tuoi token abbiano una breve scadenza e siano contrassegnati con lo stato corretto man mano che il flusso di lavoro procede.
Prossimi passi
Si spera che questo ti abbia aiutato a codificare una funzione di reimpostazione della password sicura e intuitiva.
- Se sei interessato a saperne di più sulla sicurezza crittografica, ti consiglio il riepilogo di Wikipedia (attenzione, è denso!).
- Se vuoi aggiungere ancora più sicurezza all'autenticazione della tua app, esamina 2FA. Ci sono molte opzioni diverse là fuori.
- Se ti ho spaventato dal creare il tuo flusso di reimpostazione della password, puoi fare affidamento su sistemi di accesso di terze parti come Google e Facebook. PassportJS è un middleware che puoi utilizzare per NodeJS che implementa queste strategie.