tutoriales.com

Aprovechando la API de Credenciales Web en PWAs: Autenticación sin Contraseña

Descubre cómo integrar la API de Credenciales Web (WebAuthn) en tus Progressive Web Apps (PWAs) para proporcionar una autenticación sin contraseña robusta y segura. Este tutorial te guiará a través de los conceptos clave de WebAuthn, su implementación práctica y cómo mejora la experiencia del usuario y la seguridad.

Intermedio18 min de lectura17 views
Reportar error

🚀 Introducción a la Autenticación sin Contraseña en PWAs con WebAuthn

Las contraseñas han sido durante mucho tiempo la base de la seguridad en línea, pero también son un punto débil notorio. Son difíciles de recordar, propensas a ser robadas o filtradas, y a menudo mal gestionadas por los usuarios. Aquí es donde entra en juego la autenticación sin contraseña, y en el mundo de las Progressive Web Apps (PWAs), la API de Credenciales Web (WebAuthn) es la tecnología clave para hacerla realidad.

WebAuthn, parte de la iniciativa FIDO2, permite a los usuarios autenticarse en aplicaciones web utilizando métodos criptográficos fuertes, a menudo a través de autenticadores de hardware (como llaves de seguridad USB o lectores de huellas dactilares integrados) o biométricos (reconocimiento facial, huella dactilar) en sus dispositivos. Esto no solo mejora drásticamente la seguridad al eliminar el riesgo de robo de contraseñas, sino que también ofrece una experiencia de usuario mucho más fluida y conveniente.

En este tutorial, exploraremos en profundidad cómo puedes integrar WebAuthn en tus PWAs para crear una experiencia de inicio de sesión moderna, segura y sin fricciones. ¡Prepárate para decir adiós a las contraseñas!


🔑 ¿Qué es WebAuthn y Por Qué es Importante para las PWAs?

La API de Credenciales Web (WebAuthn) es un estándar web que permite la autenticación criptográfica de claves públicas. Es un componente central del conjunto de estándares FIDO2, cuyo objetivo es reducir la dependencia de las contraseñas. Funciona emparejando un usuario con un autenticador (un dispositivo o software que genera claves criptográficas) que puede confirmar su identidad.

✅ Beneficios de WebAuthn en PWAs:

  • Seguridad Mejorada: Elimina los riesgos asociados a las contraseñas, como el phishing, el keylogging y los ataques de fuerza bruta. Las credenciales WebAuthn son específicas del sitio web y no pueden ser reutilizadas.
  • Experiencia de Usuario Superior: Los usuarios pueden iniciar sesión con un solo toque o un escaneo biométrico, sin necesidad de recordar o escribir contraseñas complejas. Esto es especialmente beneficioso en dispositivos móviles, donde escribir contraseñas puede ser engorroso.
  • Resistencia al Phishing: Dado que la credencial está vinculada al dominio del sitio web, no puede ser utilizada en sitios falsos (phishing).
  • Estándar Abierto: Es un estándar de la W3C, lo que garantiza su interoperabilidad y soporte en la mayoría de los navegadores modernos y sistemas operativos.
  • Idóneo para PWAs: Complementa perfectamente la filosofía de las PWAs de ofrecer una experiencia nativa y de alto rendimiento, extendiendo esa calidad a la autenticación.
🔥 Importante: WebAuthn no solo reemplaza las contraseñas, sino que eleva el estándar de seguridad de la autenticación al aprovechar la criptografía de clave pública, haciendo los ataques de phishing y robo de credenciales mucho más difíciles.

💡 ¿Cómo Funciona WebAuthn? Conceptos Clave

WebAuthn se basa en un modelo de clave pública/privada. Cuando un usuario se registra con WebAuthn, el autenticador crea un par de claves: una clave privada que permanece segura en el dispositivo del usuario y una clave pública que se envía al servidor y se almacena allí. Para iniciar sesión, el autenticador usa la clave privada para firmar un desafío enviado por el servidor, demostrando así la posesión de la clave.

Componentes principales:

  • Autenticador (Authenticator): El dispositivo de hardware (ej. YubiKey) o software (ej. lector de huellas dactilares del teléfono) que gestiona las claves y realiza las operaciones criptográficas.
  • Parte Confiable (Relying Party - RP): Tu PWA y el servidor backend que la soporta. Es la entidad que confía en el autenticador para verificar la identidad del usuario.
  • Cliente (Client): El navegador web que facilita la comunicación entre la PWA y el autenticador.
