Erstellen sicherer Passwortflüsse mit NodeJS und MySQL
Veröffentlicht: 2022-03-10Wenn Sie wie ich sind, haben Sie Ihr Passwort mehr als einmal vergessen, insbesondere auf Websites, die Sie seit einiger Zeit nicht mehr besucht haben. Sie haben wahrscheinlich auch E-Mails zum Zurücksetzen von Passwörtern gesehen, die Ihr Passwort im Klartext enthalten, und/oder haben sich darüber geschämt.
Leider wird der Workflow zum Zurücksetzen des Passworts während der Anwendungsentwicklung nur kurz behandelt und nur begrenzt beachtet. Dies kann nicht nur zu einer frustrierenden Benutzererfahrung führen, sondern Ihre Anwendung auch mit klaffenden Sicherheitslücken hinterlassen.
Wir werden behandeln, wie Sie einen sicheren Workflow zum Zurücksetzen von Passwörtern erstellen. Wir verwenden NodeJS und MySQL als unsere Basiskomponenten. Wenn Sie mit einer anderen Sprache, einem anderen Framework oder einer anderen Datenbank schreiben, können Sie trotzdem davon profitieren, die allgemeinen „Sicherheitstipps“ zu befolgen, die in jedem Abschnitt beschrieben sind.
Ein Fluss zum Zurücksetzen des Passworts besteht aus den folgenden Komponenten:
- Ein Link, um den Benutzer zum Start des Workflows zu schicken.
- Ein Formular, mit dem der Benutzer seine E-Mail senden kann.
- Eine Suche, die die E-Mail validiert und eine E-Mail an die Adresse sendet.
- Eine E-Mail, die das Reset-Token mit einem Ablaufdatum enthält, mit dem der Benutzer sein Passwort zurücksetzen kann.
- Ein Formular, mit dem der Benutzer ein neues Passwort generieren kann.
- Speichern des neuen Passworts und erneutes Anmelden des Benutzers mit dem neuen Passwort.
Neben Node, Express & MySQL verwenden wir die folgenden Bibliotheken:
- ORM sequenzieren
- Nodemailer
Sequelize ist ein NodeJS-Datenbank-ORM, das die Ausführung von Datenbankmigrationen sowie Sicherheitsabfragen erleichtert. Nodemailer ist eine beliebte NodeJS-E-Mail-Bibliothek, die wir verwenden, um E-Mails zum Zurücksetzen von Passwörtern zu senden.
Sicherheitstipp Nr. 1
Einige Artikel schlagen vor, dass sichere Passwortabläufe mithilfe von JSON Web Tokens (JWT) entworfen werden können, wodurch die Notwendigkeit einer Datenbankspeicherung entfällt (und daher einfacher zu implementieren ist). Wir verwenden diesen Ansatz auf unserer Website nicht, da JWT-Token-Secrets normalerweise direkt im Code gespeichert werden. Wir möchten vermeiden, „ein Geheimnis“ zu haben, um sie alle zu beherrschen (aus dem gleichen Grund, aus dem Sie Passwörter nicht mit demselben Wert salzen), und müssen diese Informationen daher in eine Datenbank verschieben.
Installation
Installieren Sie zuerst Sequelize, Nodemailer und andere zugehörige Bibliotheken:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
Fügen Sie in der Route, in der Sie Ihre Reset-Workflows einschließen möchten, die erforderlichen Module hinzu. Wenn Sie eine Auffrischung zu Express und Routen benötigen, lesen Sie deren Leitfaden.
const nodemailer = require('nodemailer');
Und konfigurieren Sie es mit Ihren E-Mail-SMTP-Anmeldeinformationen.
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 } });
Die E-Mail-Lösung, die ich verwende, ist der Simple Email Service von AWS, aber Sie können alles verwenden (Mailgun usw.).
Wenn Sie Ihren E-Mail-Sendedienst zum ersten Mal einrichten, müssen Sie einige Zeit damit verbringen, die entsprechenden Domänenschlüssel zu konfigurieren und Autorisierungen einzurichten. Wenn Sie Route 53 zusammen mit SES verwenden, ist dies super einfach und erfolgt praktisch automatisch, weshalb ich es ausgewählt habe. AWS bietet einige Tutorials zur Funktionsweise von SES mit Route53.
Sicherheitstipp #2
Um die Anmeldeinformationen außerhalb meines Codes zu speichern, verwende ich dotenv, mit dem ich eine lokale .env-Datei mit meinen Umgebungsvariablen erstellen kann. Auf diese Weise kann ich bei der Bereitstellung in der Produktion verschiedene Produktionsschlüssel verwenden, die im Code nicht sichtbar sind, und kann daher die Berechtigungen meiner Konfiguration auf nur bestimmte Mitglieder meines Teams beschränken.
Datenbank-Setup
Da wir Reset-Token an Benutzer senden werden, müssen wir diese Token in einer Datenbank speichern.
Ich gehe davon aus, dass Sie eine funktionierende Benutzertabelle in Ihrer Datenbank haben. Wenn Sie Sequelize bereits verwenden, großartig! Wenn nicht, sollten Sie Sequelize und die Sequelize-CLI auffrischen.
Wenn Sie Sequelize noch nicht in Ihrer App verwendet haben, können Sie es einrichten, indem Sie den folgenden Befehl im Stammordner Ihrer App ausführen:
$ sequelize init
Dadurch werden eine Reihe neuer Ordner in Ihrem Setup erstellt, einschließlich Migrationen und Modelle.
Dadurch wird auch eine Konfigurationsdatei erstellt. Aktualisieren Sie in Ihrer Konfigurationsdatei den development
mit den Anmeldeinformationen für Ihren lokalen MySQL-Datenbankserver.
Lassen Sie uns das CLI-Tool von Sequelize verwenden, um die Datenbanktabelle für uns zu generieren.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Diese Tabelle hat folgende Spalten:
- E-Mail-Adresse des Benutzers,
- Token, das generiert wurde,
- Ablauf dieses Tokens,
- Ob der Token verwendet wurde oder nicht.
Im Hintergrund führt sequelize-cli die folgende SQL-Abfrage aus:
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;
Überprüfen Sie, ob dies ordnungsgemäß funktioniert hat, indem Sie Ihren SQL-Client oder die Befehlszeile verwenden:
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)
Sicherheitstipp Nr. 3
Wenn Sie derzeit kein ORM verwenden, sollten Sie dies in Erwägung ziehen. Ein ORM automatisiert das Schreiben und ordnungsgemäße Maskieren von SQL-Abfragen, wodurch Ihr Code standardmäßig besser lesbar und sicherer wird. Sie helfen Ihnen, SQL-Injection-Angriffe zu vermeiden, indem sie Ihre SQL-Abfragen richtig maskieren.
Richten Sie die Route zum Zurücksetzen des Passworts ein
Erstellen Sie die Get-Route in user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Erstellen Sie dann die POST-Route, die aufgerufen wird, wenn das Formular zum Zurücksetzen des Kennworts gesendet wird. Im folgenden Code habe ich einige wichtige Sicherheitsfunktionen eingefügt.
Sicherheitstipps #4-6
- Auch wenn wir keine E-Mail-Adresse finden, geben wir als Status „ok“ zurück. Wir wollen nicht, dass unbedachte Bots herausfinden, welche E-Mails in unserer Datenbank echt oder nicht echt sind.
- Je mehr zufällige Bytes Sie in einem Token verwenden, desto unwahrscheinlicher ist es, dass es gehackt werden kann. Wir verwenden 64 zufällige Bytes in unserem Token-Generator (verwenden Sie nicht weniger als 8).
- Lassen Sie das Token in 1 Stunde ablaufen. Dadurch wird das Zeitfenster begrenzt, in dem das Reset-Token funktioniert.
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'}); });
Sie sehen eine Benutzervariable, auf die oben verwiesen wird – was ist das? Für die Zwecke dieses Tutorials gehen wir davon aus, dass Sie über ein Benutzermodell verfügen, das eine Verbindung mit Ihrer Datenbank herstellt, um Werte abzurufen. Der obige Code basiert auf Sequelize, aber Sie können ihn nach Bedarf ändern, wenn Sie die Datenbank direkt abfragen (aber ich empfehle Sequelize!).
Wir müssen jetzt die Ansicht generieren. Unter Verwendung von Bootstrap CSS, jQuery und dem in das Node Express-Framework integrierten Pug-Framework sieht die Ansicht wie folgt aus:
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(); }); });
Hier ist das Formular auf der Seite:
An diesem Punkt sollten Sie in der Lage sein, das Formular mit einer E-Mail-Adresse aus Ihrer Datenbank auszufüllen und dann eine E-Mail zum Zurücksetzen des Passworts an diese Adresse zu erhalten. Ein Klick auf den Reset-Link wird noch nichts tun.
Richten Sie die Route „Passwort zurücksetzen“ ein
Lassen Sie uns nun fortfahren und den Rest des Workflows einrichten.
Fügen Sie das Sequelize.Op-Modul zu Ihrer Route hinzu:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Lassen Sie uns nun die GET-Route für Benutzer erstellen, die auf diesen Link zum Zurücksetzen des Kennworts geklickt haben. Wie Sie unten sehen werden, möchten wir sicherstellen, dass wir das Reset-Token ordnungsgemäß validieren.
Sicherheitstipp Nr. 7:
Stellen Sie sicher, dass Sie nur Reset-Token suchen, die nicht abgelaufen sind und nicht verwendet wurden.
Zu Demonstrationszwecken räume ich hier auch alle abgelaufenen Token beim Laden auf, um die Tabelle klein zu halten. Wenn Sie eine große Website haben, verschieben Sie diese in einen 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 }); });
Lassen Sie uns nun die POST-Route erstellen, die erreicht wird, sobald der Benutzer seine neuen Passwortdetails ausfüllt.
Sicherheitstipp Nr. 8 bis 11:
- Stellen Sie sicher, dass die Passwörter übereinstimmen und Ihren Mindestanforderungen entsprechen.
- Überprüfen Sie das Reset-Token erneut, um sicherzustellen, dass es nicht verwendet wurde und nicht abgelaufen ist. Wir müssen es noch einmal überprüfen, da der Token von einem Benutzer über das Formular gesendet wird.
- Markieren Sie den Token vor dem Zurücksetzen des Passworts als verwendet. Auf diese Weise wird das Passwort nicht zurückgesetzt, wenn etwas Unvorhergesehenes passiert (z. B. Serverabsturz), solange das Token noch gültig ist.
- Verwenden Sie ein kryptografisch sicheres zufälliges Salt (in diesem Fall verwenden wir 64 zufällige Bytes).
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); } }); });
So sollte es aussehen:
Fügen Sie den Link zu Ihrer Anmeldeseite hinzu
Vergessen Sie nicht, von Ihrer Anmeldeseite aus einen Link zu diesem Ablauf hinzuzufügen! Sobald Sie dies getan haben, sollten Sie einen funktionierenden Fluss zum Zurücksetzen des Passworts haben. Stellen Sie sicher, dass Sie in jeder Phase des Prozesses gründlich testen, um zu bestätigen, dass alles funktioniert und Ihre Token eine kurze Ablaufzeit haben und im Verlauf des Workflows mit dem richtigen Status gekennzeichnet sind.
Nächste Schritte
Hoffentlich hat Ihnen das auf Ihrem Weg geholfen, eine sichere, benutzerfreundliche Funktion zum Zurücksetzen des Passworts zu programmieren.
- Wenn Sie mehr über kryptografische Sicherheit erfahren möchten, empfehle ich die Zusammenfassung von Wikipedia (Achtung, sie ist dicht!).
- Wenn Sie die Authentifizierung Ihrer App noch sicherer machen möchten, schauen Sie sich 2FA an. Es gibt viele verschiedene Optionen da draußen.
- Wenn ich Sie davon abgehalten habe, Ihren eigenen Ablauf zum Zurücksetzen von Passwörtern zu erstellen, können Sie sich auf Anmeldesysteme von Drittanbietern wie Google und Facebook verlassen. PassportJS ist eine Middleware, die Sie für NodeJS verwenden können, die diese Strategien implementiert.