⤴Top⤴

WebAuthn

博客分类: 后端
修改内容:add 2FA & TOTP

WebAuthn

WebAuthn

什么是 WebAuthn

此文章基本摘自谈谈 WebAuthn

WebAuthn(Web Authentication),是一个用于在浏览器上进行认证的 API,W3C 将其表述为 “An API for accessing Public Key Credentials”,即“一个用于访问公钥凭证的 API”。WebAuthn 很强大,强大到被认为是 Web 身份认证的未来。你有想过通过指纹或者面部识别来登录网站吗?WebAuthn 就能在保证安全和隐私的情况下让这样的想法成为现实。WebAuthn 标准是 FIDO2 标准的一部分,而 FIDO2 则是由 FIDO 联盟和 W3C 共同推出的 U2F(现称作 FIDO1)的后继标准,旨在增强网络认证的安全性。

webauthn

你可以在这个网站自行体验 WebAuthn 👈

首先我们要弄清楚一些常用的术语,在一个完整的 WebAuthn 认证流程中,通常有这么几个角色:

认证过程通常分为两种:

同时,认证过程中还会产生这些内容:

和 HTTPS 一样,WebAuthn 使用非对称加密的思路来保证安全性,具体什么是非对称加密,可以参考这篇 👈

非对称加密认证流程

和普通的密码一样,使用 WebAuthn 分为两个部分,注册和验证。注册仪式会在依赖方中将认证器的一些信息和用户建立关联;而验证仪式则是验证这些信息以登确保是用户本人在登录。即注册仪式就是认证器生成一对公私钥,然后将公钥交给依赖方;而验证仪式是依赖方发送给认证器一段文本,要求认证器用自己的私钥加密后发回以验证。在实际情况中,WebAuthn 是基于挑战-应答模型工作的:

  1. 浏览器向依赖方发送某个用户的注册请求
  2. 依赖方向浏览器发送挑战、依赖方信息和用户信息
  3. 浏览器向认证器发送挑战、依赖方信息、用户信息和客户端信息以请求创建公钥凭证
  4. 认证器请求用户动作,随后创建一对公私钥,并使用私钥签名挑战(即证明),和公钥一起交给浏览器
  5. 浏览器将签名后的挑战和公钥发送给依赖方
  6. 依赖方用公钥验证挑战是否与发送的一致,如果成功则将公钥与用户绑定,注册完成

flow-1

而之后的验证流程如下:

  1. 浏览器向依赖方发送某个用户的验证请求
  2. 依赖方向浏览器发送挑战
  3. 浏览器向认证器发送挑战、依赖方信息和客户端信息以请求获取公钥凭证
  4. 认证器请求用户动作,随后通过依赖方信息找到对应私钥,并使用私钥签名挑战(即断言),交给浏览器
  5. 浏览器将签名后的挑战发送给依赖方
  6. 依赖方用之前存储的公钥验证挑战是否与发送的一致,一致则验证成功

flow-2

可以看到,WebAuthn 在整个过程中并没有隐私数据被传输,用户信息实际上只包含用户名和用户 ID。因此我们完全可以说 WebAuthn 是安全且私密的。为了避免用户在不同依赖方之间被追踪,认证器通常会为每个依赖方和用户的组合都创建一对公私钥。不过,由于认证器的存储空间有限,认证器通常不会存储每一个私钥,而是会通过各类信息和烧录在认证器内的主密钥“算”出对应的私钥以实现无限对公私钥。具体算法根据不同厂商会有所不同。对于 Yubikey,你可以在这里了解更多。

浏览器 API

要使用 WebAuthn,我们必须要依靠浏览器作为媒介和验证器进行交互,而这就需要浏览器对于 WebAuthn 的支持了。绝大多数新版本的现代浏览器都为 WebAuthn 提供了统一的接口。我们可以使用 navigator.credentials.create() 请求认证器生成公钥凭证和 navigator.credentials.get() 请求获取公钥凭证。

navigator.credentials.create({
    publicKey: {
        challenge, // 转换为 Uint8Array 的挑战,长度至少为 16,建议为 32
        rp: { // 依赖方信息
            id, // 可选,依赖方 ID,必须为当前域名或为当前域名的子集的域名
            name // 依赖方名称,用于方便用户辨认
        },
        user: {
            id, // 转换为 Uint8Array 的字符串。出于安全考量,这应尽可能不与任何用户信息相关联,如不要包含用户名、用户邮箱等
            name, // 登录用户名
            displayName // 用于显示的用户名称
        },
        pubKeyCredParams: [ // 一个算法列表,指明依赖方接受哪些签名算法
            {
                type: "public-key", // 值只能为 "public-key"
                alg // 一个负整数,如 -7,用于标明算法。具体算法对应的数字可以在 [COSE](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) 找到
            }
        ],
        authenticatorSelection: { // 可选,用于过滤正确的认证器
            authenticatorAttachment, // 指定要求的认证器类型
            userVerification // 指定认证器是否需要验证“用户为本人 (User Verified, UV)”,否则只须“用户在场 (User Present, UP)”。
        },
        excludeCredentials: [ // 可选,用于标识要排除的凭证,可以避免同一个用户多次注册同一个认证器
            {
                id, // 要排除的凭证 ID
                transports: [], // 用于指定该凭证所需的认证器与用户代理的通信方式
                type: "public-key"
            }
        ],
        timeout // 可选,方法超时时间的毫秒数,超时后将强制终止 create() 并抛出错误。若不设置,将使用用户代理的默认值
    }
})

