Delphi 中的 Passkey:用 WebAuthn 实现无密码登录

· 组件
Delphi 中的 Passkey:用 WebAuthn 实现无密码登录

你的应用程序接受的每一个密码,都可能被猜到、在另一个网站上被重复使用、被输入到伪造的登录页面,或者从数据库备份中泄露出去。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:

单次注册请求仍然可以通过 discoverable_credential 字段覆盖默认值,将其设为 requiredpreferreddiscouraged

无需用户名登录

以空用户名请求认证选项。此时选项不携带 allowCredentials 列表,浏览器会显示它为你的 relying party 保存的 Passkey,由用户选择其中一个。没有什么要输入,也就没有什么会输错。

由于这一次服务器没有选择凭据,它会做更多检查。响应中必须包含 userHandle,该 userHandle 必须属于签名所用的凭据,而你的应用程序不认识的凭据会被拒绝。

自动填充列表中的 Passkey

自动填充,也就是 conditional mediation,会把你网站的 Passkey 放进用户名字段的建议列表中,与已保存的密码并列。这是让用户平稳过渡的温和方式:登录页面对仍在使用密码的人照常工作,而拥有 Passkey 的人则从列表中选择。

自动填充需要支持 conditional mediation 的浏览器,目前意味着当前版本的 Chrome、Edge 和 Safari。在其他浏览器中,browserSupportsWebAuthnAutofill() 会返回 False,所以页面上也要保留一个“用 Passkey 登录”按钮。

多个 Passkey,一个 userHandle

真实用户往往拥有不止一个 Passkey:笔记本电脑上一个,手机上一个,也许还有一个安全密钥留给两者都丢失的那天。用同一个用户名注册每一个 Passkey。当该用户名已经拥有服务器已知的凭据时,新的注册会重用它们的 userHandle(user.id),这样该账户的每个 Passkey 就共享同一个 userHandle,全部指向同一个用户。

服务器知道运行期间注册的凭据,以及你用 AddCredential 添加的凭据。如果你的 Passkey 保存在数据库中,请在启动时添加它们,让每个账户都保持一个统一的 userHandle。当用户确实输入了用户名时,认证选项会在 allowCredentials 中列出该用户的每个 Passkey,认证器会使用它所拥有的那一个。

把 Passkey 保存在你自己的数据库中

该组件不会持久化凭据。你的 Passkey 应该和用户表放在一起,有四个事件把两者连接起来:

保存一个 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;

DBInsertPasskeyDBFindPasskey 代表你自己的数据访问代码。你返回的记录必须携带所请求的 CredentialId,以及当此次 ceremony 以用户名开始时的相同 Username,否则登录会失败。还要在保存的记录中保留 UserId,因为服务器会把它和认证器发送的 userHandle 进行比较。这些事件在服务器的连接线程中运行,所以要像在服务器其他任何地方一样保护共享资源。

同步还是设备绑定

认证器数据中的标志位会告诉你收到的是哪种 Passkey,凭据记录把它们保存为 BackupEligible(BE 标志)和 BackupState(BS 标志):

你的策略可以对它们区别对待,例如只在管理员账户上接受设备绑定的安全密钥。每次登录时,服务器都会拒绝 BS 标志已设置而 BE 标志未设置的响应,以及 BE 标志与已保存的 BackupEligible 不同的响应,因为一个凭据的 eligibility 永远不会改变。新的 BS 标志会被复制到 BackupState,而 OnWebAuthnAuthenticationSuccessful 正是保存它的地方。

签名计数器与被克隆的认证器

有些认证器会在每次登录时递增一个签名计数器。当响应中的计数器或已保存的 SignCount 不为零时,响应中的计数器必须大于已保存的值。如果不是,登录就会被拒绝,因为两个认证器用同一把密钥作答,正是被克隆认证器的样子。

这项检查只有在你每次登录后都保存计数器,并且保存的值只会向前推进时才有效。同步的 Passkey 通常每次都报告 0,这会为它们关闭这项检查。如果你的一些认证器无法可靠地递增计数器,可以把 WebAuthnOptions.AllowSignCountLessOrEqualStoredValue 设为 True 来接受它们。已保存的值仍然永远不会被调低。

试试这个演示

演示 Demos\26.Authentication\01.Passkeys 是一个完整的 relying party:一个挂载了 TsgcWSAPIServer_WebAuthnTsgcWebSocketHTTPServer,在 https://localhost:5443 上提供一个小型登录页面。它通过上述四个事件,把每个 Passkey 保存在自己的 passkeys.json 文件中。

  1. 编译该演示,并把 libcrypto-3.dll 和 libssl-3.dll 放在可执行文件旁边。它们就在演示文件夹中。
  2. 保留 host 127.0.0.1、端口 5443 和 relying party localhost,然后点击 Start
  3. 点击 Open Browser 并接受自签名的测试证书。
  4. 输入一个用户名并点击 Register passkey。为同一个用户名再注册一个 Passkey。表单会列出每个 Passkey 的类型(同步还是设备绑定)及其签名计数器。
  5. 点击 Sign in without user name 并选择一个 Passkey。服务器会通过 OnWebAuthnAuthenticationGetCredential 找到它,日志中会显示该用户。
  6. 重新加载页面,点击用户名输入框。Passkey 会出现在自动填充列表中。选择一个即可登录。
  7. 重启应用程序。Passkey 会再次从 passkeys.json 中加载,这说明存储属于你的应用程序,而不属于该组件。

文档

获取方式

TsgcWSAPIServer_WebAuthn 包含在 sgcWebSockets 的 Enterprise 和 All-Access 版本中,支持 Delphi 和 C++ Builder,同一个组件也是 sgcWebSockets .NET 的一部分。如果你只需要身份验证,sgcAuth 套件把它和其他登录组件放在了一起。你可以在 SGC Auth 面板上找到它,在现有应用程序中,只要不把它拖到窗体上,就什么都不会改变。

延伸阅读

观看视频

eSeGeCe 频道上有一段简短视频,标题是“Passkeys in Delphi: passwordless login with WebAuthn”,发布在eSeGeCe 频道上。视频展示了 IDE 中的代码、备份标志和签名计数器所传达的信息,以及在 https://localhost 上运行的演示:一个用户注册两个 Passkey,然后在不输入用户名的情况下登录。

有问题、反馈,或者需要帮助把 Passkey 加入你的登录页面?联系我们。回复你的正是编写这些代码的人。