El uso de contraseñas es, irónicamente, la principal vulnerabilidad de cualquier sistema de autenticación moderno. Los usuarios las reutilizan, las olvidan, o son víctimas de phishing y brechas de datos masivas. La respuesta definitiva de la industria a este problema es el estándar FIDO2 / WebAuthn.

Lo fascinante de FIDO2 no es solo que elimina la contraseña, sino que puede llegar a eliminar incluso el nombre de usuario. Gracias a las Passkeys o Discoverable Credentials (Credenciales Residentes), el autenticador (como TouchID de Apple, Windows Hello, o una Yubikey) sabe quién eres. Tú solo pones el dedo en el lector, y estás dentro.

En este artículo, detallaremos la arquitectura y los pasos para implementar un flujo de autenticación FIDO2 libre de usuarios utilizando Angular en el Frontend, Express (Node.js) en el Backend y persistiendo todo en MySQL.

Arquitectura del Flujo FIDO2

WebAuthn se divide en dos ceremonias principales:

  1. Registro (Registration / Attestation): El usuario vincula su dispositivo al servicio web. El servidor lanza un "desafío" aleatorio, el dispositivo del usuario genera un par de claves criptográficas, firma el desafío, guarda la clave privada en secreto y envía la clave pública al servidor.
  2. Autenticación (Login / Assertion): El usuario quiere entrar. El servidor lanza un nuevo desafío, el dispositivo del usuario lo firma con su clave privada, y el servidor verifica la firma utilizando la clave pública registrada.

Para hacerlo "sin nombre de usuario", debemos indicar al autenticador que cree una credencial residente (requireResidentKey: true).

1. El Backend: Express.js (Node.js)

En Node.js, reinventar la rueda criptográfica de WebAuthn es un error de seguridad grave. Usaremos la librería oficial de facto: @simplewebauthn/server.

Dependencias

npm install express @simplewebauthn/server mysql2 cors

Configuración y Endpoints

El servidor debe proporcionar 4 endpoints (rutas): dos para registro y dos para login.

Primero, necesitamos almacenar temporalmente el "desafío" (Challenge) generado por el servidor, ya que luego habrá que compararlo con el que nos devuelva el usuario firmado. Un almacenamiento en caché como Redis o simplemente la memoria del proceso (como un Map) funciona, aunque en producción multi-nodo usarás Redis.

Generar opciones de Registro:

import { generateRegistrationOptions } from '@simplewebauthn/server';

app.get('/generate-registration-options', async (req, res) => {
  // Generamos un identificador único para este intento de usuario
  const internalUserId = generarUUID(); 

  const options = await generateRegistrationOptions({
    rpName: 'Mi Aplicación Segura',
    rpID: 'localhost', // En producción, el dominio ej: 'mi-app.com'
    userID: internalUserId,
    userName: 'Usuario Anónimo', // Como es passwordless/usernameless, damos un genérico
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required', // ¡Crucial para evitar pedir el login/email!
      userVerification: 'preferred',
    },
  });

  // Guardamos el options.challenge en la base de datos temporal asocidado a la sesíon
  guardarChallengeEnMemoria(req.session.id, options.challenge);

  res.send(options);
});

Verificar el Registro:

El cliente nos devuelve la firma criptográfica (attestation). Usamos verifyRegistrationResponse. Si es válido, guardamos la clave pública en MySQL.

import { verifyRegistrationResponse } from '@simplewebauthn/server';

app.post('/verify-registration', async (req, res) => {
  const { body } = req;
  const expectedChallenge = recuperarChallengeDeMemoria(req.session.id); // Sacarlo de la DB temporal/Redis

  try {
    const verification = await verifyRegistrationResponse({
      response: body,
      expectedChallenge,
      expectedOrigin: 'http://localhost:4200', // El origen de tu app Angular
      expectedRPID: 'localhost',
    });

    if (verification.verified) {
      const { registrationInfo } = verification;
      // registrationInfo contiene credencialID, clave pública (credentialPublicKey), y un contador

      // AHORA: Insertamos en MySQL
      await db.execute('INSERT INTO credentials (credential_id, public_key, counter) VALUES (?, ?, ?)', 
        [registrationInfo.credentialID, Buffer.from(registrationInfo.credentialPublicKey), registrationInfo.counter]
      );

      res.send({ verified: true });
    }
  } catch (error) {
    res.status(400).send({ error: error.message });
  }
});

