之前某个睡不着的晚上不知道为何想起关于非对称算法(或者叫公钥加密),突发奇想,用户是否能不用密码,通过一个公私钥对向服务器注册和证明自己呢?结果一搜发现还真有这样的技术,那就是 WebAuthn。

记得 今年 去年年中(怎么这篇博客写了一年啊喂!)写过一篇关于 Credential Management API 的博客,至于为什么先写了那篇文章,大概因为咱搜索知识的时候是深度优先的吧(逃)。看过 MDN 文档的小伙伴肯定发现了这个 API 与 WebAuthn API 都集成在了 CredentialsContainer 这个对象上,本文就来简单地讲讲关于 WebAuthn 以及如何实现一个简单的 WebAuthn Demo。

什么是 WebAuthn

简单来说,WebAuthn 是一套使用 Passkey 的用于认证的 Web 标准。这里有一个新词,Passkey 指的是基于非对称加密算法密钥的认证方式中的凭证(Passkey 的概念比较泛,也许有人也把这种认证方式叫做 Passkey)。

关于非对称加密,简单来说就是存在一个密钥对,一个叫私钥,一个叫公钥,其中私钥可以推算出公钥,但公钥无法推算出私钥,一段数据可以使用私钥签名,然后使用公钥进行验证,可以判断出数据是否是这个公钥所对应的私钥签名的。至于细节,感兴趣的话可以直接去网上搜索相关的科普视频,现在已经有非常多讲得很好的视频了。

基于以上的非对称加密算法,就可以将咱们的注册和认证换成以下的流程:(懒得画图了,直接上文字(逃))

注册:

  1. 用户:我想要注册,我叫 Ayaka。
  2. 服务端:请给我你的公钥。
  3. 用户生成一个私钥,并推算对应的公钥,将公钥发送给服务端,服务端把公钥保存在数据库中。注册完毕。

