Agrega la autenticación de dos factores con una llave de seguridad (WebAuthn) para asegurar tu sitio

1. Qué compilarás

Comenzarás con una aplicación web básica que admite el acceso con contraseña.

Luego, harás que admita la autenticación de dos factores a través de una llave de seguridad, basada en WebAuthn. Para ello, implementarás lo siguiente:

  • Una forma para que los usuarios registren una credencial de WebAuthn.
  • Un flujo de autenticación de dos factores donde el usuario debe ingresar el segundo factor (una credencial de WebAuthn) en caso de haberlo registrado.
  • Una interfaz para la administración de credenciales. Esto es una lista de credenciales que les permite a los usuarios cambiar los nombres de las credenciales y borrarlas.

16ce77744061c5f7.png

Observa la aplicación web terminada y pruébala.

2. Acerca de WebAuthn

Conceptos básicos de WebAuthn

¿Por qué usar WebAuthn?

La suplantación de identidad (phishing) es un problema de seguridad masivo en la Web, ya que la mayoría de las violaciones a la seguridad de las cuentas aprovechan las contraseñas débiles o robadas que se reutilizan en los sitios. La respuesta colectiva de la industria a este problema fue la autenticación de varios factores. Sin embargo, las implementaciones están fragmentadas y muchas aún no abordan la suplantación de identidad (phishing) de manera adecuada.

La API de Web Authentication, o WebAuthn, es un protocolo estandarizado y resistente a la suplantación de identidad que se puede usar en cualquier aplicación web.

Cómo funciona

Fuente: webauthn.guide

WebAuthn permite que los servidores registren y autentiquen usuarios mediante la criptografía de clave pública en lugar de una contraseña. Los sitios web pueden crear una credencial. Esta está compuesta por dos claves, una pública y otra privada.

  • La clave privada se almacena de forma segura en el dispositivo del usuario.
  • Por otro lado, la clave pública y el ID de la credencial generado aleatoriamente se envían al servidor para su almacenamiento.

El servidor utiliza la clave pública para demostrar la identidad del usuario. Esta no es secreta, ya que no tiene sentido sin la clave privada correspondiente.

Beneficios

WebAuthn tiene dos beneficios principales:

  • No hay secretos compartidos: el servidor no almacena ningún secreto. De esta forma, las bases de datos son menos atractivas para los hackers, ya que las claves públicas no les resultan útiles.
  • Credenciales con alcance: una credencial registrada para site.example no se puede usar en evil-site.example. De esta forma, WebAuthn es resistente a la suplantación de identidad (phishing).

Casos de uso

Un caso de uso para WebAuthn es la autenticación de dos factores con una llave de seguridad. Esto puede ser particularmente relevante para aplicaciones web empresariales.

Navegadores compatibles

Está escrito por el W3C y el FIDO, con la participación de Google, Mozilla, Microsoft, Yubico y otros.

Glosario

  • Autenticador: Es una entidad de hardware o software que puede registrar a un usuario y, más tarde, confirmar que cuente con la credencial registrada. Existen dos tipos de autenticadores:
  • Autenticador de roaming: Es un autenticador que se puede usar con cualquier dispositivo desde el que el usuario intente acceder. Por ejemplo, una llave de seguridad USB o un smartphone.
  • Autenticador de plataforma: Es un autenticador integrado en el dispositivo de un usuario. Por ejemplo, una ID táctil de Apple.
  • Credencial: El par de claves pública y privada.
  • Parte de confianza: El (servidor para el) sitio web que intenta autenticar al usuario.
  • Servidor FIDO: El servidor que se usa para la autenticación. FIDO es una familia de protocolos desarrollados por la alianza FIDO; WebAuthn es uno de estos protocolos.

En este taller, usaremos un autenticador de roaming.

3. Antes de comenzar

Requisitos

Para completar este codelab, necesitarás lo siguiente:

  • Conocimientos básicos sobre WebAuthn.
  • Conocimientos básicos sobre JavaScript y HTML.
  • Un navegador actualizado compatible con WebAuthn.
  • Una llave de seguridad que cumpla con U2F.

Puedes usar una de las siguientes opciones como llave de seguridad:

  • Un teléfono con Android 7 o una versión posterior (Nougat) que ejecute Chrome. En este caso, también necesitarás una máquina que ejecute Windows, macOS o Chrome OS con Bluetooth.
  • Una llave USB, como una YubiKey.

6539dc7ffec2538c.png

Fuente: https://www.yubico.com/products/security-key/

dd56e2cfe0f7ced2.png

Qué aprenderás

Lo que aprenderás ✅

  • Cómo registrarte y usar una llave de seguridad como segundo factor para la autenticación de WebAuthn.
  • Cómo facilitar este proceso.

