서버 측 패스키 등록

개요

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

패스키 등록 흐름

  • 패스키를 만드는 옵션을 정의합니다. 웹의 WebAuthn API 호출 navigator.credentials.create 및 Android의 credentialManager.createCredential와 같은 비밀번호 생성 호출에 전달할 수 있도록 클라이언트에 전송합니다. 사용자가 패스키 생성을 확인하면 패스키 생성 호출이 해결되고 사용자 인증 정보 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가 이미 있을 수 있지만 필요한 경우 패스키 전용으로 만들어 개인 식별 정보가 포함되지 않도록 합니다.
  • excludeCredentials: 패스키 제공업체에서 패스키가 중복되지 않도록 기존 사용자 인증 정보의 ID 목록입니다. 이 필드를 채우려면 데이터베이스에서 이 사용자의 기존 사용자 인증 정보를 조회하세요. 이미 있는 경우 새 패스키 생성 방지에서 세부정보를 검토하세요.
  • challenge: 사용자 인증 정보 등록의 경우 증명(더 고급 기술로, 패스키 제공업체의 ID와 내보내는 데이터를 확인하는 데 사용됨)을 사용하지 않는 한 챌린지는 관련이 없습니다. 하지만 증명을 사용하지 않더라도 챌린지는 필수 필드입니다. 인증을 위한 보안 챌린지를 만드는 방법은 서버 측 패스키 인증을 참고하세요.

인코딩 및 디코딩

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

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

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

이 예에서는 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 {
      aaguid,
      credentialPublicKey,
      credentialID,
      credentialBackedUp
    } = registrationInfo;

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

    const base64CredentialID = isoBase64URL.fromBuffer(credentialID);
    const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);

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

    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      passkey_user_id: user.passkey_user_id,
      publicKey: base64PublicKey,
      name,
      aaguid,
      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 });
  }
});

부록: AuthenticatorAttestationResponse

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

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

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

  • 공개 키 사용자 인증 정보 자체와 공개 키 사용자 인증 정보의 고유한 사용자 인증 정보 ID
  • 사용자 인증 정보와 연결된 RP ID입니다.
  • 패스키가 생성될 때 사용자 상태를 설명하는 플래그입니다. 사용자가 실제로 있었는지, 사용자가 성공적으로 인증되었는지 여부를 나타냅니다 (userVerification 심층 분석 참고).
  • AAGUID는 Google 비밀번호 관리자와 같은 패스키 제공업체의 식별자입니다. AAGUID를 기반으로 패스키 제공자를 식별하고 패스키 관리 페이지에 이름을 표시할 수 있습니다. (AAGUID로 패스키 제공자 확인 참고)

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

부록: 등록 응답 확인

등록 응답 확인은 다음과 같은 검사로 구성됩니다.

  • RP ID가 사이트와 일치하는지 확인합니다.
  • 요청의 출처가 사이트의 예상 출처 (기본 사이트 URL, Android 앱)인지 확인합니다.
  • 사용자 확인이 필요한 경우 사용자 확인 플래그 authenticatorData.uvtrue인지 확인합니다.
  • 사용자 존재 플래그 authenticatorData.up는 일반적으로 true일 것으로 예상되지만, 사용자 인증 정보가 조건부로 생성된 경우 false일 것으로 예상됩니다.
  • 클라이언트가 제공한 챌린지를 제공할 수 있는지 확인합니다. 증명을 사용하지 않는 경우 이 검사는 중요하지 않습니다. 하지만 이 검사를 구현하는 것이 좋습니다. 나중에 증명을 사용하기로 결정할 경우 코드가 준비되어 있기 때문입니다.
  • 사용자에게 아직 사용자 인증 정보 ID가 등록되지 않았는지 확인합니다.
  • 인증서 생성을 위해 패스키 제공업체에서 사용하는 알고리즘이 나열된 알고리즘인지 확인합니다 (publicKeyCredentialCreationOptions.pubKeyCredParams의 각 alg 필드에 나열됨. 일반적으로 서버 측 라이브러리 내에 정의되며 사용자에게는 표시되지 않음). 이렇게 하면 사용자가 허용하도록 선택한 알고리즘으로만 등록할 수 있습니다.

자세한 내용은 SimpleWebAuthn의 verifyRegistrationResponse 소스 코드를 확인하거나 사양의 전체 확인 목록을 참고하세요.

다음 단계

서버 측 패스키 인증