查看原文
其他

Web认证与WebAuthn

何文力 奇舞周刊 2019-05-16

编者按:本文作者奇舞团前端开发工程师何文力,同时也是 W3C CSS 工作组成员。

W3C Credential Management API

Credential Management API 是一套给提供给网站用于存储用户登陆信息的 API,简单地说可以作为一种自动账号密码填充的功能:

navigator.credentials.store({

  type: 'password',

  id: 'id',

  password: '1234567'

})

下一次登录的时候,我们可以通过上一次存储的账号密码直接登录:

navigator.credentials

    .get({ password: true })

    .then(credential => {

      if (!credential) return

      fetch('/login', {

      method: 'POST',

      body: JSON.stringify({

        username: credential.id,

        password: credential.password

      }).

      credentials: 'include'

    }).then(res => {

      // ....

    })

    })

FIDO2 WebAuthn

那么,WebAuthn 又是干嘛的呢?它是 Credential Management API 的公钥扩展,FIDO2 无密码输入认证体验的 web 部分标准,同时也是 W3C 的官方标准。接下来就来简单了解一下 WebAuthn。

WebAuthn的验证流程

完整的认证流程采用了一种 挑战-应答 (challenge-response)的模式:

  1. 首先客户端要求用户输入用户名发起挑战请求

  2. 随后服务器根据用户名对客户端发起挑战

  3. 随后客户端进行回应,如果此时服务端验证回应是满足的,那么认证成功

WebAuthn 主要分为四个层面:

  • 用户层面:包含输入用户名,以及生物认证等操作

  • API层面:创建公钥,产生断言验证

  • 协议层面:规定挑战-应答流程,对抗钓鱼,对抗重放攻击等实现

  • 硬件层面:在硬件上实现公钥的产生和断言验证的产生的协议: CTAP2

使用 WebAuthn 进行用户注册

用户注册主要需要如下的步骤:

  • 收集用户的基本信息,如用户名等(不需要设置密码)

  • 将信息发给服务端进行验证(如有没有被注册过等)

  • 服务端对此向客户端发起挑战

  • 客户端生成认证信息并响应给服务端

  • 服务端验证客户端的响应,注册完成

收集信息并发送给服务端

这个步骤我们只要将信息收集好传送给后端进行验证即可:

axios.post('/auth/register', {

  username,

  password

})

服务端根据这些信息判断某个用户名是否已经被注册过,这些逻辑就不在这里编写了

服务端向客户端发起挑战

要创建认证信息,我们需要调用 credential 的 create 方法,使用公钥的方式(publicKey)进行生成,publicKey 主要接受以下的参数,这些参数均需要由认证提供方(服务端)进行生成:

dictionary PublicKeyCredentialCreationOptions {

    required PublicKeyCredentialRpEntity rp;

    required PublicKeyCredentialUserEntity user;


    required BufferSource challenge;

    required sequence<PublicKeyCredentialParameters> pubKeyCredParams;


    unsigned long timeout;

    sequence<PublicKeyCredentialDescriptor> excludeCredentials = [];

    AuthenticatorSelectionCriteria authenticatorSelection;

    AttestationConveyancePreference attestation = "none";

    AuthenticationExtensionsClientInputs extensions;

};

下面对必填参数进行简单介绍:

  • rp: 代表了进行认证方 (Replying Party) 的信息,我们需要提供一个名字(name) 参数即可

  • user: 代表了认证方正在进行验证的用户信息,必须携带用户名(name)、显示名称(displayName)以及用户id (Buffer)

  • Challenge: 代表了服务端给客户端发送的"挑战"字符串

  • pubKeyCredParams: 输入一个数组,对公钥生成算法进行协商,算法选项越往前,代表服务更青睐于这一种生成方式

当注册信息验证没有问题之后,我们就可以开始发起挑战了

const crypto = require('crypto')

function requestChallenge({name, displayName, id}) {

  return {

    challenge: crypto.randomBytes(32, (_, buffer) => buffer.toString('hex')),

    rp: {

      name: 'Example Company'

    },

    user: {

      id,

      name,

      displayName

    },

    pubKeyCredParams: [

      { type: 'public-key', alg: -7 }, // COSEAlgorithmIdentifier -7 ES256

      { type: 'public-key', alg: -257 } // COSEAlgorithmIdentifier -257 RS256

    ]

  }

}

客户端生成认证信息

现在,我们已经拿到了服务端给的挑战信息 ,还需要稍稍处理一下才能直接给到 create 方法中,

前面提到 challenge 以及 user.id 必须是一个 buffer, 但是 JSON 不能传输 buffer, 我们在上面的方法中都已经处理成hex字符串了,所以我们需要将字符串转换回 buffer。

axios

  .post('/auth/register', info)

    .then(data => {

      data.challenge = Buffer.from(data.challenge, 'hex')

      data.user.id = Buffer.from(data.user.id, 'hex')

      // 生成

      navigator.credentials.create({ publicKey: data });

    })

如果一切正确,那么浏览器将会弹出认证窗口:

浏览器会列出所有支持的认证方式,选择一个你喜欢的认证方式进行认证:

认证完成之后,我们会从认证器中得到如下的响应 ,将响应发送到到服务端进行验证:

同样的,buffer 要转换一下

服务端对响应进行验证

我们主要对 attestationObject 进行验证

