서버 측 패스키 등록

개요

다음은 패스키 등록과 관련된 주요 단계의 개요입니다.

패스키 등록 흐름

  • 패스키를 생성하는 옵션을 정의합니다. 패스키 생성 호출에 전달할 수 있도록 클라이언트로 전송합니다. 즉, WebAuthn API 호출(웹에서는 WebAuthn API 호출), Android에서는 credentialManager.createCredential로 전송합니다.navigator.credentials.create 사용자가 패스키 생성을 확인하면 패스키 생성 호출이 해결되고 사용자 인증 정보 PublicKeyCredential가 반환됩니다.
  • 사용자 인증 정보를 확인하고 서버에 저장합니다.

다음 섹션에서는 각 단계의 구체적인 내용을 자세히 설명합니다.

사용자 인증 정보 생성 옵션 만들기

서버에서 실행해야 하는 첫 번째 단계는 PublicKeyCredentialCreationOptions 객체를 만드는 것입니다.

이렇게 하려면 FIDO 서버 측 라이브러리를 사용하세요. 일반적으로 이러한 옵션을 만들 수 있는 유틸리티 함수를 제공합니다. SimpleWebAuthn은 다음과 같은 서비스를 제공합니다. generateRegistrationOptions

PublicKeyCredentialCreationOptions에는 사용자 정보, RP에 관한 정보, 만들려는 사용자 인증 정보의 속성 구성 등 패스키 생성에 필요한 모든 항목이 포함되어야 합니다. 모두 정의한 후에는 필요에 따라 PublicKeyCredentialCreationOptions 객체 생성을 담당하는 FIDO 서버 측 라이브러리의 함수에 전달합니다.

PublicKeyCredentialCreationOptions의 필드 중 일부는 상수일 수 있습니다. 그 외는 서버에서 동적으로 정의해야 합니다.

  • rpId: 서버에 RP ID를 채우려면 example.com과 같이 웹 애플리케이션의 호스트 이름을 제공하는 서버 측 함수 또는 변수를 사용합니다.
  • user.nameuser.displayName: 이 필드를 채우려면 로그인한 사용자의 세션 정보 (또는 사용자가 가입 시 패스키를 생성하는 경우 신규 사용자 계정 정보)를 사용합니다. user.name는 일반적으로 이메일 주소이며 RP별로 고유합니다. user.displayName은 사용자 친화적인 이름입니다. 일부 플랫폼에서는 displayName를 사용하지 않습니다.
  • user.id: 계정 생성 시 생성되는 임의의 고유한 문자열입니다. 수정 가능한 사용자 이름과 달리 이름은 영구적이어야 합니다. 사용자 ID는 계정을 식별하지만 개인 식별 정보 (PII)를 포함해서는 안 됩니다. 시스템에 이미 사용자 ID가 있을 수 있지만 필요한 경우 패스키 전용 사용자 ID를 만들어 PII가 포함되지 않도록 합니다.
  • excludeCredentials: 패스키 제공업체의 패스키가 중복되지 않도록 하기 위한 기존 사용자 인증 정보 ID 목록입니다. 이 필드를 채우려면 데이터베이스에서 이 사용자의 기존 사용자 인증 정보를 조회합니다. 이미 패스키가 있는 경우 새 패스키 생성 방지에서 세부정보를 검토하세요.
  • challenge: 사용자 인증 정보 등록의 경우 패스키 제공업체의 ID와 패스키 제공업체에서 내보내는 데이터를 확인하는 고급 기술인 증명을 사용하지 않으면 보안 질문이 관련이 없습니다. 증명을 사용하지 않더라도 필수 입력란입니다. 이 경우 편의상 이 챌린지를 단일 0로 설정할 수 있습니다. 인증을 위한 보안 질문을 만드는 방법은 서버 측 패스키 인증에서 확인할 수 있습니다.

인코딩 및 디코딩

서버에서 전송된 PublicKeyCredentialCreationOptions
서버에서 PublicKeyCredentialCreationOptions을(를) 전송했습니다. challenge, user.id, excludeCredentials.credentials는 서버 측에서 base64URL로 인코딩되어야 PublicKeyCredentialCreationOptions가 HTTPS를 통해 전송될 수 있습니다.

PublicKeyCredentialCreationOptions에는 ArrayBuffer인 필드가 포함되므로 JSON.stringify()에서 지원되지 않습니다. 즉, 현재 HTTPS를 통해 PublicKeyCredentialCreationOptions를 전송하려면 일부 필드를 base64URL를 사용하여 서버에서 수동으로 인코딩한 다음 클라이언트에서 디코딩해야 합니다.

  • 서버에서 인코딩 및 디코딩은 일반적으로 FIDO 서버 측 라이브러리에서 처리됩니다.
  • 현재 클라이언트에서는 인코딩과 디코딩이 수동으로 실행되어야 합니다. 앞으로는 더 쉬워질 예정입니다. 옵션을 JSON으로 PublicKeyCredentialCreationOptions로 변환하는 메서드가 제공될 예정입니다. Chrome에서 구현의 상태를 확인하세요.

