解释 JavaScript 中的 WebAuthn (FIDO2) 如何实现更安全的无密码认证,并避免钓鱼攻击。

各位观众,早上好!(或者下午好、晚上好,取决于你们熬夜编程的热情程度…)

今天咱们来聊聊一个听起来高大上,但实际上贼好用的东西:WebAuthn,江湖人称“FIDO2”,它是如何革了密码的命,并让钓鱼攻击无处遁形的。

密码,你的时代要结束了!

密码这玩意,简直是安全界的“猪队友”。咱们每天要记住一大堆密码,稍不留神就被黑客盗了去。而且,密码复用、弱密码等问题层出不穷,简直防不胜防。更别提那些无聊的“找回密码”流程,简直比连续剧还长。

WebAuthn的出现,就是为了解决这些问题。它提供了一种更安全、更便捷的身份验证方式,让你彻底摆脱密码的困扰。

WebAuthn:身份验证的超级英雄

WebAuthn,全称Web Authentication,它是一种Web API,允许网站使用公钥密码学进行用户身份验证。简单来说,就是用你的设备(比如指纹识别器、面部识别器、安全密钥)来生成一对密钥:公钥和私钥。公钥交给网站,私钥留在你的设备上。每次你需要登录时,你的设备会用私钥对服务器发来的请求进行签名,然后把签名发回服务器。服务器用之前保存的公钥验证签名,如果验证成功,就说明你是本人。

这样一来,黑客就算盗走了你的公钥,也没用,因为他们没有你的私钥,无法冒充你登录。而且,WebAuthn还具有抗钓鱼攻击的能力,因为私钥只在你的设备上生成和使用,不会被泄露到互联网上。

FIDO2:WebAuthn的幕后推手

FIDO2是WebAuthn背后的技术标准,它定义了WebAuthn的工作方式和安全性要求。FIDO2由两个主要组件组成:

  • CTAP(Client to Authenticator Protocol): 用于浏览器(或应用程序)和身份验证器(比如指纹识别器、安全密钥)之间的通信。
  • WebAuthn API: 用于Web应用程序与浏览器之间的通信,从而触发身份验证流程。

WebAuthn的优势:

优点 描述
安全性高 使用公钥密码学,私钥永远不离开你的设备,有效防止密码泄露和重放攻击。
抗钓鱼 验证过程绑定到特定的网站域名,即使黑客伪造了网站,你的身份验证器也不会上当。
用户体验好 使用指纹、面部识别等生物特征进行身份验证,无需记住复杂的密码。
跨平台兼容 WebAuthn是一个开放标准,可以在各种浏览器和设备上使用。
无密码认证 允许完全摆脱密码,仅使用身份验证器进行登录。
多因素认证 (MFA) 可以作为一种强大的多因素认证方式,提高账户安全性。

WebAuthn实战:代码撸起来!

好了,理论讲得差不多了,咱们开始撸代码。下面我将演示如何使用JavaScript实现WebAuthn的注册和登录流程。

1. 注册流程:

首先,我们需要在服务器端生成注册请求。这个请求包含一些元数据,比如网站的域名、用户的ID等。

// 服务器端代码 (Node.js)
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const { generateRegistrationOptions } = require('@simplewebauthn/server');

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

