https://d226lax1qjow5r.cloudfront.net/blog/blogposts/trusted-group-authentication-with-sms-and-express/group-authentication.png

Autenticación de grupos de confianza con SMS y Express

Publicado el April 10, 2024

Tiempo de lectura: 12 minutos

Introducción

Trabajar en solitario en un proyecto puede resultar a veces tedioso y solitario. Por eso, colaborar con amigos puede hacer que el proceso sea mucho más agradable. Sin embargo, organizar reuniones y encontrar horarios convenientes para todos puede ser un engorro. Por suerte, existe una solución: crear una aplicación web que permita trabajar juntos en línea sin problemas.

Este tutorial te guiará en la creación de un flujo de autenticación simple usando Verify API de Vonage. Esto permitirá que tus amigos accedan de manera segura a la aplicación colaborativa sin tener que lidiar con complejas políticas de contraseñas o encriptación. En su lugar, aprovecharás sus números de teléfono y el sistema de verificación de Vonage para autenticarlos y mantenerlos conectados a través de cookies de sesión.

tl;drI si desea saltar y desplegar inmediatamente la aplicación, usted puede encontrar todos los el código necesario en GitHub.

Requisitos previos

Antes de sumergirnos, asegúrate de que tienes:

  1. Node.js y npm instalados

  2. Familiaridad básica con Express.js y SQLite

  3. A Cuenta API de Vonage

  4. Un número virtual de Vonage con funciones de SMS

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

Opcionalmente, puede utilizar una herramienta como ngrok para exponer públicamente tu servidor local de desarrollo durante las pruebas.

Puesta en marcha

Empieza creando un nuevo proyecto Node.js e instalando los paquetes necesarios:

npm init npm install express sqlite3 connect-sqlite3 cookie-parser express-session @vonage/server-sdk

Esto añadirá Express, soporte de base de datos SQLite3, middleware para sesiones/cookies, y la librería cliente API de Vonage.

A continuación, crea un archivo .env y añade tus credenciales de Vonage y tu número virtual:

API_KEY="YOUR_API_KEY" API_SECRET="YOUR_API_SECRET" APP_NUM="YOUR_VIRTUAL_NUMBER"

También querrás generar una clave secreta para el almacenamiento de tu sesión:

SESH_SECRET="a_very_complex_random_string"

Establece cualquier otra variable de entorno que puedas necesitar, como un código de invitación, el dominio del proyecto, el ID de la aplicación de Vonage, etc.

ADMINS="kelly,michelle,beyonce" INVITE_CODE="code123" PRIVATE_KEY_PATH=private.key APP_ID=49f7d24b-42343ds-vs234-vxcsfds PORT=3003

Configurar Express

En su archivo de servidor principal (por ejemplo, app.js), requiera Express y configure el servidor básico:

const express = require('express');
const app = express();
const port = 3000;
 
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

A continuación, añada middleware para analizar solicitudes JSON, gestionar sesiones y habilitar el almacenamiento de sesiones SQLite3:

// parse client requests in JSON
app.use(express.json());
 
// install packages to do session management
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
 
// configure automatic session storage in SQLite db
app.use(require('cookie-parser')());
app.use(session({
  store: new SQLiteStore,
  secret: process.env.SESH_SECRET || 'your-secret-key-here', // Provide a secret option
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } // 1 week
}));

No olvides inicializar el objeto Vonage Server SDK con tus credenciales de API:

const Vonage = require('@vonage/server-sdk');
const vonage = new Vonage({
  apiKey: process.env.API_KEY,
  apiSecret: process.env.API_SECRET, 
  applicationId: process.env.APP_ID,
  privateKey: process.env.PRIVATE_KEY_PATH,
});

Configuración de la base de datos SQLite

Necesitarás algunas tablas SQLite para almacenar los datos de los usuarios y las sesiones durante el flujo de autenticación:

const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database(':memory:');
 
db.serialize(function(){
  if (!exists) {
    db.run('CREATE TABLE Sessions (phone NUMERIC, id TEXT)');
    db.run('CREATE TABLE allowlist (phone NUMERIC, username TEXT)');
    db.run('CREATE TABLE Authors (username TEXT)');
  }
});

La tabla Sessions contiene temporalmente los números de teléfono de los usuarios y los ID de solicitud de Vonage durante la verificación. El sitio Allowlist realiza un seguimiento de las inscripciones permitidas, opcionalmente con un nombre de usuario preaprobado. Authors es la lista persistente de nombres de usuario autenticados.

Rutas de las manijas

Rutas para sus vistas

Su servidor ya tiene una ruta para la página principal en /. Debajo de ella, puedes añadir dos rutas más para una página de registro o inicio de sesión para tus usuarios y una página de administración para ti:

// View Routes
app.get('/', function(request, response) {
  response.sendFile(__dirname + '/views/index.html');
});
 
app.get('/signup', function(request, response) {
  response.sendFile(__dirname + '/views/signup.html');
});
 
app.get('/admin', function(request, response) {
  if (isAdmin(request.session)) {
    response.sendFile(__dirname + '/views/admin.html');
  } else {
    response.sendFile(__dirname + '/views/index.html'); 
  }
});

Para empezar a gestionar el acceso a la funcionalidad de administración, también debe declarar la función isAdmin a la que se hace referencia en la ruta /admin ruta. Dividirá tu lista de administradores de .env en una matriz y buscará una coincidencia exacta con el nombre de usuario en la sesión actual:

function isAdmin(sesh) {
  let admins = process.env.ADMINS.split(',');
  return admins.includes(sesh.username);
}

El punto final Admin

El primer paso para añadir un usuario es que el administrador añada su número de teléfono a una lista de permitidos. Como es posible que la misma persona quiera iniciar sesión desde distintos dispositivos (lo que requeriría cookies de sesión adicionales), o que su sesión caduque, el administrador puede asociar opcionalmente el número de teléfono a un nombre de usuario existente.

Para empezar, declara el endpoint en /invite y añade una comprobación de seguridad para asegurarte de que esta persona sigue siendo un administrador:

app.post('/invite', function(request, response) {
  if (!isAdmin(request.session)) {
    response.status(500).send({message: "Sorry, you're not an admin"});
    return;
  }
 
});

Si la persona que intenta añadir una invitación no es un administrador, la solicitud debería fallar.

Lo siguiente que hará la función es obtener el número de teléfono de la solicitud. Tras una ligera comprobación de validez, se añade a la lista de permitidos. Si el administrador ha especificado un nombre de usuario, también se añadirá:

app.post('/invite', function(request, response) {
  if (!isAdmin(request.session)) {
    ...
  }
  let phone = request.body.phone;
  if (!isNaN(phone)) {
    if (request.body.username) {
      db.run('INSERT INTO Allowlist (phone, username) VALUES ($phone, $user)', {
        $phone: phone,
        $user: request.body.username
      });
    } else {
      db.run('INSERT INTO Allowlist (phone) VALUES ($phone)', {
        $phone: phone
      });
    }
 
  }
});

Una vez añadido el nuevo número de teléfono a la lista de permitidos, lo último que hay que hacer es enviar un SMS de invitación al nuevo usuario. El texto será enviado por el número de teléfono que ha guardado en .envy el usuario recibirá el código de invitación actual para responder.

Podría omitir este paso por completo y enviar el PIN de verificación. Sin embargo, esto te permite proporcionar cualquier información contextual de la que el usuario pueda beneficiarse, como la URL de inscripción. Como los PIN de Verify de Vonage solo son válidos por cinco minutos, también ayuda a garantizar que el PIN del destinatario no caduque antes de que lo vea:

app.post('/invite', function(request, response) {
  if (!isAdmin(request.session)) {
    ...
  }
  let phone = request.body.phone;
  if (!isNaN(phone)) {
    if (request.body.username) {
      ...
    } else {
      ...
    }
    vonage.messages.send(
      new SMS(
        `Please reply to this message with "${process.env.INVITE_CODE}" to get your PIN.`,
        phone,
        process.env.APP_NUM,
      ),
    );
  }
});

El punto final Webhook

Antes de poder recibir textos entrantes con la API Messages API de Vonage, debemos configurar los ajustes de nuestra aplicación en el panel de Vonage. Compraremos un número virtual y estableceremos la URL de Inbound Webhook bajo la configuración del número en nuestra URL ngrok local que expone nuestro servidor Node.js.

Por ejemplo, podemos establecer la URL del Webhook de entrada en algo como http://1234abc.ngrok.io/answer para enviar mensajes entrantes a nuestro endpoint local /answer.

Una vez configurado el número de teléfono, puede añadir la lógica para el punto final del webhook. Comprobarás que el texto contiene el código de invitación actual y que el número de teléfono del que procede está en la lista permitida. Si se cumplen estas condiciones, enviarás una solicitud de verificación y guardarás el número de teléfono y el ID que obtengas como respuesta en tu base de datos de Sesiones:

app.post('/answer', function(request, response) {
  let from = request.body.from;
  if (request.body.text === process.env.INVITE_CODE) {
    db.all('SELECT * from Allowlist WHERE phone = $from', 
      {$from: from}, 
      function(err, rows) {
      if (rows.length) {
        vonage.verify.request({
          number: from,
          brand: process.env.PROJECT_DOMAIN
        }, (err, result) => {
          db.run('INSERT INTO Sessions (phone, id) VALUES ($phone, $id)', {
            $phone: from,
            $id: result.request_id
          });
          response.status(204).end();
        });
      }
    });
  }
});