코드 예시: 사용자 인증 정보 생성 옵션 만들기

이 예에서는 SimpleWebAuthn 라이브러리를 사용합니다. 여기에서는 공개 키 사용자 인증 정보 옵션 생성을 generateRegistrationOptions 함수에 넘깁니다.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } 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: isoBase64URL.toBuffer(cred.id),
          type: 'public-key',
          transports: cred.transports,
        });
      }
    }

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

    // 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 });
  }
});

공개 키 저장

서버에서 전송된 PublicKeyCredentialCreationOptions
navigator.credentials.createPublicKeyCredential 객체를 반환합니다.

클라이언트에서 navigator.credentials.create가 성공적으로 확인되면 패스키가 성공적으로 생성된 것입니다. PublicKeyCredential 객체가 반환됩니다.

PublicKeyCredential 객체에는 패스키를 생성하라는 클라이언트의 명령에 대한 패스키 제공자의 응답을 나타내는 AuthenticatorAttestationResponse 객체가 포함되어 있습니다. 여기에는 나중에 사용자를 인증하기 위해 RP로 필요한 새 사용자 인증 정보에 대한 정보가 포함됩니다. 부록: AuthenticatorAttestationResponse에서 AuthenticatorAttestationResponse에 대해 자세히 알아보세요.

PublicKeyCredential 객체를 서버로 전송합니다. 코드를 받으면 인증합니다.

이 확인 단계를 FIDO 서버 측 라이브러리에 전달합니다. 일반적으로 이러한 용도로 유틸리티 기능을 제공합니다. SimpleWebAuthn은 다음과 같은 서비스를 제공합니다. verifyRegistrationResponse 부록: 등록 응답 확인에서 내부적인 상황을 알아보세요.

확인이 완료되면 사용자가 나중에 해당 사용자 인증 정보와 연결된 패스키로 인증할 수 있도록 데이터베이스에 사용자 인증 정보 정보를 저장합니다.

패스키와 연결된 공개 키 사용자 인증 정보에 전용 테이블을 사용합니다. 사용자는 하나의 비밀번호만 가질 수 있지만 여러 개의 패스키를 가질 수 있습니다. 예를 들어 Apple iCloud 키체인을 통해 동기화된 패스키와 Google 비밀번호 관리자를 통해 동기화된 패스키를 예로 들 수 있습니다.

다음은 사용자 인증 정보를 저장하는 데 사용할 수 있는 스키마의 예입니다.

패스키의 데이터베이스 스키마

  • Users 테이블:
    • user_id: 기본 사용자 ID입니다. 사용자의 고유한 영구적인 임의 ID입니다. 이 키를 사용자 테이블의 기본 키로 사용합니다.
    • username. 사용자가 정의한 사용자 이름(수정 가능)
    • passkey_user_id: 등록 옵션에서 user.id로 표시되는 패스키별 개인 식별 정보 없는 사용자 ID입니다. 나중에 사용자가 인증을 시도하면 인증자는 userHandle의 인증 응답에서 이 passkey_user_id를 사용할 수 있도록 합니다. passkey_user_id는 기본 키로 설정하지 않는 것이 좋습니다. 기본 키는 광범위하게 사용되기 때문에 시스템에서 사실상의 PII가 되는 경향이 있습니다.
  • 공개 키 사용자 인증 정보 테이블:
    • id: 사용자 인증 정보 ID입니다. 이 키를 공개 키 사용자 인증 정보 테이블의 기본 키로 사용합니다.
    • public_key: 사용자 인증 정보의 공개 키입니다.
    • passkey_user_id: Users 테이블과의 링크를 설정하는 외래 키로 사용합니다.
    • backed_up: 패스키 제공업체가 패스키를 동기화하면 패스키가 백업됩니다. 백업 상태를 저장하면 향후 backed_up 패스키를 보유한 사용자의 비밀번호를 삭제할 것을 고려할 때 유용합니다. authenticatorData에서 플래그를 검사하거나 이 정보에 쉽게 액세스할 수 있도록 일반적으로 제공되는 FIDO 서버 측 라이브러리 기능을 사용하여 패스키가 백업되었는지 확인할 수 있습니다. 백업 자격 요건을 저장하면 잠재적인 사용자 문의를 해결하는 데 도움이 될 수 있습니다.
    • name: (선택사항) 사용자가 사용자 인증 정보에 맞춤 이름을 제공할 수 있는 사용자 인증 정보의 표시 이름입니다.
    • transports: 교통의 배열입니다. 전송을 저장하는 것은 사용자 인증 환경에 유용합니다. 전송을 사용할 수 있는 경우 브라우저는 적절하게 작동하고 패스키 제공자가 클라이언트와 통신하는 데 사용하는 전송과 일치하는 UI를 표시할 수 있습니다(특히 allowCredentials가 비어 있지 않은 재인증 사용 사례).