app.post('/register/begin', async (req, res) => {
  const { username } = req.body;

  // 模拟数据库查询,检查用户名是否已存在
  const user = {
    id: Buffer.from(username).toString('hex'), // 将用户名转换为唯一的id,实际场景应该从数据库获取
    name: username,
    registered: false, // 假设用户还未注册
  };

  if (user.registered) {
    return res.status(400).json({ error: 'User already registered' });
  }

  const options = await generateRegistrationOptions({
    rpName: 'My Awesome App', // 网站名称
    rpID: 'localhost', // 网站域名 (实际部署时需要改为真实域名)
    userID: user.id, // 用户ID
    userName: user.name, // 用户名
    userDisplayName: user.name, // 用户显示名称
    attestationType: 'direct', // attestation类型,可以设置为'direct', 'indirect', 'none'
    excludeCredentials: [], //  排除已注册的凭据,避免重复注册
    authenticatorSelection: {
      residentKey: 'preferred', //  要求认证器存储凭据(resident key)
      userVerification: 'preferred', //  要求用户验证(user verification)
    },
  });

  // 模拟将注册选项存储到数据库中,以便稍后验证
  // 在实际场景中,应该使用数据库存储
  options.username = username; //  存储用户名,方便后续步骤使用
  global.registrationOptions = options;

  res.json(options);
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

这段代码使用 @simplewebauthn/server 库来生成注册选项。你需要替换 rpNamerpID 为你自己的网站信息。 userID 应该是一个唯一的标识符,用于标识用户。

接下来,我们需要在客户端使用WebAuthn API来调用身份验证器,并获取注册结果。

// 客户端代码 (JavaScript)
async function register() {
  const username = document.getElementById('username').value;
  const registrationData = await fetch('http://localhost:3000/register/begin', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ username }),
  }).then(res => res.json());

  if (registrationData.error) {
    alert(registrationData.error);
    return;
  }

  try {
    const credential = await navigator.credentials.create({
      publicKey: registrationData,
    });

    // 将注册结果发送到服务器进行验证
    const verificationResult = await fetch('http://localhost:3000/register/finish', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        username: registrationData.username, //  传递用户名
        credential: {
          id: credential.id,
          rawId: Array.from(new Uint8Array(credential.rawId)), // 将 ArrayBuffer 转换为数字数组
          type: credential.type,
          response: {
            attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)), // 将 ArrayBuffer 转换为数字数组
            clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)), // 将 ArrayBuffer 转换为数字数组
          },
        },
      }),
    }).then(res => res.json());

    if (verificationResult.success) {
      alert('Registration successful!');
    } else {
      alert('Registration failed: ' + verificationResult.message);
    }
  } catch (error) {
    console.error('Registration error:', error);
    alert('Registration error: ' + error.message);
  }
}

这段代码首先从服务器获取注册选项,然后调用 navigator.credentials.create() 方法来创建凭据。这个方法会提示用户使用他们的身份验证器(比如指纹识别器、安全密钥)进行身份验证。

最后,我们需要将注册结果发送到服务器进行验证。

// 服务器端代码 (Node.js)
const { verifyRegistrationResponse } = require('@simplewebauthn/server');

app.post('/register/finish', async (req, res) => {
  const { username, credential } = req.body;

  // 模拟从数据库中获取注册选项
  const options = global.registrationOptions;

  const expectedChallenge = options.challenge;

  try {
    const verification = await verifyRegistrationResponse({
      credential,
      expectedChallenge,
      expectedOrigin: `http://localhost:3000`, // 网站 origin (实际部署时需要改为真实 origin)
      expectedRPID: 'localhost', // 网站域名 (实际部署时需要改为真实域名)
    });

    const { verified, registrationInfo } = verification;

    if (verified) {
      // 模拟将用户信息和凭据信息存储到数据库中
      // 在实际场景中,应该使用数据库存储
      const user = {
        id: Buffer.from(username).toString('hex'),
        name: username,
        registered: true,
        credentialId: credential.id,
        credentialPublicKey: registrationInfo.credentialPublicKey,
      };
      global.user = user; //  模拟存储用户
      return res.json({ success: true });
    } else {
      return res.json({ success: false, message: 'Registration verification failed' });
    }
  } catch (error) {
    console.error('Registration verification error:', error);
    return res.status(500).json({ success: false, message: error.message });
  }
});

这段代码使用 @simplewebauthn/server 库来验证注册结果。你需要替换 expectedOriginexpectedRPID 为你自己的网站信息。如果验证成功,你需要将用户信息和凭据信息存储到数据库中。

2. 登录流程:

登录流程与注册流程类似,首先我们需要在服务器端生成登录请求。

// 服务器端代码 (Node.js)
const { generateAuthenticationOptions } = require('@simplewebauthn/server');

app.post('/login/begin', async (req, res) => {
  const { username } = req.body;

  // 模拟从数据库中查询用户信息
  // 在实际场景中,应该使用数据库查询
  const user = global.user; //  假设在注册流程中存储了用户信息

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  const options = await generateAuthenticationOptions({
    allowCredentials: [{
      id: user.credentialId, //  允许的凭据 ID
      type: 'public-key',
    }],
    userVerification: 'required', // 要求用户验证(user verification)
  });

  // 模拟将登录选项存储到数据库中,以便稍后验证
  options.username = username;
  global.authenticationOptions = options;

  res.json(options);
});

这段代码使用 @simplewebauthn/server 库来生成登录选项。你需要从数据库中查询用户信息,并将用户的凭据ID添加到 allowCredentials 数组中。

接下来,我们需要在客户端使用WebAuthn API来调用身份验证器,并获取登录结果。