Lo que no aprenderás ❌

  • Cómo compilar un servidor FIDO, que se usa para la autenticación. Esto está bien porque, por lo general, como una aplicación web o un desarrollador de sitios, dependerías de las implementaciones existentes en el servidor FIDO. Asegúrate de verificar siempre la funcionalidad y la calidad de las implementaciones de servidor con las que cuentas. En este codelab, el servidor FIDO usa SimpleWebAuthn. Para ver otras opciones, consulta la página oficial de FIDO Alliance. En el caso de las bibliotecas de código abierto, consulta webauthn.io o AwesomeWebAuthn.

Renuncia de responsabilidad

El usuario debe ingresar una contraseña para acceder. Sin embargo, para simplificar este codelab, la contraseña no se almacena ni se verifica. En una aplicación real, se comprobaría que sea correcta respecto del servidor.

En este codelab, se implementan controles de seguridad básicos, como los controles de CSRF, la validación de sesiones y la limpieza de entradas. Sin embargo, muchas medidas de seguridad no se implementan. Por ejemplo, no hay un límite de entradas para las contraseñas a fin de evitar ataques de fuerza bruta. Esto no importa porque las contraseñas no se almacenan, pero asegúrate de no usar este código tal como está en producción.

4. Configura el autenticador

Si usas un teléfono Android como autenticador

  • Asegúrate de que Chrome esté actualizado en la computadora de escritorio y el teléfono.
  • En ambos dispositivos, abre Chrome y accede con el mismo perfil, el que quieres usar para este taller.
  • Activa la sincronización para este perfil en la computadora de escritorio y el teléfono. Para hacerlo, usa chrome://settings/syncSetup.
  • Activa el Bluetooth en ambas ubicaciones.
  • En el navegador Chrome de la computadora al que accediste con el mismo perfil, abre webauthn.io.
  • Ingresa un nombre de usuario sencillo. Deja el Tipo de certificación y Tipo de autenticador en los valores Ninguna y Sin especificar (predeterminado). Haz clic en Registrar.

6b49ff0298f5a0af.png

  • Se debería abrir una ventana del navegador para solicitarte que verifiques tu identidad. Selecciona tu teléfono de la lista.

ffebe58ac826eaf2.png 852de328fcd4eb42.png

  • En el teléfono, deberías recibir una notificación titulada Verifica tu identidad. Presiónala.
  • En el teléfono, deberás ingresar el código PIN (o tocar el sensor de huellas dactilares). Ingrésalo.
  • En webauthn.io en la computadora, debería aparecer un indicador de "Éxito".

fc0acf00a4d412fa.png

  • En webauthn.io en la computadora, haz clic en el botón Acceder.
  • Nuevamente, se debería abrir una ventana en el navegador. Allí, selecciona tu teléfono de la lista.
  • En el teléfono, presiona la notificación de la ventana emergente y, luego, ingresa tu PIN (o toca el sensor de huellas dactilares).
  • webauthn.io debería indicarte que accediste. El teléfono funciona correctamente como una llave de seguridad. ¡Está todo listo para el taller!

Si usas una llave de seguridad USB como autenticador

  • En el navegador Chrome de la computadora, abre webauthn.io.
  • Ingresa un nombre de usuario sencillo. Deja el Tipo de certificación y Tipo de autenticador en los valores Ninguna y Sin especificar (predeterminado). Haz clic en Registrar.
  • Se debería abrir una ventana del navegador para solicitarte que verifiques tu identidad. Selecciona Llave de seguridad USB de la lista.

ffebe58ac826eaf2.png 9fe75f04e43da035.png

  • Inserta la llave de seguridad en el escritorio y tócala.

923d5adb8aa8286c.png

  • En webauthn.io en la computadora, debería aparecer un indicador de "Éxito".

fc0acf00a4d412fa.png

  • En webauthn.io en la computadora, haz clic en el botón Acceder.
  • Nuevamente, se debería abrir una ventana en el navegador. Allí, selecciona Llave de seguridad USB de la lista.
  • Presiona la llave.
  • Webauthn.io debería indicarte que accediste. La llave de seguridad USB funciona correctamente. ¡Está todo listo para el taller!

7e1c0bb19c9f3043.png

5. Prepárate

En este codelab, usarás Glitch, un editor de código en línea que implementa tu código de forma automática e instantánea.

Bifurca el código inicial

Abre el proyecto inicial.

Haz clic en el botón Remix.

Se creará una copia del código inicial. Ahora tienes tu propio código para editar. En la bifurcación (llamada "remix" en Glitch), realizarás todo el trabajo para este codelab.

cf2b9f552c9809b6.png

Explora el código inicial

Explora un poco el código inicial que acabas de bifurcar.

Observa que, en libs, ya se proporcionó una biblioteca llamada auth.js. Es una biblioteca personalizada que se encarga de la lógica de autenticación del servidor. Usa la biblioteca fido como una dependencia.

6. Implementa el registro de credenciales

Implementa el registro de credenciales