function verifyAuthnticator(resp) => {

  const att = resp.response.attestationObject

  if (att.fmt === 'fido-u2f') {

    const authData = parseAuthData(att.authData)

    if (!(authData.flags & 0x01)) {

      return new Error('认证时需要用户参与')

    }


    const publicKey = COSEECDHAtoPKCS(authData.COSEPublucKey)

    const signatureBase = Buffer.concat([

      Buffer.from([0x00]),

      authData.rpIdHash,

      crypto.createHash('SHA@%^').update(resp.response.clientDataJSON).digest(),

      authData.credID,

      publicKey

    ])


    const PEMCertificate = ASN1toPEM(att.attStmt.x5c[0])

    const signature = att.attStmt.sig;


    const success = crypto.createVerify('SHA256')

                                                .update(signatureBase)

                                                .verify(PEMCertificate, signature)


    return {

      fmt: 'fido-u2f',

      publicKey,

      counter: authData.counter,

      credID: authData.CredID

    };

  }


  return false;

}

在这个例子中,我们对 fido-u2f 的认证格式进行验证:

  1. 我们对 authData 按照规定解开


我们只要对这个 buffer 进行 slice 解开即可,需要注意的是:CredID 的长度由前面的 CredID Len 指定:

function parseAuthData(buffer) {

  const credIdLen = buffer.slice(56, 2).readUInt16BE(0) // 56 - 58

  return {

    rpIdHash: buffer.slice(0, 32), // 0-32

    flags: buffer.slice(33, 1), // 33

    counter: buffer.slice(34, 4), // 34 - 38

    aaguid: buffer.slice(39, 16), // 39 - 55

    CredId: buffer.slice(55, credIdLen), // 58 - x

    COSEPublicKey: buffer.slice(55 + credIdLen + 1, 77)

  }

}

将 COSE 公钥转换为 PKCS 作为公钥

const cbor = require('cbor')

function COSEECDHAtoPKCS(COSEPublicKey) {

  const cose = cbor.decodeAllSync(COSEPublicKey)[0];

  return Buffer.concat([

    Buffer.from([0x04]),

    cose.get(-2),

    cose.get(-3)

  ])

}

将 X.509 转为 PEM

function ASN1toPEM(buffer) {

  let type = "";

  // SPKI DER

  if (buffer.length === 65 && buffer[0] === 0x04) {

    buffer = Buffer.concat([

      // 3059... 字符串, SPKI在DER格式中是固定的

      new Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex'),

      buffer

    ])


    type = 'PUBLIC KEY'

  } else {

    type = 'CERTIFICATE'

  }


  const base64 = buffer.toString('base64')

  let PEM = '';

  for (let i = 0; i < Math.ceil(base64.length / 64); i++) {

    PEM += base64.substr(64 * i, 64) + '\n';

  }


  return `-----BEGIN ${type}-----\n${PEM}-----END ${type}-----\n`

}

验证完毕之后,我们只需要存储 publicKey, counter 以及 CredID 即可完成与某个设备的认证关联,结束注册流程。

使用 WebAuthn 进行用户认证

我们对用户注册完成之后,下次用户再进入时,就可以通过同一个设备直接认证登录了,在流程上,与注册相差不太大

  • 收集用户的用户名(不需要密码)

  • 将信息发给服务端进行验证(检查是否已经注册)

  • 服务端对此向客户端发起挑战

  • 客户端生成断言认证信息并响应给服务端

  • 服务端验证客户端的响应,登录完成

将用户名发送给服务端并发起挑战

从页面将用户名发送给服务端之后,服务端需要发起挑战,同时需要将这个用户已经完成关联的CredID 发送给客户端,以便客户端对挑战回应正确的响应:

function login(username) {

  const user = db.findOne({ username })

  return {

    challenge: '随机挑战字符串',

    allowCrendentials: user.auth

  }

}

客户端产生断言验证信息并验证

axios.post('/login', info)

    .then(res => {

      // 不要忘记 buffer 转换

      navigator.credentials.get({ publicKey: res })

    })

产生断言信息之后,将信息发送回服务端进行验证,这里和注册阶段有一些不同的是:

  • attestationObject 变成 attestationData 了

  • 验证过程中,由于我们在注册阶段已经存储了 publicKey,那么我们只要从数据库中重新取出来验证即可,也就是不需要对 COSE 进行转换了

  • authData 结构的变化


其他

Counter 的验证

客户端在每一次成功的验证时,Counter 都会增加,客户端在使用私钥签名时,会将 Counter 一同签名,那么重放攻击时,只要 Counter 没增加可以拒绝登录,当攻击者修改 Counter 时,在签名验证阶段将会无法通过。

WebAuthn 的好处

  • 消除弱密码问题,认证全由客户端完成

  • 杜绝中间人/重放攻击等

  • 对于认证方,即使出现数据泄漏,也没有认证信息可以泄漏

WebAuthn 的不方便之处

  • 需要使用固定的设备,如  Yubico 生产的 USB 设备等

小结

通过对无密码认证流程的大致了解,其在安全性和方便性上都给用户带来了很大的提升,期待硬件和网站/软件厂商的支持。

参考

  • https://www.w3.org/TR/webauthn/

  • https://w3c.github.io/webappsec-credential-management/

  • https://slides.com/fidoalliance/jan-2018-fido-seminar-webauthn-tutorial

  • https://tools.ietf.org/html/rfc8152

  • https://webauthn.guide/

  • https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API/Attestation_and_Assertion

  • https://www.yubico.com/2018/08/10-things-youve-been-wondering-about-fido2-webauthn-and-a-passwordless-world/

  • https://nostdahl.com/2017/08/11/x-509-certificates-explained/

  • https://github.com/NG-Studio-Development/FriendStep/blob/master/app/libs/alexutilities/src/main/java/com/alexutils/helpers/EncryptionHelper.java



关于奇舞周刊

《奇舞周刊》是360公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存