概览
下面简要介绍了通行密钥身份验证所涉及的关键步骤:
- 定义使用通行密钥进行身份验证时所需的验证方式和其他选项。将它们发送给客户端,以便将其传递给通行密钥身份验证调用(在网络上为
navigator.credentials.get
)。用户确认通行密钥身份验证后,系统会解析通行密钥身份验证调用并返回凭据 (PublicKeyCredential
)。该凭据包含身份验证断言。
- 验证身份验证断言。
- 如果身份验证断言有效,则对用户进行身份验证。
下面几部分将深入介绍每个步骤的细节。
创建挑战
实际上,挑战是随机字节数组,表示为 ArrayBuffer
对象。
// Example challenge, base64URL-encoded
weMLPOSx1VfSnMV6uPwDKbjGdKRMaUDGxeDEUTT5VN8
为了确保挑战实现其目的,您必须:
- 确保同一身份验证只使用一次。每次尝试登录时均生成一个新的质询。每次尝试登录之后,无论登录成功还是失败,都放弃验证。并在一段时间后舍弃挑战。切勿在一个响应中多次接受同一质询。
- 确保验证过程采用加密形式的安全性。一项挑战几乎是无法猜到的.如要在服务器端创建加密安全质询,最好依靠您信任的 FIDO 服务器端库。如果您改为自行创建挑战,请使用技术栈中提供的内置加密功能,或寻找专为加密用例设计的库。例如 Node.js 中的 iso-crypto 或 Python 中的 secrets。根据规范,为安全起见,该验证的长度必须至少为 16 个字节。
创建挑战后,将其保存在用户的会话中,以便日后进行验证。
创建凭据请求选项
以 publicKeyCredentialRequestOptions
对象的形式创建凭据请求选项。
为此,请使用您的 FIDO 服务器端库。它通常会提供一个实用函数,可为您创建这些选项。例如,SimpleWebAuthn 提供 generateAuthenticationOptions
。
publicKeyCredentialRequestOptions
应包含通行密钥身份验证所需的所有信息。将此信息传递给 FIDO 服务器端库中负责创建 publicKeyCredentialRequestOptions
对象的函数。
publicKeyCredentialRequestOptions
的部分字段可以是常量。其他属性应在服务器上动态定义:
rpId
:您希望与凭据关联的 RP ID,例如example.com
。只有当您在此处提供的 RP ID 与凭据关联的 RP ID 一致时,身份验证才能成功。如需填充 RP ID,请使用您在凭据注册期间在“publicKeyCredentialCreationOptions
”中设置的 RP ID。challenge
:通行密钥提供方将签署的一段数据,用于证明用户在收到身份验证请求时持有通行密钥。查看创建挑战中的详细信息。allowCredentials
:用于此身份验证的可接受凭据数组。传递空数组可让用户从浏览器显示的列表中选择可用的通行密钥。如需了解详情,请参阅从 RP 服务器提取验证问题和深入分析可发现的凭据。userVerification
:用于指明使用设备屏幕锁定功能进行用户验证是“必需”“首选”还是“不建议”。查看从 RP 服务器提取质询。timeout
:用户完成身份验证需要多长时间(以毫秒为单位)。它应该足够宽松,并且比challenge
的生命周期短。建议的默认值是 5 分钟,但您可以增加此时长,最长可达 10 分钟,仍在建议的范围内。如果您希望用户使用混合工作流程(通常需要较长的时间),则可以设置较长的超时。如果操作超时,系统会抛出NotAllowedError
。
创建 publicKeyCredentialRequestOptions
后,将其发送到客户端。
示例代码:创建凭据请求选项
在我们的示例中,我们使用的是 SimpleWebAuthn 库。在这里,我们将凭据请求选项的创建工作移交给其 generateAuthenticationOptions
函数。
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
router.post('/signinRequest', csrfCheck, async (req, res) => {
// Ensure you nest 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 {
// Use the generateAuthenticationOptions function from SimpleWebAuthn
const options = await generateAuthenticationOptions({
rpID: process.env.HOSTNAME,
allowCredentials: [],
});
// Save the challenge in the user session
req.session.challenge = options.challenge;
return res.json(options);
} catch (e) {
console.error(e);
return res.status(400).json({ error: e.message });
}
});
验证用户并登录
当 navigator.credentials.get
在客户端上成功解析时,会返回 PublicKeyCredential
对象。
response
是一个 AuthenticatorAssertionResponse
。它表示通行密钥提供程序对客户端指令的响应,即创建在 RP 上尝试使用通行密钥进行身份验证所需的内容。其中包含:
response.authenticatorData
和response.clientDataJSON
,例如通行密钥注册步骤。response.signature
,其中包含针对这些值的签名。
将 PublicKeyCredential
对象发送到服务器。
在服务器上,执行以下操作:
- 收集验证断言和对用户进行身份验证所需的信息:
- 在生成身份验证选项时获取您在会话中存储的预期质询。
- 获取预期的来源和 RP ID。
- 在数据库中查找用户。对于可检测到的凭据,您不知道发出身份验证请求的用户是谁。为此,您有以下两种选择:
- 方式 1:在
PublicKeyCredential
对象中使用response.userHandle
。在 Users 表中,查找与userHandle
匹配的passkey_user_id
。 - 方式 2:使用
PublicKeyCredential
对象中存在的凭据id
。在公钥凭据表中,查找与PublicKeyCredential
对象中存在的凭据id
匹配的凭据id
。然后,使用外键passkey_user_id
在 Users 表中查找相应的用户。
- 方式 1:在
- 在数据库中找到与您收到的身份验证断言匹配的公钥凭据信息。为此,请在公钥凭据表中查找与
PublicKeyCredential
对象中存在的凭据id
匹配的凭据id
。
验证身份验证断言。将此验证步骤交给您的 FIDO 服务器端库,该库通常会提供用于此目的的实用函数。例如,SimpleWebAuthn 提供
verifyAuthenticationResponse
。参阅附录:身份验证响应的验证,了解后台发生的情况。无论验证成功与否,均删除验证,以防止重放攻击。
让用户登录。如果验证成功,请更新会话信息以将用户标记为已登录。您可能还需要向客户端返回一个
user
对象,以便前端使用与新登录用户关联的信息。
示例代码:验证用户并登录
在我们的示例中,我们使用的是 SimpleWebAuthn 库。在这里,我们将身份验证响应的验证交由其 verifyAuthenticationResponse
函数完成。
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
router.post('/signinResponse', csrfCheck, async (req, res) => {
const response = req.body;
const expectedChallenge = req.session.challenge;
const expectedOrigin = getOrigin(req.get('User-Agent'));
const expectedRPID = process.env.HOSTNAME;
// 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 {
// Find the credential stored to the database by the credential ID
const cred = Credentials.findById(response.id);
if (!cred) {
throw new Error('Credential not found.');
}
// Find the user - Here alternatively we could look up the user directly
// in the Users table via userHandle
const user = Users.findByPasskeyUserId(cred.passkey_user_id);
if (!user) {
throw new Error('User not found.');
}
// Base64URL decode some values
const authenticator = {
credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey),
credentialID: isoBase64URL.toBuffer(cred.id),
transports: cred.transports,
};
// Verify the credential
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
authenticator,
requireUserVerification: false,
});
if (!verified) {
throw new Error('User verification failed.');
}
// Kill the challenge for this session.
delete req.session.challenge;
req.session.username = user.username;
req.session['signed-in'] = 'yes';
return res.json(user);
} catch (e) {
delete req.session.challenge;
console.error(e);
return res.status(400).json({ error: e.message });
}
});
附录:对身份验证响应的验证
验证身份验证响应包括以下检查:
- 确保 RP ID 与您的网站匹配。
- 请确保请求的来源与您网站的登录来源一致。对于 Android 应用,请参阅验证来源。
- 检查设备是否能提供您给出的挑战。
- 验证在身份验证过程中,用户是否遵守了您作为 RP 的强制要求。如果您需要进行用户验证,请确保
authenticatorData
中的uv
(已经过用户验证)标志为true
。检查authenticatorData
中的up
(用户存在)标志是否为true
,因为通行密钥始终必须具备用户存在状态。 - 验证签名。如需验证签名,您需要:
- 签名,即已签名的质询:
response.signature
- 用于验证签名的公钥。
- 原始的已签名数据。这是要验证其签名的数据。
- 用于创建签名的加密算法。
- 签名,即已签名的质询:
如需详细了解这些步骤,请查看 SimpleWebAuthn 的 verifyAuthenticationResponse
源代码,或深入了解规范中的完整验证列表。