Creación de flujos de contraseñas seguras con NodeJS y MySQL
Publicado: 2022-03-10Si eres como yo, has olvidado tu contraseña más de una vez, especialmente en sitios que no has visitado en mucho tiempo. Probablemente también haya visto, o se haya sentido mortificado por, correos electrónicos de restablecimiento de contraseña que contienen su contraseña en texto sin formato.
Desafortunadamente, el flujo de trabajo de restablecimiento de contraseña recibe poca atención y atención limitada durante el desarrollo de la aplicación. Esto no solo puede conducir a una experiencia de usuario frustrante, sino que también puede dejar su aplicación con enormes agujeros de seguridad.
Vamos a cubrir cómo crear un flujo de trabajo de restablecimiento seguro de contraseña. Usaremos NodeJS y MySQL como nuestros componentes base. Si está escribiendo utilizando un lenguaje, marco o base de datos diferente, aún puede beneficiarse de seguir los "Consejos de seguridad" generales que se describen en cada sección.
Un flujo de restablecimiento de contraseña consta de los siguientes componentes:
- Un enlace para enviar al usuario al inicio del flujo de trabajo.
- Un formulario que permite al usuario enviar su correo electrónico.
- Una búsqueda que valida el correo electrónico y envía un correo electrónico a la dirección.
- Un correo electrónico que contiene el token de restablecimiento con una caducidad que permite al usuario restablecer su contraseña.
- Un formulario que permite al usuario generar una nueva contraseña.
- Guardar la nueva contraseña y permitir que el usuario inicie sesión nuevamente con la nueva contraseña.
Además de Node, Express y MySQL, usaremos las siguientes bibliotecas:
- Secuela ORM
- Nodemailer
Sequelize es un ORM de base de datos de NodeJS que facilita la ejecución de migraciones de bases de datos, así como la creación de consultas de seguridad. Nodemailer es una biblioteca de correo electrónico popular de NodeJS que usaremos para enviar correos electrónicos de restablecimiento de contraseña.
Consejo de seguridad #1
Algunos artículos sugieren que los flujos de contraseñas seguras se pueden diseñar utilizando JSON Web Tokens (JWT), que eliminan la necesidad de almacenamiento en bases de datos (y, por lo tanto, son más fáciles de implementar). No usamos este enfoque en nuestro sitio, porque los secretos de los tokens JWT generalmente se almacenan directamente en el código. Queremos evitar tener 'un secreto' para gobernarlos a todos (por la misma razón que no saltas las contraseñas con el mismo valor) y, por lo tanto, necesitamos mover esta información a una base de datos.
Instalación
Primero, instale Sequelize, Nodemailer y otras bibliotecas asociadas:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
En la ruta en la que desea incluir sus flujos de trabajo de restablecimiento, agregue los módulos necesarios. Si necesita un repaso sobre Express y rutas, consulte su guía.
const nodemailer = require('nodemailer');
Y configúrelo con sus credenciales SMTP de correo electrónico.
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 solución de correo electrónico que estoy usando es el Servicio de correo electrónico simple de AWS, pero puede usar cualquier cosa (Mailgun, etc.).
Si es la primera vez que configura su servicio de envío de correo electrónico, deberá dedicar algún tiempo a configurar las claves de dominio adecuadas y configurar las autorizaciones. Si usa Route 53 junto con SES, esto es súper simple y se realiza de forma prácticamente automática, por eso lo elegí. AWS tiene algunos tutoriales sobre cómo funciona SES con Route53.
Consejo de seguridad #2
Para almacenar las credenciales lejos de mi código, uso dotenv, que me permite crear un archivo .env local con mis variables de entorno. De esa manera, cuando implemento en producción, puedo usar diferentes claves de producción que no están visibles en el código y, por lo tanto, me permite restringir los permisos de mi configuración solo a ciertos miembros de mi equipo.
Configuración de la base de datos
Dado que vamos a enviar tokens de reinicio a los usuarios, debemos almacenar esos tokens en una base de datos.
Supongo que tiene una tabla de usuarios en funcionamiento en su base de datos. Si ya estás usando Sequelize, ¡genial! De lo contrario, es posible que desee repasar Sequelize y Sequelize CLI.
Si aún no ha usado Sequelize en su aplicación, puede configurarlo ejecutando el siguiente comando en la carpeta raíz de su aplicación:
$ sequelize init
Esto creará una serie de nuevas carpetas en su configuración, incluidas las migraciones y los modelos.
Esto también creará un archivo de configuración. En su archivo de configuración, actualice el bloque de development
con las credenciales de su servidor de base de datos mysql local.
Usemos la herramienta CLI de Sequelize para generar la tabla de base de datos para nosotros.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Esta tabla tiene las siguientes columnas:
- Dirección de correo electrónico del usuario,
- Token que se ha generado,
- Caducidad de ese token,
- Si el token se ha utilizado o no.
En segundo plano, sequelize-cli ejecuta la siguiente consulta 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;
Verifique que esto funcionó correctamente usando su cliente SQL o la línea de comando:
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)
Consejo de seguridad #3
Si actualmente no está utilizando un ORM, debería considerar hacerlo. Un ORM automatiza la escritura y el escape adecuado de las consultas SQL, lo que hace que su código sea más legible y más seguro de forma predeterminada. Le ayudarán a evitar los ataques de inyección de SQL escapando correctamente de sus consultas SQL.
Configurar ruta de restablecimiento de contraseña
Cree la ruta de obtención en user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
A continuación, cree la ruta POST, que es la ruta que se activa cuando se publica el formulario de restablecimiento de contraseña. En el siguiente código, he incluido un par de funciones de seguridad importantes.
Consejos de seguridad #4-6
- Incluso si no encontramos una dirección de correo electrónico, devolvemos 'ok' como nuestro estado. No queremos que los bots indeseables descubran qué correos electrónicos son reales y no reales en nuestra base de datos.
- Cuantos más bytes aleatorios utilice en un token, es menos probable que pueda ser pirateado. Estamos usando 64 bytes aleatorios en nuestro generador de tokens (no use menos de 8).
- Caduca el token en 1 hora. Esto limita la ventana de tiempo en que funciona el token de reinicio.
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'}); });
Verá una variable de usuario a la que se hace referencia anteriormente. ¿Qué es esto? Para los fines de este tutorial, asumimos que tiene un modelo de usuario que se conecta a su base de datos para recuperar valores. El código anterior se basa en Sequelize, pero puede modificarlo según sea necesario si consulta la base de datos directamente (¡pero recomiendo Sequelize!).
Ahora necesitamos generar la vista. Usando Bootstrap CSS, jQuery y el marco pug integrado en el marco Node Express, la vista se ve así:
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(); }); });
Aquí está el formulario en la página:
En este punto, debería poder completar el formulario con una dirección de correo electrónico que esté en su base de datos y luego recibir un correo electrónico de restablecimiento de contraseña en esa dirección. Hacer clic en el enlace de reinicio no hará nada todavía.
Configurar la ruta "Restablecer contraseña"
Ahora sigamos adelante y configuremos el resto del flujo de trabajo.
Agregue el módulo Sequelize.Op a su ruta:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Ahora, construyamos la ruta GET para los usuarios que han hecho clic en el enlace para restablecer la contraseña. Como verá a continuación, queremos asegurarnos de que estamos validando el token de reinicio de manera adecuada.
Consejo de seguridad #7:
Asegúrese de buscar únicamente tokens de restablecimiento que no hayan caducado y que no se hayan utilizado.
Para fines de demostración, también elimino todos los tokens vencidos que se cargan aquí para mantener la mesa pequeña. Si tiene un sitio web grande, muévalo a 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 }); });
Ahora vamos a crear la ruta POST, que es lo que se activa una vez que el usuario completa los detalles de su nueva contraseña.
Consejo de seguridad del 8 al 11:
- Asegúrese de que las contraseñas coincidan y cumplan con los requisitos mínimos.
- Vuelva a comprobar el token de reinicio para asegurarse de que no se haya utilizado y no haya caducado. Necesitamos verificarlo nuevamente porque un usuario está enviando el token a través del formulario.
- Antes de restablecer la contraseña, marque el token como utilizado. De esa manera, si ocurre algo imprevisto (por ejemplo, un bloqueo del servidor), la contraseña no se restablecerá mientras el token siga siendo válido.
- Use una sal aleatoria criptográficamente segura (en este caso, usamos 64 bytes aleatorios).
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); } }); });
Así es como debería verse:
Agregue el enlace a su página de inicio de sesión
Por último, ¡no olvide agregar un enlace a este flujo desde su página de inicio de sesión! Una vez que haga esto, debería tener un flujo de restablecimiento de contraseña en funcionamiento. Asegúrese de realizar pruebas minuciosas en cada etapa del proceso para confirmar que todo funciona y que sus tokens tienen un vencimiento breve y se marcan con el estado correcto a medida que avanza el flujo de trabajo.
Próximos pasos
Con suerte, esto lo ayudó en su camino hacia la codificación de una función de restablecimiento de contraseña segura y fácil de usar.
- Si está interesado en aprender más sobre la seguridad criptográfica, le recomiendo el resumen de Wikipedia (¡advertencia, es denso!).
- Si desea agregar aún más seguridad a la autenticación de su aplicación, consulte 2FA. Hay muchas opciones diferentes por ahí.
- Si te he asustado de crear tu propio flujo de restablecimiento de contraseña, puedes confiar en los sistemas de inicio de sesión de terceros como Google y Facebook. PassportJS es un middleware que puede usar para NodeJS que implementa estas estrategias.