Criando fluxos de senha segura com NodeJS e MySQL
Publicados: 2022-03-10Se você for como eu, esqueceu sua senha mais de uma vez, especialmente em sites que não visita há algum tempo. Você provavelmente também já viu e/ou ficou mortificado por e-mails de redefinição de senha que contêm sua senha em texto simples.
Infelizmente, o fluxo de trabalho de redefinição de senha recebe pouca atenção e atenção limitada durante o desenvolvimento do aplicativo. Isso não só pode levar a uma experiência de usuário frustrante, mas também pode deixar seu aplicativo com falhas de segurança.
Vamos abordar como criar um fluxo de trabalho de redefinição de senha seguro. Usaremos NodeJS e MySQL como nossos componentes básicos. Se você estiver escrevendo usando uma linguagem, estrutura ou banco de dados diferente, ainda poderá se beneficiar seguindo as "Dicas de segurança" gerais descritas em cada seção.
Um fluxo de redefinição de senha consiste nos seguintes componentes:
- Um link para enviar o usuário para o início do fluxo de trabalho.
- Um formulário que permite ao usuário enviar seu e-mail.
- Uma pesquisa que valida o email e envia um email para o endereço.
- Um email que contém o token de redefinição com uma expiração que permite ao usuário redefinir sua senha.
- Um formulário que permite ao usuário gerar uma nova senha.
- Salvando a nova senha e deixando o usuário logar novamente com a nova senha.
Além do Node, Express e MySQL, usaremos as seguintes bibliotecas:
- Sequela ORM
- Nodemailer
Sequelize é um ORM de banco de dados NodeJS que facilita a execução de migrações de banco de dados, bem como consultas de criação de segurança. Nodemailer é uma biblioteca de e-mail NodeJS popular que usaremos para enviar e-mails de redefinição de senha.
Dica de segurança nº 1
Alguns artigos sugerem que fluxos de senhas seguras podem ser projetados usando JSON Web Tokens (JWT), que eliminam a necessidade de armazenamento de banco de dados (e, portanto, são mais fáceis de implementar). Não usamos essa abordagem em nosso site, porque os segredos do token JWT geralmente são armazenados diretamente no código. Queremos evitar ter 'um segredo' para governá-los todos (pelo mesmo motivo que você não salga senhas com o mesmo valor) e, portanto, precisamos mover essas informações para um banco de dados.
Instalação
Primeiro, instale Sequelize, Nodemailer e outras bibliotecas associadas:
$ npm install --save sequelize sequelize-cli mysql crypto nodemailer
Na rota em que você deseja incluir seus fluxos de trabalho de redefinição, adicione os módulos necessários. Se você precisar de uma atualização no Expresso e rotas, confira o guia.
const nodemailer = require('nodemailer');
E configure-o com suas credenciais SMTP de 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 } });
A solução de e-mail que estou usando é o Simple Email Service da AWS, mas você pode usar qualquer coisa (Mailgun, etc).
Se esta for a primeira vez que configura seu serviço de envio de e-mail, você precisará gastar algum tempo configurando as chaves de domínio apropriadas e configurando as autorizações. Se você usa o Route 53 junto com o SES, isso é super simples e feito praticamente de forma automática, e é por isso que eu o escolhi. A AWS tem alguns tutoriais sobre como o SES funciona com o Route53.
Dica de segurança nº 2
Para armazenar as credenciais longe do meu código, uso dotenv, que me permite criar um arquivo .env local com minhas variáveis de ambiente. Dessa forma, ao implantar na produção, posso usar diferentes chaves de produção que não são visíveis no código e, portanto, permite restringir as permissões da minha configuração a apenas alguns membros da minha equipe.
Configuração do banco de dados
Como enviaremos tokens de redefinição para os usuários, precisamos armazenar esses tokens em um banco de dados.
Estou assumindo que você tem uma tabela de usuários em funcionamento em seu banco de dados. Se você já está usando o Sequelize, ótimo! Se não, você pode querer retocar o Sequelize e a CLI do Sequelize.
Se você ainda não usou o Sequelize em seu aplicativo, você pode configurá-lo executando o comando abaixo na pasta raiz do seu aplicativo:
$ sequelize init
Isso criará várias novas pastas em sua configuração, incluindo migrações e modelos.
Isso também criará um arquivo de configuração. Em seu arquivo de configuração, atualize o bloco de development
com as credenciais para seu servidor de banco de dados mysql local.
Vamos usar a ferramenta CLI do Sequelize para gerar a tabela do banco de dados para nós.
$ sequelize model:create --name ResetToken --attributes email:string,token:string,expiration:date,used:integer $ sequelize db:migrate
Esta tabela tem as seguintes colunas:
- Endereço de e-mail do usuário,
- Token que foi gerado,
- Expiração desse token,
- Se o token foi usado ou não.
Em segundo plano, o sequelize-cli está executando a seguinte 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 se isso funcionou corretamente usando seu cliente SQL ou a linha 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)
Dica de segurança nº 3
Se você não estiver usando um ORM no momento, considere fazê-lo. Um ORM automatiza a escrita e o escape adequado de consultas SQL, tornando seu código mais legível e mais seguro por padrão. Eles o ajudarão a evitar ataques de injeção de SQL, escapando adequadamente de suas consultas SQL.
Configurar rota de redefinição de senha
Crie a rota get em user.js :
router.get('/forgot-password', function(req, res, next) { res.render('user/forgot-password', { }); });
Em seguida, crie a rota POST, que é a rota que é atingida quando o formulário de redefinição de senha é postado. No código abaixo, incluí alguns recursos de segurança importantes.
Dicas de segurança nº 4-6
- Mesmo que não encontremos um endereço de e-mail, retornamos 'ok' como nosso status. Nós não queremos bots desagradáveis descobrindo quais e-mails são reais ou não reais em nosso banco de dados.
- Quanto mais bytes aleatórios você usar em um token, menor a probabilidade de ele ser hackeado. Estamos usando 64 bytes aleatórios em nosso gerador de token (não use menos de 8).
- Expirar o token em 1 hora. Isso limita a janela de tempo em que o token de redefinição funciona.
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'}); });
Você verá uma variável User referenciada acima — o que é isso? Para os propósitos deste tutorial, estamos assumindo que você tem um modelo de usuário que se conecta ao seu banco de dados para recuperar valores. O código acima é baseado no Sequelize, mas você pode modificar conforme necessário se consultar o banco de dados diretamente (mas eu recomendo o Sequelize!).