认证:

  1. 用户:我是 Ayaka,我想要登录。
  2. 服务端:请用 Ayaka 的私钥签名这段数据:abcdef……xxx。(服务端随便生成的一段数据,这段数据叫做挑战(challenge)
  3. 用户将数据使用私钥签名后发送给服务端。
  4. 服务端使用此前保存好的用户 Ayaka 的公钥验证这段数据是否被 Ayaka 的私钥所签名。

通过这种认证方式,只要你的私钥不泄漏,理论上就没有人可以冒充你。

WebAuthn 用途

前面说到 WebAuthn 只是一种认证的标准,它只是在注册和认证的过程中替代了密码的作用,因此密码可以做什么,自然也可以使用 WebAuthn 进行替换。例如开发无密码应用(指整个应用从注册到认证都不需要用户提供密码),或者将其用于 2FA(2 factor authentication,双因素认证),或者是作为已经使用密码的站点的备选认证方法。

本文后面会从 WebAuthn API 解析并编写一个简单的 Demo,当然如果理解了 WebAuthn,也能很轻易地将其用于 2FA 或者其他认证场景,你也可以发挥想象力,想想还能把它用在什么样的场景下。毕竟 Node.js 发明出来的时候只是为了让 JavaScript 运行在服务端,谁能想到后来凭借 Node.js 的能力,反而促成了前端工程化快速发展,大量前端编译构建的工具都是基于 Node.js 开发的(虽然现在因为性能原因很多都在锈化或者锈化的路上了(逃))。

相关术语

对于刚刚接触 WebAuthn 的人来说新的术语实在是太多了,咱在学习的时候因为没有先整理好并且理解相关术语,导致看后续内容的时候一直云里雾里,因此这里先放出来后面会说到的一些术语。

挑战(Challenge)
服务端生成的一段随机数据,交给用户签名,以此来对用户进行认证。

依赖方(Relying Party)
一般简写为 RP。在 Web 应用中通常指的就是你所注册/登录的网站。

认证器(Authenticator)
在注册认证流程中,会有一个硬件设备为你生成公私钥对,以及将你传递给它的挑战使用指定的私钥签名,这个设备就是认证器。
认证器通常会分为平台认证器和跨平台认证器。平台认证器是指内嵌在你的设备上的认证器,一般它利用你的笔记本或者手机上的指纹或人脸识别等硬件进行认证。跨平台认证器是指可以带着跑的随时换一台设备使用的认证器,比如那些需要通过 USB、NFC 或者蓝牙与设备进行通信才能帮你完成认证的。

证明(Attestation)和断言(Assertion)
证明是指在注册仪式中,RP 要求认证器对自己进行证明的过程。断言是指在认证仪式中,RP 要求认证器对挑战进行签名的过程。

仪式(Ceremony)
WebAuthn 将用户的认证和注册的流程分为了两个仪式,也就是注册和认证。搞出来这个概念可能是因为仪式可能需要涉及到与有状态服务器的多轮交互,一般用户是无法通过一个 HTTP 请求完成注册和认证过程的,服务器也无法在无状态的架构下完成对用户的注册和认证。

从 API 开始

下面先从 CredentialsContainer 所提供的 WebAuthn API 来看看 WebAuthn 在前端是如何使用的。

create

在 WebAuthn API 中,使用 navigator.credentials.create 方法来让浏览器调用设备上的认证器创建一个公私钥对。

先看 General Syntax,它接受一个对象参数,对象可以包含三种键,除了之前的文章中讲到过的 federatedpassword 以外,就是 publicKey 了,WebAuthn API 在 CredentialContainer 中会一直使用这个 key 作为它的专属键名,表明你想操作的凭证是 WebAuthn 的凭证。

  • attestation
    指明 RP(依赖方)是否需要认证器对进行证明。一般没有比较高的安全需求设置为 none 即可。

  • attestationFormats
    指明依赖方期望认证器使用什么格式来证明。因为一般情况下不需要证明,这里就不展开了。

  • authenticatorSelection

    • authenticatorAttachment
      指明应该使用哪种认证器,值可以是 platform(平台认证器)或者 cross-platform(跨平台认证器),如果不指明则表明两种认证器都可以使用。
      • requireResidentKey
        这个值已经被废弃,保留是为了兼容 WebAuthn 1。其实也可以发现 WebAuthn 的 API 在设计上更喜欢设定按照偏好程度来协商的选项,
      • residentKey
        指明 RP 是否希望客户端创建客户端可发现凭证,与之对应的是服务端凭证,这个参数的值是 discouraged preferred required 三件套,之所以叫三件套是因为你还会在别的参数中看到它们仨,它们分别表示 “服务端不希望你这样做”“服务端希望你这样做”和“服务端要求你这样做”。一般情况下使用服务端凭证就好了,因此设置 discouraged。咱在 Windows Hello 上配合这些参数使用没有感到有什么区别,猜测是因为 Windows Hello 会永远创建客户端可发现凭证。
      • userVerification
        指明 RP 是否希望对用户进行验证。它的参数是 discouraged preferred required 三件套。据说不需要高安全的情况下设置为 discouraged 就够了,可以减少对用户的打扰,但是咱使用 Windows Hello 测试的时候并没有感觉到有什么区别,也许在使用 Yubiki 之类的认证器的时候会有不一样的体验吧!(以后有钱了买一个试试!)
  • challenge
    RP 生成的挑战,类型是 ArrayBuffer,如果没有要求或者认证器不想证明,就不会签名,反之认证器其会对这段数据进行签名。

  • excludeCredentials
    这是一个对象的数组,用于排除掉用户已经注册过的认证器,防止一个认证器反复注册。对象包含以下字段:

    • id 凭证的 ID,类型是 ArrayBuffer。
    • transports 描述认证器的传输方式。
    • type 目前只能是 public-key
    • extensions 认证器在创建凭证的过程中希望添加的一些额外数据。
  • pubKeyCredParams
    一个对象数组,表明 RP 支持的签名算法。

  • rp
    一个对象,描述了 RP 的信息

    • id RP 的 ID,等同于 HTTP 中的 Origin 这个概念去掉协议(因为 WebAuthn 只能在安全上下文中使用)和端口部分。
    • name 显示给用户的 RP 名称。
  • timeout
    本次操作等待的超时时间,如果超时则会终止用户的操作并抛出一个异常。

  • user
    描述用户账号的对象

    • id 表示用户账号 ID 的 ArrayBuffer。
    • name 用户名,一般使用邮箱或用户名等用户注册时使用的唯一字段。
    • displayName 更人性化一些的用户名,一般使用用户设定的昵称等。

get

在 WebAuthn API 中,使用 navigator.credentials.get 方法来让浏览器调用设备上的认证器为一个挑战签名。

create 一样,这个方法也是接受一个选项对象,WebAuthn API 的参数写在 publicKey 属性中。publicKey 对象的属性如下:

  • allowCredentials
    一个对象数组,表示允许使用的凭证。其对象结构和 create 选项中的 excludeCredentials 参数是一样的,这里就不重复写了。这个属性的主要作用是服务端提供用户已经注册的认证器,Agent(在 Web 的场景下 Agent 就是浏览器)好限制展示给用户的认证器,避免用户选择错误的认证器进行签名。
  • rpId RP 的 id,与 create 方法中 rp.id 参数的写法相同。这个参数可以被 Web 应用用来检查是否与 RP 匹配,也可以被认证器用来检查是否与认证仪式所使用凭证的 rpId 是否相等。

除了这两个属性,还有 attestationattestationFormatschallengeextensionstimeoutuserVerification,不过它们都在 create 方法中出现过、而且其意思也完全一样,就不重复介绍了。

思考

看完了 WebAuthn 的相关概念和在前端的 API 后,让咱们来顺着这些信息思考一下,一个 WebAuthn 的前端应用应该做哪些事、如何做?

从仪式这个概念可以知道,前端与服务端交互一共有两个仪式,分别是注册和认证,但一个仪式并不代表前端只需要和服务器交互一次。

从 API 的参数中,可以看到有不少需要服务端告知前端的部分,譬如 RP 是否需要认证器证明,但这些内容直接 hard code 到前端代码中也不是不行,因为用户的前端 JavaScript 文件也是从服务端获取的,如果这些策略改变了,只需要更新 JavaScript 文件即可。但……create 中的 excludeCredentials 呢?这里需要服务端先告知前端该用户已经注册了哪些认证器,以防止重复注册,再看 get 中的 allowCredentials 也是同理,需要服务端先告知前端该用户注册了哪些认证器,才能知道允许使用哪些认证器登录这个账号,因此这两个方法都需要前端先与服务端进行交互才能确定其中的参数。

那么不妨干脆把这些参数都交由服务端来确定,服务端根据用户要登录的用户名,把参数生成并响应给前端后,前端直接使用响应结果传入 create 或者 get 方法中。实际上 @simplewebauthn 就是这样实现的,在后面的 Demo 中咱们会直接使用 @simplewebauthn 提供的包。

在前端执行完成 createget 方法后,还需要将执行的结果传给服务端,因此一个注册/认证仪式至少需要两次请求才能完成,第一次从服务端获取选项参数,将选项参数传递给 WebAuthn API 后,将签名结果再通过一次请求传回服务端,然后服务端才能进行验证。

既然至少要通过两次请求与服务端进行交互,并且这两次交互有依赖关系(要先获取了挑战才能验证)。那么服务端就注定需要一个有状态的实现,当然有状态的实现方式可以有很多种,最常见的是使用 Session 或者 NoSQL 的数据库来实现,在 Demo 中咱们将使用 express-session 配合 Redis 作为 session store 来实现。(诶?怎么好像既用了 session 又用了 NoSQL 数据库(逃))

不过使用什么方案保存状态是次要的,咱们重点关注 WebAuthn 部分的业务。

写一个 Demo

经过了对 WebAuthn API 的基本了解以及简单的思考,下面就开始试着着手写一个无密码 WebAuthn Demo 吧!

下面有些代码不会在文章中展示,Demo 的完整代码请移步咱的 GitHub仓库

后端

注册

首先写注册部分,再写认证部分,毕竟得先注册了账号才能测试认证部分有没有写对嘛。

项目为了简洁采用了 Prisma 作为 ORM 框架(Prisma 是真的很简洁),使用 express 作为后端服务。在这个 Demo 里,使用的是 SQLite 数据库,当然也以很轻松地换成别的数据库。Prisma Schema 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}

