Passkeys

通行密钥使用由用户的身份验证器创建的密钥对来取代密码:Windows Hello、iCloud 钥匙串、Google 密码管理器、密码管理器或 FIDO2 安全密钥。私钥永远不会离开身份验证器,服务器只存储公钥,并且签名与站点的域名绑定,因此通行密钥无法被钓鱼、重复使用,也不会从服务器数据库中泄露。

从技术上讲,通行密钥是一个可发现的 WebAuthn 凭据(常驻密钥):身份验证器会将用户句柄与私钥一同保存,因此用户无需输入用户名即可登录,浏览器也可以在用户名字段的自动填充列表中提供该站点的通行密钥。TsgcWSAPIServer_WebAuthn 服务器通过下面描述的设置和事件支持通行密钥。

Availability

WebAuthn 服务器包含在 sgcWebSockets Enterprise 和 sgcAuth 包中。WebAuthn 需要安全上下文:请通过 https(或 localhost)提供页面,并将 WebAuthnOptions.RelyingParty 设置为浏览器中显示的主机名。

注册可发现的凭据

属性 WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential 设置注册选项中请求的 residentKey:

值residentKeyMeaning
waundcPreferred (default)preferred身份验证器会在可能的情况下创建可发现的凭据。
waundcRequiredrequired仅接受可发现的凭据。用于通行密钥和无用户名登录。
waundcDiscourageddiscouraged身份验证器应创建服务器端凭据,该凭据需要用户名才能登录。

注册请求可以通过字段 discoverable_credential("required"、"preferred" 或 "discouraged")覆盖默认值。仅当请求本身未指定特定的 residentKey 时,该设置才会生效。


WebAuthn.WebAuthnOptions.RelyingParty := 'www.example.com';
// ... passkeys are discoverable credentials: require a resident key
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential :=
  waundcRequired;
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.UserVerification :=
  waunuvPreferred;
WebAuthn.WebAuthnOptions.DefaultOptions.Authentication.UserVerification :=
  waunuvPreferred;

每个用户多个通行密钥

用户通常拥有多个通行密钥(passkey),例如电脑上一个、手机上一个。请使用同一个用户名注册每一个:当该用户名已经拥有服务器已知的凭据时,注册会复用它们的用户句柄(user.id),因此该账户的每个通行密钥都共享同一个用户句柄。服务器知道运行期间注册的凭据以及通过 AddCredential 添加的凭据,因此当您的凭据存放在数据库中时,请在启动时添加它们,以便每个账户只保留一个用户句柄。使用用户名时,身份验证选项中的 allowCredentials 会包含该用户的每个通行密钥。

无用户名登录

使用空用户名请求身份验证选项。此时选项不带任何 allowCredentials,浏览器会显示它为该依赖方持有的通行密钥,由用户选择其中一个。服务器要求响应中包含 userHandle,并检查其是否属于所使用的凭据,应用程序未知的凭据会被拒绝。


// ... "Sign in with a passkey" button: the browser lists the passkeys of the site
const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
const assertion = await SimpleWebAuthnBrowser.startAuthentication(options);
const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);

通行密钥自动填充

在条件中介模式下,通行密钥会出现在用户名字段的自动填充列表中,与已保存的密码并列显示。请在输入框中添加 autocomplete="username webauthn",加载 /sgcWebAuthn.js(由组件提供,端点为 EndpointsOptions.WebAuthn),并在页面加载时使用无用户名选项调用 startAuthentication(options, true)。当用户选择某个通行密钥后,该 promise 会被兑现。