PWA (Cliente) Autenticador Servidor RP 1. Inicia registro 2. Pide Challenge 3. Genera y envía Challenge 4. Pasa Challenge 5. Genera par de claves 6. Firma + Clave Púb + ID 7. Envía credenciales 8. Verifica firma y guarda datos Registro Exitoso

🛠️ Configuración Inicial: Backend y Frontend

Para implementar WebAuthn, necesitarás tanto lógica en el frontend (tu PWA) como en el backend (tu servidor). El backend es crucial para generar desafíos criptográficos, verificar las respuestas del cliente y almacenar las claves públicas de los usuarios.

📦 Requisitos del Backend

El backend debe ser capaz de:

  1. Generar desafíos: Strings aleatorias criptográficamente seguras para prevenir ataques de replay.
  2. Verificar credenciales: Decodificar y verificar las firmas criptográficas recibidas del cliente.
  3. Almacenar credenciales: Guardar la clave pública y el credentialId asociado a cada usuario.

Existen muchas librerías y frameworks que simplifican la implementación de WebAuthn en el backend. Algunos ejemplos populares incluyen web-auth/web-authn-lib para PHP, py_webauthn para Python, o node-webauthn para Node.js.

// Ejemplo simplificado de un endpoint de registro de WebAuthn en el backend (Node.js con una librería)
const express = require('express');
const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');

const app = express();
app.use(express.json());

const rpName = 'Mi PWA Sin Contraseña'; // Nombre de la Relying Party
const rpID = 'localhost'; // Dominio de la Relying Party (usar tu dominio real en producción)
const origin = `http://${rpID}:3000`; // Origen de la PWA
const users = new Map(); // Almacén de usuarios (en producción usar DB)

app.post('/generate-registration-options', async (req, res) => {
  const { username } = req.body;
  if (!username) {
    return res.status(400).send('Username es requerido');
  }

  let user = users.get(username);
  if (!user) {
    user = { id: Date.now().toString(), username, authenticator: [] };
    users.set(username, user);
  }

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: user.username,
    attestationType: 'none',
    excludeCredentials: user.authenticator.map(authenticator => ({
      id: authenticator.credentialID,
      type: 'public-key',
      transports: authenticator.transports,
    })),
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      residentKey: 'required',
      userVerification: 'preferred',
    },
  });

  // Guardar el desafío en la sesión del usuario para verificación posterior
  user.currentChallenge = options.challenge;
  res.json(options);
});

app.post('/verify-registration', async (req, res) => {
  const { username, attResp } = req.body;
  const user = users.get(username);

  if (!user || !user.currentChallenge) {
    return res.status(400).send('Desafío no encontrado o usuario no válido.');
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: attResp,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      requireUserVerification: false,
    });
  } catch (error) {
    console.error(error);
    return res.status(400).send({ error: error.message });
  }

  const { verified, registrationInfo } = verification;
  if (verified && registrationInfo) {
    const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } = registrationInfo;

    const newAuthenticator = {
      credentialID: credentialID.toString('base64'),
      credentialPublicKey: credentialPublicKey.toString('base64'),
      counter: counter,
      transports: attResp.response.transports,
      credentialDeviceType: credentialDeviceType,
      credentialBackedUp: credentialBackedUp,
    };
    user.authenticator.push(newAuthenticator);
    user.currentChallenge = undefined; // Limpiar el desafío
    return res.send('Registro exitoso!');
  } else {
    return res.status(500).send('Error de verificación.');
  }
});

// ... endpoints para login ...

app.listen(3001, () => console.log('Backend WebAuthn escuchando en el puerto 3001'));
📌 Nota: En un entorno de producción, nunca uses un `Map()` para almacenar datos de usuario. Utiliza una base de datos segura y persistente. Además, asegúrate de que tu PWA se sirva a través de HTTPS, ya que WebAuthn solo funciona en contextos seguros.

🌐 Lógica del Frontend (PWA)

En el frontend, utilizarás la API navigator.credentials para interactuar con el autenticador del usuario. Esta API es la puerta de entrada a WebAuthn en el navegador.

Flujo de registro:

  1. El usuario hace clic en "Registrarse sin contraseña".
  2. La PWA solicita opciones de registro al backend.
  3. El backend devuelve un objeto de opciones (PublicKeyCredentialCreationOptions) que incluye un desafío.
  4. La PWA llama a navigator.credentials.create() con estas opciones.
  5. El navegador solicita al usuario que interactúe con su autenticador (ej. toque la llave de seguridad, escanee la huella dactilar).
  6. El autenticador genera el par de claves, firma el desafío y devuelve la respuesta al navegador.
  7. La PWA recibe la PublicKeyCredential y la envía al backend para su verificación y almacenamiento.
