NodeJS ve MySQL ile Güvenli Parola Akışları Oluşturma
Yayınlanan: 2022-03-10Benim gibiyseniz, özellikle bir süredir ziyaret etmediğiniz sitelerde şifrenizi bir kereden fazla unuttunuz. Muhtemelen şifrenizi düz metin olarak içeren şifre sıfırlama e-postalarını da görmüş ve/veya bu e-postalardan utanmışsınızdır.
Ne yazık ki, parola sıfırlama iş akışı, uygulama geliştirme sırasında kısa süreli ve sınırlı ilgi görüyor. Bu, yalnızca sinir bozucu bir kullanıcı deneyimine yol açmakla kalmaz, aynı zamanda uygulamanızda güvenlik açıkları bırakabilir.
Güvenli bir parola sıfırlama iş akışının nasıl oluşturulacağını ele alacağız. Temel bileşenlerimiz olarak NodeJS ve MySQL kullanacağız. Farklı bir dil, çerçeve veya veritabanı kullanarak yazıyorsanız, yine de her bölümde özetlenen genel "Güvenlik İpuçları"ndan yararlanabilirsiniz.
Parola sıfırlama akışı aşağıdaki bileşenlerden oluşur:
- Kullanıcıyı iş akışının başlangıcına göndermek için bir bağlantı.
- Kullanıcının e-postasını göndermesine izin veren bir form.
- E-postayı doğrulayan ve adrese bir e-posta gönderen bir arama.
- Kullanıcının parolasını sıfırlamasına olanak tanıyan bir sona erme süresi olan sıfırlama belirtecini içeren bir e-posta.
- Kullanıcının yeni bir parola oluşturmasını sağlayan bir form.
- Yeni şifrenin kaydedilmesi ve kullanıcının yeni şifre ile tekrar oturum açmasına izin verilmesi.
Node, Express ve MySQL'in yanı sıra aşağıdaki kitaplıkları kullanacağız:
- ORM'yi sıraya koy
- nodemailer
Sequelize, güvenlik oluşturma sorgularının yanı sıra veritabanı geçişlerini çalıştırmayı kolaylaştıran bir NodeJS veritabanı ORM'sidir. Nodemailer, parola sıfırlama e-postaları göndermek için kullanacağımız popüler bir NodeJS e-posta kitaplığıdır.
Güvenlik İpucu #1
Bazı makaleler, güvenli parola akışlarının, veritabanı depolama ihtiyacını ortadan kaldıran (ve dolayısıyla uygulanması daha kolay olan) JSON Web Belirteçleri (JWT) kullanılarak tasarlanabileceğini önermektedir. JWT token sırları genellikle doğrudan kodda saklandığından sitemizde bu yaklaşımı kullanmıyoruz. Hepsine hükmedecek 'tek bir sır' bulundurmaktan kaçınmak istiyoruz (aynı nedenle aynı değere sahip şifreleri tuzlamazsınız) ve bu nedenle bu bilgiyi bir veritabanına taşımamız gerekir.
Kurulum
İlk önce Sequelize, Nodemailer ve diğer ilişkili kitaplıkları kurun:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
Sıfırlama iş akışlarınızı dahil etmek istediğiniz rotaya gerekli modülleri ekleyin. Ekspres ve rotalarda tazelemeye ihtiyacınız varsa, kılavuzlarına bakın.
const nodemailer = require('nodemailer');
Ve e-posta SMTP kimlik bilgilerinizle yapılandırın.
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 } });
Kullandığım e-posta çözümü AWS'nin Basit E-posta Hizmeti'dir, ancak her şeyi (Mailgun, vb.) kullanabilirsiniz.
E-posta gönderme hizmetinizi ilk kez kuruyorsanız, uygun Etki Alanı Anahtarlarını yapılandırmak ve yetkilendirmeleri ayarlamak için biraz zaman harcamanız gerekir. Route 53'ü SES ile birlikte kullanırsanız, bu çok basittir ve neredeyse otomatik olarak yapılır, bu yüzden onu seçtim. AWS'de, SES'in Route53 ile nasıl çalıştığına dair bazı eğitimler bulunmaktadır.
Güvenlik ipucu #2
Kimlik bilgilerini kodumdan uzakta saklamak için, ortam değişkenlerimle yerel bir .env dosyası oluşturmama izin veren dotenv kullanıyorum. Bu şekilde, üretime dağıttığımda, kodda görünmeyen farklı üretim anahtarlarını kullanabilirim ve bu nedenle yapılandırmamın izinlerini yalnızca ekibimin belirli üyeleriyle kısıtlamama izin verir.
Veritabanı Kurulumu
Kullanıcılara sıfırlama jetonları göndereceğimiz için, bu jetonları bir veritabanında saklamamız gerekiyor.
Veritabanınızda çalışan bir kullanıcılar tablonuz olduğunu varsayıyorum. Zaten Sequelize kullanıyorsanız, harika! Değilse, Sequelize ve Sequelize CLI'yi tazelemek isteyebilirsiniz.
Uygulamanızda henüz Sequelize kullanmadıysanız, uygulamanızın kök klasöründe aşağıdaki komutu çalıştırarak ayarlayabilirsiniz:
$ sequelize init
Bu, kurulumunuzda geçişler ve modeller dahil olmak üzere bir dizi yeni klasör oluşturacaktır.
Bu aynı zamanda bir yapılandırma dosyası oluşturacaktır. Yapılandırma dosyanızda, development
bloğunu yerel mysql veritabanı sunucunuzun kimlik bilgileriyle güncelleyin.
Bizim için veritabanı tablosunu oluşturmak için Sequelize'in CLI aracını kullanalım.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Bu tabloda aşağıdaki sütunlar bulunur:
- Kullanıcının e-posta adresi,
- Oluşturulan jeton,
- Bu jetonun sona ermesi,
- Simgenin kullanılıp kullanılmadığı.
Arka planda, sequelize-cli aşağıdaki SQL sorgusunu çalıştırıyor:
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;
SQL istemcinizi veya komut satırını kullanarak bunun düzgün çalıştığını doğrulayın:
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)
Güvenlik İpucu #3
Şu anda bir ORM kullanmıyorsanız, bunu yapmayı düşünmelisiniz. Bir ORM, SQL sorgularının yazılmasını ve uygun şekilde çıkarılmasını otomatikleştirerek, kodunuzu varsayılan olarak daha okunabilir ve daha güvenli hale getirir. SQL sorgularınızdan düzgün bir şekilde kaçarak SQL enjeksiyon saldırılarından kaçınmanıza yardımcı olurlar.
Şifre Rotasını Sıfırla Ayarla
user.js'de get rotasını oluşturun:
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Ardından, parola sıfırlama formu gönderildiğinde ulaşılan rota olan POST yolunu oluşturun. Aşağıdaki koda birkaç önemli güvenlik özelliği ekledim.
Güvenlik İpuçları #4-6
- Bir e-posta adresi bulamasak bile durumumuz olarak 'ok' döndürürüz. Veritabanımızda hangi e-postaların gerçek ve gerçek olmadığını bulan istenmeyen botlar istemiyoruz.
- Bir belirteçte ne kadar rastgele bayt kullanırsanız, saldırıya uğrama olasılığı o kadar az olur. Belirteç oluşturucumuzda 64 rastgele bayt kullanıyoruz (8'den az kullanmayın).
- Jetonun süresi 1 saat içinde sona erer. Bu, sıfırlama belirtecinin çalıştığı süreyi sınırlar.
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'}); });
Yukarıda atıfta bulunulan bir Kullanıcı değişkeni göreceksiniz - bu nedir? Bu öğreticinin amaçları doğrultusunda, değerleri almak için veritabanınıza bağlanan bir Kullanıcı modeliniz olduğunu varsayıyoruz. Yukarıdaki kod Sequelize'a dayanmaktadır, ancak doğrudan veritabanını sorgularsanız gerektiği gibi değiştirebilirsiniz (ancak Sequelize'i öneririm!).
Şimdi görünümü oluşturmamız gerekiyor. Bootstrap CSS, jQuery ve Node Express çerçevesinde yerleşik pug çerçevesini kullanarak, görünüm aşağıdaki gibi görünür:
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(); }); });
İşte sayfadaki form:
Bu noktada, veritabanınızda bulunan bir e-posta adresi ile formu doldurabilmeniz ve ardından bu adrese bir şifre sıfırlama e-postası alabilmeniz gerekir. Sıfırlama bağlantısını tıklamak henüz hiçbir şey yapmaz.
“Şifreyi Sıfırla” Rotasını Ayarlayın
Şimdi devam edelim ve iş akışının geri kalanını ayarlayalım.
Sequelize.Op modülünü rotanıza ekleyin:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Şimdi, parola sıfırlama bağlantısını tıklayan kullanıcılar için GET yolunu oluşturalım. Aşağıda göreceğiniz gibi, sıfırlama jetonunu uygun şekilde doğruladığımızdan emin olmak istiyoruz.
Güvenlik İpucu #7:
Yalnızca süresi dolmamış ve kullanılmamış sıfırlama jetonlarını aradığınızdan emin olun.
Gösteri amacıyla, tabloyu küçük tutmak için burada yüklenen tüm süresi dolmuş jetonları da temizliyorum. Büyük bir web siteniz varsa, bunu bir cronjob'a taşıyın.
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 }); });
Şimdi, kullanıcı yeni şifre ayrıntılarını doldurduğunda isabet eden POST yolunu oluşturalım.
8'den 11'e kadar güvenlik ipucu:
- Parolaların eşleştiğinden ve minimum gereksinimlerinizi karşıladığından emin olun.
- Kullanılmadığından ve süresinin dolmadığından emin olmak için sıfırlama jetonunu tekrar kontrol edin. Belirteç form aracılığıyla bir kullanıcı tarafından gönderildiği için tekrar kontrol etmemiz gerekiyor.
- Şifreyi sıfırlamadan önce jetonu kullanılmış olarak işaretleyin. Bu şekilde, öngörülemeyen bir şey olursa (örneğin sunucu çökmesi), belirteç hala geçerliyken parola sıfırlanmaz.
- Şifreli olarak güvenli bir rastgele tuz kullanın (bu durumda 64 rastgele bayt kullanırız).
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); } }); });
Bu şöyle görünmelidir:
Bağlantıyı Giriş Sayfanıza Ekleyin
Son olarak, giriş sayfanızdan bu akışa bir bağlantı eklemeyi unutmayın! Bunu yaptığınızda, çalışan bir parola sıfırlama akışına sahip olmalısınız. Her şeyin çalıştığını ve belirteçlerinizin kısa bir süre sona erdiğini ve iş akışı ilerledikçe doğru durumla işaretlendiğini doğrulamak için sürecin her aşamasında kapsamlı bir şekilde test ettiğinizden emin olun.
Sonraki adımlar
Umarım bu, güvenli, kullanıcı dostu bir parola sıfırlama özelliğini kodlama yolunda size yardımcı olmuştur.
- Kriptografik güvenlik hakkında daha fazla bilgi edinmekle ilgileniyorsanız, Wikipedia'nın özetini öneririm (uyarı, yoğun!).
- Uygulamanızın kimlik doğrulamasına daha da fazla güvenlik eklemek istiyorsanız 2FA'ya bakın. Orada birçok farklı seçenek var.
- Sizi kendi şifre sıfırlama akışınızı oluşturmaktan korkuttuysam, Google ve Facebook gibi üçüncü taraf giriş sistemlerine güvenebilirsiniz. PassportJS, bu stratejileri uygulayan NodeJS için kullanabileceğiniz bir ara katman yazılımıdır.