El flujo de Login (Assertion) sigue la misma arquitectura (Opciones generateAuthenticationOptions -> Front -> Verificación verifyAuthenticationResponse), pero en este caso extraemos el credentialID enviado por el navegador, lo buscamos en MySQL, recuperamos la clave pública vinculada y verificamos.

2. La Base de Datos: MySQL

La complejidad de WebAuthn radica en que las claves públicas no son strings normales en base64. Algunas implementaciones las guardan como BLOB o largas secuencias alfanuméricas (con CBOR encoding). La tabla en MySQL quedaría así:

CREATE TABLE users (
    id VARCHAR(36) PRIMARY KEY, -- UUID interno
    internal_name VARCHAR(255) DEFAULT 'Usuario Anónimo',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE credentials (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id VARCHAR(36),
    credential_id VARCHAR(255) UNIQUE NOT NULL, -- El ID proporcionado por el dispositivo
    public_key BLOB NOT NULL, -- La firma almacenada en formato binario o texto
    counter BIGINT DEFAULT 0, -- WebAuthn usa un contador para evitar clonado de tokens
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

Es vital guardar el counter. Si el dispositivo envía una firma con un contador inferior al que tienes en la base de datos, significa que tu usuario ha sido clonado (o restaurado desde una copia de seguridad), lo cual según el estándar FIDO2 es una anomalía de seguridad que debe bloquear la cuenta.

3. El Frontend: Angular

En el cliente, Angular solo tiene que consumir nuestra API de Express. Para no pelear con la API nativa de navigator.credentials del navegador (que requiere transformar Arrays Uint8 hacia ida y vuelta y conversiones base64URL engorrosas), usaremos la librería complementaria @simplewebauthn/browser.

import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import { HttpClient } from '@angular/common/http';
import { Component } from '@angular/core';

@Component({
  selector: 'app-fido2-login',
  template: `
    <div class="login-container">
      <h2>Biometría o Llave USB</h2>
      <button (click)="register()">Registrarse (Crear Passkey)</button>
      <button (click)="login()">Entrar</button>
    </div>
  `,
})
export class Fido2LoginComponent {
  constructor(private http: HttpClient) {}

  async register() {
    // 1. Pedir opciones a Express
    const options = await this.http.get<any>('http://localhost:3000/generate-registration-options').toPromise();

    try {
      // 2. Aquí sale el popup del navegador pidiendo la huella dactilar/PIN
      const attResp = await startRegistration(options);

      // 3. Enviar la huella firmada a Express
      const verificationResp = await this.http.post('http://localhost:3000/verify-registration', attResp).toPromise();
      console.log('Registro exitoso', verificationResp);
    } catch (err) {
      console.error('El usuario canceló o falló la biometría');
    }
  }

  async login() {
    // 1. Pedir desafío de login. Puesto que es usernameless, NO le pasamos ID de usuario al backend. 
    // El backend genera opciones genéricas.
    const options = await this.http.get<any>('http://localhost:3000/generate-authentication-options').toPromise();

    try {
      // 2. Popup del navegador. Al usar Resident Keys, el navegador SABE qué huella usar
      // sin necesidad de haber introducido el email
      const asseResp = await startAuthentication(options);

      // 3. Enviar aserción firmada
      const verificationResp = await this.http.post('http://localhost:3000/verify-authentication', asseResp).toPromise();
      console.log('Login exitoso. ¡Estás dentro!', verificationResp);
    } catch (err) {
      console.error('Fallo de login FIDO2');
    }
  }
}

Conclusión

El uso de FIDO2 está maduro y bien soportado por iOS Safari, Chrome, Windows Hello y Android. Implementarlo sin nombre de usuario (Usernameless) combinando el flag residentKey: 'required' te permite ofrecer un inicio de sesión que parece sacado del futuro, reduciendo la fricción a cero: un botón, una huella y sesión iniciada. No hay contraseñas que resetear y no hay nada que pueda robar un ciberdelincuente desde otra ubicación geográfica.

Entrada Anterior Siguiente Entrada