// Ejemplo simplificado de registro de WebAuthn en el frontend de la PWA
async function registerWebAuthn(username) {
  try {
    // 1. Obtener opciones de registro del backend
    const response = await fetch('/generate-registration-options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username }),
    });
    const options = await response.json();

    // Convertir Uint8Array en base64url para las opciones recibidas del servidor
    options.challenge = Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0));
    options.user.id = Uint8Array.from(atob(options.user.id), c => c.charCodeAt(0));
    options.excludeCredentials = options.excludeCredentials.map(cred => ({
      ...cred,
      id: Uint8Array.from(atob(cred.id), c => c.charCodeAt(0)),
    }));

    // 2. Crear la credencial utilizando navigator.credentials.create
    const credential = await navigator.credentials.create({
      publicKey: options,
    });

    // 3. Enviar la respuesta de registro al backend para verificación
    const attestationResponse = {
      id: credential.id,
      rawId: Array.from(new Uint8Array(credential.rawId)),
      response: {
        clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
        attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
      },
      type: credential.type,
    };

    const verifyResponse = await fetch('/verify-registration', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, attResp: attestationResponse }),
    });

    if (verifyResponse.ok) {
      alert('¡Registro WebAuthn exitoso!');
    } else {
      const errorData = await verifyResponse.json();
      alert(`Error en el registro: ${errorData.error || verifyResponse.statusText}`);
    }

  } catch (error) {
    console.error('Error durante el registro WebAuthn:', error);
    alert(`Error de registro: ${error.message}`);
  }
}

// Ejemplo de botón en el HTML de la PWA
// <button onclick="registerWebAuthn('usuarioEjemplo')">Registrarse con WebAuthn</button>
💡 Consejo: Para simplificar la conversión de datos entre el frontend y el backend (especialmente `Uint8Array` y strings codificadas en base64url), considera usar librerías cliente-servidor compatibles como `@simplewebauthn/browser` y `@simplewebauthn/server`.

🔐 Flujo de Autenticación (Login) sin Contraseña

Una vez que el usuario se ha registrado con WebAuthn, el proceso de inicio de sesión es aún más sencillo.

Flujo de autenticación:

  1. El usuario indica que quiere iniciar sesión (ej. hace clic en un botón).
  2. La PWA solicita opciones de autenticación al backend (opciones que incluyen un desafío y los credentialId registrados para ese usuario).
  3. El backend devuelve un objeto de opciones (PublicKeyCredentialRequestOptions).
  4. La PWA llama a navigator.credentials.get() con estas opciones.
  5. El navegador solicita al usuario que autentique su identidad (ej. con un sensor biométrico o una llave de seguridad).
  6. El autenticador firma el desafío con la clave privada correspondiente y devuelve la respuesta.
  7. La PWA recibe la PublicKeyCredential y la envía al backend para su verificación.
  8. El backend verifica la firma y, si es válida, autentica al usuario.
// Ejemplo simplificado de login de WebAuthn en el backend (Node.js con una librería)
const { generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');

// ... (código anterior del backend)

app.post('/generate-authentication-options', async (req, res) => {
  const { username } = req.body;
  const user = users.get(username);

  if (!user || user.authenticator.length === 0) {
    return res.status(400).send('Usuario no encontrado o no tiene autenticadores registrados.');
  }

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: user.authenticator.map(authenticator => ({
      id: Buffer.from(authenticator.credentialID, 'base64'),
      type: 'public-key',
      transports: authenticator.transports,
    })),
    userVerification: 'preferred',
  });

  user.currentChallenge = options.challenge;
  res.json(options);
});

app.post('/verify-authentication', async (req, res) => {
  const { username, authResp } = req.body;
  const user = users.get(username);

  if (!user || !user.currentChallenge) {
    return res.status(400).send('Desafío no encontrado o usuario no válido.');
  }

  let dbAuthenticator = user.authenticator.find(
    (authenticator) => authenticator.credentialID === authResp.id
  );

  if (!dbAuthenticator) {
    return res.status(400).send('Credencial no encontrada para este usuario.');
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: authResp,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      authenticator: dbAuthenticator,
      requireUserVerification: false,
    });
  } catch (error) {
    console.error(error);
    return res.status(400).send({ error: error.message });
  }

  const { verified, authenticationInfo } = verification;
  if (verified) {
    const { newCounter } = authenticationInfo;

    // Actualizar el contador del autenticador en la DB
    dbAuthenticator.counter = newCounter;
    user.currentChallenge = undefined;
    return res.send('Autenticación exitosa!');
  } else {
    return res.status(500).send('Error de verificación.');
  }
});