model User {
id Int @id @default(autoincrement())
username String
device Device[]
}

model Device {
id Int @id @default(autoincrement())
credentialPublicKey String
credentialID String
counter Int
transports AuthenticatorTransports[]
user User @relation(fields: [userId], references: [id])
userId Int
}

model AuthenticatorTransports {
id Int @id @default(autoincrement())
type String
device Device? @relation(fields: [deviceId], references: [id])
deviceId Int?
}

这里采用 \/(register|auth)\/(option|verify) 的规则命名接口,分别表示注册/认证仪式中的获取选项/验证接口。在具体项目中建议根据项目需要调整(比如你的 App 并不是无密码应用)。

首先创建一个 /register/option 接口,由于要知道用户想要注册的用户名,因此从 body 中读入一个 username

1
2
3
app.post('/register/option', async (req, res) => {
const username = req.body.username as string
})

在获取到用户名后,接下来就需要根据自己的需求编写用户是否注册的逻辑。在这个简单的 Demo 中,咱们是允许用户直接进行注册的,当然在生产中应当不允许已经注册过的用户直接注册新的认证器。

如果要做的是无密码应用的话,应该只允许没有注册过的用户注册全新的用户与一个初始的认证器,如果是把 WebAuthn 作为 2FA 的应用的话,也应当只允许用户在登录状态下注册新的认证器。这些相信每个写过后端的程序员都不言自明。

