相信每一个经常使用浏览器互联网冲浪的小伙伴对浏览器提示是否要保存密码和自动填充密码的对话框不陌生,实际上浏览器还提供了这样一个 API 用来提供更丝滑的登录体验:Credential Management API。网上查了一圈发现很少有谈这个 API 的,于是写一篇 Blog 记录一下。

要学习这个浏览器给咱们提供的便捷 API,要先看一下浏览器的这部分设置,这里以 Chrome 为例:

Chrome 密码管理工具 设置页面

当你在一个陌生网站首次注册或者登录的时候,Chrome 会提示你是否要保存该网站的密码,“罪魁祸首”就是第一个选项,如果你不想使用 Chrome 的密码保存,关掉这个就好。

而第二个选项就与 Credential Management API 有关了,这个选项的意思是如果当前网站利用 Credential Management API 保存了密码,就会自动登录,在后面的例子中可以看到这个选项的效果。

密码凭证

英文中管咱们用来登录的这个密码叫做“凭证”,也就是这个 API 名字中的 Credential,实际上它不仅指密码,任何用于登录的东西都叫做凭证,只是密码是比较常见的手段。国内平台的凭证基本只有密码,短信/邮件验证码个人认为不算凭证,因为只是短时间有效的。

创建凭证

如果你处于安全上下文中,就可以使用 window 对象上的 PasswordCredential 构造函数创建一个凭证对象。需要注意的是本地调试的时候只有 localhost 是安全上下文,任何通过 IP 访问的其他局域网均不属于安全上下文。

而且在用户的浏览器中也不一定支持该 API,生产中在使用前最好判断一下:

1
2
3
4
5
6
7
if (!window.PasswordCredential) {
console.log(
'Your agent not support PasswordCredential \
or you are in an unsafe context.',
)
// normal login
}

如果不支持使用,则 fallback 到普通的登录/注册方式。

判断支持后,可以构造一个对象:

1
2
const credential = new PasswordCredential({ id: username, password })
console.log(credential)

运行之后得到这样的对象:

PasswordCredential 对象

可以看到一共有五个属性,其中 password 为固定值。

iconURL 是用于在凭证页面上为这个凭证展示一个 icon,可以根据自己的需要展示,对于 PasswordCredential,个人认为可以把用户的头像放上去,不过这个参数应该是为了第三方登录之类的(也就是 FederatedCredential)所设计的,用于展示不同第三方网站的 Logo,但对于 PasswordCredential 并没有区分这些的必要。留空或者设置成当前网站的 favicon 即可。

name 属性是用于方便用户在第一眼区分不同账户的名称,通常情况下,name 对应用户系统中的昵称,而 id 对应用户系统中不可重复的登录字段,例如邮箱或用户名。

在本 Demo 中就不区分这二者了,name 属性省略也是可以的。在 Chrome 中具体展示区别如下:

选择登录身份

第一个凭证为同时设置了 name 和 id,第二个凭证为只设置了 id。

password 属性就是用户所保存的登录密码。

保存凭证

在创建完凭证对象后,还需要将凭证保存到浏览器中:

1
navigator.credentials.store(credential)

前面说到 id 应当对应用户系统中不可重复的登录字段,如果要保存的 id 与已经保存的凭证 id 有重复,则这个函数会直接返回,跳过保存。

执行这行代码后,浏览器会弹出一个对话框询问用户是否要保存凭证。

询问保存凭证

没错,这个对话框就是 Chrome 自动询问保存用户名密码的那一个。

不论用户有没有保存,store 都只会返回 undefined

读取凭证

保存凭证到本地后,就可以在下次登录的时候直接使用这个凭证登录。在登录时使用 navigator.credentials.get 来获取已经保存的凭证:

1
2
3
4
navigator.credentials.get({
password: true,
mediation: 'optional',
})

先解释一下参数,password 表示要获取的凭证是一个密码凭证,因此在本 Demo 中应始终为 truemediation(调度)有四个可以取的值:conditionaloptionalrequiredsilent。但是 conditional 不能用于密码凭证,因此实际只能使用后三个值。

先讲一下 required,使用这个值会使浏览器弹出一个对话框,让用户选择用来登录的凭证,对话框可以参见上面选择登录身份的截图。

optionalsilent 则需要根据本文最开头提到的浏览器设置中“自动登录”的选项来决定行为。

当只有一个凭证并且自动登录开启时,optionalsilent 都会直接使用这个凭证。当有多个凭证或者没有开启自动登录的时候,silent 会返回 null,表示不能静默登录,而 optional 则会弹出对话框让用户选择一个要使用的凭证。

如果浏览器中还没有保存任何凭证,则这个函数会直接返回 null。当通过这个函数获取到了用户的凭证信息时,这个 Promise 就会返回当时创建的 PasswordCredential。

这样,就可以在页面载入的时候就获取一下凭证,如果能够获取到,则直接请求后端进行登录,而不需要用户手动输入密码进行登录了。在使用这个函数获取了用户凭证之后,浏览器会弹一个提示告诉用户正在使用什么身份进行验证。

登录身份提示

阻止静默登录

