很久很久以前,在一个遥远的星球上…
网络钓鱼!?
被 WebAuthn/FIDO2 彻底消灭啦!
那么…
什么是 FIDO2 和 WebAuthn 呢?本文将带大家一起讨论,了解浏览器中的凭证管理,FIDO2 和 WebAuthn,以及如何消灭密码,带领大家进入一个防网络钓鱼的无密码认证世界。
FIDO,即 FIDO 联盟,致力于开发安全、开放、标准、防网络钓鱼的身份认证协议。该联盟始创于 2013 年,目前成员数已达 300 多位,遍布全球。FIDO 制定的 UAF、U2F 以及 FIDO2 三项协议均属于同一系列,都是基于来源、挑战—响应、防网络钓鱼以及数字签名的身份认证协议。
下图解释了 FIDO 协议的整个工作过程:
依赖方(即服务器)向客户端(即浏览器)发送挑战和先前注册的凭证标识符。客户端附加上调用来源等依赖方信息后,发送到认证器。
认证器首先会要求用户按下按钮来检查用户是否在场,或使用设备 PIN 码、生物识别等进行全面的用户验证。
身份验证完成后,设备使用由凭证 ID 标识的私钥对有效负载进行签名,并将断言返回给客户端。然后,客户端附加上之前发送给认证器的信息,作为签名负载的一部分,并发送给依赖方。
依赖方对这些信息进行检查,确保 RP 信息中包含应有的来源与挑战信息,然后再检查签名。如果过程中有任一步骤出现问题,我们就可以检测到网络钓鱼攻击,并采取防御措施。
更多关于 FIDO 协议的深度讨论,点这里。
FIDO 联盟发布的协议中出现了很多术语,例如 FIDO、FIDO2、WebAuthn、CTAP1、CTAP2 等等,很多用户都对此不甚了解。下面我们就逐个解释一下这些术语的含义。
WebAuthn 实际上是凭证管理 API 的一个扩展。所以,在我们深入探讨无密码认证之前,首先来了解一下凭证管理 API。
简单来说,凭证管理 API 是一个 JS“自动填充插件”。之前通常用 UI 预测工具完成的工作,如“保存密码”,现在都可以用 JS 实现。例如,我们可以使用包含 PasswordCredential 对象的 navigator.credentials.store 添加新凭证:
var passwordcred = new PasswordCredential({
'type': 'password',
'id': 'alice',
'password': 'VeryRandomPassword123456'
})
navigator.credentials.store(passwordcred)
注意:请确保 Chrome 已启用“建议保存密码”)
调用发起后,你会看到一个提示,让你确认是否保存新凭证(即用户名 + 密码):
如果想要找回的话,我们可以调用navigator.credentials.get,“password”设置为“true”:
navigator.credentials.get({ 'password': true })
.then((credential) => {
if(!credential)
throw new Error('No credential found')
// sendServer(credential) // PasswordCredential {iconURL: "", name: "", password: "VeryRandomPassword123456", id: "alice", type: "password"}
})
如果有多个凭证,用户将收到提示进行选择:
所以,基本的方法包括“store”和“get”,当然还有“create”,将在下一部分讨论。
更多关于凭证管理 API 的信息,点这里。
WebAuthn 是一个管理公钥凭证的 API。简单来说,就是与 FIDO 认证器对话的接口,向其发送挑战后,会返回断言。
在这中间需要了解两项操作,分别是MakeCredential和GetAssertion。
var challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);
var userID = 'Kosv9fPtkDoh4Oz7Yq/pVgWHS8HhdlCto5cR0aBoVMw='
var id = Uint8Array.from(window.atob(userID), c=>c.charCodeAt(0))
var publicKey = {
'challenge': challenge,
'rp': {
'name': 'Example Inc.'
},
'user': {
'id': id,
'name': 'alice@example.com',
'displayName': 'Alice Liddell'
},
'pubKeyCredParams': [
{ 'type': 'public-key', 'alg': -7 },
{ 'type': 'public-key', 'alg': -257 }
]
}
navigator.credentials.create({ 'publicKey': publicKey })
.then((newCredentialInfo) => {
console.log('SUCCESS', newCredentialInfo)
})
.catch((error) => {
console.log('FAIL', error)
})
我们来看一下上面这个例子:
上述为创建凭证的基本参数,还包括一些可选键值:
“publicKey”结构需要封装进字典,作为“publicKey”的键值,然后就可以调用navigator.credentials.create。
上述代码可以在这里自行尝试。
调用 WebAuthn API 时,浏览器(至少 Chrome 是这样)还会跳出弹框,让用户选择认证器:
如果用户拥有安全密钥,安全密钥就会作为其中一个选项,否则将使用浏览器/平台内置的认证器。点击选项后,轻触认证器,或使用指纹完成身份验证,你会看到弹窗中显示“成功”。
打开控制台,WebAuthn API 已返回 PublicKeyCredential 对象。
依赖方检查挑战是否设置为预期值,从而防止 MITM 攻击。来源字段应设置为预期来源,否则可能造成网络钓鱼攻击。ClientDataJSON 通过加密保护,clientDataHash 在签名前附加到 authData。
上述结构未来可能会发生变化,这也是为什么依赖方必须正确解码结构并验证字段,而不能使用任何形式的基于模版的验证。
结果证明是一个包含三个字段的 CBOR 字典:fmt 表示证明格式,authData 表示新生成的凭证,attStmt 表示设备证明。
在上面的例子中,由于我们没有要求证明,所以证明格式为“none”。在这种情况下,平台会把 attStmt 设置为空映射,fmt 设置为 none,authData 中的设备 aaguid 为 00000000–0000–0000–0000–000000000000。因为 attestation 存在一些固有的隐私问题,这样设置能够保护用户的隐私。更多详细讨论,点这里。
AuthData 是一个字节数组,包含调用方主机的串联哈希(如 webauthnworks.github.io 的 SHA256 哈希值)、计数器、标志(告知依赖方是否进行了用户身份验证)、设备 GUID、新的凭证 ID(同 result.id/rawId)以及新生成的公钥。更多关于验证结果的相关内容,点这里。
证明是一种 FIDO 协议机制,能够让依赖方识别和证明设备模型。这种证明机制对于银行和政府等依赖方来说非常有用,但大多数依赖方都不需要也不应该使用证明,可以把证明字段设置为 none,或不对其进行定义。
如果明确需要证明,可以在调用中将证明字段设置为“direct”,然后会提示用户同意(Popup)。
var challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);
var userID = 'Kosv9fPtkDoh4Oz7Yq/pVgWHS8HhdlCto5cR0aBoVMw='
var id = Uint8Array.from(window.atob(userID), c=>c.charCodeAt(0))
var publicKey = {
'challenge': challenge,
'rp': {
'name': 'Example Inc.'
},
'user': {
'id': id,
'name': 'alice@example.com',
'displayName': 'Alice Liddell'
},
'pubKeyCredParams': [
{ 'type': 'public-key', 'alg': -7 },
{ 'type': 'public-key', 'alg': -257 }
],
'attestation': 'direct'
}
navigator.credentials.create({ 'publicKey': publicKey })
.then((newCredentialInfo) => {
console.log('SUCCESS', newCredentialInfo)
})
.catch((error) => {
console.log('FAIL', error)
})
上述代码可以在这里自行测试。
用户添加认证器后,就可使用 FIDO2 身份认证来登录账户。通过 navigator.credentials.get 实现。
var publicKey = {
challenge: challenge,
allowCredentials: [
{ type: "public-key", id: credentialId }
]
}
navigator.credentials.get({ 'publicKey': publicKey })
.then((getAssertionResponse) => {
alert('SUCCESSFULLY GOT AN ASSERTION! Open your browser console!')
console.log('SUCCESSFULLY GOT AN ASSERTION!', getAssertionResponse)
})
.catch((error) => {
alert('Open your browser console!')
console.log('FAIL', error)
})
对于可发现凭证,并不强制使用 allowList,稍后我们会进行讨论。
一旦断言返回到浏览器,ClientDataJSON 就会将整个结构返回给用户:
上述代码可以在这里自行尝试。
GetAssertion 结果中的字段与 MakeCredential 有所不同:
此结构将返回到服务器进行验证,具体的验证过程可以查看文章“WebAuthn/FIDO2: Verifying responses”。
在这个部分,我们将讨论一下各种不同的 WebAuthn 身份验证应用方案。
“我就是个猫咪博客,只需要安全的 2FA 就行了”
这个是最简单的应用场景。只要用户使用他的用户名和密码登录,就开始了 2FA 流程。
document.getElementById('register').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
let password = this.password.value;
let displayName = this.displayName.value;
registerPassword({username, password, displayName})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOEnrollment')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
return getMakeCredentialChallenge()
})
.then((makeCredChallenge) => {
/*{
"challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
"rp": {
"name": "Example Inc."
},
"user": {
"id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
"name": "alice@example.com",
"displayName": "Alice von Delingher"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"status": "ok"
}*/
makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
return navigator.credentials.create({ 'publicKey': makeCredChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return makeCredentialResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
在这个例子中:
首先,进行用户注册,注册成功后,调用“getMakeCredentialChalleng”来获取创建凭证挑战。
这些字段都为 base64url 编码,由于无法使用 JSON 发送缓冲区,所以必须通过使用 preformatMakeCreReq 预处理请求来解码 base64url 编码的字段。
接着,调用格式化请求的 WebAuthn API,运用 publicKeyCredToJSON 将结果证明解码到 JSON 并发送到服务器,服务器对其进行验证。
document.getElementById('login').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
let password = this.password.value;
loginPassword({username, password})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOAuthentication')
throw new Error('Error logging in! Server returned: ' + serverResponse.errorMessage);
return getGetAssertionChallenge()
})
.then((getAssertionChallenge) => {
/* Server GetAssertion sample */
/*{
"challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
"allowCredentials": [
{
"type": "public-key",
"id": "SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiMDkkSpK0uPmsLb-CYigbog"
}
],
"status": "ok"
}*/
getAssertionChallenge = preformatGetAssertReq(getAssertionChallenge);
return navigator.credentials.get({ 'publicKey': getAssertionChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return getAssertionResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
登录的流程与注册基本相同,使用之前注册的用户名和密码进行登录。
客户端获取一个登录挑战,使用 preformatGetAssertReq 对其解码,然后传递到 WebAuthn API。
响应经过编码后会发送到服务器,如果服务器成功验证,则返回状态:ok。
上述代码可以在这里自行尝试。
“我是一家银行,需要证明用户的认证器”
这个应用场景和前面那个完全相同,只不过添加了“证明”键并设置为“direct”,要求客户端返回完整证明:
document.getElementById('register').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
let password = this.password.value;
let displayName = this.displayName.value;
registerPassword({username, password, displayName})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOEnrollment')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
return getMakeCredentialChallenge({'attestation': 'direct'})
})
.then((makeCredChallenge) => {
/*{
"challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
"rp": {
"name": "Example Inc."
},
"user": {
"id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
"name": "alice@example.com",
"displayName": "Alice von Delingher"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
// SETTING ATTESTATION DIRECT
"attestation": "direct",
"status": "ok"
}*/
makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
return navigator.credentials.create({ 'publicKey': makeCredChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return makeCredentialResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
上述代码可以在这里行尝试。
“我要消灭密码!”
部分读者可能对“无密码”这个词无法认同,因为现在很多公司大谈特谈“无密码”,过度滥用,一旦听到说密码在某些情况下比生物识别优势更大,就会条件反射似的反驳。因此,在展开讨论“无密码认证”之前,我们先来了解一下无密码的含义?
这三个术语需要加以理解和认识:
“密码认证”中没有认证器,所以身份认证和验证是等同的,依赖方单独完成这两项工作,验证了你的密码,也就验证了你的身份。
但在“无密码认证”中,这是两个不同的流程。依赖方只能看到认证器生成的断言,而用户验证则由认证器通过生物特征识别、PIN 等用户验证方式完成。
这也说明了 FIDO2 能够进行无密码认证的原因,因为密码不需要在网络上传输。如果使用的是客户端 PIN,由于 PIN 码存储在设备上,所以暴力破解的可能性微乎其微,用户就算使用的是弱 PIN 码也不必担心凭证损害。而且,利用生物特征识别也使得整个验证过程更加友好便捷。
(之所以称之为“无密码认证”,是因为密码不会在网络上传输,从而也就没有密码可以被泄漏。密码/PIN 这些还是可以用在验证过程中的,和生物特征识别一样。)
回到无密码认证的实现。如果要使用无密码身份认证,你只要进行用户验证这一步。
在无密码认证的应用场景中,用户验证是至关重要的一步。倘若依赖方不执行用户验证,就无法知道用户声明是否真实。如此一来,身份认证就不再是多因素的,因为只剩下了用户设备证明这唯一一个因素。攻击者只需要用户名和安全密钥就能进入账户。
所以,为了避免上述攻击,用户在创建凭证时,我们在 makeCredential 调用中添加了“userVerification”并设置为“required”。
document.getElementById('register').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
let displayName = this.displayName.value;
startPasswordlessEnrolment({username, displayName})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOEnrolmentPasswordless')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
return getMakeCredentialChallenge({'uv': true})
})
.then((makeCredChallenge) => {
/*{
"challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
"rp": {
"name": "Example Inc."
},
"user": {
"id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
"name": "alice@example.com",
"displayName": "Alice von Delingher"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"authenticatorSelection": {
"userVerification": "required"
},
"status": "ok"
}*/
makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
return navigator.credentials.create({ 'publicKey': makeCredChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return makeCredentialResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
在这段代码中,你首先会看到工作流中缺少了 password 字段,因此这是无密码认证。
其次,“userVerification”设置为“required”,要求认证器使用生物识别、客户端 PIN 等方法的其中一种进行用户验证。如果认证器不支持用户验证,则命令失败,返回错误。 依赖方收到响应后,解码 authData,检查用户验证 UV 标志为“true”。
document.getElementById('login').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
startAuthenticationPasswordless({username})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOAuthentication')
throw new Error('Error logging in! Server returned: ' + serverResponse.errorMessage);
return getGetAssertionChallenge()
})
.then((getAssertionChallenge) => {
/*{
"challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
"allowCredentials": [
{
"type": "public-key",
"id": "SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiMDkkSpK0uPmsLb-CYigbog"
}
],
"userVerification": "required",
"status": "ok"
}*/
getAssertionChallenge = preformatGetAssertReq(getAssertionChallenge);
return navigator.credentials.get({ 'publicKey': getAssertionChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return getAssertionResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
在上面的代码中,只有用户名被发送。服务器生成了 GetAssertion 挑战,且“userVerification”设置为“required”。
那么,无密码认证的过程是什么样子呢?大家可以在这里一探究竟。
这就是让密码失效的方法。现在,下一个是谁?
“为什么只需要手机或者电脑就能实现认证呢?”——平台认证器
现如今,每台安卓、iOS、MacOS 和 Windows 设备都包含操作系统内置的认证器,我们称之为平台认证器。这意味着,任何网站都可以开始使用 FIDO 认证而无需购买安全密钥。
平台认证器是全系统范围的,所有的浏览器都能访问相同的密钥库,也就是说 Chrome 中注册凭证,在 Firefox 上也依然能够访问。
到 2021 年 1 月为止,MacOS 尚处于积极开发阶段,所以基于 Chromium 的浏览器的凭证和 UI/UX 体验会不同于 Safari,但之后都会改变。
平台认证器可用于下列平台:
检测平台是否支持 FIDO 认证,可使用 PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():
const isWebAuthnSupported = () => {
return !!window.PublicKeyCredential
}
const isPlatformAuthenticatorSupported = () => {
if (!isWebAuthnSupported()) {
return Promise.reject(new Error("WebAuthn API is not available"))
}
if (!PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
return Promise.resolve(false)
} else {
return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
}
}
isWebAuthnSupported
通过检查 PublickeyCredential 是否定义来检测是否支持 WebAuthn API。
isWebAuthnSupported
检查 WebAuthn API 是否可用,如果支持isUserVerifyingPlatformAuthenticatorAvailable()
,就会进行调用。
大部分浏览器都支持 WebAuthn API,但也存在部分例外,包括“Samsung Internet”三星浏览器以及许多小型浏览器。
在调用平台 API 时,默认情况下用户会收到提示,选择使用平台认证器或安全密钥进行注册。
如果只想让用户看见平台选项,则必须把authenticatorSelection.authenticatorAttachment
设置为 platform。(注意:跨平台只适用于安全密钥)
document.getElementById('register').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
let displayName = this.displayName.value;
startPasswordlessEnrolment({username, displayName})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOEnrolmentPasswordless')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
return getMakeCredentialChallenge({'uv': true})
})
.then((makeCredChallenge) => {
/*{
"challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
"rp": {
"name": "Example Inc."
},
"user": {
"id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
"name": "alice@example.com",
"displayName": "Alice von Delingher"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"authenticatorSelection": {
"authenticatorAttachment": "platform"
},
"status": "ok"
}*/
makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
return navigator.credentials.create({ 'publicKey': makeCredChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return makeCredentialResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
从依赖方的角度来看,证明响应不会产生变化,但是可能需要在数据库条目中添加一些标志或说明来表明这是一个平台凭证,利于分析和用户体验等等。
GetAssertion 过程其实也没有多少变化。依赖方希望指明凭证为平台,可以通过在凭证描述符 transports 字段值设置为“internal”来实现。
{
"challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
"allowCredentials": [
{
"type": "public-key",
"id": "SIT9gAgwUyzOLB_F9fA_LwMOu--dcXHSlzvEXipg2QP3-Shr5f-nldK5V1Wc9BdiMDkkSpK0uPmsLb-CYigbog",
"transports": ["internal"]
}
],
"userVerification": "required",
"status": "ok"
}
“用户名也必须消灭”——关于可发现凭证
大家可能会觉得至少用户名应该在身份验证中保留,但实际情况并不是如此。上面提到的可发现凭证(Discoverable Credentials),过去称作常驻凭证(Resident Credentials),是一种特殊的凭证,能够在预先不知道所识别凭证的情况下获取。基本流程是用挑战进行调用,用户选择凭证,然后等待响应。
依赖方有两个重要标识符来对结果凭证进行绑定:id 和 userHandle。依赖方可以通过凭证 id 或 userHandle 来识别凭证,二者都是在注册凭证时依赖方设置的 user.id。
然后,依赖方就可以通过搜索具有响应 id 或 userHandle 的认证器来找到用户。
document.getElementById('register').addEventListener('submit', function(event) {
event.preventDefault();
let username = this.username.value;
let displayName = this.displayName.value;
startPasswordlessEnrolment({username, displayName})
.then((serverResponse) => {
if(serverResponse.status !== 'startFIDOEnrolmentRK')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
return getMakeCredentialChallenge()
})
.then((makeCredChallenge) => {
/*{
"challenge": "YPpAQ5-8yw7ty1GxvZRoosKoYraXWpeNJ4jNffh-gy0",
"rp": {
"name": "Example Inc."
},
"user": {
"id": "pH4atM-uM2FlifiEVD5OtZnSrvxMcS1OXao8fEP6UFs",
"name": "alice@example.com",
"displayName": "Alice von Delingher"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"authenticatorSelection": {
"requireResidentKey": true
},
"status": "ok"
}*/
makeCredChallenge = preformatMakeCredReq(makeCredChallenge);
return navigator.credentials.create({ 'publicKey': makeCredChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return makeCredentialResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
MakeCredential 部分不需要变化太多,和上面的“无密码认证”方案中的相同。
创建可发现凭证,需要依赖方将“requireResidentKey”设置为“true”,从而强制平台仅使用支持可发现凭证的认证器。如果找不到认证器,则调用失败。
结果与“无用户名”方案相同。
document.getElementById('login').addEventListener('submit', function(event) {
event.preventDefault();
return getGetAssertionChallenge()
.then((getAssertionChallenge) => {
/*{
"challenge": "Ld0vp5byLeFZBOpclgKP3BEc8AA4aBewYPlwbkgLh98",
"status": "ok"
}*/
getAssertionChallenge = preformatGetAssertReq(getAssertionChallenge);
return navigator.credentials.get({ 'publicKey': getAssertionChallenge })
})
.then((newCredentialInfo) => {
newCredentialInfo = publicKeyCredentialToJSON(newCredentialInfo)
return getAssertionResponse(newCredentialInfo)
})
.then((serverResponse) => {
if(serverResponse.status !== 'ok')
throw new Error('Error registering user! Server returned: ' + serverResponse.errorMessage);
alert('Success!');
})
.catch((error) => {
alert('FAIL' + error)
console.log('FAIL', error)
})
})
在身份认证过程中,由于我们将平台切换到了可发现凭证模式,所以依赖方只能发送一个挑战。
依赖方可以使用 id 或 userHandle 值找到用户。
现在用户名也没有了,一切只剩下唯一一个按钮来进行登录:
大家可以在这里自行尝试:
residentKey 字段是对 requireResidentKey 的替换。依赖方之后可以指定可发现凭证的偏好。例如,用户常驻凭证已设置为“preferred”但用户的设备并不支持,那么在这种情况下,平台就会竭力去创建可发现凭证,只不过同时也允许常规的、非可发现凭证的使用。依赖方也可以将可发现凭证设置为“required”。
从目前来看,依赖方在创建可发现凭证时,将此功能添加到 API 调用中,但是对 residentKey 的支持还需要一些时日。
截止 2021 年 1 月,对可发现凭证的支持依然存在问题。Windows 目前已完全支持,但所有其他平台要么完全不支持,要么还处于早期阶段,原因主要有以下几点:
1、CTAP2.0 隐私问题:在 CTAP2.0 中,平台可以调用任一 RK 凭证而无需用户验证。这就意味着,如果你在一些“不规矩”的网站上使用安全密钥,攻击者可以直接把你的安全密钥插入破解器,查看是否有该网站的凭证。CTAP2.1 已对这个问题进行修复,后面会详细讨论。
2、CTAP2.0 缺乏凭证管理:在 CTAP2.0 中,用户无法管理他们的凭证。安全密钥的存储量非常有限,大约 25-50 个凭证,所以空间很快就会被填满。而删除凭证的唯一方法只有重置、抹除整个设备。CTAP2.1 也已解决这个问题。
CTAP2.1 是 FIDO2 认证器的最新更新,包含四个非常重要的功能,使得可发现凭证可被安全使用:
上述四项功能的变更还需要 6 到 12 个月(截至 2022 年 1 月)才能实现平台支持,而且支持 CTAP2.1 的安全密钥才刚刚进入市场,所以还需要一定时间才能将一切落实到位。
原文地址:https://medium.com/webauthnworks/introduction-to-webauthn-api-5fd1fb46c285