Rejestracja klucza po stronie serwera

Przegląd

Oto ogólny przegląd najważniejszych kroków związanych z rejestracją klucza dostępu:

Proces rejestracji klucza dostępu

  • Zdefiniuj opcje tworzenia klucza dostępu. Wyślij je do klienta, aby można było je przekazać do wywołania tworzenia klucza dostępu: wywołania interfejsu WebAuthn API navigator.credentials.create w internecie i credentialManager.createCredential na Androidzie. Gdy użytkownik potwierdzi utworzenie klucza dostępu, wywołanie tworzenia klucza dostępu zostanie rozwiązane i zwróci dane logowania PublicKeyCredential.
  • Zweryfikuj dane logowania i zapisz je na serwerze.

W sekcjach poniżej znajdziesz szczegółowe informacje o poszczególnych krokach.

Tworzenie opcji tworzenia danych logowania

Pierwszym krokiem, który musisz wykonać na serwerze, jest utworzenie obiektu PublicKeyCredentialCreationOptions.

Aby to zrobić, użyj biblioteki FIDO po stronie serwera. Zwykle oferuje funkcję użytkową, która może utworzyć te opcje. SimpleWebAuthn oferuje na przykład generateRegistrationOptions.

PublicKeyCredentialCreationOptions powinien zawierać wszystko, co jest potrzebne do utworzenia klucza dostępu: informacje o użytkowniku, o RP i konfigurację właściwości tworzonych danych logowania. Gdy zdefiniujesz wszystkie te parametry, przekaż je w razie potrzeby do funkcji w bibliotece po stronie serwera FIDO, która odpowiada za tworzenie obiektu PublicKeyCredentialCreationOptions.

Niektóre pola PublicKeyCredentialCreationOptions mogą być stałymi. Inne powinny być definiowane dynamicznie na serwerze:

  • rpId: aby wypełnić identyfikator RP na serwerze, użyj funkcji lub zmiennych po stronie serwera, które podają nazwę hosta aplikacji internetowej, np. example.com.
  • user.name i user.displayName: aby wypełnić te pola, użyj informacji o sesji zalogowanego użytkownika (lub informacji o nowym koncie użytkownika, jeśli użytkownik tworzy klucz dostępu podczas rejestracji). user.name to zwykle adres e-mail, który jest unikalny dla platformy uzależnionej. user.displayName to przyjazna dla użytkownika nazwa. Pamiętaj, że nie wszystkie platformy będą używać displayName.
  • user.id: losowy, unikalny ciąg znaków generowany podczas tworzenia konta. Powinien być stały, w przeciwieństwie do nazwy użytkownika, którą można edytować. Identyfikator użytkownika identyfikuje konto, ale nie powinien zawierać żadnych informacji umożliwiających identyfikację. Prawdopodobnie masz już identyfikator użytkownika w systemie, ale w razie potrzeby utwórz go specjalnie na potrzeby kluczy dostępu, aby nie zawierał żadnych informacji umożliwiających identyfikację.
  • excludeCredentials: lista identyfikatorów istniejących danych logowania, która zapobiega duplikowaniu klucza dostępu przez dostawcę klucza dostępu. Aby wypełnić to pole, wyszukaj w bazie danych istniejące dane logowania tego użytkownika. Szczegółowe informacje znajdziesz w sekcji Zapobieganie tworzeniu nowego klucza dostępu, jeśli już istnieje.
  • challenge: w przypadku rejestracji danych logowania wyzwanie nie ma znaczenia, chyba że używasz atestu, czyli bardziej zaawansowanej techniki weryfikacji tożsamości dostawcy klucza dostępu i emitowanych przez niego danych. Jednak nawet jeśli nie używasz atestu, wyzwanie jest nadal wymaganym polem. Instrukcje tworzenia bezpiecznego wyzwania na potrzeby uwierzytelniania znajdziesz w artykule Uwierzytelnianie za pomocą klucza dostępu po stronie serwera.

Kodowanie i dekodowanie

PublicKeyCredentialCreationOptions wysłane przez serwer
PublicKeyCredentialCreationOptions wysłane przez serwer. Pola binarne, takie jak challenge i user.id, są kodowane po stronie serwera w formacie base64URL, dzięki czemu opcje mogą być dostarczane jako JSON przez HTTPS.