Esta vez, el nuevo usuario recibirá un texto generado automáticamente por Vonage Verify que contiene su PIN. Has proporcionado el número de teléfono de tu aplicación y el dominio en ngrok para identificarte, pero aparte de eso, el texto es repetitivo. Tiene que haber algo para que el usuario responda en este mensaje. Con un registro almacenado, esperaremos a que completen el paso final a través de la aplicación web.

El punto final de registro o inicio de sesión

El nuevo usuario enviará su número de teléfono, nombre de usuario y PIN desde el cliente web. Sólo se almacenará el nombre de usuario. Los otros valores son para el proceso de autenticación, y si este inicio de sesión tiene éxito, los eliminaremos del almacén de datos.

Añade un nuevo /login a tu servidor, y como primer paso, realiza una rápida validación del nombre de usuario. El ejemplo aquí sólo permite caracteres básicos, lo que puede estar bien para sus propósitos, o puede que desee un conjunto más robusto de opciones. Con eso validado, encontrarás la sesión para el número de teléfono suministrado:

app.post('/login', function(request, response) {
  let allowed = RegExp('[A-Za-z0-9_-]+');
  let username = request.body.username;
  if (!allowed.test(username)) {
    return;
  }
  db.each('SELECT * FROM Sessions WHERE phone = $phone', {
    $phone: request.body.phone
  }, function(error, sesh) {
 
  }); 
});

Dentro de la devolución de llamada que proporciona la fila de sesión, realizará otra comprobación del nombre de usuario: esta vez comprobará si ya está en uso y, en caso afirmativo, si este número de teléfono está autorizado a iniciar sesión con él:

app.post('/login', function(request, response) {
  ...
  db.each('SELECT * FROM Sessions WHERE phone = $phone',{
    $phone: request.body.phone
  }, function(error, sesh) {
 
    let broken = false;
    db.all('SELECT * FROM Authors WHERE username = $user', {
      $user: username
    }, function(err, rows) {
 
      if (rows.length) {
        db.all('SELECT * FROM Allowlist WHERE username = $user AND phone = $phone', {
          $user: username,
          $phone: sesh.phone
        }, function(e, r) {
          if (e || !r.length) broken = true; 
        });
      }   
    });
 
    if (!broken) {
 
    }
  }); 
});

Si todas las comprobaciones del nombre de usuario son correctas, utilizarás un indicador para confirmar que está bien continuar y confirmar que el PIN recibido del cliente es correcto para esta solicitud de verificación. Si lo es, obtendrás un estado 0 en la respuesta y podrás borrar con seguridad la sesión y los registros de la lista de permisos que utilizaste durante este proceso. El último paso es poner el nombre de usuario en la sesión:

app.post('/login', function (request, response) {
  let allowed = RegExp('[A-Za-z0-9_-]+');
  let username = request.body.username;
  if (!allowed.test(username)) {
    response.status(500).send({ message: 'Please use basic characters for your username' });
    return;
  }
  db.each('SELECT * FROM Sessions WHERE phone = $phone', {
    $phone: request.body.phone
  }, function (error, sesh) {
 
    db.all('SELECT * FROM Authors WHERE username = $user', { $user: username }, function (err, rows) {
      if (rows.length) {
        db.all('SELECT * FROM Allowlist WHERE username = $user AND phone = $phone', {
          $user: username,
          $phone: sesh.phone
        }, function (e, r) {
          if (e || !r.length) {
            response.status(500).send({ message: 'Please choose a different username' });
            return;
          }
        });
      }
 
      vonage.verify.check(sesh.id, request.body.pin)
        .then(result => {
          if (result && result.status === '0') {
            db.serialize(function () {
              db.run('INSERT INTO Authors (username) VALUES ($user)', {
                $user: username
              });
              db.run('DELETE FROM Allowlist WHERE phone = $phone', {
                $phone: sesh.phone
              });
              db.run('DELETE FROM Sessions WHERE phone = $phone', {
                $phone: sesh.phone
              });
            });
            request.session.username = username;
            response.status(200).send({ message: "Success" });
          }
        })
        .catch(err => {
          // handle errors
          console.error(err);
 
          if (err) {
            console.log('Error occurred:', err);
            response.status(500).send({ message: 'Error verifying your info' });
          }
        });
    });
  });
});

Añada algunas marcas

Para recopilar datos del lado del cliente, necesitarás dos formularios similares: un formulario de administración y un formulario de registro. El formulario de administración enviará invitaciones a nuevos usuarios, y el formulario de registro creará nuevas sesiones. Tu página index.html será una página de aterrizaje para tu proyecto; puedes usarla para proporcionar cualquier información o funcionalidad que desees. Sin embargo, copiar su contenido a admin.html y signup.html puede ser útil para que tengas tu andamiaje en su lugar.

