你的应用程序接受的每一个密码,都可能被猜到、在另一个网站上被重复使用、被输入到伪造的登录页面,或者从数据库备份中泄露出去。Passkey 一次性解决了这四个问题。用户用已经能解锁手机或笔记本电脑的指纹、面容或 PIN 码登录,你的服务器上也就没有值得窃取的秘密了。
在新登录组件概览一文中,Passkey 只占了五个要点,而SAML 那篇文章介绍了为运营自有身份提供商的企业提供的登录方式。这篇文章讲的是无密码的一面:TsgcWSAPIServer_WebAuthn 如何注册 Passkey、在没有用户名的情况下登录用户、在浏览器自动填充列表中显示 Passkey,并把每个凭据保存在你自己的数据库中。
什么是 Passkey
Passkey 是由用户的认证器创建的一对密钥:Windows Hello、iCloud 钥匙串、Google 密码管理器、某个密码管理器,或者一个 FIDO2 安全密钥。私钥永远不会离开认证器。你的服务器只保存公钥,所以即使 Passkey 表泄露,也没有人能借此登录。每个签名都与你网站的域名绑定,所以一个仿冒的钓鱼域名什么也拿不到可用的东西。这正是这里所说的抗钓鱼的含义。
在 WebAuthn 的术语中,Passkey 是一种可发现凭据,也叫 resident key。认证器把 userHandle 和私钥保存在一起,这也是为什么用户可以不输入用户名就登录,浏览器也能自行列出你网站的 Passkey。
请求可发现凭据
WebAuthn 需要安全上下文,所以要通过 https 提供页面,或者在开发时使用 localhost,并把 WebAuthnOptions.RelyingParty 设置为浏览器显示的主机名。注册选项所要求的 residentKey 来自 WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential:
waundcPreferred,默认值。认证器会在可能的情况下创建可发现凭据。waundcRequired。只接受可发现凭据。用于 Passkey 和无用户名登录。waundcDiscouraged。认证器应创建服务器端凭据,登录时需要用户名。
单次注册请求仍然可以通过 discoverable_credential 字段覆盖默认值,将其设为 required、preferred 或 discouraged。
无需用户名登录
以空用户名请求认证选项。此时选项不携带 allowCredentials 列表,浏览器会显示它为你的 relying party 保存的 Passkey,由用户选择其中一个。没有什么要输入,也就没有什么会输错。
由于这一次服务器没有选择凭据,它会做更多检查。响应中必须包含 userHandle,该 userHandle 必须属于签名所用的凭据,而你的应用程序不认识的凭据会被拒绝。
自动填充列表中的 Passkey
自动填充,也就是 conditional mediation,会把你网站的 Passkey 放进用户名字段的建议列表中,与已保存的密码并列。这是让用户平稳过渡的温和方式:登录页面对仍在使用密码的人照常工作,而拥有 Passkey 的人则从列表中选择。
- 在用户名输入框上添加
autocomplete="username webauthn"。 - 在页面中加载
/sgcWebAuthn.js。该组件会自行提供这个文件,来自EndpointsOptions.WebAuthn中设置的端点。 - 页面加载时,请求无用户名选项并调用
startAuthentication(options, true)。当用户从列表中选择一个 Passkey 时,该 promise 会解决。
自动填充需要支持 conditional mediation 的浏览器,目前意味着当前版本的 Chrome、Edge 和 Safari。在其他浏览器中,browserSupportsWebAuthnAutofill() 会返回 False,所以页面上也要保留一个“用 Passkey 登录”按钮。
多个 Passkey,一个 userHandle
真实用户往往拥有不止一个 Passkey:笔记本电脑上一个,手机上一个,也许还有一个安全密钥留给两者都丢失的那天。用同一个用户名注册每一个 Passkey。当该用户名已经拥有服务器已知的凭据时,新的注册会重用它们的 userHandle(user.id),这样该账户的每个 Passkey 就共享同一个 userHandle,全部指向同一个用户。
服务器知道运行期间注册的凭据,以及你用 AddCredential 添加的凭据。如果你的 Passkey 保存在数据库中,请在启动时添加它们,让每个账户都保持一个统一的 userHandle。当用户确实输入了用户名时,认证选项会在 allowCredentials 中列出该用户的每个 Passkey,认证器会使用它所拥有的那一个。
把 Passkey 保存在你自己的数据库中
该组件不会持久化凭据。你的 Passkey 应该和用户表放在一起,有四个事件把两者连接起来:
OnWebAuthnRegistrationSuccessful。保存新的凭据记录,例如aCredentialRecord.AsJSON,以及它的CredentialId和Username。OnWebAuthnAuthenticationOptionsRequest。如果有用户名,把该用户的 Passkey 添加到CredentialRecords。没有用户名时不添加任何内容。OnWebAuthnAuthenticationGetCredential。当浏览器选择的凭据不在此次 ceremony 的列表中时触发,每次无用户名登录和自动填充登录都是这种情况。查找该凭据,填充aCredentialRecord并设置Found。OnWebAuthnAuthenticationSuccessful。保存aAuthentication.Credential.CredentialRecord新的SignCount和BackupState,然后创建会话。
保存一个 Passkey 并把它重新加载回来,只需要两个简短的处理程序:
uses
sgcWebAuthn_Classes;
// registration: store the whole record, keyed by its credential id
procedure TForm1.WebAuthnWebAuthnRegistrationSuccessful(Sender: TObject;
const aRegistration: TsgcWebAuthn_Registration;
const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Accept: Boolean);
begin
DBInsertPasskey(aCredentialRecord.CredentialId, aCredentialRecord.Username,
aCredentialRecord.AsJSON);
end;
// usernameless and autofill sign-in: the browser chose the passkey,
// find it by its credential id and hand it back to the server
procedure TForm1.WebAuthnWebAuthnAuthenticationGetCredential(Sender: TObject;
const aCredentialId: string;
const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Found: Boolean);
var
vJSON: string;
begin
Found := DBFindPasskey(aCredentialId, vJSON);
if Found then
aCredentialRecord.ReadJSON(vJSON);
end;
DBInsertPasskey 和 DBFindPasskey 代表你自己的数据访问代码。你返回的记录必须携带所请求的 CredentialId,以及当此次 ceremony 以用户名开始时的相同 Username,否则登录会失败。还要在保存的记录中保留 UserId,因为服务器会把它和认证器发送的 userHandle 进行比较。这些事件在服务器的连接线程中运行,所以要像在服务器其他任何地方一样保护共享资源。
同步还是设备绑定
认证器数据中的标志位会告诉你收到的是哪种 Passkey,凭据记录把它们保存为 BackupEligible(BE 标志)和 BackupState(BS 标志):
- BackupEligible 为 True,BackupState 为 True。一个同步的 Passkey,由平台或密码管理器备份,并可在用户的其他设备上使用。
- BackupEligible 为 True,BackupState 为 False。一个尚未备份的多设备 Passkey。
- BackupEligible 为 False,BackupState 为 False。一个设备绑定的 Passkey,例如永远不会离开设备的安全密钥或 TPM 密钥。建议用户再注册一个 Passkey,因为丢失该设备就意味着丢失这个凭据。
你的策略可以对它们区别对待,例如只在管理员账户上接受设备绑定的安全密钥。每次登录时,服务器都会拒绝 BS 标志已设置而 BE 标志未设置的响应,以及 BE 标志与已保存的 BackupEligible 不同的响应,因为一个凭据的 eligibility 永远不会改变。新的 BS 标志会被复制到 BackupState,而 OnWebAuthnAuthenticationSuccessful 正是保存它的地方。
签名计数器与被克隆的认证器
有些认证器会在每次登录时递增一个签名计数器。当响应中的计数器或已保存的 SignCount 不为零时,响应中的计数器必须大于已保存的值。如果不是,登录就会被拒绝,因为两个认证器用同一把密钥作答,正是被克隆认证器的样子。
这项检查只有在你每次登录后都保存计数器,并且保存的值只会向前推进时才有效。同步的 Passkey 通常每次都报告 0,这会为它们关闭这项检查。如果你的一些认证器无法可靠地递增计数器,可以把 WebAuthnOptions.AllowSignCountLessOrEqualStoredValue 设为 True 来接受它们。已保存的值仍然永远不会被调低。
试试这个演示
演示 Demos\26.Authentication\01.Passkeys 是一个完整的 relying party:一个挂载了 TsgcWSAPIServer_WebAuthn 的 TsgcWebSocketHTTPServer,在 https://localhost:5443 上提供一个小型登录页面。它通过上述四个事件,把每个 Passkey 保存在自己的 passkeys.json 文件中。
- 编译该演示,并把 libcrypto-3.dll 和 libssl-3.dll 放在可执行文件旁边。它们就在演示文件夹中。
- 保留 host 127.0.0.1、端口 5443 和 relying party localhost,然后点击 Start。
- 点击 Open Browser 并接受自签名的测试证书。
- 输入一个用户名并点击 Register passkey。为同一个用户名再注册一个 Passkey。表单会列出每个 Passkey 的类型(同步还是设备绑定)及其签名计数器。
- 点击 Sign in without user name 并选择一个 Passkey。服务器会通过
OnWebAuthnAuthenticationGetCredential找到它,日志中会显示该用户。 - 重新加载页面,点击用户名输入框。Passkey 会出现在自动填充列表中。选择一个即可登录。
- 重启应用程序。Passkey 会再次从 passkeys.json 中加载,这说明存储属于你的应用程序,而不属于该组件。
文档
获取方式
TsgcWSAPIServer_WebAuthn 包含在 sgcWebSockets 的 Enterprise 和 All-Access 版本中,支持 Delphi 和 C++ Builder,同一个组件也是 sgcWebSockets .NET 的一部分。如果你只需要身份验证,sgcAuth 套件把它和其他登录组件放在了一起。你可以在 SGC Auth 面板上找到它,在现有应用程序中,只要不把它拖到窗体上,就什么都不会改变。
延伸阅读
- Passkey、SAML SSO、LDAP 与 TOTP 双因素认证:Delphi 登录组件
- 在 Delphi 中通过 Entra ID、Okta 和 AD FS 实现 SAML 单点登录
- WebAuthn 通行密钥与密码的终结
观看视频
eSeGeCe 频道上有一段简短视频,标题是“Passkeys in Delphi: passwordless login with WebAuthn”,发布在eSeGeCe 频道上。视频展示了 IDE 中的代码、备份标志和签名计数器所传达的信息,以及在 https://localhost 上运行的演示:一个用户注册两个 Passkey,然后在不输入用户名的情况下登录。
有问题、反馈,或者需要帮助把 Passkey 加入你的登录页面?联系我们。回复你的正是编写这些代码的人。
