Passkeys

Klucze dostępu zastępują hasła parą kluczy tworzoną przez uwierzytelniacz użytkownika: Windows Hello, iCloud Keychain, Google Password Manager, menedżer haseł lub klucz bezpieczeństwa FIDO2. Klucz prywatny nigdy nie opuszcza uwierzytelniacza, serwer przechowuje tylko klucz publiczny, a podpis jest powiązany z domeną witryny, dzięki czemu kluczy dostępu nie można wyłudzić phishingiem, ponownie użyć ani wyciec z bazy danych serwera.

Technicznie klucz dostępu to wykrywalne poświadczenie WebAuthn (klucz rezydentny): uwierzytelniacz przechowuje uchwyt użytkownika obok klucza prywatnego, dzięki czemu użytkownik może się zalogować bez wpisywania nazwy użytkownika, a przeglądarka może zaproponować klucze dostępu witryny na liście autouzupełniania pola nazwy użytkownika. Serwer TsgcWSAPIServer_WebAuthn obsługuje klucze dostępu za pomocą ustawień i zdarzeń opisanych poniżej.

Availability

Serwer WebAuthn jest zawarty w sgcWebSockets Enterprise oraz w pakiecie sgcAuth. WebAuthn wymaga bezpiecznego kontekstu: serwuj strony przez https (lub localhost) i ustaw WebAuthnOptions.RelyingParty na nazwę hosta wyświetlaną w przeglądarce.

Rejestrowanie poświadczeń wykrywalnych

Właściwość WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential ustawia residentKey żądane w opcjach rejestracji:

WartośćresidentKeyMeaning
waundcPreferred (default)preferredUwierzytelniacz tworzy poświadczenie wykrywalne, gdy tylko może.
waundcRequiredrequiredAkceptowane są tylko poświadczenia wykrywalne (discoverable). Używaj tego dla kluczy dostępu i logowania bez nazwy użytkownika.
waundcDiscourageddiscouragedUwierzytelniacz powinien utworzyć poświadczenie po stronie serwera, które do zalogowania potrzebuje nazwy użytkownika.

Żądanie rejestracji może nadpisać wartość domyślną za pomocą pola discoverable_credential ("required", "preferred" lub "discouraged"). Ustawienie jest stosowane tylko wtedy, gdy samo żądanie nie wskazuje konkretnego 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;

Kilka kluczy dostępu na użytkownika

Użytkownik zwykle ma kilka kluczy dostępu (passkey), na przykład jeden na komputerze i jeden na telefonie. Rejestruj każdy z nich pod tą samą nazwą użytkownika: gdy nazwa użytkownika ma już poświadczenia znane serwerowi, rejestracja ponownie wykorzystuje ich uchwyt użytkownika (user.id), dzięki czemu każdy klucz dostępu konta współdzieli jeden uchwyt użytkownika. Serwer zna poświadczenia zarejestrowane podczas swojego działania oraz te dodane za pomocą AddCredential, więc jeśli poświadczenia są przechowywane w bazie danych, dodaj je przy starcie, aby zachować jeden uchwyt użytkownika na konto. Przy podanej nazwie użytkownika opcje uwierzytelniania zawierają każdy klucz dostępu użytkownika w allowCredentials.

Logowanie bez nazwy użytkownika

Zażądaj opcji uwierzytelniania z pustą nazwą użytkownika. Opcje nie zawierają wtedy allowCredentials, przeglądarka pokazuje klucze dostępu, które ma dla strony ufającej, a użytkownik wybiera jeden z nich. Serwer wymaga userHandle odpowiedzi i sprawdza, czy należy on do użytego poświadczenia, a poświadczenie nieznane aplikacji jest odrzucane.


// ... "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);

Autouzupełnianie kluczem dostępu

Przy mediacji warunkowej klucze dostępu pojawiają się na liście autouzupełniania pola nazwy użytkownika, obok zapisanych haseł. Dodaj autocomplete="username webauthn" do pola input, wczytaj /sgcWebAuthn.js (udostępniane przez komponent, punkt końcowy EndpointsOptions.WebAuthn) i wywołaj startAuthentication(options, true) z opcjami logowania bez nazwy użytkownika, gdy strona się ładuje. Obietnica (promise) rozwiązuje się, gdy użytkownik wybierze klucz dostępu.


<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>

Autouzupełnianie wymaga przeglądarki obsługującej mediację warunkową (aktualne wersje Chrome, Edge i Safari). browserSupportsWebAuthnAutofill() zwraca False w pozostałych przeglądarkach, zachowaj dla nich przycisk „Zaloguj się kluczem dostępu”.