Dentro de la etiqueta <main> de admin.html, sustituya el código HTML por un sencillo formulario para obtener un número de teléfono y un nombre de usuario:

<main>
  <h2>
    Invite people to your application  
  </h2>
 
  <form action="/invite" method="post">
    <label>Phone number:
      <input type="phone" id="phone" name="phone" />
    </label>
 
    <label>Username (optional):
      <input type="text" id="username" name="username" />
    </label>
 
    <input type="submit" value="Invite" id="invite_btn" />
    <h3 id="feedback"></h3>
  </form>
</main>

El contenido de <main> en signup.html debería ser muy similar, salvo que allí también recogerá un PIN:

<main>
  <h2>
    Sign up or log in
  </h2>
 
  <form action="/login" method="post">
    <label>Phone number:
      <input type="phone" id="phone" name="phone" />
    </label>
 
    <label>Username:
      <input type="text" id="username" name="username" />
    </label>
 
    <label>PIN:
      <input type="text" id="pin" name="pin" />
    </label>
        <input type="submit" value="Sign up" id="signup_btn" />
        <h3 id="feedback"></h3>
      </form>
</main>

Manteniendo la estructura HTML por defecto, seguirá enlazando client.js en ambas páginas. Dado que los formularios de estas páginas comparten un diseño similar, un único archivo de script, client.jspuede gestionar eficazmente su funcionalidad. Después de restablecer client.js a un estado en blanco, es el lugar perfecto para implementar su script personalizado.

Dentro de este script, empieza por identificar y recoger los elementos del formulario cruciales para la interacción. A continuación, detecta los botones de envío en ambos formularios y establece escuchas de eventos de clic. Estos escuchadores invocarán una función común que gestiona el envío de los datos del formulario al servidor a la vez que intercepta y previene el comportamiento estándar de envío de formularios. Aunque es posible gestionar el envío de formularios únicamente con HTML, el uso de JavaScript para esta tarea ofrece una mayor flexibilidad, especialmente a medida que la aplicación evoluciona y exige una gestión más sofisticada.

let phone = document.querySelector('#phone');
let username = document.querySelector('#username');
let feedback = document.querySelector('#feedback');

// Invite Form
let invite_btn = document.querySelector('#invite_btn');
if (invite_btn) {
  invite_btn.onclick = function(e) {
    e.preventDefault();
    let body = JSON.stringify({
      phone: phone.value,
      username: username.value
    });
    goFetch('/invite', body, e.target);
    return false;
  };
}

// Sign Up From
let signup_btn = document.querySelector('#signup_btn');
if (signup_btn) {
  signup_btn.onclick = function(e) {
    e.preventDefault();
    let body = JSON.stringify({
      phone: phone.value,
      username: username.value,
      pin: document.querySelector('#pin').value
    });
    goFetch('/login', body, e.target);
    return false;
  };
}

function goFetch(url, body, btn) {
  fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: body
  })
  .then(response => response.json())
  .then(data => {
    feedback.innerText = data.message || 'Thank you!';
    btn.style.display = 'none';
  });
}

Ejecutar el proyecto

Para iniciar el proyecto, ejecuta 'npm run start' desde tu terminal en el mismo puerto donde tienes ngrok ejecutándose.

Nota: Tu aplicación está lista, pero hay un problema: necesitas derechos de administrador para invitar a otros, pero para obtener estos derechos necesitas una invitación. Solucionar este problema podría implicar la creación de una solución para los desarrolladores (como tú) con acceso completo al código. Una solución rápida es modificar la función isAdmin de su código a return truepermitiendo temporalmente el acceso sin restricciones.

Conclusión y próximos pasos

¡Has llegado al final de este tutorial! Añadir la gestión de errores mejorará las interacciones del usuario, como la selección de un nombre de usuario ya utilizado o la introducción de caracteres no válidos. Además, el establecimiento de puntos finales de gestión de usuarios, junto con estrategias para gestionar las listas de permisos y ampliar la duración de las sesiones en función de la actividad del usuario o mediante una función de renovación, facilitará el acceso continuo al tiempo que minimizará la dependencia de las verificaciones periódicas por SMS.

El código código de este tutorial está disponible en GitHub.

Para estar al día de las últimas novedades, conéctate con nosotros en nuestra Desarrolladoresen X, antes conocido como Twittery en eventos.

Compartir:

https://a.storyblok.com/f/270183/400x400/3f6b0c045f/amanda-cavallaro.png
Amanda CavallaroDefensor del Desarrollador