Membuat Arus Kata Sandi Aman Dengan NodeJS Dan MySQL
Diterbitkan: 2022-03-10Jika Anda seperti saya, Anda lupa kata sandi Anda lebih dari sekali, terutama di situs yang sudah lama tidak Anda kunjungi. Anda mungkin juga pernah melihat, dan/atau merasa malu dengan, menyetel ulang email sandi yang berisi sandi Anda dalam bentuk teks biasa.
Sayangnya, alur kerja reset password mendapat sedikit perhatian dan perhatian terbatas selama pengembangan aplikasi. Ini tidak hanya dapat menyebabkan pengalaman pengguna yang membuat frustrasi, tetapi juga dapat meninggalkan aplikasi Anda dengan lubang keamanan yang menganga.
Kita akan membahas bagaimana membangun alur kerja reset password yang aman. Kami akan menggunakan NodeJS dan MySQL sebagai komponen dasar kami. Jika Anda menulis menggunakan bahasa, kerangka kerja, atau database yang berbeda, Anda masih dapat mengambil manfaat dari mengikuti "Tips Keamanan" umum yang diuraikan di setiap bagian.
Alur reset password terdiri dari komponen berikut:
- Tautan untuk mengirim pengguna ke awal alur kerja.
- Formulir yang memungkinkan pengguna mengirimkan email mereka.
- Pencarian yang memvalidasi email dan mengirim email ke alamat tersebut.
- Email yang berisi token reset dengan masa berlaku yang memungkinkan pengguna untuk mereset kata sandi mereka.
- Formulir yang memungkinkan pengguna membuat kata sandi baru.
- Menyimpan kata sandi baru dan membiarkan pengguna masuk lagi dengan kata sandi baru.
Selain Node, Express & MySQL, kami akan menggunakan library berikut:
- Sekuel ORM
- Nodemailer
Sequelize adalah ORM database NodeJS yang memudahkan menjalankan migrasi database serta membuat kueri keamanan. Nodemailer adalah perpustakaan email NodeJS populer yang akan kami gunakan untuk mengirim email pengaturan ulang kata sandi.
Tip Keamanan #1
Beberapa artikel menyarankan alur kata sandi yang aman dapat dirancang menggunakan JSON Web Tokens (JWT), yang menghilangkan kebutuhan akan penyimpanan basis data (dan dengan demikian lebih mudah diimplementasikan). Kami tidak menggunakan pendekatan ini di situs kami, karena rahasia token JWT biasanya disimpan langsung dalam kode. Kami ingin menghindari 'satu rahasia' untuk mengatur semuanya (untuk alasan yang sama Anda tidak memberi garam kata sandi dengan nilai yang sama), dan karena itu perlu memindahkan informasi ini ke dalam database.
Instalasi
Pertama, instal Sequelize, Nodemailer, dan library terkait lainnya:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
Di rute tempat Anda ingin memasukkan alur kerja reset, tambahkan modul yang diperlukan. Jika Anda membutuhkan penyegaran di Express dan rute, lihat panduan mereka.
const nodemailer = require('nodemailer');
Dan konfigurasikan dengan kredensial SMTP email Anda.
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 } });
Solusi email yang saya gunakan adalah Layanan Email Sederhana AWS, tetapi Anda dapat menggunakan apa saja (Mailgun, dll).
Jika ini pertama kalinya Anda menyiapkan layanan pengiriman email, Anda perlu meluangkan waktu untuk mengonfigurasi Kunci Domain yang sesuai dan menyiapkan otorisasi. Jika Anda menggunakan Route 53 bersama dengan SES, ini sangat sederhana dan dilakukan secara virtual secara otomatis, itulah sebabnya saya memilihnya. AWS memiliki beberapa tutorial tentang cara kerja SES dengan Route53.
Tip keamanan #2
Untuk menyimpan kredensial dari kode saya, saya menggunakan dotenv, yang memungkinkan saya membuat file .env lokal dengan variabel lingkungan saya. Dengan begitu, ketika saya menerapkan ke produksi, saya dapat menggunakan kunci produksi yang berbeda yang tidak terlihat dalam kode, dan karena itu memungkinkan saya membatasi izin konfigurasi saya hanya untuk anggota tertentu dari tim saya.
Pengaturan Basis Data
Karena kita akan mengirimkan token reset ke pengguna, kita perlu menyimpan token tersebut dalam database.
Saya berasumsi Anda memiliki tabel pengguna yang berfungsi di database Anda. Jika Anda sudah menggunakan Sequelize, bagus! Jika tidak, Anda mungkin ingin memoles Sequelize dan Sequelize CLI.
Jika Anda belum menggunakan Sequelize di aplikasi Anda, Anda dapat mengaturnya dengan menjalankan perintah di bawah ini di folder root aplikasi Anda:
$ sequelize init
Ini akan membuat sejumlah folder baru di penyiapan Anda, termasuk migrasi dan model.
Ini juga akan membuat file konfigurasi. Di file konfigurasi Anda, perbarui blok development
dengan kredensial ke server database mysql lokal Anda.
Mari gunakan alat CLI Sequelize untuk menghasilkan tabel database untuk kita.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Tabel ini memiliki kolom berikut:
- Alamat email pengguna,
- Token yang telah dibuat,
- Kedaluwarsa token itu,
- Apakah token telah digunakan atau tidak.
Di latar belakang, sekuel-cli menjalankan kueri SQL berikut:
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;
Pastikan ini berfungsi dengan baik menggunakan klien SQL Anda atau baris perintah:
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)
Tip Keamanan #3
Jika saat ini Anda tidak menggunakan ORM, Anda harus mempertimbangkan untuk melakukannya. ORM mengotomatiskan penulisan dan pelepasan kueri SQL yang tepat, membuat kode Anda lebih mudah dibaca dan lebih aman secara default. Mereka akan membantu Anda menghindari serangan injeksi SQL dengan keluar dari kueri SQL Anda dengan benar.
Atur Atur Ulang Rute Kata Sandi
Buat rute get di user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Kemudian buat rute POST, yaitu rute yang terkena saat form reset password diposting. Dalam kode di bawah ini, saya telah menyertakan beberapa fitur keamanan penting.
Tips Keamanan #4-6
- Bahkan jika kami tidak menemukan alamat email, kami mengembalikan 'ok' sebagai status kami. Kami tidak ingin bot yang tidak diinginkan mencari tahu email apa yang asli vs tidak nyata di database kami.
- Semakin banyak byte acak yang Anda gunakan dalam token, semakin kecil kemungkinannya untuk diretas. Kami menggunakan 64 byte acak di generator token kami (jangan gunakan kurang dari 8).
- Kedaluwarsa token dalam 1 jam. Ini membatasi jendela waktu token reset bekerja.
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'}); });
Anda akan melihat variabel Pengguna yang dirujuk di atas — apa ini? Untuk keperluan tutorial ini, kami mengasumsikan Anda memiliki model Pengguna yang terhubung ke database Anda untuk mengambil nilai. Kode di atas didasarkan pada Sequelize, tetapi Anda dapat memodifikasi sesuai kebutuhan jika Anda menanyakan database secara langsung (tapi saya sarankan Sequelize!).
Kita sekarang perlu menghasilkan tampilan. Menggunakan Bootstrap CSS, jQuery, dan kerangka kerja pug yang dibangun ke dalam kerangka kerja Node Express, tampilannya terlihat seperti berikut:
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(); }); });
Berikut formulir di halaman:
Pada titik ini, Anda harus dapat mengisi formulir dengan alamat email yang ada di database Anda, dan kemudian menerima email reset kata sandi di alamat itu. Mengklik tautan setel ulang tidak akan menghasilkan apa-apa.
Atur Rute “Reset Kata Sandi”
Sekarang mari kita lanjutkan dan mengatur sisa alur kerja.
Tambahkan modul Sequelize.Op ke rute Anda:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Sekarang mari kita buat rute GET untuk pengguna yang telah mengklik tautan setel ulang kata sandi itu. Seperti yang akan Anda lihat di bawah, kami ingin memastikan bahwa kami memvalidasi token reset dengan tepat.
Tip Keamanan #7:
Pastikan Anda hanya mencari token reset yang belum kedaluwarsa dan belum digunakan.
Untuk tujuan demonstrasi, saya juga menghapus semua token kadaluarsa yang dimuat di sini untuk menjaga tabel tetap kecil. Jika Anda memiliki situs web besar, pindahkan ini ke 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 }); });
Sekarang mari kita buat rute POST yang menjadi hit setelah pengguna mengisi detail kata sandi baru mereka.
Tip keamanan #8 hingga 11:
- Pastikan kata sandi cocok dan memenuhi persyaratan minimum Anda.
- Periksa kembali token reset untuk memastikan belum digunakan dan belum expired. Kami perlu memeriksanya lagi karena token sedang dikirim oleh pengguna melalui formulir.
- Sebelum mengatur ulang kata sandi, tandai token sebagai digunakan. Dengan begitu, jika terjadi sesuatu yang tidak terduga (server crash, misalnya), kata sandi tidak akan direset selama token masih berlaku.
- Gunakan garam acak yang aman secara kriptografis (dalam hal ini, kami menggunakan 64 byte acak).
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); } }); });
Ini adalah apa yang seharusnya terlihat seperti:
Tambahkan Tautan Ke Halaman Login Anda
Terakhir, jangan lupa untuk menambahkan tautan ke alur ini dari halaman login Anda! Setelah Anda melakukan ini, Anda harus memiliki aliran kata sandi reset yang berfungsi. Pastikan untuk menguji secara menyeluruh pada setiap tahap proses untuk memastikan semuanya berfungsi dan token Anda memiliki masa berlaku yang singkat dan ditandai dengan status yang benar saat alur kerja berlangsung.
Langkah selanjutnya
Semoga ini membantu Anda dalam mengkodekan fitur reset kata sandi yang aman dan ramah pengguna.
- Jika Anda tertarik untuk mempelajari lebih lanjut tentang keamanan kriptografi, saya merekomendasikan ringkasan Wikipedia (peringatan, padat!).
- Jika Anda ingin menambahkan lebih banyak keamanan ke autentikasi aplikasi Anda, lihat 2FA. Ada banyak pilihan berbeda di luar sana.
- Jika saya membuat Anda takut untuk membuat alur pengaturan ulang kata sandi Anda sendiri, Anda dapat mengandalkan sistem masuk pihak ketiga seperti Google dan Facebook. PassportJS adalah middleware yang dapat Anda gunakan untuk NodeJS yang mengimplementasikan strategi ini.