Credential Management API 还提供了一个 API,它的作用是调用之后下次登录会阻止静默登录,即 mediationsilent 的登录。这个 API 就是 navigator.credentials.preventSilentAccess()

This method is typically called after a user signs out of a website, ensuring this user’s login information is not automatically passed on the next site visit.

上面是摘自 MDN Docs 的原文,但是并没有说明什么是 next site visit。经过咱的实测,刷新和重新打开窗口都不是下次访问网站,而是调用了 navigator.credentials.store 才被视为下次访问网站,因此在登录之后呢,也不需要开发者去判断什么凭证 ID 是否重复了。第一,存储重复的 ID 并不会报错,反而可以在用户更新了密码的时候更新存储的凭证;第二,如果不在登录时继续调用 navigator.credentials.store 的话,浏览器不认为是下次访问网站,那么用户在退出登录一次之后,就再也不能使用静默登录了。

联合登录凭证

除了 PasswordCredential 以外,还提供了另一个类用于存储第三方凭证——FederatedCredential,Federated 即“联合的”意思,它的使用方法和 PasswordCredential 基本一致,因为它们的本质就是将凭证信息存储在浏览器。本节只介绍联合凭证与密码凭证在使用上不同的地方。

首先是构造函数,FederatedCredential 需要一个 provider 参数:

1
2
3
4
5
6
const cred = new FederatedCredential({
id: 'test123id',
name: '咕噜咕噜', // optional
provider: 'https://account.google.com', // required
})
navigator.credentials.store(cred)

这个参数是用于指代第三方登录的提供商,内容一般是写提供商的域名。

另外在获取凭证时也有一些区别,还记得 password: true 是表示获取密码凭证吧?获取联合凭证需要额外的参数:

1
2
3
4
5
6
navigator.credentials.get({
password: true,
federated: {
providers: ['https://account.google.com'],
},
})

其中 password: true 表示在获取联合凭证的同时也获取密码凭证,否则如果只有一个联合凭证则会根据调度直接使用这个凭证登录了,相信这不是你想要的…

联合凭证的调度也同上面讲到的是一样的,只是在使用 navigator.credential.get 方法时,配置了 providers 之后就会额外返回符合条件的联合凭证。最终这个函数返回的是哪种凭证,取决于当前网站保存了什么凭证以及用户如果有选择的过程,用户选择了什么凭证。因此在后续的代码中需要做一下判断,可以使用 instanceof 运算符。

顺带一提,浏览器自动保存密码和 PasswordCredential 的提示窗口是一样的,但是 FederatedCredential 和前面两者都不一样:

登录身份提示

关于最佳实践

国外的一些技术文章,以及 navigator.credentials.preventSilentAccess() 这个 API 的设计,都似乎在暗暗地鼓励使用 silent 来让用户登录。

不过个人认为只使用 silent 调度加上 preventSilentAccess 的使用并不是很灵活。原因如下:

silent 会在站点有多个登录身份的时候直接拒绝静默登录。

有的人可能会觉得大多数人也不会在同一个网站拥有两个账号吧?但这里的“身份”不清楚是不是 Chrome 的误译,或是原文就弄错了,这里的概念咱认为应当和 API 名字统一,是凭证,而不是身份。也就是说如果你登录一个网站的同一个账号,有分密码登录和第三方登录,那么在这里实际上存储的是两个不同的“身份”,因此个人认为这里沿用“凭证”更好些。

因此如果你在一个网站同时存储了一个第三方登录和一个密码登录,此时 silent 调度就会直接返回 null

而如果自行托管一个 flag,用户手动退出登录,则将这个 flag 设置为 true,用户成功登录则设置为 false。调度策略则始终使用 optional。在用户准备登录时,如果 flag 为真,则展示登录表单,反之则直接使用 optional 调度调用 API,这样可以使用浏览器的身份选择器替代登录表单,如果用户选择了当然就可以直接登录,如果未选择,再展示登录表单。这样的灵活性显然更高一些,也可以让用户尽量使用 Credential Management API 进行登录,最大程度免去不必要的输入账号密码和跳转第三方登录。

而其他人支持使用 silent 调度的理由是:“直接登录会使用户感到困惑”。但咱实际体验下来,也许不会有想象中那困惑,因为浏览器在发现用户第一次登录 Credential Management API 的网站并且保存密码时,会询问用户是否要开本文开头提到的自动登录设置项。对,这个功能是默认关闭的,也就意味着用户如果不打开,则 silent 调度永远不会生效。

不过退一步讲,“做产品要永远把用户当成傻子”,兴许用户哪天就忘记自己打开过这个开关了,于是对你的网站感到困惑也有可能。

如果 Chrome 能把“自动登录”这个选项为不同网站分开设置就好了。“你是否要为此网站开启自动登录?如果 Chrome 发现有可用的凭证,则会自动为你登录此网站。”这样至少用户在使用同一个网站时,登录体验在不同时间总是一致的,不那么容易感到困惑。

总之这只是一个 API,不是规范,一千个人心里有一千种最佳实践。

咱用 Vue 写了一个简单的 Demo,涵盖了上述两种实践。之所以使用 Vue,是因为使用框架隐藏同步数据和视图的繁琐更有利于关注如何使用 API 本身。

参考