而对于 navigator.credentials.get(),我们可以传入如下的参数:

navigator.credentials.get({
    publicKey: {
        challenge,
        rpId, // 可选,依赖方 ID,需要和注册认证器时的一致
        userVerification,
        allowCredentials: [ // 可选,用于标识允许的凭证 ID,使用户代理找到正确的认证器
            {
                id,
                transports: [],
                type: "public-key"
            }
        ],
        timeout
    }
})

不管是调用哪个,我们就可以拿到一个 Promise,并可以在 then 中获得认证器返回的 PublicKeyCredential 对象:

PublicKeyCredential {
    rawId: ArrayBuffer(32) {}, // ArrayBuffer 的原始凭证 ID
    response: AuthenticatorAttestationResponse {
        attestationObject: ArrayBuffer(390) {}, // CBOR 编码的认证器数据,包含凭证公钥、凭证 ID、签名(如果有)、签名计数等信息
        clientDataJSON: ArrayBuffer(121) {} // 客户端数据,包含 origin(即凭证请求来源)、挑战等信息
    },
    id: "VByF2w2hDXkVsevQFZdbOJdyCTGOrI1-sVEzOzsNnY0", // Base64 URL 编码的凭证 ID
    type: "public-key"
}

举一个稍微完整点的例子,用的 ejs 模板:

navigator.credentials
  .get({
    publicKey: {
      // random, cryptographically secure, at least 16 bytes
      challenge: base64url.decode("<%= challenge %>"),
      allowCredentials: [
        {
          id: base64url.decode("<%= id %>"),
          type: "public-key",
        },
      ],
      timeout: 15000,
      authenticatorSelection: { userVerification: "preferred" },
    },
  })
  .then((res) => {
    var json = publicKeyCredentialToJSON(res);
    // Send data to relying party's servers
    post("/webauthn/authenticate", {
      state: "<%= state %>",
      provider: "<%= provider %>",
      res: JSON.stringify(json),
    });
  })
  .catch((err) => {
    alert("Invalid FIDO device");
  });

双因素认证与 TOTP

一般来说,三种不同类型的证据,可以证明一个人的身份:

这些证据就称为三种”因素”(factor)。因素越多,证明力就越强,身份就越可靠。双因素认证(Two-factor authentication,简称 2FA)则代表同时需要两个因素的证据。银行卡就是最常见的双因素认证。用户必须同时提供银行卡和密码,才能取到现金。还有国内的很多网站要求,用户输入密码时,需要提供短消息发送的验证码,以证明用户确实拥有该手机。

双因素认证的优点在于,比单纯的密码登录安全得多。就算密码泄露,只要手机还在,账户就是安全的。各种密码破解方法,都对双因素认证无效。但是,短消息是不安全的,容易被拦截和伪造,SIM 卡也可以克隆。已经有案例,先伪造身份证,再申请一模一样的手机号码,把钱转走。因此,安全的双因素认证不是密码 + 短消息的组合,而是下面要介绍的 TOTP。

TOTP 的全称是”基于时间的一次性密码”(Time-based One-time Password)。它是公认的可靠解决方案,已经写入国际标准 RFC6238:

  1. 用户开启双因素认证后,服务器生成一个密钥。
  2. 服务器提示用户扫描二维码(或者使用其他方式),把密钥保存到用户的手机。也就是说,服务器和用户的手机,现在都有了同一把密钥。注意,密钥必须跟手机绑定。一旦用户更换手机,就必须生成全新的密钥。
  3. 用户登录时,手机客户端使用这个密钥和当前时间戳,生成一个哈希,有效期默认为 30 秒。用户在有效期内,把这个哈希提交给服务器。
  4. 服务器也使用密钥和当前时间戳,生成一个哈希,跟用户提交的哈希比对。只要两者不一致,就拒绝登录。

其实现原理也较简单:

var tfa = require('2fa')

// 生成一个 32 位字符的密钥
// b5jjo0cz87d66mhwa9azplhxiao18zlx
tfa.generateKey(32, function(err, key) {
  console.log(key)
})

// 生成哈希,且有效期默认为 30s
var tc = Math.floor(Date.now() / 1000 / 30)
var totp = tfa.generateCode(key, tc)
console.log(totp) // 683464

参考链接

  1. 谈谈 WebAuthn By 无垠
  2. Web Authentication
  3. 双因素认证(2FA)教程 By 阮一峰