app.listen(3001, () => console.log('Backend WebAuthn escuchando en el puerto 3001'));
// Ejemplo simplificado de login de WebAuthn en el frontend de la PWA
async function loginWebAuthn(username) {
  try {
    // 1. Obtener opciones de autenticación del backend
    const response = await fetch('/generate-authentication-options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username }),
    });
    const options = await response.json();

    // Convertir Uint8Array en base64url para las opciones recibidas del servidor
    options.challenge = Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0));
    options.allowCredentials = options.allowCredentials.map(cred => ({
      ...cred,
      id: Uint8Array.from(atob(cred.id), c => c.charCodeAt(0)),
    }));

    // 2. Obtener la credencial utilizando navigator.credentials.get
    const credential = await navigator.credentials.get({
      publicKey: options,
    });

    // 3. Enviar la respuesta de autenticación al backend para verificación
    const assertionResponse = {
      id: credential.id,
      rawId: Array.from(new Uint8Array(credential.rawId)),
      response: {
        clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
        authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
        signature: Array.from(new Uint8Array(credential.response.signature)),
        userHandle: credential.response.userHandle ? Array.from(new Uint8Array(credential.response.userHandle)) : null,
      },
      type: credential.type,
    };

    const verifyResponse = await fetch('/verify-authentication', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, authResp: assertionResponse }),
    });

    if (verifyResponse.ok) {
      alert('¡Login WebAuthn exitoso!');
    } else {
      const errorData = await verifyResponse.json();
      alert(`Error en el login: ${errorData.error || verifyResponse.statusText}`);
    }

  } catch (error) {
    console.error('Error durante el login WebAuthn:', error);
    alert(`Error de login: ${error.message}`);
  }
}

// Ejemplo de botón en el HTML de la PWA
// <button onclick="loginWebAuthn('usuarioEjemplo')">Iniciar Sesión con WebAuthn</button>
Usuario / PWA Autenticador Servidor RP 1. Usuario inicia login 2. Petición de Challenge y IDs 3. Envía Challenge + Credential IDs 4. Pasa datos al Autenticador 5. Busca Clave Privada, firma y genera ID credencial Envía firma a PWA 6. Envía firma y datos al Servidor 7. Verifica firma con Clave Pública y Contador 8. Usuario Autenticado (Éxito) SESIÓN OK
⚠️ Advertencia: La conversión entre `Uint8Array` (usado por la API del navegador) y strings (para JSON) es un paso crucial y propenso a errores. Asegúrate de usar funciones de conversión `ArrayBuffer` a `base64url` y viceversa correctamente. Las librerías de WebAuthn a menudo se encargan de esto por ti.

📈 Mejorando la Experiencia del Usuario y la Seguridad

La implementación de WebAuthn va más allá de un simple create y get. Hay consideraciones adicionales que mejoran tanto la experiencia del usuario como la robustez de la seguridad.

🛡️ Seguridad Avanzada: Verificación de Usuario y Atribución

  • Verificación de Usuario (User Verification - UV): Puedes solicitar al autenticador que verifique la presencia del usuario (ej. PIN, huella dactilar) antes de la autenticación. Esto se controla con la propiedad userVerification en las opciones de creación/solicitud. Valores posibles: required, preferred, discouraged.
  • Atribución (Attestation): Durante el registro, el autenticador puede proporcionar un certificado de atribución que prueba que el autenticador es genuino y de un tipo particular (ej. un modelo específico de YubiKey). Esto añade una capa extra de confianza, aunque a menudo es opcional y puede generar preocupaciones de privacidad.

✨ Credenciales Residentes (Passkeys) y Autocompletado

Las credenciales residentes (también conocidas como passkeys o claves de paso) son un tipo de credencial WebAuthn que el autenticador puede almacenar sin necesidad de que el servidor almacene el credentialId o el userHandle. Esto permite una experiencia de inicio de sesión aún más fluida, donde el usuario simplemente selecciona su cuenta de una lista ofrecida por el sistema operativo, sin siquiera necesitar ingresar un nombre de usuario.

Para habilitar credenciales residentes en el registro, establece authenticatorSelection.residentKey: 'required' y authenticatorSelection.requireResidentKey: true (deprecated en favor de residentKey: 'required' en versiones recientes).