1
2
3
4
5
6
7
8
9
10
const user = await prisma.user.findFirst({ where: { username }, include: { device: { include: { transports: true } } } })

const devices: AuthenticatorDevice[] = user?.device ?
user.device.map(device => ({
counter: device.counter,
credentialID: isoBase64URL.toBuffer(device.credentialID),
credentialPublicKey: isoBase64URL.toBuffer(device.credentialPublicKey),
transports: device.transports.map(transport => transport.type as AuthenticatorTransportFuture),
})) :
[]

这里查询到用户后直接查询用户注册过的认证器,主要是为了给下一步生成注册选项的 excludeCredentials 用,如果用户不存在,直接给一个空数组就好。

然后就是生成一个选项返回给用户,咱们可以直接使用 @simplewebauthn 提供的函数来生成(否则自己生成和验证挑战什么的…太难了啦)。参数基本都可以顾名思义,一些涉及到 WebAuthn 术语的参数咱写上了注释。最后要把选项给返回给用户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const uuid = randomUUID()

const opts = await generateRegistrationOptions({
rpName: 'Ayaka\'s Clubhouse',
rpID,
userID: uuid,
userName: username,
userDisplayName: username,
timeout: 60 * 1000,
attestationType: 'none', // rp 是否向 authenticator 索要证明
authenticatorSelection: {
residentKey: 'discouraged', // 是否创建客户端凭证
},
excludeCredentials: devices.map(dev => ({ // 排除用户已经注册的 authenticator
id: dev.credentialID,
type: 'public-key',
transport: dev.transports,
})),
supportedAlgorithmIDs: [-7, -257], // 支持的加密算法
})

req.session.challenge = opts.challenge
req.session.user = user ?? undefined
req.session.username = username

res.json(opts)

这里在返回数据之前还在 session 对象上保存了一些信息,主要是为了记住当前正在执行注册仪式的用户以及刚刚生成的挑战,以便接收到用户的签名后来认证。

