Uwierzytelnianie klucza po stronie serwera

Przegląd

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

Proces uwierzytelniania za pomocą klucza dostępu

  • Zdefiniuj wyzwanie i inne opcje wymagane do uwierzytelniania za pomocą klucza dostępu. Wyślij je do klienta, aby można było przekazać je do wywołania uwierzytelniania klucza dostępu (navigator.credentials.get w internecie). Gdy użytkownik potwierdzi uwierzytelnianie za pomocą klucza dostępu, wywołanie uwierzytelniania za pomocą klucza dostępu zostanie rozwiązane i zwróci dane logowania (PublicKeyCredential). Dane logowania zawierają asercję uwierzytelniania.
  • Zweryfikuj asercję uwierzytelniania.
  • Jeśli asercja uwierzytelniania jest prawidłowa, uwierzytelnij użytkownika.

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

Tworzenie wyzwania

W praktyce wyzwanie to tablica losowych bajtów reprezentowana jako obiekt ArrayBuffer.

// Example challenge, base64URL-encoded
weMLPOSx1VfSnMV6uPwDKbjGdKRMaUDGxeDEUTT5VN8

Aby wyzwanie spełniło swoje zadanie, musisz:

  1. Upewnij się, że ten sam ciąg kontrolny nie jest używany więcej niż raz. Generuj nowe wyzwanie przy każdej próbie logowania. Odrzuć test zabezpieczający po każdej próbie logowania, niezależnie od tego, czy się powiodła, czy nie. Odrzuć wyzwanie po określonym czasie. Nigdy nie akceptuj tego samego wyzwania w odpowiedzi więcej niż raz.
  2. Upewnij się, że test jest zabezpieczony kryptograficznie. Odgadnięcie odpowiedzi na pytanie zabezpieczające powinno być praktycznie niemożliwe. Aby utworzyć kryptograficznie bezpieczne wyzwanie po stronie serwera, najlepiej użyć zaufanej biblioteki FIDO po stronie serwera. Jeśli zamiast tego tworzysz własne wyzwania, użyj wbudowanej funkcji kryptograficznej dostępnej w Twoim stosie technologicznym lub poszukaj bibliotek przeznaczonych do zastosowań kryptograficznych. Przykłady to iso-crypto w Node.js lub secrets w Pythonie. Zgodnie ze specyfikacją wyzwanie musi mieć co najmniej 16 bajtów, aby było uznawane za bezpieczne.

Po utworzeniu wyzwania zapisz je w sesji użytkownika, aby później je zweryfikować.

Tworzenie opcji żądania danych logowania

Utwórz opcje żądania danych logowania jako obiekt publicKeyCredentialRequestOptions.

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

publicKeyCredentialRequestOptions powinien zawierać wszystkie informacje potrzebne do uwierzytelniania za pomocą klucza dostępu. Przekaż te informacje do funkcji w bibliotece po stronie serwera FIDO, która odpowiada za tworzenie obiektu publicKeyCredentialRequestOptions.

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

  • rpId: identyfikator RP, z którym mają być powiązane dane logowania, np. example.com. Uwierzytelnianie powiedzie się tylko wtedy, gdy podany tutaj identyfikator RP będzie zgodny z identyfikatorem RP powiązanym z danymi logowania. Aby wypełnić identyfikator RP, użyj tej samej wartości co identyfikator RP ustawiony w publicKeyCredentialCreationOptions podczas rejestracji danych logowania.
  • challenge: dane, które dostawca klucza dostępu podpisze, aby potwierdzić, że użytkownik ma klucz dostępu w momencie wysyłania żądania uwierzytelnienia. Sprawdź szczegóły w sekcji Tworzenie wyzwania.
  • allowCredentials: tablica akceptowalnych danych uwierzytelniających na potrzeby tego uwierzytelniania. Przekaż pustą tablicę, aby umożliwić użytkownikowi wybranie dostępnego klucza dostępu z listy wyświetlanej przez przeglądarkę. Szczegółowe informacje znajdziesz w sekcjach Pobieranie wyzwania z serwera RP i Szczegółowe omówienie wykrywalnych danych logowania.
  • userVerification: wskazuje, czy weryfikacja użytkownika za pomocą blokady ekranu urządzenia jest „wymagana”, „preferowana” czy „niezalecana”. Zapoznaj się z sekcją Pobieranie wyzwania z serwera RP.
  • timeout: czas (w milisekundach), w którym użytkownik może ukończyć uwierzytelnianie. Powinien być odpowiednio długi i krótszy niż czas życia challenge. Zalecana wartość domyślna to 5 minut, ale możesz ją zwiększyć do 10 minut, co nadal mieści się w zalecanym zakresie. Długie czasy oczekiwania mają sens, jeśli oczekujesz, że użytkownicy będą korzystać z hybrydowego przepływu pracy, który zwykle trwa nieco dłużej. Jeśli przekroczony zostanie limit czasu operacji, zostanie wygenerowany błąd NotAllowedError.

