概览
下面将简要介绍通行密钥注册所涉及的关键步骤:
- 定义用于创建通行密钥的选项。将它们发送给客户端,以便将其传递给通行密钥创建调用:WebAuthn API 在网页上调用
navigator.credentials.create
,在 Android 上调用credentialManager.createCredential
。在用户确认创建通行密钥后,系统会解析通行密钥创建调用并返回凭据PublicKeyCredential
。 - 验证凭据并将其存储在服务器上。
下面几部分将深入介绍每个步骤的细节。
创建凭据创建选项
您需要在服务器上执行的第一步是创建一个 PublicKeyCredentialCreationOptions
对象。
为此,请使用您的 FIDO 服务器端库。它通常会提供一个实用函数,可为您创建这些选项。例如,SimpleWebAuthn 提供 generateRegistrationOptions
。
PublicKeyCredentialCreationOptions
应包含创建通行密钥所需的所有信息:与用户相关的信息、RP 以及您要创建的凭据的属性的配置。定义所有上述代码后,请根据需要将它们传递给 FIDO 服务器端库中负责创建 PublicKeyCredentialCreationOptions
对象的函数。
PublicKeyCredentialCreationOptions
的部分字段可以是常量。其他属性应在服务器上动态定义:
rpId
:如需在服务器上填充 RP ID,请使用可为您提供 Web 应用主机名的服务器端函数或变量,例如example.com
。user.name
和user.displayName
:如需填充这些字段,请使用已登录用户的会话信息(如果用户在注册时创建通行密钥,则请使用新的用户帐号信息)。user.name
通常是电子邮件地址,对于 RP 而言是唯一的。user.displayName
是一个易于理解的名称。请注意,并非所有平台都会使用displayName
。user.id
:创建帐号时生成的随机唯一字符串。用户名应该是永久性的,而不是可修改的用户名。用户 ID 可用于识别帐号,但不得包含任何个人身份信息 (PII)。您的系统中可能已经有了用户 ID,但如果需要,请专门为通行密钥创建一个用户 ID,以避免其中的任何个人身份信息。excludeCredentials
:现有凭据的 ID 列表,以防止从通行密钥提供商处复制通行密钥。如需填充此字段,请在数据库中查找此用户的现有凭据。如需了解详情,请参阅禁止创建新的通行密钥(如果已存在)。challenge
:对于凭据注册,验证与验证无关,除非您使用证明,这是一种更高级的技术来验证通行密钥提供程序的身份及其发出的数据。不过,即使您不使用证明,质询也仍是必填字段。在这种情况下,为简单起见,您可以将此挑战设置为单个0
。如需了解如何创建安全验证以进行身份验证,请参阅服务器端通行密钥身份验证。
编码和解码
PublicKeyCredentialCreationOptions
包含 ArrayBuffer
字段,因此 JSON.stringify()
不支持这些字段。这意味着,目前为了通过 HTTPS 传送 PublicKeyCredentialCreationOptions
,某些字段必须使用 base64URL
在服务器上手动编码,然后在客户端上解码。
- 在服务器上,编码和解码通常由 FIDO 服务器端库处理。
- 在客户端上,编码和解码目前需要手动完成。未来将变得更容易:将提供一种方法,可将 JSON 格式的选项转换为
PublicKeyCredentialCreationOptions
。查看 Chrome 中实现的状态。
示例代码:创建凭据创建选项
在我们的示例中,我们使用的是 SimpleWebAuthn 库。在这里,我们将公钥凭据选项的创建工作移交给其 generateRegistrationOptions
函数。
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
const { user } = res.locals;
// Ensure you nest verification function calls in try/catch blocks.
// If something fails, throw an error with a descriptive error message.
// Return that message with an appropriate error code to the client.
try {
// `excludeCredentials` prevents users from re-registering existing
// credentials for a given passkey provider
const excludeCredentials = [];
const credentials = Credentials.findByUserId(user.id);
if (credentials.length > 0) {
for (const cred of credentials) {
excludeCredentials.push({
id: isoBase64URL.toBuffer(cred.id),
type: 'public-key',
transports: cred.transports,
});
}
}
// Generate registration options for WebAuthn create
const options = generateRegistrationOptions({
rpName: process.env.RP_NAME,
rpID: process.env.HOSTNAME,
userID: user.id,
userName: user.username,
userDisplayName: user.displayName || '',
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: true
},
});
// Keep the challenge in the session
req.session.challenge = options.challenge;
return res.json(options);
} catch (e) {
console.error(e);
return res.status(400).send({ error: e.message });
}
});
存储公钥
当 navigator.credentials.create
在客户端上成功解析时,即表示已成功创建通行密钥。系统会返回 PublicKeyCredential
对象。
PublicKeyCredential
对象包含一个 AuthenticatorAttestationResponse
对象,该对象表示通行密钥提供程序对客户端的通行密钥创建指令的响应。其中包含有关新凭据的信息,您需要将相应凭据作为 RP 稍后对用户进行身份验证。如需详细了解 AuthenticatorAttestationResponse
,请参阅附录:AuthenticatorAttestationResponse
。
将 PublicKeyCredential
对象发送到服务器。收到验证码后,请进行验证。
将此验证步骤交给您的 FIDO 服务器端库。它通常会出于此目的提供一个实用函数。例如,SimpleWebAuthn 提供 verifyRegistrationResponse
。参阅附录:注册响应验证,了解后台发生的情况。
验证成功后,将凭据信息存储在数据库中,以便用户日后使用与该凭据关联的通行密钥进行身份验证。
使用专用表存储与通行密钥关联的公钥凭据。一位用户只能设置一个密码,但可以设置多个通行密钥。例如,一个通过 Apple iCloud 钥匙串同步的通行密钥和一个通过 Google 密码管理工具同步的通行密钥。
以下是可用于存储凭据信息的示例架构:
- “Users”表:
user_id
:主要用户 ID。用户永久随机的 ID。请将此用作 Users 表的主键。username
:用户定义的用户名,可能可修改。passkey_user_id
:通行密钥专用的不含个人身份信息的用户 ID,在注册选项中用user.id
表示。当用户稍后尝试进行身份验证时,身份验证器会在其userHandle
的身份验证响应中提供此passkey_user_id
。我们建议您不要将passkey_user_id
设置为主键。由于主键被广泛使用,在系统中往往会成为实际个人身份信息。
- 公钥凭据表:
id
:凭据 ID。将此用作公钥凭据表的主密钥。public_key
:凭据的公钥。passkey_user_id
:使用此外键与 Users 表建立关联。backed_up
:如果通行密钥提供方同步了通行密钥,则会备份通行密钥。如果想考虑日后为持有backed_up
个通行密钥的用户删除密码,存储备份状态会非常有用。您可以检查authenticatorData
中的标志或使用 FIDO 服务器端库功能(通常可让您轻松访问这些信息),检查通行密钥是否已备份。存储备份资格条件有助于解决潜在的用户咨询。name
:(可选)凭据的显示名称,使用户能够为凭据提供自定义名称。transports
:一个传输数组。存储传输有助于改善身份验证用户体验。当支持传输时,浏览器会相应地做出行为,并显示与通行密钥提供程序用于与客户端通信时所用的传输机制相匹配的界面,尤其是在allowCredentials
不为空的重新身份验证用例中。
其他信息(包括通行密钥提供程序、凭据创建时间和上次使用时间等)可能有助于存储,有助于提升用户体验。如需了解详情,请参阅通行密钥界面设计。
示例代码:存储凭据
在我们的示例中,我们使用的是 SimpleWebAuthn 库。
在这里,我们将注册响应验证移交给其 verifyRegistrationResponse
函数。
import { isoBase64URL } from '@simplewebauthn/server/helpers';
router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
const expectedChallenge = req.session.challenge;
const expectedOrigin = getOrigin(req.get('User-Agent'));
const expectedRPID = process.env.HOSTNAME;
const response = req.body;
// This sample code is for registering a passkey for an existing,
// signed-in user
// Ensure you nest verification function calls in try/catch blocks.
// If something fails, throw an error with a descriptive error message.
// Return that message with an appropriate error code to the client.
try {
// Verify the credential
const { verified, registrationInfo } = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
requireUserVerification: false,
});
if (!verified) {
throw new Error('Verification failed.');
}
const { credentialPublicKey, credentialID } = registrationInfo;
// Existing, signed-in user
const { user } = res.locals;
// Save the credential
await Credentials.update({
id: base64CredentialID,
publicKey: base64PublicKey,
// Optional: set the platform as a default name for the credential
// (example: "Pixel 7")
name: req.useragent.platform,
transports: response.response.transports,
passkey_user_id: user.passkey_user_id,
backed_up: registrationInfo.credentialBackedUp
});
// Kill the challenge for this session
delete req.session.challenge;
return res.json(user);
} catch (e) {
delete req.session.challenge;
console.error(e);
return res.status(400).send({ error: e.message });
}
});
附录:AuthenticatorAttestationResponse
AuthenticatorAttestationResponse
包含两个重要的对象:
response.clientDataJSON
是客户端数据的 JSON 版本,在网络上是浏览器看到的数据。它包含 RP 来源、质询和androidPackageName
(如果客户端是 Android 应用)。作为 RP,读取clientDataJSON
可让您访问浏览器在发出create
请求时看到的信息。response.attestationObject
包含两条信息:attestationStatement
,除非您使用证明,否则该 API 不相关。authenticatorData
是通行密钥提供程序看到的数据。作为 RP,读取authenticatorData
可让您访问通行密钥提供程序看到的数据,并且在请求create
时返回数据。
authenticatorData
包含与新创建的通行密钥关联的公钥凭据的基本信息:
- 公钥凭据本身及其唯一凭据 ID。
- 与此凭据关联的 RP ID。
- 这些标志用于描述通行密钥创建时的用户状态:用户是否确实存在,以及用户是否已成功通过验证(请参阅
userVerification
)。 - AAGUID:用于标识通行密钥提供程序。显示通行密钥提供商对用户很有用,尤其是当他们在多个通行密钥提供商上为您的服务注册了通行密钥时。
虽然 authenticatorData
嵌套在 attestationObject
中,但无论您是否使用认证,通行密钥实现都需要其包含的信息。authenticatorData
已编码,且包含以二进制格式编码的字段。您的服务器端库通常会处理解析和解码。如果您使用的不是服务器端库,不妨考虑利用 getAuthenticatorData()
客户端在服务器端进行一些解析和解码工作。
附录:注册响应的验证
实际上,验证注册响应包括以下检查:
- 确保 RP ID 与您的网站匹配。
- 确保请求的来源是您网站的预期来源(主网站网址、Android 应用)。
- 如果您需要进行用户验证,请确保用户验证标志
authenticatorData.uv
为true
。检查用户在线状态标志authenticatorData.up
是否为true
,因为通行密钥始终需要用户参与状态。 - 检查客户是否能够提供您提出的挑战。如果您不使用证明,则此项检查并不重要。不过,最好还是实施这项检查:如果您日后决定使用证明,它可确保您的代码已准备就绪。
- 确保尚未为任何用户注册凭据 ID。
- 验证通行密钥提供程序创建凭据时使用的算法是否是您在
publicKeyCredentialCreationOptions.pubKeyCredParams
的每个alg
字段中列出的算法,该字段通常在您的服务器端库中定义,不会向您显示。这样可以确保用户只能使用您选择允许的算法注册。
如需了解详情,请查看 SimpleWebAuthn 的 verifyRegistrationResponse
源代码,或深入了解规范中的完整验证列表。