<script src="/sgcWebAuthn.js"></script>
<input id="username" name="username" autocomplete="username webauthn">
<script>
async function post(url, body) {
  const r = await fetch(url, {method: "POST",
    headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
  return r.json();
}
async function passkeyAutofill() {
  if (!(await SimpleWebAuthnBrowser.browserSupportsWebAuthnAutofill())) return;
  // ... no user name: the options carry no allowCredentials
  const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
  // ... true: conditional mediation, the passkeys appear in the autofill list
  const assertion = await SimpleWebAuthnBrowser.startAuthentication(options, true);
  const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);
  if (result.verified) { location.href = "/"; }
}
passkeyAutofill();
</script>

自动填充需要支持条件中介(conditional mediation)的浏览器(当前的 Chrome、Edge 和 Safari)。browserSupportsWebAuthnAutofill() 在其他浏览器中会返回 False,请为这些浏览器保留一个“使用通行密钥登录”按钮。

在您自己的数据库中存储通行密钥

组件不持久化凭据。有四个事件用于将其与您的存储连接起来:

事件应对方式
OnWebAuthnRegistrationSuccessful保存 aCredentialRecord(例如 aCredentialRecord.AsJSON),以及它的 CredentialId 和 Username。
OnWebAuthnAuthenticationOptionsRequest使用用户名时,将该用户的通行密钥添加到 CredentialRecords 中。不使用用户名时,则不添加任何内容。
OnWebAuthnAuthenticationGetCredential当浏览器使用的凭据不在此次仪式的凭据列表中时触发,这是无用户名登录或自动填充登录的情况。请查找该凭据 ID,填充 aCredentialRecord 并将 Found 设置为 True。
OnWebAuthnAuthenticationSuccessful保存 SignCount 和 BackupState 的更新值(属于 aAuthentication.Credential.CredentialRecord),并创建会话。

// ... 1. registration: store the new passkey (the whole record as JSON)
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;

// ... 2. sign in with a user name: the passkeys of that user
procedure TForm1.WebAuthnWebAuthnAuthenticationOptionsRequest(Sender: TObject;
  const aRequest: TsgcWebAuthn_AuthenticationOptions_Request;
  var CredentialRecords: TsgcWebAuthn_CredentialRecords; var Accept: Boolean);
var
  i: Integer;
  oList: TStringList;
begin
  if aRequest.Username = '' then
    Exit; // ... usernameless or autofill: no allowCredentials
  oList := TStringList.Create;
  try
    DBGetPasskeysOfUser(aRequest.Username, oList); // ... one JSON per line
    for i := 0 to oList.Count - 1 do
      CredentialRecords.AddCredentialRecordFromJSON(oList[i]);
    Accept := oList.Count > 0;
  finally
    oList.Free;
  end;
end;

// ... 3. usernameless and autofill: find the passkey by its credential id
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;

// ... 4. signed in: save the new signature counter and backup state
procedure TForm1.WebAuthnWebAuthnAuthenticationSuccessful(Sender: TObject;
  const aRequest: TsgcWebAuthn_AuthenticationVerify_Request;
  const aAuthentication: TsgcWebAuthn_Authentication; var Accept: Boolean);
var
  oRecord: TsgcWebAuthn_CredentialRecord;
begin
  oRecord := aAuthentication.Credential.CredentialRecord;
  DBUpdatePasskey(oRecord.CredentialId, oRecord.AsJSON);
  CreateUserSession(oRecord.Username);
end;

OnWebAuthnAuthenticationGetCredential 返回的记录必须具有所请求的 CredentialId,并且当该仪式是以用户名开始时,还必须具有相同的 Username,否则登录会失败。请在存储的记录中保留 UserId(用户句柄),它会与身份验证器发送的 userHandle 进行比较。这些事件在服务器连接线程中运行,请相应地保护共享资源。

已同步或设备绑定

身份验证器数据标志说明了服务器收到的是哪种通行密钥。它们会以 BackupEligible(BE 标志)和 BackupState(BS 标志)的形式存储在凭据记录中:

BackupEligibleBackupStateMeaning
TrueTrue已同步的通行密钥,由平台或密码管理器备份,并可在用户的其他设备上使用。
TrueFalse尚未备份的多设备通行密钥。
FalseFalse设备绑定的通行密钥,例如安全密钥或永不离开设备的 TPM 密钥。建议用户注册第二个通行密钥。

function GetPasskeyKind(aRecord: TsgcWebAuthn_CredentialRecord): string;
begin
  if aRecord.BackupEligible and aRecord.BackupState then
    Result := 'synced passkey'
  else if aRecord.BackupEligible then
    Result := 'multi-device passkey, not backed up yet'
  else
    Result := 'device-bound passkey';
end;

每次登录时,服务器都会拒绝设置了 BS 标志但未设置 BE 标志的响应,以及 BE 标志与存储的 BackupEligible 不同的响应,因为凭据的资格永远不会改变。新的 BS 标志会被复制到 BackupState 中,请在 OnWebAuthnAuthenticationSuccessful 中保存它。

签名计数器和被克隆的身份验证器

某些身份验证器会在每次登录时递增签名计数器。当响应的计数器或存储的 SignCount 不为零时,响应的计数器必须大于存储值,否则登录会因身份验证器可能已被克隆而被拒绝。存储值只会向前推进,并且必须在每次登录后保存才能使该检测生效。已同步的通行密钥通常始终报告 0,这会使该检查对它们失效。将 WebAuthnOptions.AllowSignCountLessOrEqualStoredValue 设置为 True 可以接受不递增的计数器(存储值仍然永远不会被降低)。

Demo

示例 Demos\26.Authentication\01.Passkeys 为每个用户注册多个通行密钥,支持无用户名登录和自动填充登录,显示每个通行密钥是已同步还是设备绑定,并通过上述事件将凭据存储在应用程序的 JSON 文件中。另请参见 WebAuthn 和JavaScript 客户端。