Passkeys完全指南:下一代Web身份验证技术解析
开发平台要求
使用Passkeys进行开发的平台包括:
- iOS 16或macOS 13上的Safari
- Windows 22H2上的Chrome Canary(需设置
chrome://flags#webauthn-conditional-ui
)
- macOS上的Chrome Canary(需设置
chrome://flags#webauthn-conditional-ui
)
数据库变更
每个用户都需要一个Passkey用户ID。用户ID用于识别账户,但不包含任何个人身份信息(PII)。建议为用户表添加新列来存储Passkey ID:
1
2
3
4
5
|
ALTER TABLE users ADD COLUMN passkey_id blob DEFAULT(randomblob(16));
UPDATE USERS SET passkey_id=hex(randomblob(CASE rowid WHEN 0
THEN 16
ELSE 16 END));
|
用户只能有一个密码,但可以有多个Passkeys。创建Passkeys表:
1
2
3
4
5
6
|
CREATE TABLE passkeys (
id BLOB PRIMARY KEY,
username STRING NOT NULL,
public_key_spki BLOB,
backed_up BOOLEAN,
FOREIGN KEY(username) REFERENCES users(username));
|
安全上下文
WebAuthn的所有功能都必须在安全上下文中运行,因此必须使用HTTPS。
注册现有用户
当用户使用密码登录时,可以提示他们在本地设备上创建Passkey以便下次更轻松登录。
首先检查设备是否具有本地认证器且浏览器支持Passkeys:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
if (!window.PublicKeyCredential ||
!(PublicKeyCredential as any).isConditionalMediationAvailable) {
return;
}
Promise.all([
(PublicKeyCredential as any).isConditionalMediationAvailable(),
PublicKeyCredential
.isUserVerifyingPlatformAuthenticatorAvailable()])
.then((values) => {
if (values.every(x => x === true)) {
promptUserToCreatePlatformCredential();
}
})
|
如果用户接受,请求浏览器创建本地凭证:
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
32
33
34
35
36
37
38
39
40
41
|
var createOptions : CredentialCreationOptions = {
publicKey: {
rp: {
id: SEE_BELOW,
name: "",
},
user: {
id: Uint8Array.from(atob(userIdBase64), c => c.charCodeAt(0)),
name: username,
displayName: "",
},
excludeCredentials: existingCredentialIdsBase64.map(id => {
return {
type: "public-key",
id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
};
}),
pubKeyCredParams: [{
type: "public-key",
alg: -7
}, {
type: "public-key",
alg: -257
}],
challenge: new Uint8Array([0]),
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
},
timeout: 180000,
}
};
navigator.credentials.create(createOptions).then(
handleCreation, handleCreationError);
|
RP IDs
RP代表"依赖方",RP ID是一个域名,每个Passkey在创建时都有一个固定的RP ID。每个Passkey操作都会断言一个RP ID,如果不匹配,则该Passkey对该操作不存在。
站点可以使用通过从左丢弃零个或多个标签直到遇到eTLD形成的任何RP ID。例如https://www.foo.co.uk
可以断言www.foo.co.uk
、foo.co.uk
,但不能断言co.uk
。
记录Passkey
当navigator.credentials.create
的Promise成功解析时,您就有一个新创建的Passkey!现在需要确保服务器记录它。
首先进行一些健全检查:
1
2
3
4
5
6
7
|
const cdj = JSON.parse(
new TextDecoder().decode(cred.response.clientDataJSON));
if (cdj.type != 'webauthn.create' ||
(('crossOrigin' in cdj) && cdj.crossOrigin) ||
cdj.origin != 'https://YOURSITEHERE') {
// handle error
}
|
在服务器端,需要将行插入到passkeys
表中。认证器数据是一种相当简单的二进制格式。偏移量32包含标志字节。检查第7位是否设置,然后提取:
- 第4位作为
backed_up
的值
- 偏移量53处的大端uint16作为凭证ID的长度
- 偏移量55处的那么多字节作为
id
的值
处理注册异常
create()
的Promise可能会导致异常。InvalidStateError
是特殊的,意味着本地设备已存在Passkey。这不是错误,不会向用户显示任何错误。
NotAllowedError
意味着用户取消了操作。其他异常意味着发生了更意外的情况。
使用自动完成登录
在用户名输入元素上,将webauthn
添加到autocomplete
属性:
1
|
<input type="text" name="username" autocomplete="username webauthn">
|
在页面加载时运行函数来设置条件请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
if (!window.PublicKeyCredential ||
!(PublicKeyCredential as any).isConditionalMediationAvailable) {
return;
}
(PublicKeyCredential as any).isConditionalMediationAvailable()
.then((result: boolean) => {
if (!result) {
return;
}
startConditionalRequest();
});
|
然后开始条件请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
var getOptions : CredentialRequestOptions = {
mediation: "conditional" as CredentialMediationRequirement,
publicKey: {
challenge: Uint8Array.from(atob(CHALLENGE_SEE_BELOW), c =>
c.charCodeAt(0)),
rpId: SAME_AS_YOU_USED_FOR_REGISTRATION,
}
};
navigator.credentials.get(getOptions).then(
handleSignIn, handleSignInError);
|
挑战
挑战是由服务器生成的大型随机值,在使用Passkey时会被签名。由于它们是大型随机值,服务器知道签名必须在其生成挑战后生成。
处理登录
如果用户选择Passkey,handleSignIn
将被调用。将rawId
、response.clientDataJSON
、response.authenticatorData
和response.signature
的ArrayBuffer发送到服务器。
在服务器端,首先查找Passkey:
1
|
SELECT (username, public_key_spki, backed_up) FROM passkey WHERE id = ?
|
要验证签名,需要构造签名数据并解析公钥。来自数据库的public_key_spki
值以SubjectPublicKeyInfo格式存储。
还需要检查clientDataJSON
:
type
成员为"webauthn.get"
challenge
成员等于服务器为此登录给出的挑战的base64url编码
origin
成员等于您站点的登录来源
crossOrigin
成员(如果存在)为false
检查authenticatorData
:
- 前32字节等于您使用的RP ID的SHA-256哈希
- 偏移量32处的字节的第0位为1
移除密码
如果用户使用Passkeys登录,并且账户有备份的Passkey,可以考虑提示用户移除密码。
注册新的仅Passkey用户
对于新用户注册,如果功能检测满意,考虑使他们仅使用Passkey。
设置
可以为Passkeys提供两个按钮:
- 添加Passkey的按钮,使用上面的
createOptions
对象,但删除authenticatorAttachment
以允许注册其他设备
- “重置Passkeys"按钮,会提示新的Passkey注册,删除所有其他Passkeys,并使用户的所有其他活动会话无效
测试向量
提供Python 3代码来检查断言签名:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import codecs
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_der_public_key
# 测试代码实现签名验证
def sha256(m):
digest = hashes.Hash(hashes.SHA256())
digest.update(m)
return digest.finalize()
signed_message = (from_hex(authenticator_data_hex) +
sha256(from_hex(client_data_json_hex)))
public_key = load_der_public_key(from_hex(public_key_spki_hex))
public_key.verify(from_hex(signature_hex),
signed_message,
ec.ECDSA(hashes.SHA256()))
|
提问渠道
StackOverflow是提问的合理场所,使用passkey标签。