获取注册选项的接口到这里就写完了,接下来看一下验证注册的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.post('/register/verify', async (req, res) => {
const body: RegistrationResponseJSON = req.body
const user = req.session.user
const username = req.session.username
const challenge = req.session.challenge
if (!challenge || !username) {
return res.status(400).send({ error: 'session not valid' })
}
let newUser
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challenge,
expectedOrigin: false ? `http://${rpID}` : `http://${rpID}:${port}`,
expectedRPID: rpID,
requireUserVerification: false, // 要求验证用户
})
}

这里的代码只有两个部分,第一部分是获取请求参数,第二部分直接使用 @simplewebauthn 提供的函数对注册进行验证,返回一个验证结果的对象。

后面的一些处理在这里就不列出来了,基本就是从验证结果中提取认证器的一些信息,以及从 session 中取出之前保存的用户名把信息存储到数据库中,完整的代码可以直接看咱的 Demo 的仓库。

不过还有一个需要注意的地方,就是服务器还要判断一下用户的认证器是否注册过。为什么之前明明已经告诉过用户排除哪些认证器了,为什么还要判断一次呢,因为前端发来的数据永远是不可信的,你不知道用户可以用什么手段绕过浏览器的这个检查,然后调用同一个认证器又给你注册一次。

1
2
3
const existingDevice = user?.device?.find(device => device.credentialID === credentialIDHex)
// ensure duplicate devices are not added
if (!existingDevice) {}

在验证并且把用户新注册的认证器插入数据库之后,就需要进行清理工作了,主要是清理之前 session 上保存的信息,以及是否要在用户注册后直接变成登录状态(一般用户友好的应用都会这样做)。最后就是把注册结果返回给用户。

1
2
3
4
5
6
req.session.challenge = undefined
req.session.user = undefined
if (verified) {
req.session.loggedUserId = user?.id ?? newUser!.id
}
res.send({ verified })

注册流程的后端代码就到这里了,至于前端,不管是注册还是认证的代码都很简单:对选项接口请求,把返回的结果传递给 @simplewebauthn 提供的工具函数,然后把函数返回的数据作为参数对验证接口请求就好了,至于调用 navigator.credentials 上方法的流程,@simplewebauthn 已经帮咱们封装好了。

不过前端部分还有几个需要注意的点,在整个 Demo 写完之后会提到。

认证

认证部分的选项接口很简单,首先判断要登录的用户是否存在:

1
2
3
4
5
6
7
8
9
10
11
app.post('/login/option', async (req, res) => {
const username = req.body.username as string
let user
if (username) {
user = await prisma.user.findFirst({ where: { username }, include: { device: { include: { transports: true } } } })
console.log(username, user)
if (!user) {
return res.status(400).send({ error: 'user not exists' })
}
}
})

在查询的时候可以顺便把用户注册过的认证器查出来,给选项 allowCredentials 使用。

然后直接调用生成认证选项的函数就好,这里基本没有什么陌生的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const opts = await generateAuthenticationOptions({
userVerification: 'discouraged',
rpID,
timeout: 60 * 1000,
allowCredentials: user ?
user.device.map(device => ({
id: isoBase64URL.toBuffer(device.credentialID),
type: 'public-key',
transports: device.transports.map(transport => transport.type as AuthenticatorTransportFuture),
})) :
[],
})
req.session.challenge = opts.challenge
res.send(opts)

在生成完毕后,在 session 上保存正在进行的挑战给之后验证使用。

接下来是验证认证的接口。在验证用户的签名之前,首先要检查这个认证器是否被注册过:

1
2
3
4
5
6
7
app.post('/login/verify', async (req, res) => {
// ...
const authenticator = await prisma.device.findFirst({ where: { credentialID: body.rawId }, include: { transports: true } })
if (!authenticator) {
return res.status(400).send({ error: 'This authenticator is not registered' })
}
})

上面的代码省略了一些提取请求参数和验证的模板代码。

然后调用验证认证选项的函数,把从数据库中查到的认证器信息和请求中的信息传入即可,这个函数会帮咱们检查认证器信息是否正确以及挑战是否被正确的认证器签名。