Po utworzeniu publicKeyCredentialRequestOptions wyślij go do klienta.

publicKeyCredentialCreationOptions wysłane przez serwer
Opcje wysyłane przez serwer. challenge dekodowanie odbywa się po stronie klienta.

Przykładowy kod: tworzenie opcji żądania danych logowania

W naszych przykładach używamy biblioteki SimpleWebAuthn. W tym miejscu przekazujemy tworzenie opcji żądania danych logowania do funkcji generateAuthenticationOptions.

import {
  generateAuthenticationOptions,
} from '@simplewebauthn/server';

router.post('/signinRequest', csrfCheck, async (req, res) => {

  // Ensure you nest 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 {
    // Use the generateAuthenticationOptions function from SimpleWebAuthn
    const options = await generateAuthenticationOptions({
      rpID: process.env.HOSTNAME,
      allowCredentials: [],
    });
    // Save the challenge in the user session
    req.session.challenge = options.challenge;

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

Weryfikowanie i logowanie użytkownika

Gdy metoda navigator.credentials.get zostanie pomyślnie rozwiązana na kliencie, zwraca obiekt PublicKeyCredential.

Obiekt PublicKeyCredential wysłany przez serwer
navigator.credentials.get zwraca PublicKeyCredential.

response to AuthenticatorAssertionResponse. Reprezentuje odpowiedź dostawcy klucza dostępu na instrukcję klienta, aby utworzyć to, co jest potrzebne do uwierzytelnienia za pomocą klucza dostępu na stronie uzależnionej. Zawiera te informacje:

  • response.authenticatorData i response.clientDataJSON, np. na etapie rejestracji klucza dostępu.
  • response.signature, który zawiera podpis tych wartości.

Wyślij obiekt PublicKeyCredential na serwer.

Na serwerze wykonaj te czynności:

Schemat bazy danych
Sugerowany schemat bazy danych. Więcej informacji o tym rozwiązaniu znajdziesz w artykule Rejestracja klucza dostępu po stronie serwera.
  • Zbierz informacje potrzebne do zweryfikowania asercji i uwierzytelnienia użytkownika:
    • Pobierz oczekiwany ciąg znaków, który został zapisany w sesji podczas generowania opcji uwierzytelniania.
    • Uzyskaj oczekiwane źródło i identyfikator RP.
    • Znajdź w bazie danych, kim jest użytkownik. W przypadku danych logowania możliwych do wykrycia nie wiesz, kto wysyła żądanie uwierzytelnienia. Możesz to sprawdzić na 2 sposoby:
      • Opcja 1. Użyj pola response.userHandle w obiekcie PublicKeyCredential. W tabeli Użytkownicy znajdź passkey_user_id, który odpowiada userHandle.
      • Opcja 2. Użyj danych logowania id obecnych w obiekcie PublicKeyCredential. W tabeli Dane logowania klucza publicznego znajdź dane logowania id, które odpowiadają danym logowania id w obiekcie PublicKeyCredential. Następnie wyszukaj odpowiedniego użytkownika, używając klucza obcego passkey_user_id do tabeli Użytkownicy.
    • Znajdź w bazie danych informacje o danych logowania klucza publicznego, które pasują do otrzymanej asercji uwierzytelniania. Aby to zrobić, w tabeli Dane logowania klucza publicznego znajdź dane logowania id, które odpowiadają danym logowania id w obiekcie PublicKeyCredential.
  • Zweryfikuj asercję uwierzytelniania. Przekaż ten krok weryfikacji do biblioteki FIDO po stronie serwera, która zwykle oferuje funkcję użytkową do tego celu. SimpleWebAuthn oferuje na przykład verifyAuthenticationResponse. Więcej informacji o tym, co dzieje się w tle, znajdziesz w sekcji Dodatek: weryfikacja odpowiedzi uwierzytelniającej.

  • Usuń wyzwanie niezależnie od tego, czy weryfikacja się powiedzie, czy nie, aby zapobiec atakom typu replay.

  • Zaloguj użytkownika. Jeśli weryfikacja się powiedzie, zaktualizuj informacje o sesji, aby oznaczyć użytkownika jako zalogowanego. Możesz też zwrócić obiekt user do klienta, aby interfejs mógł używać informacji powiązanych z nowo zalogowanym użytkownikiem.

Przykładowy kod: weryfikacja i logowanie użytkownika

W naszych przykładach używamy biblioteki SimpleWebAuthn. W tym miejscu przekazujemy weryfikację odpowiedzi uwierzytelniania do funkcji verifyAuthenticationResponse.

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

router.post('/signinResponse', csrfCheck, async (req, res) => {
  const response = req.body;
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;

  // 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 {
    // Find the credential stored to the database by the credential ID
    const cred = Credentials.findById(response.id);
    if (!cred) {
      throw new Error('Credential not found.');
    }
    // Find the user - Here alternatively we could look up the user directly
    // in the Users table via userHandle
    const user = Users.findByPasskeyUserId(cred.passkey_user_id);
    if (!user) {
      throw new Error('User not found.');
    }
    // Prepare credential information for verification
    const credential = {
      id: cred.id,
      publicKey: isoBase64URL.toBuffer(cred.publicKey),
      transports: cred.transports,
    };

    // Verify the credential
    const { verified, authenticationInfo } = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      credential,
      requireUserVerification: false,
    });

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

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

    req.session.username = user.username;
    req.session['signed-in'] = 'yes';

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

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

Dodatek: weryfikacja odpowiedzi uwierzytelniającej

Weryfikacja odpowiedzi uwierzytelniającej obejmuje te czynności:

  • Upewnij się, że identyfikator RP pasuje do Twojej witryny.
  • Upewnij się, że źródło żądania jest zgodne ze źródłem logowania w witrynie. W przypadku aplikacji na Androida zapoznaj się z sekcją Weryfikowanie pochodzenia.
  • Sprawdź, czy urządzenie było w stanie odpowiedzieć na wyzwanie.
  • Sprawdź, czy podczas uwierzytelniania użytkownik spełnił wymagania, które nakładasz jako strona uzależniona. Jeśli wymagasz weryfikacji użytkownika, upewnij się, że flaga uv (użytkownik zweryfikowany) w authenticatorData ma wartość true. Sprawdź, czy flaga up (obecność użytkownika) w authenticatorData ma wartość true, ponieważ obecność użytkownika jest zawsze wymagana w przypadku kluczy dostępu.
  • Sprawdź podpis. Aby zweryfikować podpis, musisz mieć:
    • podpis, czyli podpisane zadanie: response.signature
    • klucz publiczny, za pomocą którego można zweryfikować podpis;
    • Oryginalne podpisane dane. Są to dane, których podpis ma zostać zweryfikowany.
    • algorytm kryptograficzny użyty do utworzenia podpisu,

Aby dowiedzieć się więcej o tych krokach, zapoznaj się z kodem źródłowym SimpleWebAuthn dla verifyAuthenticationResponse lub przejrzyj pełną listę weryfikacji w specyfikacji.