// 客户端代码 (JavaScript)
async function login() {
  const username = document.getElementById('username').value;
  const authenticationData = await fetch('http://localhost:3000/login/begin', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ username }),
  }).then(res => res.json());

  if (authenticationData.error) {
    alert(authenticationData.error);
    return;
  }

  try {
    const credential = await navigator.credentials.get({
      publicKey: authenticationData,
    });

    // 将登录结果发送到服务器进行验证
    const verificationResult = await fetch('http://localhost:3000/login/finish', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        username: authenticationData.username,
        credential: {
          id: credential.id,
          rawId: Array.from(new Uint8Array(credential.rawId)),
          type: credential.type,
          response: {
            authenticatorData: Array.from(new Uint8Array(credential.response.authenticatorData)),
            clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
            signature: Array.from(new Uint8Array(credential.response.signature)),
            userHandle: credential.response.userHandle ? Array.from(new Uint8Array(credential.response.userHandle)) : null, //  用户句柄可能为空
          },
        },
      }),
    }).then(res => res.json());

    if (verificationResult.success) {
      alert('Login successful!');
    } else {
      alert('Login failed: ' + verificationResult.message);
    }
  } catch (error) {
    console.error('Login error:', error);
    alert('Login error: ' + error.message);
  }
}

这段代码首先从服务器获取登录选项,然后调用 navigator.credentials.get() 方法来获取凭据。这个方法会提示用户使用他们的身份验证器进行身份验证。

最后,我们需要将登录结果发送到服务器进行验证。

// 服务器端代码 (Node.js)
const { verifyAuthenticationResponse } = require('@simplewebauthn/server');

app.post('/login/finish', async (req, res) => {
  const { username, credential } = req.body;

  // 模拟从数据库中获取用户信息
  const user = global.user;

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  const options = global.authenticationOptions;

  try {
    const verification = await verifyAuthenticationResponse({
      credential,
      expectedChallenge: options.challenge,
      expectedOrigin: `http://localhost:3000`, // 网站 origin (实际部署时需要改为真实 origin)
      expectedRPID: 'localhost', // 网站域名 (实际部署时需要改为真实域名)
      authenticator: {
        credentialID: user.credentialId,
        credentialPublicKey: user.credentialPublicKey,
        counter: 0, // 认证器计数器,用于防止重放攻击,实际场景中应该从数据库获取并更新
      },
    });

    const { verified } = verification;

    if (verified) {
      // 登录成功
      return res.json({ success: true });
    } else {
      return res.json({ success: false, message: 'Login verification failed' });
    }
  } catch (error) {
    console.error('Login verification error:', error);
    return res.status(500).json({ success: false, message: error.message });
  }
});

这段代码使用 @simplewebauthn/server 库来验证登录结果。你需要从数据库中查询用户信息,并将其中的凭据信息传递给 verifyAuthenticationResponse 方法。如果验证成功,说明用户已经成功登录。

注意事项:

  • 错误处理: 在实际开发中,你需要添加更完善的错误处理机制,以应对各种可能出现的问题。
  • 数据库存储: 你需要使用数据库来存储用户信息、凭据信息和注册/登录选项。
  • 安全性: 你需要采取额外的安全措施,比如防止跨站请求伪造(CSRF)攻击。
  • 用户体验: 你需要设计良好的用户界面,让用户能够轻松地进行注册和登录。
  • Origin 和 RPID: 务必正确配置 expectedOriginexpectedRPID, 否则会导致验证失败。 expectedOrigin 是网站的完整 URL (例如 https://example.com), expectedRPID 是网站的域名 (例如 example.com)。 在开发环境中,可以使用 http://localhost:<port> 作为 origin, localhost 作为 RPID。 在生产环境中,务必替换为真实值。
  • ArrayBuffer 转换: WebAuthn API 返回的数据通常是 ArrayBuffer 类型,需要将其转换为数字数组 (例如 Array.from(new Uint8Array(arrayBuffer))) 才能通过 JSON 序列化传递到服务器。 在服务器端,收到数据后,可能需要将其转换回 BufferArrayBuffer 类型进行处理。
  • Counter (计数器): authenticator.counter 用于防止重放攻击。 每次成功认证后,认证器的计数器都会递增。 在服务器端,需要存储认证器的计数器值,并在下次认证时验证计数器值是否大于上次的值。 如果计数器值小于或等于上次的值,则说明可能存在重放攻击。

总结:

WebAuthn (FIDO2) 是一种革命性的身份验证技术,它能够有效地提高网站的安全性,并改善用户体验。 虽然实现起来可能需要一些时间和精力,但它绝对值得你投入。

希望今天的讲座能帮助你更好地理解WebAuthn。 记住,安全无小事,让我们一起努力,打造更安全的互联网!

有什么问题,欢迎提问! (我尽力回答,毕竟我也是在不断学习中…)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注