Tworzenie bezpiecznych przepływów haseł za pomocą NodeJS i MySQL
Opublikowany: 2022-03-10Jeśli jesteś podobny do mnie, nie raz zapomniałeś hasła, zwłaszcza w witrynach, których nie odwiedzałeś od jakiegoś czasu. Prawdopodobnie zdarzyło Ci się również zobaczyć i/lub zmartwiły Cię wiadomości e-mail dotyczące resetowania hasła, które zawierają Twoje hasło w postaci zwykłego tekstu.
Niestety, przepływ pracy resetowania hasła jest krótkotrwały i ma ograniczoną uwagę podczas tworzenia aplikacji. Może to nie tylko prowadzić do frustrującego doświadczenia użytkownika, ale może również pozostawić aplikację z lukami w zabezpieczeniach.
Omówimy, jak zbudować bezpieczny przepływ pracy resetowania hasła. Będziemy używać NodeJS i MySQL jako naszych podstawowych komponentów. Jeśli piszesz w innym języku, strukturze lub bazie danych, nadal możesz skorzystać z ogólnych „Wskazówek dotyczących bezpieczeństwa” opisanych w każdej sekcji.
Przepływ resetowania hasła składa się z następujących elementów:
- Link do przesłania użytkownika na początek przepływu pracy.
- Formularz, który umożliwia użytkownikowi przesłanie wiadomości e-mail.
- Wyszukiwanie, które weryfikuje wiadomość e-mail i wysyła wiadomość e-mail na adres.
- Wiadomość e-mail zawierająca token resetowania z datą wygaśnięcia, która umożliwia użytkownikowi zresetowanie hasła.
- Formularz, który pozwala użytkownikowi wygenerować nowe hasło.
- Zapisanie nowego hasła i umożliwienie użytkownikowi ponownego zalogowania się przy użyciu nowego hasła.
Oprócz Node, Express i MySQL będziemy używać następujących bibliotek:
- Sequelizuj ORM
- Nodemailer
Sequelize to ORM bazy danych NodeJS, który ułatwia przeprowadzanie migracji bazy danych, a także tworzenie zapytań zabezpieczających. Nodemailer to popularna biblioteka e-mail NodeJS, której będziemy używać do wysyłania wiadomości e-mail dotyczących resetowania hasła.
Porada dotycząca bezpieczeństwa nr 1
Niektóre artykuły sugerują, że bezpieczne przepływy haseł można zaprojektować przy użyciu tokenów sieci Web JSON (JWT), które eliminują potrzebę przechowywania bazy danych (a tym samym są łatwiejsze do wdrożenia). Nie stosujemy tego podejścia w naszej witrynie, ponieważ klucze tajne tokenów JWT są zwykle przechowywane bezpośrednio w kodzie. Chcemy uniknąć posiadania „jednego sekretu”, aby rządzić nimi wszystkimi (z tego samego powodu, dla którego nie rozróżnia się haseł o tej samej wartości), dlatego musimy przenieść te informacje do bazy danych.
Instalacja
Najpierw zainstaluj Sequelize, Nodemailer i inne powiązane biblioteki:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
W trasie, w której chcesz uwzględnić swoje przepływy pracy resetowania, dodaj wymagane moduły. Jeśli potrzebujesz odświeżenia na temat Express i tras, zapoznaj się z ich przewodnikiem.
const nodemailer = require('nodemailer');
I skonfiguruj go za pomocą poświadczeń SMTP poczty 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 } });
Rozwiązanie e-mail, którego używam, to prosta usługa poczty e-mail AWS, ale możesz użyć wszystkiego (Mailgun itp.).
Jeśli konfigurujesz usługę wysyłania e-maili po raz pierwszy, musisz poświęcić trochę czasu na skonfigurowanie odpowiednich kluczy domeny i skonfigurowanie autoryzacji. Jeśli używasz Route 53 wraz z SES, jest to bardzo proste i wykonywane praktycznie automatycznie, dlatego właśnie ją wybrałem. AWS ma kilka samouczków na temat współpracy SES z Route53.
Porada dotycząca bezpieczeństwa nr 2
Aby przechowywać dane uwierzytelniające z dala od mojego kodu, używam dotenv, który pozwala mi utworzyć lokalny plik .env z moimi zmiennymi środowiskowymi. W ten sposób podczas wdrażania w środowisku produkcyjnym mogę używać różnych kluczy produkcyjnych, które nie są widoczne w kodzie, co pozwala mi ograniczyć uprawnienia mojej konfiguracji tylko do niektórych członków mojego zespołu.
Konfiguracja bazy danych
Ponieważ zamierzamy wysyłać użytkownikom tokeny resetowania, musimy je przechowywać w bazie danych.
Zakładam, że masz w swojej bazie danych działającą tabelę użytkowników. Jeśli już używasz Sequelize, świetnie! Jeśli nie, możesz odświeżyć Sequelize i Sequelize CLI.
Jeśli nie używałeś jeszcze Sequelize w swojej aplikacji, możesz to skonfigurować, uruchamiając poniższe polecenie w folderze głównym aplikacji:
$ sequelize init
Spowoduje to utworzenie wielu nowych folderów w Twojej konfiguracji, w tym migracji i modeli.
Spowoduje to również utworzenie pliku konfiguracyjnego. W swoim pliku konfiguracyjnym zaktualizuj blok development
danymi uwierzytelniającymi do lokalnego serwera bazy danych mysql.
Użyjmy narzędzia CLI Sequelize, aby wygenerować dla nas tabelę bazy danych.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Ta tabela ma następujące kolumny:
- adres e-mail użytkownika,
- Token, który został wygenerowany,
- Wygaśnięcie tego tokena,
- Czy token został użyty, czy nie.
W tle sequelize-cli wykonuje następujące zapytanie 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;
Sprawdź, czy działa to poprawnie, używając klienta SQL lub wiersza poleceń:
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)
Porada dotycząca bezpieczeństwa nr 3
Jeśli obecnie nie korzystasz z ORM, powinieneś to rozważyć. ORM automatyzuje pisanie i prawidłowe wyprowadzanie zapytań SQL, dzięki czemu Twój kod jest domyślnie bardziej czytelny i bezpieczniejszy. Pomogą one uniknąć ataków wstrzykiwania SQL poprzez prawidłowe unikanie zapytań SQL.
Skonfiguruj trasę resetowania hasła
Utwórz trasę pobierania w user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Następnie utwórz trasę POST, która jest trasą, która zostanie trafiona po opublikowaniu formularza resetowania hasła. W poniższym kodzie zawarłem kilka ważnych funkcji bezpieczeństwa.
Wskazówki dotyczące bezpieczeństwa #4-6
- Nawet jeśli nie znajdziemy adresu e-mail, jako nasz status zwracamy „ok”. Nie chcemy, aby nieprzyjemne boty zastanawiały się, które e-maile są prawdziwe, a które nieprawdziwe w naszej bazie danych.
- Im więcej losowych bajtów użyjesz w tokenie, tym mniej prawdopodobne jest, że zostanie zhakowany. W naszym generatorze tokenów używamy 64 losowych bajtów (nie używaj mniej niż 8).
- Wygasa token za 1 godzinę. Ogranicza to czas działania tokena resetowania.
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'}); });
Zobaczysz zmienną użytkownika, o której mowa powyżej — co to jest? Na potrzeby tego samouczka zakładamy, że masz model użytkownika, który łączy się z bazą danych w celu pobrania wartości. Powyższy kod jest oparty na Sequelize, ale możesz modyfikować w razie potrzeby, jeśli wysyłasz zapytanie bezpośrednio do bazy danych (ale polecam Sequelize!).
Teraz musimy wygenerować widok. Używając Bootstrap CSS, jQuery i frameworka mops wbudowanego w framework Node Express, widok wygląda następująco:
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(); }); });
Oto formularz na stronie:
W tym momencie powinieneś być w stanie wypełnić formularz za pomocą adresu e-mail, który znajduje się w Twojej bazie danych, a następnie otrzymać wiadomość e-mail umożliwiającą zresetowanie hasła na ten adres. Kliknięcie linku resetowania jeszcze nic nie da.
Skonfiguruj trasę „Resetuj hasło”
Teraz przejdźmy dalej i skonfigurujmy resztę przepływu pracy.
Dodaj moduł Sequelize.Op do swojej trasy:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Teraz zbudujmy trasę GET dla użytkowników, którzy kliknęli link resetowania hasła. Jak zobaczysz poniżej, chcemy się upewnić, że prawidłowo weryfikujemy token resetowania.
Porada dotycząca bezpieczeństwa nr 7:
Upewnij się, że szukasz tylko tokenów resetowania, które nie wygasły i nie były używane.
W celach demonstracyjnych usuwam tutaj również wszystkie wygasłe tokeny podczas ładowania, aby stół był mały. Jeśli masz dużą witrynę, przenieś ją do 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 }); });
Teraz utwórzmy trasę POST, która zostanie trafiona, gdy użytkownik wypełni swoje nowe dane hasła.
Porada dotycząca bezpieczeństwa od 8 do 11:
- Upewnij się, że hasła są zgodne i spełniają minimalne wymagania.
- Sprawdź ponownie token resetowania, aby upewnić się, że nie był używany i nie wygasł. Musimy to sprawdzić ponownie, ponieważ token jest wysyłany przez użytkownika za pośrednictwem formularza.
- Przed zresetowaniem hasła oznacz token jako używany. W ten sposób, jeśli wydarzy się coś nieprzewidzianego (na przykład awaria serwera), hasło nie zostanie zresetowane, dopóki token jest nadal ważny.
- Użyj kryptograficznie bezpiecznej losowej soli (w tym przypadku używamy 64 losowych bajtów).
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); } }); });
Tak powinno wyglądać:
Dodaj link do swojej strony logowania
Na koniec nie zapomnij dodać linku do tego przepływu ze swojej strony logowania! Gdy to zrobisz, powinieneś mieć działający przepływ resetowania hasła. Pamiętaj, aby dokładnie przetestować na każdym etapie procesu, aby upewnić się, że wszystko działa, a Twoje tokeny mają krótką ważność i są oznaczane poprawnym statusem w miarę postępu przepływu pracy.
Następne kroki
Mam nadzieję, że pomogło ci to na drodze do zakodowania bezpiecznej, przyjaznej dla użytkownika funkcji resetowania hasła.
- Jeśli chcesz dowiedzieć się więcej o bezpieczeństwie kryptograficznym, polecam podsumowanie Wikipedii (uwaga, jest gęste!).
- Jeśli chcesz dodać jeszcze więcej zabezpieczeń do uwierzytelniania swojej aplikacji, zapoznaj się z 2FA. Istnieje wiele różnych opcji.
- Jeśli odstraszyłem Cię od zbudowania własnego przepływu resetowania hasła, możesz polegać na systemach logowania innych firm, takich jak Google i Facebook. PassportJS to oprogramowanie pośredniczące, którego można używać dla NodeJS, które implementuje te strategie.