Standardowy obiekt WebAuthn PublicKeyCredentialCreationOptions zawiera pola, które są ArrayBuffer, i nie można ich bezpośrednio serializować za pomocą JSON.stringify(). Aby wysyłać te opcje przez HTTPS, pola binarne muszą być zakodowane jako ciągi znaków base64URL:

  • Na serwerze biblioteka FIDO po stronie serwera (np.SimpleWebAuthn) automatycznie generuje obiekt opcji możliwy do serializacji w formacie JSON (np. PublicKeyCredentialCreationOptionsJSON) z polami zakodowanymi w formacie Base64URL.
  • Po stronie klienta nowoczesne przeglądarki obsługują PublicKeyCredential.parseCreationOptionsFromJSON(), aby konwertować opcje JSON bezpośrednio na PublicKeyCredentialCreationOptions. Jeśli używasz bibliotek po stronie klienta, takich jak @simplewebauthn/browser, funkcje takie jak startRegistration({ optionsJSON: options }) automatycznie obsługują tę konwersję.

Przykładowy kod: tworzenie opcji tworzenia danych logowania

W naszych przykładach używamy biblioteki SimpleWebAuthn. W tym przypadku przekazujemy tworzenie opcji danych logowania klucza publicznego do funkcji generateRegistrationOptions.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';