1
2
3
4
5
6
7
8
9
10
11
12
13
const verification = await verifyAuthenticationResponse({
response: body,
requireUserVerification: false,
expectedChallenge: challenge,
expectedOrigin: false ? `http://${rpID}` : `http://${rpID}:${port}`,
expectedRPID: rpID,
authenticator: {
credentialID: isoBase64URL.toBuffer(authenticator.credentialID),
credentialPublicKey: isoBase64URL.toBuffer(authenticator.credentialPublicKey),
counter: authenticator.counter,
transports: authenticator.transports.map(transport => transport.type as AuthenticatorTransportFuture),
},
})

如果验证成功,除了通常的需要记录用户的状态为已登录以外,还需要更新一下认证器的上的计数器。这个计数器必须从解析认证器信息中获取,不能简单地加一,认证器的上次认证到本次认证之间也有可能在其他地方使用过。

1
2
3
4
5
6
7
8
const { verified, authenticationInfo } = verification
if (verified) {
const device = await prisma.device.update({ where: { id: authenticator.id }, data: { counter: authenticationInfo.newCounter } })
const user = await prisma.user.findFirst({ where: { id: device.userId } })
req.session.loggedUserId = user?.id
}
req.session.challenge = undefined
res.send({ verified })

最后就是清理 session 中存储的信息并返回用户验证结果。

前端

前端部分整体还是挺简单的,可以通过 unpkg 引入 @simplewebauthn

1
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script>

然后就可以通过 window.SimpleWebAuthnBrowser 这个对象调用 @simplewebauthn 提供的各种函数啦。完整的代码这里就不贴了,可以看咱的 Demo 仓库。

需要注意的是这些封装的方法都帮咱们调用了 navigator.credentials 上的 API,因此它们会返回一个 Promise,如果 API 抛出错误也是需要自己处理的。

这里列出三种情况,分别对应两种 Error:

1
2
3
4
5
6
7
8
9
.catch((err) => {
if (err.name === 'AbortError') {
return console.log('Ceremony aborted')
}
if (err.name === 'NotAllowedError') {
return console.log('User canceled or timeout')
}
console.error(err)
})

另外一点是你可能会发现一些网站在第一次进入的时候就可以选择要使用的 Passkey,选择后就可以直接登录网站了,不需要用户输入想登录的用户名,整个登录流程更加流畅了。

Conditional UI

这个其实叫 Conditional UI。如果要启用,需要在自动显示 Conditional UI 的元素上添加 autocomple:

1
<input type="text" id="username" name="username" autocomplete="username webauthn">

然后在用户进入页面的时候就发送一次请求获取认证选项,并调用 navigator.credentials.get,而且比正常的调用要多两个参数:

1
2
3
4
5
6
7
const abortController = new AbortController();

const credential = await navigator.credentials.get({
// omitted others
signal: abortController.signal,
mediation: 'conditional'
});

如果使用 @simplewebauthn 的话,则只需要在 startAuthentication 函数的第二个参数写上 true 就可以了。

调用了这个 API 之后,当用户聚焦在 autocomplete="webauthn" 的元素上时,就会显示 Conditional UI。

用户选择完要使用的 Passkey 后,startAuthentication / navigator.credentials.get 返回的 Promise 就会 resolve,后面的流程就和正常的认证流程是完全一样的。

如果用户选择了其他操作,你需要在响应用户的操作前先调用 AbortController 上的 abort 方法来中止之前的 Conditional UI 流程,否则 WebAuthn API 会一直等待 Conditional UI 的那个 Promise 完成。调用了 abort 后,这个 Promise 就会抛出一个 AbortError,也就是上面两种 Error 中的一种。

不过如果你使用了 @simplewebauthn,则不需要考虑自己处理 AbortController,这个包会在用户进行其他操作的时候自动帮你 Abort 掉之前的 Conditional UI。

参考

  1. 谈谈 WebAuthn - 无垠
  2. What is WebAuthn? - WebAuthn | WebAuthn.wtf
  3. CredentialsContainer: create() method - Web APIs | MDN
  4. CredentialsContainer: get() method - Web APIs | MDN
  5. Attestation and Assertion - Web APIs | MDN