패스키 제공업체, 사용자 인증 정보 생성 시간, 마지막 사용 시간과 같은 항목 등 기타 정보를 사용자 환경을 위해 저장하면 도움이 될 수 있습니다. 패스키 사용자 인터페이스 디자인에서 자세히 알아보세요.

코드 예시: 사용자 인증 정보 저장

이 예에서는 SimpleWebAuthn 라이브러리를 사용합니다. 여기서는 등록 응답 확인을 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 { credentialPublicKey, credentialID } = registrationInfo;

    // Existing, signed-in user
    const { user } = res.locals;
    
    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      publicKey: base64PublicKey,
      // Optional: set the platform as a default name for the credential
      // (example: "Pixel 7")
      name: req.useragent.platform, 
      transports: response.response.transports,
      passkey_user_id: user.passkey_user_id,
      backed_up: registrationInfo.credentialBackedUp
    });

    // 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 });
  }
});

부록: AuthenticatorAttestationResponse

AuthenticatorAttestationResponse에는 두 가지 중요한 객체가 포함되어 있습니다.

  • response.clientDataJSON클라이언트 데이터의 JSON 버전으로, 웹에서는 브라우저에 표시되는 데이터입니다. 여기에는 RP 출처, 챌린지, 클라이언트가 Android 앱인 경우 androidPackageName가 포함됩니다. RP로서 clientDataJSON을 읽으면 create 요청 시 브라우저에서 확인한 정보에 액세스할 수 있습니다.
  • response.attestationObject에는 두 가지 정보가 포함됩니다.
    • attestationStatement는 증명을 사용하지 않는 한 관련이 없습니다.
    • authenticatorData는 패스키 제공업체에 표시되는 데이터입니다. RP로 authenticatorData을 읽으면 패스키 제공업체가 확인하고 create 요청 시 반환된 데이터에 액세스할 수 있습니다.

authenticatorData에는 새로 생성된 패스키와 연결된 공개 키 사용자 인증 정보에 대한 필수 정보가 포함됩니다.

  • 공개 키 사용자 인증 정보 자체 및 고유한 사용자 인증 정보 ID
  • 사용자 인증 정보와 연결된 RP ID입니다.
  • 패스키가 생성되었을 때의 사용자 상태를 설명하는 플래그입니다. 사용자가 실제로 있는지 여부 및 사용자가 성공적으로 확인되었는지 여부입니다 (userVerification 참고).
  • AAGUID - 패스키 제공자를 식별합니다. 특히 사용자가 여러 패스키 제공업체의 서비스에 등록된 패스키를 가지고 있는 경우 패스키 제공업체를 표시하는 것이 사용자에게 유용할 수 있습니다.

authenticatorDataattestationObject 내에 중첩되어 있지만 증명 사용 여부와 관계없이 패스키를 구현하려면 포함된 정보가 필요합니다. authenticatorData는 인코딩되며 바이너리 형식으로 인코딩된 필드를 포함합니다. 서버 측 라이브러리는 일반적으로 파싱 및 디코딩을 처리합니다. 서버 측 라이브러리를 사용하지 않는다면 getAuthenticatorData() 클라이언트 측을 활용하여 서버 측에서 파싱 및 디코딩 작업을 줄이는 것이 좋습니다.

부록: 등록 응답 확인

내부적으로 등록 응답 확인은 다음 확인 단계로 구성됩니다.

  • RP ID가 사이트와 일치하는지 확인합니다.
  • 요청의 출처가 사이트의 예상 출처인지 확인합니다 (기본 사이트 URL, Android 앱).
  • 사용자 확인이 필요한 경우 사용자 확인 플래그 authenticatorData.uvtrue인지 확인합니다. 패스키에는 사용자 접속 상태가 항상 필요하므로 사용자 접속 상태 플래그 authenticatorData.uptrue인지 확인합니다.
  • 고객이 제시한 도전과제를 제시할 수 있었는지 확인합니다. 증명을 사용하지 않는 경우 이 검사는 중요하지 않습니다. 그러나 이 검사를 구현하는 것이 좋습니다. 그러면 향후 증명을 사용하려는 경우 코드가 준비되었는지 확인할 수 있습니다.
  • 사용자 인증 정보 ID가 아직 어떤 사용자에게도 등록되지 않았는지 확인합니다.
  • 패스키 제공업체에서 사용자 인증 정보를 만드는 데 사용하는 알고리즘이 개발자가 등록한 알고리즘인지 확인합니다 (publicKeyCredentialCreationOptions.pubKeyCredParams의 각 alg 필드. 일반적으로 서버 측 라이브러리 내에서 정의되며 사용자에게는 표시되지 않음). 이렇게 하면 허용된 알고리즘으로만 사용자가 등록할 수 있습니다.

자세히 알아보려면 SimpleWebAuthn의 verifyRegistrationResponse 소스 코드를 확인하거나 사양에서 전체 인증 목록을 살펴보세요.

다음 단계

서버 측 패스키 인증