router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
  const { user } = res.locals;
  // 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 {
    // `excludeCredentials` prevents users from re-registering existing
    // credentials for a given passkey provider
    const excludeCredentials = [];
    const credentials = Credentials.findByUserId(user.id);
    if (credentials.length > 0) {
      for (const cred of credentials) {
        excludeCredentials.push({
          id: cred.id,
          type: 'public-key',
          transports: cred.transports,
        });
      }
    }

    // Generate registration options for WebAuthn create
    const options = await generateRegistrationOptions({
      rpName: process.env.RP_NAME,
      rpID: process.env.HOSTNAME,
      userID: isoUint8Array.fromUTF8String(user.id),
      userName: user.username,
      userDisplayName: user.displayName || '',
      attestationType: 'none',
      excludeCredentials,
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        residentKey: 'required',
        userVerification: 'preferred',
      },
    });

    // Keep the challenge in the session
    req.session.challenge = options.challenge;

    return res.json(options);
  } catch (e) {
    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

Przechowywanie klucza publicznego

PublicKeyCredentialCreationOptions wysłane przez serwer
navigator.credentials.create zwraca obiekt PublicKeyCredential.

Gdy navigator.credentials.create zostanie pomyślnie rozwiązany na kliencie, oznacza to, że klucz dostępu został utworzony. Zwracany jest obiekt PublicKeyCredential.

Obiekt PublicKeyCredential zawiera obiekt AuthenticatorAttestationResponse, który reprezentuje odpowiedź dostawcy klucza dostępu na instrukcję klienta dotyczącą utworzenia klucza dostępu. Zawiera informacje o nowych danych uwierzytelniających, których potrzebujesz jako strona uzależniona, aby później uwierzytelnić użytkownika. Więcej informacji o AuthenticatorAttestationResponse znajdziesz w Dodatku: AuthenticatorAttestationResponse.

Wyślij obiekt PublicKeyCredential na serwer. Gdy otrzymasz kod, zweryfikuj go.

Przekaż ten krok weryfikacji do biblioteki po stronie serwera FIDO. Zwykle oferuje w tym celu funkcję użytkową. SimpleWebAuthn oferuje na przykład verifyRegistrationResponse. Więcej informacji o tym, co dzieje się w tle, znajdziesz w sekcji Dodatek: weryfikacja odpowiedzi rejestracyjnej.

Po pomyślnej weryfikacji zapisz dane logowania w bazie danych, aby użytkownik mógł później uwierzytelnić się za pomocą klucza dostępu powiązanego z tymi danymi.

Użyj dedykowanej tabeli dla danych uwierzytelniających klucza publicznego powiązanych z kluczami dostępu. Użytkownik może mieć tylko jedno hasło, ale może mieć wiele kluczy dostępu – na przykład klucz dostępu zsynchronizowany za pomocą pęku kluczy Apple iCloud i klucz dostępu zsynchronizowany za pomocą Menedżera haseł Google.

Oto przykładowy schemat, którego możesz użyć do przechowywania informacji o danych logowania:

Schemat bazy danych kluczy dostępu

  • Tabela Users:
    • user_id: podstawowy identyfikator użytkownika. Losowy, unikalny i stały identyfikator użytkownika. Użyj tego jako klucza podstawowego w tabeli Użytkownicy.
    • username. Nazwa użytkownika zdefiniowana przez użytkownika, którą można edytować.
    • passkey_user_id: identyfikator użytkownika powiązany z kluczem dostępu, który nie zawiera informacji umożliwiających identyfikację, reprezentowany przez user.id w opcjach rejestracji. Gdy użytkownik spróbuje się później uwierzytelnić, moduł uwierzytelniający udostępni ten passkey_user_id w odpowiedzi uwierzytelniającej w userHandle. Zalecamy, aby nie ustawiać pola passkey_user_id jako klucza podstawowego. Klucze podstawowe stają się de facto informacjami umożliwiającymi identyfikację w systemach, ponieważ są powszechnie używane.
  • Tabela Dane logowania klucza publicznego:
    • id: identyfikator danych logowania. Użyj go jako klucza podstawowego w tabeli Dane uwierzytelniające klucza publicznego.
    • public_key: klucz publiczny danych logowania.
    • passkey_user_id: użyj tego jako klucza obcego, aby utworzyć połączenie z tabelą Użytkownicy.
    • backed_up: klucz dostępu ma utworzoną kopię zapasową, jeśli jest synchronizowany przez dostawcę klucza dostępu. Przechowywanie stanu kopii zapasowej jest przydatne, jeśli w przyszłości chcesz rozważyć rezygnację z haseł w przypadku użytkowników, którzy mają klucze dostępu backed_up. Możesz sprawdzić, czy kopia zapasowa klucza dostępu została utworzona, sprawdzając flagę BE w authenticatorData lub korzystając z funkcji biblioteki po stronie serwera FIDO, która zwykle jest dostępna i zapewnia łatwy dostęp do tych informacji. Przechowywanie informacji o możliwości utworzenia kopii zapasowej może być przydatne w przypadku potencjalnych pytań użytkowników.
    • name: opcjonalnie wyświetlana nazwa danych logowania, która umożliwia użytkownikom nadawanie im niestandardowych nazw.
    • transports: tablica transports. Przechowywanie transportów jest przydatne w procesie uwierzytelniania użytkowników. Gdy transporty są dostępne, przeglądarka może zachowywać się odpowiednio i wyświetlać interfejs pasujący do transportu, którego dostawca klucza dostępu używa do komunikacji z klientami – zwłaszcza w przypadku ponownego uwierzytelniania, gdy pole allowCredentials nie jest puste.

Inne informacje, które mogą być przydatne do przechowywania w celu poprawy wrażeń użytkownika, to m.in. dostawca klucza dostępu, czas utworzenia danych logowania i czas ostatniego użycia. Więcej informacji znajdziesz w artykule Projekt interfejsu kluczy dostępu.

Przykładowy kod: przechowywanie danych logowania

W naszych przykładach używamy biblioteki SimpleWebAuthn. W tym miejscu przekazujemy weryfikację odpowiedzi na rejestrację do funkcji verifyRegistrationResponse.

import { isoBase64URL } from '@simplewebauthn/server/helpers';


router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;
  const response = req.body;
  // This sample code is for registering a passkey for an existing,
  // signed-in user

  // 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 {
    // Verify the credential
    const { verified, registrationInfo } = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      requireUserVerification: false,
    });

    if (!verified) {
      throw new Error('Verification failed.');
    }

    const {
      aaguid,
      credential,
      credentialBackedUp
    } = registrationInfo;

    // Name the credential based on AAGUID
    const name =
      aaguid === undefined ||
      aaguid === '00000000-0000-0000-0000-000000000000' ?
        req.useragent?.platform : aaguids[aaguid]?.name;

    const base64PublicKey = isoBase64URL.fromBuffer(credential.publicKey);

    // Existing, signed-in user
    const { user } = res.locals;

    // Save the credential
    await Credentials.update({
      id: credential.id,
      passkey_user_id: user.passkey_user_id,
      publicKey: base64PublicKey,
      name,
      aaguid,
      transports: credential.transports || response.response.transports,
      backed_up: credentialBackedUp,
      registered_at: new Date().getTime()
    });

    // Kill the challenge for this session
    delete req.session.challenge;

    return res.json(user);
  } catch (e) {
    delete req.session.challenge;

    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

Dodatek: AuthenticatorAttestationResponse

AuthenticatorAttestationResponse zawiera 2 ważne obiekty:

  • response.clientDataJSON to wersja JSON danych klienta, które w internecie są danymi widocznymi dla przeglądarki. Zawiera on źródło RP, wyzwanie i androidPackageName, jeśli klientem jest aplikacja na Androida. Jako RP, odczytując clientDataJSON, uzyskujesz dostęp do informacji, które przeglądarka widziała w momencie żądania create.
  • response.attestationObjectzawiera 2 informacje:
    • attestationStatement, która nie jest istotna, chyba że używasz atestu.
    • authenticatorData to dane widoczne dla dostawcy klucza dostępu. Jako strona uzależniona, odczytanie authenticatorData daje Ci dostęp do danych widocznych dla dostawcy klucza dostępu i zwracanych w momencie żądania create.

authenticatorData zawiera podstawowe informacje o danych uwierzytelniających klucza publicznego powiązanych z nowo utworzonym kluczem dostępu:

Mimo że element authenticatorData jest zagnieżdżony w elemencie attestationObject, informacje, które zawiera, są potrzebne do implementacji klucza dostępu, niezależnie od tego, czy używasz atestu. Element authenticatorData jest zakodowany i zawiera pola zakodowane w formacie binarnym. Analizą i dekodowaniem zajmuje się zwykle biblioteka po stronie serwera. Jeśli nie używasz biblioteki po stronie serwera, rozważ wykorzystanie getAuthenticatorData() po stronie klienta, aby zaoszczędzić sobie pracy związanej z analizowaniem i dekodowaniem po stronie serwera.

Dodatek: weryfikacja odpowiedzi na rejestrację

Weryfikacja odpowiedzi rejestracyjnej obejmuje te czynności:

  • Upewnij się, że identyfikator RP pasuje do Twojej witryny.
  • Upewnij się, że źródło żądania jest oczekiwanym źródłem dla Twojej witryny (główny adres URL witryny, aplikacja na Androida).
  • Jeśli wymagasz weryfikacji użytkownika, upewnij się, że flaga weryfikacji użytkownika authenticatorData.uv jest ustawiona na true.
  • Flaga obecności użytkownika authenticatorData.up zwykle powinna mieć wartość true, ale jeśli dane logowania są tworzone warunkowo, powinna mieć wartość false.
  • Sprawdź, czy klient był w stanie podać wyzwanie, które mu przekazano. Jeśli nie używasz atestu, ta kontrola nie jest ważna. Wdrożenie tej kontroli jest jednak sprawdzoną metodą: dzięki temu kod będzie gotowy, jeśli w przyszłości zdecydujesz się używać atestu.
  • Upewnij się, że identyfikator danych logowania nie jest jeszcze zarejestrowany dla żadnego użytkownika.
  • Sprawdź, czy algorytm używany przez dostawcę klucza dostępu do utworzenia danych logowania jest algorytmem podanym przez Ciebie (w każdym polu alg w publicKeyCredentialCreationOptions.pubKeyCredParams, które jest zwykle zdefiniowane w bibliotece po stronie serwera i niewidoczne dla Ciebie). Dzięki temu użytkownicy mogą rejestrować się tylko przy użyciu algorytmów, które zostały przez Ciebie dozwolone.

Aby dowiedzieć się więcej, sprawdź kod źródłowy SimpleWebAuthn dla verifyRegistrationResponse lub zapoznaj się z pełną listą weryfikacji w specyfikacji.

Dalsze czynności

Uwierzytelnianie kluczem dostępu po stronie serwera