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.
WebAuthn se divide en dos ceremonias principales:
Para hacerlo "sin nombre de usuario", debemos indicar al autenticador que cree una credencial residente (requireResidentKey: true).
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.
npm install express @simplewebauthn/server mysql2 cors
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.
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);
});
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.
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.
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');
}
}
}
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.