También puedes usar la funcionalidad de autocompletado de credenciales en el navegador para sugerir credenciales WebAuthn. Esto se logra con el atributo autocomplete='webauthn' en los campos de usuario o botones de inicio de sesión.

<form>
  <label for="username">Usuario:</label>
  <input type="text" id="username" name="username" autocomplete="username webauthn">
  <button type="button" onclick="loginWebAuthn(document.getElementById('username').value)">Iniciar Sesión</button>
  <button type="button" onclick="registerWebAuthn(document.getElementById('username').value)">Registrarse</button>
</form>

📱 Consideraciones Móviles y UX en PWAs

  • Integración con OS: En dispositivos móviles, WebAuthn se integra con los gestores de credenciales del sistema operativo, permitiendo a los usuarios usar Face ID, Touch ID o PIN para autenticarse directamente dentro de la PWA.
  • Diseño Responsivo: Asegúrate de que los diálogos y flujos de autenticación sean amigables y claros en pantallas pequeñas.
  • Mensajes de Error Claros: Guía al usuario con mensajes útiles si la autenticación falla o si el autenticador no está disponible.
  • Fallback: Considera un método de autenticación secundario (como un PIN o Magic Link) para usuarios que no puedan o no quieran usar WebAuthn.
🌐 Soporte de Navegadores y Dispositivos La API de Credenciales Web tiene un excelente soporte en la mayoría de los navegadores modernos (Chrome, Firefox, Edge, Safari) y sistemas operativos (Windows, macOS, Android, iOS). Sin embargo, la compatibilidad con tipos específicos de autenticadores puede variar.
95% Soporte Global

Puedes verificar `typeof navigator.credentials.create === 'function'` para detectar la disponibilidad de la API en el cliente.

🎯 Despliegue en Producción y Mejores Prácticas

Preparar tu PWA con WebAuthn para producción requiere atención a detalles de seguridad y mantenimiento.

🔒 Seguridad en Producción

  • HTTPS OBLIGATORIO: WebAuthn solo funciona en contextos seguros. Tu PWA debe servirse siempre a través de HTTPS.
  • Validación Estricta: Tu backend debe realizar una validación exhaustiva de todas las respuestas de WebAuthn, incluyendo la verificación del desafío, el origen (origin), el ID de la Relying Party (RP ID), y el contador de firmas (sign counter) para prevenir ataques de replay.
  • Almacenamiento Seguro de Credenciales: Las claves públicas y los credentialId deben almacenarse de forma segura en tu base de datos, asociadas al usuario. Nunca almacenes las claves privadas.
  • Rotación de Credenciales: Permite a los usuarios añadir o eliminar autenticadores/credenciales desde su perfil, para mayor flexibilidad y seguridad.

🔄 Gestión de Credenciales de Usuario

Una buena PWA con WebAuthn debe permitir a los usuarios gestionar sus credenciales registradas. Esto incluye:

  • Verificar credenciales activas: Mostrar al usuario qué autenticadores tienen registrados.
  • Añadir nuevas credenciales: Facilitar el registro de un nuevo dispositivo o llave de seguridad.
  • Eliminar credenciales: Permitir la revocación de un autenticador perdido o comprometido.
Paso 1: Implementar endpoint `getCredentials` en el backend: Devuelve la lista de `credentialId` para un usuario.
Paso 2: Desarrollar UI de gestión en la PWA: Muestra los autenticadores y botones para añadir/eliminar.
Paso 3: Implementar endpoint `deleteCredential` en el backend: Permite eliminar una credencial específica.
Paso 4: Realizar pruebas exhaustivas: Asegurar que todos los flujos funcionan correctamente con diferentes autenticadores.

📊 Monitoreo y Análisis

Registra eventos de autenticación (éxito/falla) para monitorear el uso y detectar posibles anomalías o ataques. Esto te ayudará a entender cómo los usuarios están interactuando con tu sistema de autenticación sin contraseña y a mejorarlo continuamente.


🔚 Conclusión: El Futuro de la Autenticación en tus PWAs

La API de Credenciales Web representa un avance significativo en la seguridad y usabilidad de la autenticación en la web. Al integrar WebAuthn en tus Progressive Web Apps, no solo estás adoptando una tecnología de vanguardia, sino que también estás proporcionando a tus usuarios una experiencia más segura, rápida y sin fricciones. Eliminar la dependencia de las contraseñas es un paso crucial hacia un internet más seguro y fácil de usar. Empieza a experimentar con WebAuthn hoy mismo y lleva la autenticación de tu PWA al siguiente nivel.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!