Création de flux de mots de passe sécurisés avec NodeJS et MySQL

Publié: 2022-03-10
Résumé rapide ↬ La fonctionnalité de réinitialisation du mot de passe est un enjeu de table pour toute application conviviale. Cela peut aussi être un cauchemar pour la sécurité. En utilisant NodeJS et MySQL, Darshan montre comment créer avec succès un flux de mot de passe de réinitialisation sécurisé afin que vous puissiez éviter ces pièges.

Si vous êtes comme moi, vous avez oublié votre mot de passe plus d'une fois, en particulier sur des sites que vous n'avez pas visités depuis un moment. Vous avez probablement également vu et/ou été mortifié par des e-mails de réinitialisation de mot de passe contenant votre mot de passe en texte brut.

Malheureusement, le flux de travail de réinitialisation du mot de passe reçoit peu d'attention et une attention limitée lors du développement de l'application. Cela peut non seulement conduire à une expérience utilisateur frustrante, mais peut également laisser votre application avec des failles de sécurité béantes.

Nous allons expliquer comment créer un workflow de réinitialisation de mot de passe sécurisé. Nous utiliserons NodeJS et MySQL comme composants de base. Si vous écrivez à l'aide d'un langage, d'un framework ou d'une base de données différents, vous pouvez toujours bénéficier des "Conseils de sécurité" généraux décrits dans chaque section.

Un flux de réinitialisation de mot de passe comprend les composants suivants :

  • Un lien pour envoyer l'utilisateur au début du workflow.
  • Un formulaire qui permet à l'utilisateur de soumettre son e-mail.
  • Une recherche qui valide l'e-mail et envoie un e-mail à l'adresse.
  • Un e-mail contenant le jeton de réinitialisation avec une date d'expiration permettant à l'utilisateur de réinitialiser son mot de passe.
  • Un formulaire qui permet à l'utilisateur de générer un nouveau mot de passe.
  • Enregistrer le nouveau mot de passe et permettre à l'utilisateur de se reconnecter avec le nouveau mot de passe.

Outre Node, Express et MySQL, nous utiliserons les bibliothèques suivantes :

  • Séquelle ORM
  • Nodemailer

Sequelize est un ORM de base de données NodeJS qui facilite l'exécution des migrations de base de données ainsi que les requêtes de création de sécurité. Nodemailer est une bibliothèque de messagerie NodeJS populaire que nous utiliserons pour envoyer des e-mails de réinitialisation de mot de passe.

Conseil de sécurité #1

Certains articles suggèrent que des flux de mots de passe sécurisés peuvent être conçus à l'aide de jetons Web JSON (JWT), qui éliminent le besoin de stockage de base de données (et sont donc plus faciles à mettre en œuvre). Nous n'utilisons pas cette approche sur notre site, car les secrets des jetons JWT sont généralement stockés directement dans le code. Nous voulons éviter d'avoir « un seul secret » pour les gouverner tous (pour la même raison, vous ne salez pas les mots de passe avec la même valeur), et nous devons donc déplacer ces informations dans une base de données.

Plus après saut! Continuez à lire ci-dessous ↓

Installation

Tout d'abord, installez Sequelize, Nodemailer et les autres bibliothèques associées :

 $ npm install --save sequelize sequelize-cli mysql crypto nodemailer

Dans la route où vous souhaitez inclure vos workflows de réinitialisation, ajoutez les modules requis. Si vous avez besoin d'un rappel sur Express et les itinéraires, consultez leur guide.

 const nodemailer = require('nodemailer');

Et configurez-le avec vos informations d'identification SMTP de messagerie.

 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 solution de messagerie que j'utilise est le service de messagerie simple d'AWS, mais vous pouvez utiliser n'importe quoi (Mailgun, etc.).

Si c'est la première fois que vous configurez votre service d'envoi d'e-mails, vous devrez passer du temps à configurer les clés de domaine appropriées et à configurer les autorisations. Si vous utilisez Route 53 avec SES, c'est super simple et fait pratiquement automatiquement, c'est pourquoi je l'ai choisi. AWS propose des didacticiels sur le fonctionnement de SES avec Route53.

Conseil de sécurité #2

Pour stocker les informations d'identification loin de mon code, j'utilise dotenv, ce qui me permet de créer un fichier .env local avec mes variables d'environnement. De cette façon, lorsque je déploie en production, je peux utiliser différentes clés de production qui ne sont pas visibles dans le code, et me permet donc de restreindre les autorisations de ma configuration à certains membres de mon équipe uniquement.

Configuration de la base de données

Puisque nous allons envoyer des jetons de réinitialisation aux utilisateurs, nous devons stocker ces jetons dans une base de données.

Je suppose que vous avez une table d'utilisateurs fonctionnelle dans votre base de données. Si vous utilisez déjà Sequelize, c'est parfait ! Si ce n'est pas le cas, vous voudrez peut-être approfondir Sequelize et Sequelize CLI.

Si vous n'avez pas encore utilisé Sequelize dans votre application, vous pouvez le configurer en exécutant la commande ci-dessous dans le dossier racine de votre application :

 $ sequelize init

Cela créera un certain nombre de nouveaux dossiers dans votre configuration, y compris les migrations et les modèles.

Cela créera également un fichier de configuration. Dans votre fichier de configuration, mettez à jour le bloc de development avec les informations d'identification de votre serveur de base de données mysql local.

Utilisons l'outil CLI de Sequelize pour générer la table de base de données pour nous.

 $ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate

Ce tableau comporte les colonnes suivantes :

  • Adresse e-mail de l'utilisateur,
  • Jeton qui a été généré,
  • Expiration de ce jeton,
  • Si le jeton a été utilisé ou non.