Przechowuj klucze dostępu we własnej bazie danych

Komponent nie zapisuje trwale poświadczeń. Cztery zdarzenia łączą go z Twoim magazynem:

ZdarzenieCo zrobić
OnWebAuthnRegistrationSuccessfulZapisz aCredentialRecord, na przykład aCredentialRecord.AsJSON, wraz z jego CredentialId i Username.
OnWebAuthnAuthenticationOptionsRequestZ nazwą użytkownika dodaj klucze dostępu tego użytkownika do CredentialRecords. Bez nazwy użytkownika nie dodawaj niczego.
OnWebAuthnAuthenticationGetCredentialWywoływane, gdy poświadczenie użyte przez przeglądarkę nie znajduje się na liście poświadczeń ceremonii, co ma miejsce przy logowaniu bez nazwy użytkownika lub z autouzupełnianiem. Wyszukaj identyfikator poświadczenia, wypełnij aCredentialRecord i ustaw Found na True.
OnWebAuthnAuthenticationSuccessfulZapisz zaktualizowane SignCount i BackupState z aAuthentication.Credential.CredentialRecord i utwórz sesję.

// ... 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;

Rekord zwrócony przez OnWebAuthnAuthenticationGetCredential musi mieć żądane CredentialId oraz to samo Username, gdy ceremonia została rozpoczęta z nazwą użytkownika, w przeciwnym razie logowanie kończy się niepowodzeniem. Przechowuj UserId (uchwyt użytkownika) w zapisanym rekordzie, jest on porównywany z userHandle wysłanym przez uwierzytelniacz. Zdarzenia działają w wątkach połączeń serwera, odpowiednio chroń zasoby współdzielone.

Zsynchronizowany lub powiązany z urządzeniem

Flagi danych uwierzytelniacza mówią, jaki rodzaj klucza dostępu otrzymał serwer. Są przechowywane w rekordzie poświadczenia jako BackupEligible (flaga BE) i BackupState (flaga BS):

BackupEligibleBackupStateMeaning
TrueTrueZsynchronizowany klucz dostępu, którego kopię zapasową tworzy platforma lub menedżer haseł, dostępny na innych urządzeniach użytkownika.
TrueFalseKlucz dostępu obsługujący wiele urządzeń, dla którego kopia zapasowa nie została jeszcze utworzona.
FalseFalseKlucz dostępu powiązany z urządzeniem, na przykład klucz bezpieczeństwa lub klucz TPM, który nigdy nie opuszcza urządzenia. Zalecaj użytkownikowi zarejestrowanie drugiego klucza dostępu.

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;

Przy każdym logowaniu serwer odrzuca odpowiedź z ustawioną flagą BS i nieustawioną flagą BE, a także odpowiedź, której flaga BE różni się od zapisanej BackupEligible, ponieważ kwalifikowalność poświadczenia nigdy się nie zmienia. Nowa flaga BS jest kopiowana do BackupState, zapisz ją w OnWebAuthnAuthenticationSuccessful.

Licznik podpisów i sklonowane uwierzytelniacze

Niektóre uwierzytelniacze zwiększają licznik podpisów przy każdym logowaniu. Gdy licznik odpowiedzi lub zapisane SignCount nie jest zerem, licznik odpowiedzi musi przekraczać zapisaną wartość, w przeciwnym razie logowanie jest odrzucane, ponieważ uwierzytelniacz mógł zostać sklonowany. Zapisana wartość może się tylko zwiększać i musi być zapisywana po każdym logowaniu, aby wykrywanie działało. Zsynchronizowane klucze dostępu zwykle zawsze zgłaszają 0, co wyłącza dla nich powyższe sprawdzenie. Ustaw WebAuthnOptions.AllowSignCountLessOrEqualStoredValue na True, aby akceptować licznik, który się nie zwiększa (zapisana wartość nadal nigdy nie jest obniżana).

Demo

Demo Demos\26.Authentication\01.Passkeys rejestruje kilka kluczy dostępu na użytkownika, loguje bez nazwy użytkownika i z autouzupełnianiem, pokazuje, czy każdy klucz dostępu jest zsynchronizowany, czy powiązany z urządzeniem, i przechowuje poświadczenia w pliku JSON aplikacji za pomocą powyższych zdarzeń. Zobacz też WebAuthn i klienta JavaScript.