Para configurar una autenticación de dos factores con una llave de seguridad, lo primero que necesitamos es permitir que el usuario cree una credencial.

Entonces, agregaremos una función que cumpla esta tarea en el código del cliente.

Ten en cuenta que, en public/auth.client.js hay una función llamada registerCredential() que aún no realiza ninguna acción. Agrega allí el siguiente código:

async function registerCredential() {
  // Fetch the credential creation options from the backend
  const credentialCreationOptionsFromServer = await _fetch(
    "/auth/credential-options",
    "POST"
  );
  // Decode the credential creation options
  const credentialCreationOptions = decodeServerOptions(
    credentialCreationOptionsFromServer
  );
  // Create a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
  const credential = await navigator.credentials.create({
    publicKey: {
      ...credentialCreationOptions,
    }
  });
  // Encode the newly created credential to send it to the backend
  const encodedCredential = encodeCredential(credential);
  // Send the encoded credential to the backend for storage
  return await _fetch("/auth/credential", "POST", encodedCredential);
}

Ten en cuenta que esta función ya se exportó.

Esto es lo que hace registerCredential:

  • Recupera las opciones de creación de credenciales del servidor (/auth/credential-options).
  • Dado que las opciones del servidor regresan codificadas, usa la función de utilidad decodeServerOptions para decodificarlas.
  • Llama a la API de la Web navigator.credential.create para crear una credencial. Cuando se llama a navigator.credential.create, el navegador toma el control y le pide al usuario que elija una llave de seguridad.
  • Decodifica la credencial que se creó recientemente.
  • Registra la nueva credencial del servidor mediante una solicitud a /auth/credential que contiene la credencial codificada.

Apartado: Observa el código del servidor

registerCredential() realiza dos llamadas al servidor, así que nos tomaremos un momento para analizar lo que sucede en el backend.

Opciones de creación de credenciales

Cuando el cliente realiza una solicitud a (/auth/credential-options), el servidor genera un objeto de opciones y lo envía de regreso al cliente.

Luego, el cliente utiliza este objeto en la llamada de creación de la credencial real:

navigator.credentials.create({
    publicKey: {
    // Options generated server-side
    ...credentialCreationOptions
// ...
}

¿Qué elementos de este credentialCreationOptions se usan en última instancia en el registerCredential del cliente que implementaste en el paso anterior?

Consulta el código del servidor en router.post("/credential-options", ….

No analizaremos todas las propiedades, pero hay algunas interesantes que puedes ver en el objeto de opciones del código del servidor, que se genera con la biblioteca fido2 y, en última instancia, regresa al cliente. Algunas de esas propiedades son las siguientes:

  • rpName y rpId describen la organización con la que se registra y autentica al usuario. Recuerda que en WebAuthn las credenciales están limitadas a un alcance determinado, lo cual es un beneficio de seguridad. En este caso, rpName y rpId se usan para definir el alcance de la credencial. Un rpId válido es, por ejemplo, el nombre de host de tu sitio. Ten en cuenta que se actualizarán de forma automática a medida que bifurques el proyecto inicial. 🧘 ♀️
  • excludeCredentials es una lista de credenciales. La credencial nueva no se puede crear en un autenticador que también contenga una de las credenciales incluidas en excludeCredentials. En nuestro codelab, excludeCredentials es una lista de credenciales existentes para este usuario. Con ella y user.id, nos aseguramos de que cada credencial que cree un usuario se encuentre en un autenticador diferente (llave de seguridad). Esta es una práctica recomendada porque implica que, si un usuario registró varias credenciales, utilizará distintos autenticadores (llaves de seguridad). De esta manera, perder una llave de seguridad no impediría que acceda a su cuenta.
  • authenticatorSelection define el tipo de autenticadores que quieres permitir en tu aplicación web. Analicemos authenticatorSelection con más detalle:
    • residentKey: preferred significa que esta aplicación no implementa credenciales detectables del cliente. Una credencial detectable del cliente es un tipo especial de credencial que permite autenticar a un usuario sin la necesidad de identificarlo en primer lugar. En este caso, configuramos preferred porque este codelab se enfoca en la implementación básica. Las credenciales detectables son para flujos más avanzados.
    • requireResidentKey solo está presente en la retrocompatibilidad con WebAuthn v1.
    • userVerification: preferred significa que, si el autenticador admite la verificación del usuario (por ejemplo, si es una llave de seguridad biométrica o una clave con una función de PIN integrada), la parte de confianza la solicitará cuando cree la credencial. Si el autenticador no lo hace (llave de seguridad básica), el servidor no solicitará la verificación del usuario.
  • ​​pubKeyCredParam describe, en orden de preferencia, las propiedades criptográficas deseadas de la credencial.

Todas estas opciones son decisiones que la aplicación web debe tomar para su modelo de seguridad. Observa que, en el servidor, estas opciones se definen en un solo objeto authSettings.

Desafío

Otro dato más interesante es req.session.challenge = options.challenge;.

Dado que WebAuthn es un protocolo criptográfico, depende de desafíos aleatorios para evitar ataques de repetición. Algunos casos podrían ser cuando un atacante roba una carga útil para volver a reproducir la autenticación y cuando no es el propietario de la clave privada que permitiría la autenticación.

Para mitigar este problema, se genera un desafío en el servidor y se firma sobre la marcha. Luego, la firma se compara con la que se espera obtener. De esta manera, se verifica que el usuario retenga la clave privada al momento de generar la credencial.

Código de registro de credenciales

Revisa el código del servidor en router.post("/credential", ....

Aquí es donde se registra la credencial del servidor.

¿Qué sucede en este caso?

Uno de los bits más notables de este código es la llamada de verificación mediante fido2.verifyAttestationResponse, en ella ocurre lo siguiente:

  • Se verifica el desafío firmado y se garantiza que quien creó la credencial haya retenido la clave privada al momento de su creación.
  • También se verifica el ID del usuario de confianza, vinculado a su origen. Así se garantiza que la credencial esté vinculada a esta aplicación web (y a ninguna otra).

Agrega esta funcionalidad a la IU

Ahora que la función para crear una credencial, ``registerCredential(),está lista, pongámosla a disposición del usuario.

Deberás hacerlo desde la página de la Cuenta, ya que esta es una ubicación común para administrar la autenticación.

En el lenguaje de marcado de account.html, debajo del nombre de usuario, hay un div vacío hasta este momento con una clase de diseño class="flex-h-between". Usaremos este elemento div para los elementos de la IU relacionados con la funcionalidad 2FA.

Elabora este div:

  • Un título que dice "Autenticación de dos factores"
  • Un botón para crear una credencial
 <div class="flex-h-between">
    <h3>
        Two-factor authentication
    </h3>
    <button class="create" id="registerButton" raised>
        ➕ Add a credential
    </button>
</div>

Debajo de este elemento div, agrega el div de credencial que necesitaremos más adelante:

<div class="flex-h-between">
(HTML you've just added)
</div>
<div id="credentials"></div>

En la secuencia de comandos intercalada account.html, importa la función que acabas de crear y agrega una función register para llamarla, así como un controlador de eventos adjunto al botón que acabas de crear.

// Set up the handler for the button that registers credentials
const registerButton = document.querySelector('#registerButton');
registerButton.addEventListener('click', register);

// Register a credential
async function register() {
  let user = {};
  try {
    const user = await registerCredential();
  } catch (e) {
    // Alert the user that something went wrong
    if (Array.isArray(e)) {
      alert(
        // `msg` not `message`, this is the key's name as per the express validator API
        `Registration failed. ${e.map((err) => `${err.msg} (${err.param})`)}`
      );
    } else {
      alert(`Registration failed. ${e}`);
    }
  }
}

Muestra las credenciales para que el usuario las pueda ver

Ahora que agregaste la funcionalidad para crear una credencial, los usuarios necesitan una manera de ver las credenciales que ellos agregaron.

La página Cuenta es un buen lugar para este objetivo.

En account.html, busca la función llamada updateCredentialList().

Agrégale el siguiente código. Este realiza una llamada de backend para recuperar todas las credenciales registradas del usuario actual y muestra las credenciales que aparecen:

// Update the list that displays credentials
async function updateCredentialList() {
  // Fetch the latest credential list from the backend
  const response = await _fetch('/auth/credentials', 'GET');
  const credentials = response.credentials || [];
  // Generate the credential list as HTML and pass remove/rename functions as args
  const credentialListHtml = getCredentialListHtml(
    credentials,
    removeEl,
    renameEl
  );
  // Display the list of credentials in the DOM
  const list = document.querySelector('#credentials');
  render(credentialListHtml, list);
}

Por ahora, no te preocupes por removeEl ni renameEl. Más adelante en este codelab aprenderás al respecto.

En account.html, agrega una llamada a updateCredentialList al comienzo de tu secuencia de comandos intercalada. Con esta llamada, se recuperan las credenciales disponibles cuando el usuario llega a la página de su cuenta.

<script type="module">
    // ... (imports)
    // Initialize the credential list by updating it once on page load
    updateCredentialList();

Ahora, llama a updateCredentialList una vez que registerCredential se haya completado correctamente, de modo que las listas muestren la credencial creada recientemente:

async function register() {
  let user = {};
  try {
    // ...
  } catch (e) {
    // ...
  }
  // Refresh the credential list to display the new credential
  await updateCredentialList();
}

¡Pruébalo! 👩🏻‍💻

Ya terminaste de registrar las credenciales. Ahora los usuarios pueden crear credenciales basadas en llaves de seguridad y visualizarlas en la página de su Cuenta.

Pruébalo:

  • Salir.
  • Accede con cualquier usuario y contraseña. Como se mencionó anteriormente, a fin de simplificar el proceso en este codelab, no se verifica que la contraseña sea correcta. Ingresa una contraseña que no esté vacía.
  • Cuando estés en la página Cuenta, haz clic en Agregar una credencial.
  • Deberás insertar y tocar una llave de seguridad. Hazlo.
  • Cuando hayas creado la credencial correctamente, debería aparecer en la página de la cuenta.
  • Vuelve a cargar la página Cuenta. Deben aparecer las credenciales.
  • Si tienes dos llaves disponibles, intenta agregar dos llaves de seguridad diferentes como credenciales. Se deben mostrar ambas.
  • Intenta crear dos credenciales con el mismo autenticador (llave). Notarás que la acción no se admitirá. Esto es intencional, porque usamos excludeCredentials en el backend.

7. Habilita la autenticación de dos factores

Los usuarios pueden registrar y cancelar el registro de credenciales; pero, en realidad, aún no se usan sino que solo se muestran.

Este es el momento de usarlas y configurar la autenticación de dos factores.

En esta sección, cambiarás el flujo de autenticación de tu aplicación web de este flujo básico:

6ff49a7e520836d0.png

A este flujo de dos factores:

e7409946cd88efc7.png

Implementa la autenticación de dos factores

Primero, agregaremos la funcionalidad que necesitamos e implementaremos la comunicación con el backend. Más adelante, la agregaremos en el frontend en otro paso.

Lo que debes implementar en este momento es una función que autentique al usuario con una credencial.

En public/auth.client.js, busca la función vacía authenticateTwoFactor y agrégale el siguiente código:

async function authenticateTwoFactor() {
  // Fetch the 2F options from the backend
  const optionsFromServer = await _fetch("/auth/two-factor-options", "POST");
  // Decode them
  const decodedOptions = decodeServerOptions(optionsFromServer);
  // Get a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
  const credential = await navigator.credentials.get({
    publicKey: decodedOptions
  });
  // Encode the credential
  const encodedCredential = encodeCredential(credential);
  // Send it to the backend for verification
  return await _fetch("/auth/authenticate-two-factor", "POST", {
    credential: encodedCredential
  });
}

Ten en cuenta que esta función ya se exportó; la necesitaremos en el siguiente paso.

Esto es lo que hace authenticateTwoFactor:

  • Solicita opciones de autenticación de dos factores del servidor. Al igual que las opciones de creación de credenciales que viste antes, estas se definen en el servidor y dependen del modelo de seguridad de la aplicación web. Para obtener más detalles, analiza el código del servidor en router.post("/two-factors-options", ....
  • Cuando llamas a navigator.credentials.get, el navegador toma el control y le solicita al usuario que inserte y toque una llave registrada anteriormente. De esta forma, se selecciona una credencial para esta operación específica de autenticación de dos factores.
  • Luego, la credencial seleccionada se pasa a una solicitud de recuperación de backend("/auth/authenticated-two-factor"). Si la credencial es válida para el usuario, se podrá autenticar.

Apartado: Observa el código del servidor

Ten en cuenta que server.js ya se ocupa de algunos procesos de navegación y acceso. Garantiza que solo los usuarios autenticados puedan acceder a la página de la Cuenta y realiza algunos redireccionamientos necesarios.

Ahora, observa el código del servidor en router.post("/initialize-authentication", ....

Hay dos aspectos interesantes por destacar:

  • En esta etapa, la contraseña y la credencial se verifican de forma simultánea. Esta es una medida de seguridad: En el caso de los usuarios que tienen configurada la autenticación de dos factores, no es recomendable que los flujos de la IU se vean diferentes en función de que la contraseña ingresada sea o no correcta. Es por eso que, en este paso, verificamos la contraseña y la credencial de manera simultánea.
  • Si la contraseña y la credencial son válidas, completamos la autenticación llamando a completeAuthentication(req, res);. Esto significa que, en la práctica, cambiamos de una sesión temporal de auth en la que el usuario aún no está autenticado a la sesión principal main en la que sí está autenticado.

Incluye la página de autenticación de dos factores en el flujo de usuarios

En la carpeta views, observa la página nueva second-factor.html.

Tiene un botón que dice Usar llave de seguridad, pero no realiza ninguna acción por el momento.

Haz que este botón llame a authenticateTwoFactor() cuando se haga clic sobre él.

  • Si authenticateTwoFactor() funciona correctamente, redireccionará al usuario a la página de su Cuenta.
  • Si no funciona, indícale al usuario que se produjo un error. En una aplicación real, implementarías mensajes de error más útiles. Para hacerlo más simple en esta demostración, solo usaremos una alerta de ventana.
    <main>
...
    </main>
    <script type="module">
      import { authenticateTwoFactor, authStatuses } from "/auth.client.js";

      const button = document.querySelector("#authenticateButton");
      button.addEventListener("click", async e => {
        try {
          // Ask the user to authenticate with the second factor; this will trigger a browser prompt
          const response = await authenticateTwoFactor();
          const { authStatus } = response;
          if (authStatus === authStatuses.COMPLETE) {
            // The user is properly authenticated => Navigate to the Account page
            location.href = "/account";
          } else {
            throw new Error("Two-factor authentication failed");
          }
        } catch (e) {
          // Alert the user that something went wrong
          alert(`Two-factor authentication failed. ${e}`);
        }
      });
    </script>
  </body>
</html>

Usa la autenticación de dos factores

Ya está todo listo para que agregues un paso de autenticación de dos factores.

Ahora, lo que debes hacer es agregar este paso de index.html para los usuarios que configuraron la autenticación de dos factores.

322a5c49d865a0d8.png

En index.html, debajo de location.href = "/account";, agrega un código que envíe condicionalmente al usuario a la página de autenticación de dos factores en caso de que la haya configurado.

En este codelab, cuando creas una credencial habilitas automáticamente al usuario en la autenticación de dos factores.

Ten en cuenta que server.js también implementa la verificación de la sesión del servidor. Esto garantiza que solo los usuarios autenticados puedan acceder a account.html.

const { authStatus } = response;
if (authStatus === authStatuses.COMPLETE) {
  // The user is properly authenticated => navigate to account
  location.href = '/account';
} else if (authStatus === authStatuses.NEED_SECOND_FACTOR) {
  // Navigate to the two-factor-auth page because two-factor-auth is set up for this user
  location.href = '/second-factor';
}

¡Pruébalo! 👩🏻‍💻

  • Accede con un usuario nuevo juanperez.
  • Sal.
  • Accede a tu cuenta como juanperez. Notarás que solo se necesita una contraseña.
  • Crea una credencial. Esto significará que activaste la autenticación de dos factores como juanperez.
  • Sal.
  • Inserta tu nombre de usuario juanperez y la contraseña.
  • Observa cómo navegas de forma automática a la página de autenticación de dos factores.
  • (Intenta acceder a la página de la Cuenta en /account. Observa que se te redirecciona a la página del índice porque no completaste la autenticación: falta un segundo factor).
  • Vuelve a la página de autenticación de dos factores y haz clic en Usar llave de seguridad para completarla.
  • Ya accediste y deberías ver la página de tu Cuenta.

8. Facilita el uso de credenciales

Completaste la funcionalidad básica de la autenticación de dos factores, que incluye una llave de seguridad. 🚀

Pero… ¿Notaste que…?

Por el momento, nuestra lista de credenciales no es muy conveniente: la ID de credencial y la clave pública son strings largas, por lo que no resultan útiles para administrar credenciales. Los humanos no somos muy buenos con las strings largas y los números. 🤖

Así que mejoremos este aspecto y agreguemos funcionalidad para nombrar credenciales y cambiarles el nombre con strings legibles.

Consulta renameCredential

Para que ahorres tiempo en la implementación de esta función, no es necesario hacer nada demasiado innovador, ya que se agregó una función para cambiar el nombre de una credencial en el código inicial, en auth.client.js:

async function renameCredential(credId, newName) {
  const params = new URLSearchParams({
    credId,
    name: newName
  });
  return _fetch(
    `/auth/credential?${params}`,
    "PUT"
  );
}

Esta es una llamada normal de actualización de la base de datos: El cliente envía una solicitud PUT al backend, con una ID de credencial y un nombre nuevo para esa credencial.

Implementa nombres de credenciales personalizados

En account.html, observa la función vacía rename.

Agrégale el siguiente código:

// Rename a credential
async function rename(credentialId) {
  // Let the user input a new name
  const newName = window.prompt(`Name this credential:`);
  // Rename only if the user didn't cancel AND didn't enter an empty name
  if (newName && newName.trim()) {
    try {
      // Make the backend call to rename the credential (the name is sanitized) server-side
      await renameCredential(credentialId, newName);
    } catch (e) {
      // Alert the user that something went wrong
      if (Array.isArray(e)) {
        alert(
          // `msg` not `message`, this is the key's name as per the express validator API
          `Renaming failed. ${e.map((err) => `${err.msg} (${err.param})`)}`
        );
      } else {
        alert(`Renaming failed. ${e}`);
      }
    }
    // Refresh the credential list to display the new name
    await updateCredentialList();
  }
}

Es posible que tenga más sentido nombrar una credencial solo después de haberla creado correctamente. Así que, creemos una credencial sin nombre y, después de crearla correctamente, lo cambiaremos. Sin embargo, esto generará dos llamadas de backend.

Usa la función rename en register() para permitir que los usuarios asignen nombres a las credenciales cuando se registren:

async function register() {
  let user = {};
  try {
    const user = await registerCredential();
    // Get the latest credential's ID (newly created credential)
    const allUserCredentials = user.credentials;
    const newCredential = allUserCredentials[allUserCredentials.length - 1];
    // Rename it
    await rename(newCredential.credId);
  } catch (e) {
    // ...
  }
  // Refresh the credential list to display the new credential
  await updateCredentialList();
}

Ten en cuenta que la entrada del usuario se validará y se limpiará en el backend:

  check("name")
    .trim()
    .escape()

Muestra nombres de credenciales

Dirígete a getCredentialHtml en templates.js.

Ten en cuenta que ya hay un código para mostrar el nombre de la credencial en la parte superior de la tarjeta de credenciales:

// Register credential
const getCredentialHtml = (credential, removeEl, renameEl) => {
 const { name, credId, publicKey } = credential;
 return html`
    <div class="credential-card">
      <div class="credential-name">
        ${name
          ? html`
              ${name}
            `
          : html`
              <span class="unnamed">(Unnamed)</span>
            `}
      </div>
     // ...
    </div>
  `;
};

¡Pruébalo! 👩🏻‍💻

  • Crea una credencial.
  • Deberás asignarle un nombre.
  • Ingresa un nombre nuevo y haz clic en Aceptar.
  • Ahora se cambió el nombre de la credencial.
  • Repite el proceso y verifica que todo funcione sin problemas cuando dejes el campo del nombre vacío.

Habilita el cambio de nombres de credenciales

Es posible que los usuarios deban cambiar el nombre de sus credenciales; por ejemplo, cuando agreguen una segunda clave y necesiten distinguirlas mejor.

En account.html, busca la función renameEl que se encuentra vacía hasta el momento y agrégala al siguiente código:

// Rename a credential via HTML element
async function renameEl(el) {
  // Define the ID of the credential to update
  const credentialId = el.srcElement.dataset.credentialId;
  // Rename the credential
  await rename(credentialId);
  // Refresh the credential list to display the new name
  await updateCredentialList();
}

En el elemento getCredentialHtml de templates.js, dentro del elemento div de class="flex-end", agrega el siguiente código. Este código agrega un botón Cambiar nombre a la plantilla de la tarjeta de credenciales. Cuando el usuario lo presione, el botón llamará a la función renameEl que acabamos de crear:

const getCredentialHtml = (credential, removeEl, renameEl) => {
// ...
 <div class="flex-end">
  <button
    data-credential-id="${credId}"
    @click="${renameEl}"
    class="secondary right"
  >
   Rename
  </button>
 </div>
 // ...
  `;
};

¡Pruébalo! 👩🏻‍💻

  • Haz clic en Cambiar nombre.
  • Ingresa un nombre nuevo cuando recibas la indicación.
  • Haz clic en ACEPTAR.
  • El nombre de la credencial debió cambiarse correctamente y la lista se debería actualizar de forma automática.
  • Volver a cargar la página debería mostrar el nombre nuevo (esto indica que el nombre nuevo avanzó al servidor).

Muestra la fecha de creación de la credencial

La fecha de creación no aparece en las credenciales creadas mediante navigator.credential.create().

Sin embargo, como esta información puede ser útil para que el usuario distinga entre las credenciales, ajustamos la biblioteca del servidor en el código inicial por ti y agregamos un campo creationDate igual a Date.now() cuando se almacenan credenciales nuevas.

En templates.js dentro de class="creation-date" div, agrega lo siguiente para mostrarle al usuario información sobre la fecha de creación:

<div class="creation-date">
  <label>Created:</label>
  <div class="info">
    ${new Date(creationDate).toLocaleDateString()}
    ${new Date(creationDate).toLocaleTimeString()}
  </div>
</div>

9. Optimiza tu código para el futuro

Hasta ahora, solo le solicitamos al usuario que registre un autenticador de roaming simple que, luego, se use como segundo factor durante el acceso.

Un enfoque más avanzado sería utilizar un tipo de autenticador más potente: Un autenticador de roaming que verifica el usuario (UVRA). Un UVRA puede proporcionar dos factores de autenticación y resistencia a la suplantación de identidad (phishing) en los flujos de acceso con un solo paso.

Lo ideal es que admitas ambos enfoques. Para ello, debes personalizar la experiencia del usuario:

  • Si un usuario solo tiene un autenticador de roaming simple (que no verifica el usuario), permite que lo use para lograr una inicialización de cuenta resistente a la suplantación de identidad (phishing), pero también deberá escribir un nombre de usuario y una contraseña. Esto es de lo que nuestro codelab ya se encarga.
  • Si otro usuario cuenta con un autenticador de roaming más avanzado que verifique el usuario, podrá omitir el paso de la contraseña (e incluso el paso del nombre de usuario) durante la inicialización de la cuenta.

Obtén más información en Inicialización de una cuenta resistente a la suplantación de identidad (phishing) con acceso opcional sin contraseña.

En este codelab, en realidad no personalizaremos la experiencia del usuario. Sin embargo, configuraremos tu base de código para que tengas los datos necesarios cuando quieras hacerlo.

Necesitas dos elementos:

  • Establece residentKey: preferred en la configuración de tu backend. Ya nos encargamos de este paso.
  • Configura una forma de averiguar si se creó una credencial detectable (también llamada clave residente).

Para averiguar si se creó una credencial detectable, sigue estos pasos:

  • Consulta el valor de credProps cuando se cree la credencial (credProps: true).
  • Consulta el valor de transports cuando se cree la credencial. De esta forma, podrás determinar si la plataforma subyacente admite funciones de UVRA; es decir, por ejemplo, si se trata realmente de un teléfono celular.
  • Almacena el valor de credProps y transports en el backend. Ya nos encargamos de este paso en el código inicial. Si te interesa, echa un vistazo a auth.js.

Obtengamos el valor de credProps y transports para enviarlos al backend. En auth.client.js, modifica registerCredential de la siguiente manera:

  • Agrega un campo extensions cuando llames a navigator.credentials.create.
  • Configura encodedCredential.transports y encodedCredential.credProps antes de enviar la credencial al backend para su almacenamiento.

registerCredential debe verse de la siguiente manera:

async function registerCredential() {
  // Fetch the credential creation options from the backend
  const credentialCreationOptionsFromServer = await _fetch(
    '/auth/credential-options',
    'POST'
  );
  // Decode the credential creation options
  const credentialCreationOptions = decodeServerOptions(
    credentialCreationOptionsFromServer
  );
  // Create a credential via the browser API; this will prompt the user
  const credential = await navigator.credentials.create({
    publicKey: {
      ...credentialCreationOptions,
      extensions: {
        credProps: true,
      },
    },
  });
  // Encode the newly created credential to send it to the backend
  const encodedCredential = encodeCredential(credential);
  // Set transports and credProps for more advanced user flows
  encodedCredential.transports = credential.response.getTransports();
  encodedCredential.credProps =
    credential.getClientExtensionResults().credProps;
  // Send the encoded credential to the backend for storage
  return await _fetch('/auth/credential', 'POST', encodedCredential);
}

10. Garantiza la compatibilidad entre navegadores

Admite navegadores que no sean de Chromium

En la función registerCredential de public/auth.client.js, llamamos a credential.response.getTransports() en la credencial creada recientemente para guardar esta información en el backend como una sugerencia para el servidor.

Sin embargo, getTransports() no se implementa actualmente en todos los navegadores (a diferencia de getClientExtensionResults, que se admite en todos): la llamada getTransports() arrojará un error en Firefox y Safari, lo que impedirá que se creen credenciales en estos navegadores.

Para asegurarte de que el código se ejecute en todos los navegadores principales, une la llamada encodedCredential.transports en una condición:

if (credential.response.getTransports) {
  encodedCredential.transports = credential.response.getTransports();
}

Ten en cuenta que, en el servidor, transports se configura como transports || []. En Firefox y Safari, la lista transports no será undefined, sino una lista vacía [], lo que evita errores.

Advierte a los usuarios que usan navegadores que no admiten WebAuthn

1e9c1be837d66ce8.png

Aunque WebAuthn es compatible con todos los navegadores principales, se recomienda mostrar una advertencia en los navegadores que no lo sean.

En index.html, observa la presencia de este div:

<div id="warningbanner" class="invisible">
⚠️ Your browser doesn't support WebAuthn. Open this demo in Chrome, Edge, Firefox or Safari.
</div>

En la secuencia de comandos intercalada de index.html, agrega el siguiente código para mostrar el banner en navegadores que no admiten WebAuthn:

// Display a banner in browsers that don't support WebAuthn
if (!window.PublicKeyCredential) {
  document.querySelector('#warningbanner').classList.remove('invisible');
}

En una aplicación web real, harías algo más elaborado y tendrías un mecanismo de resguardo adecuado para estos navegadores, pero así puedes ver cómo comprobar la compatibilidad con WebAuthn.

11. ¡Bien hecho!

✨ Listo

Implementaste la autenticación de dos factores con una llave de seguridad.

En este codelab, abarcamos los conceptos básicos. Si quieres explorar más de WebAuthn para 2FA, aquí tienes algunas ideas sobre lo que podrías intentar:

  • Agrega la información de "Último uso" a la tarjeta de credenciales. Esta información es útil para que los usuarios determinen si una llave de seguridad en particular se usa activamente o no, en especial si registraron varias.
  • Implementa un manejo de errores más sólido y mensajes de error más precisos.
  • Observa auth.js y explora lo que sucede cuando cambias parte de la authSettings, en especial cuando se usa una clave que admite la verificación del usuario.