En arrière-plan, sequelize-cli exécute la requête SQL suivante :

 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;

Vérifiez que cela a fonctionné correctement à l'aide de votre client SQL ou de la ligne de commande :

 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)

Conseil de sécurité #3

Si vous n'utilisez pas actuellement d'ORM, vous devriez envisager de le faire. Un ORM automatise l'écriture et l'échappement correct des requêtes SQL, rendant votre code plus lisible et plus sécurisé par défaut. Ils vous aideront à éviter les attaques par injection SQL en évitant correctement vos requêtes SQL.

Configurer la route de réinitialisation du mot de passe

Créez la route get dans user.js :

 router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });

Créez ensuite la route POST, qui est la route qui est atteinte lorsque le formulaire de réinitialisation du mot de passe est publié. Dans le code ci-dessous, j'ai inclus quelques fonctionnalités de sécurité importantes.

Conseils de sécurité #4-6

  1. Même si nous ne trouvons pas d'adresse e-mail, nous renvoyons 'ok' comme statut. Nous ne voulons pas que des bots fâcheux déterminent quels e-mails sont réels ou non dans notre base de données.
  2. Plus vous utilisez d'octets aléatoires dans un jeton, moins il est susceptible d'être piraté. Nous utilisons 64 octets aléatoires dans notre générateur de jetons (n'en utilisez pas moins de 8).
  3. Faire expirer le jeton dans 1 heure. Cela limite la fenêtre de temps pendant laquelle le jeton de réinitialisation fonctionne.
 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'}); });

Vous verrez une variable utilisateur référencée ci-dessus - qu'est-ce que c'est ? Pour les besoins de ce didacticiel, nous supposons que vous disposez d'un modèle User qui se connecte à votre base de données pour récupérer des valeurs. Le code ci-dessus est basé sur Sequelize, mais vous pouvez le modifier si nécessaire si vous interrogez directement la base de données (mais je recommande Sequelize !).

Nous devons maintenant générer la vue. En utilisant Bootstrap CSS, jQuery et le framework pug intégré au framework Node Express, la vue ressemble à ceci :

 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(); }); });

Voici le formulaire sur la page :

champ de réinitialisation du mot de passe pour votre workflow de réinitialisation sécurisée du mot de passe
Votre formulaire de réinitialisation du mot de passe. ( Grand aperçu )

À ce stade, vous devriez pouvoir remplir le formulaire avec une adresse e-mail qui se trouve dans votre base de données, puis recevoir un e-mail de réinitialisation du mot de passe à cette adresse. Cliquer sur le lien de réinitialisation ne fera rien pour le moment.

Configurer la route "Réinitialiser le mot de passe"

Continuons maintenant et configurons le reste du flux de travail.

Ajoutez le module Sequelize.Op à votre route :

 const Sequelize = require('sequelize'); const Op = Sequelize.Op;

Construisons maintenant la route GET pour les utilisateurs qui ont cliqué sur ce lien de réinitialisation du mot de passe. Comme vous le verrez ci-dessous, nous voulons nous assurer que nous validons le jeton de réinitialisation de manière appropriée.

Conseil de sécurité n° 7 :

Assurez-vous de ne rechercher que les jetons de réinitialisation qui n'ont pas expiré et qui n'ont pas été utilisés.

À des fins de démonstration, j'efface également tous les jetons expirés en charge ici pour garder la table petite. Si vous avez un grand site Web, déplacez-le vers 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 }); });

Créons maintenant la route POST qui est frappée une fois que l'utilisateur a rempli ses nouveaux détails de mot de passe.

Conseil de sécurité n° 8 à 11 :

  • Assurez-vous que les mots de passe correspondent et répondent à vos exigences minimales.
  • Vérifiez à nouveau le jeton de réinitialisation pour vous assurer qu'il n'a pas été utilisé et qu'il n'a pas expiré. Nous devons le vérifier à nouveau car le jeton est envoyé par un utilisateur via le formulaire.
  • Avant de réinitialiser le mot de passe, marquez le jeton comme utilisé. De cette façon, si quelque chose d'imprévu se produit (plantage du serveur, par exemple), le mot de passe ne sera pas réinitialisé tant que le jeton est encore valide.
  • Utilisez un sel aléatoire cryptographiquement sécurisé (dans ce cas, nous utilisons 64 octets aléatoires).
 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); } }); });

Voici à quoi cela devrait ressembler :

formulaire de réinitialisation du mot de passe pour votre workflow de réinitialisation sécurisée du mot de passe
Votre formulaire de réinitialisation du mot de passe. ( Grand aperçu )

Ajoutez le lien à votre page de connexion

Enfin, n'oubliez pas d'ajouter un lien vers ce flux depuis votre page de connexion ! Une fois que vous avez fait cela, vous devriez avoir un flux de mot de passe de réinitialisation fonctionnel. Assurez-vous de tester soigneusement à chaque étape du processus pour confirmer que tout fonctionne et que vos jetons ont une courte expiration et sont marqués avec le statut correct au fur et à mesure que le flux de travail progresse.

Prochaines étapes

J'espère que cela vous a aidé à coder une fonction de réinitialisation de mot de passe sécurisée et conviviale.

  • Si vous souhaitez en savoir plus sur la sécurité cryptographique, je vous recommande le résumé de Wikipédia (attention, c'est dense !).
  • Si vous souhaitez ajouter encore plus de sécurité à l'authentification de votre application, consultez 2FA. Il existe de nombreuses options différentes.
  • Si je vous ai dissuadé de créer votre propre flux de mot de passe de réinitialisation, vous pouvez compter sur des systèmes de connexion tiers tels que Google et Facebook. PassportJS est un middleware que vous pouvez utiliser pour NodeJS qui implémente ces stratégies.