Agora precisamos gerar a view. Usando Bootstrap CSS, jQuery e o framework pug embutido no framework Node Express, a visualização se parece com o seguinte:
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(); }); });
Segue o formulário na página:

Neste ponto, você deve conseguir preencher o formulário com um endereço de e-mail que está em seu banco de dados e, em seguida, receber um e-mail de redefinição de senha nesse endereço. Clicar no link de redefinição ainda não fará nada.
Configurar a rota "Redefinir senha"
Agora vamos em frente e configurar o resto do fluxo de trabalho.
Adicione o módulo Sequelize.Op à sua rota:
const Sequelize = require('sequelize'); const Op = Sequelize.Op;
Agora vamos construir a rota GET para usuários que clicaram nesse link de redefinição de senha. Como você verá abaixo, queremos ter certeza de que estamos validando o token de redefinição adequadamente.
Dica de segurança nº 7:
Verifique se você está procurando apenas tokens de redefinição que não expiraram e não foram usados.
Para fins de demonstração, também elimino todos os tokens expirados em carga aqui para manter a tabela pequena. Se você tiver um site grande, mova-o para um 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 }); });
Agora vamos criar a rota POST, que é o que é atingido quando o usuário preenche seus novos detalhes de senha.
Dica de segurança nº 8 a 11:
- Certifique-se de que as senhas correspondam e atendam aos seus requisitos mínimos.
- Verifique o token de redefinição novamente para certificar-se de que não foi usado e não expirou. Precisamos verificar novamente porque o token está sendo enviado por um usuário por meio do formulário.
- Antes de redefinir a senha, marque o token como usado. Dessa forma, caso aconteça algum imprevisto (travamento do servidor, por exemplo), a senha não será redefinida enquanto o token ainda estiver válido.
- Use um sal aleatório criptograficamente seguro (neste caso, usamos 64 bytes aleatórios).
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); } }); });
Isto é como deve ser:

Adicione o link à sua página de login
Por fim, não se esqueça de adicionar um link para este fluxo na sua página de login! Depois de fazer isso, você deve ter um fluxo de senha de redefinição de trabalho. Certifique-se de testar minuciosamente em cada estágio do processo para confirmar que tudo funciona e seus tokens têm uma curta expiração e são marcados com o status correto à medida que o fluxo de trabalho avança.
Próximos passos
Espero que isso tenha ajudado você a codificar um recurso de redefinição de senha seguro e fácil de usar.
- Se você estiver interessado em aprender mais sobre segurança criptográfica, recomendo o resumo da Wikipedia (aviso, é denso!).
- Se você quiser adicionar ainda mais segurança à autenticação do seu aplicativo, consulte o 2FA. Existem muitas opções diferentes por aí.
- Se eu o assustei de criar seu próprio fluxo de redefinição de senha, você pode confiar em sistemas de login de terceiros, como Google e Facebook. PassportJS é um middleware que você pode usar para NodeJS que implementa essas estratégias.