웹 앱에서 양식 자동 완성으로 패스키 구현

1. 시작하기 전에

비밀번호 대신 패스키를 사용하면 웹사이트에서 사용자 계정을 더 안전하고 간편하고 쉽게 사용할 수 있습니다. 패스키를 사용하면 사용자가 지문, 얼굴 또는 기기 PIN과 같은 기기의 화면 잠금 기능을 사용하여 웹사이트 또는 앱에 로그인할 수 있습니다. 패스키를 생성하여 사용자 계정과 연결해야 하며, 공개 키를 서버에 저장해야 사용자가 로그인할 수 있습니다.

이 Codelab에서는 기본 양식 기반 사용자 이름과 비밀번호 로그인을 패스키를 지원하고 다음을 포함하는 로그인으로 전환합니다.

  • 사용자가 로그인한 후 패스키를 생성하는 버튼
  • 등록된 패스키 목록을 표시하는 UI
  • 사용자가 양식 자동 완성을 통해 등록된 패스키로 로그인할 수 있는 기존 로그인 양식

기본 요건

학습할 내용

  • 패스키 생성 방법
  • 패스키로 사용자를 인증하는 방법
  • 양식에서 패스키를 로그인 옵션으로 제안하도록 허용하는 방법

필요한 항목

다음 기기 조합 중 하나:

  • Android 9 이상을 실행하는 Android 기기에 설치된 Chrome(생체 인식 센서를 함께 사용하는 것이 좋음)
  • Windows 10 이상을 실행하는 Windows 기기에 설치된 Chrome
  • iOS 16 이상을 실행하는 iPhone 또는 iPadOS 16 이상을 실행하는 iPad 기기에 설치된 Safari 16 이상
  • macOS Ventura 이상을 실행하는 Apple 데스크톱 기기에 설치된 Safari 16 이상 또는 Chrome

2. 설정

이 Codelab에서는 JavaScript로 클라이언트 및 서버 측 코드를 수정하고 브라우저에서만 배포할 수 있는 Glitch라는 서비스를 사용합니다.

프로젝트 열기

  1. Glitch에서 프로젝트를 엽니다.
  2. 리믹스를 클릭하여 Glitch 프로젝트를 포크합니다.
  3. Glitch 하단의 탐색 메뉴에서 미리보기 > 새 창에서 미리보기를 클릭합니다. 브라우저에 다른 탭이 열립니다.

Glitch 하단의 탐색 메뉴에 있는 새 창에서 미리보기 버튼

웹사이트의 초기 상태 확인

  1. 미리보기 탭에서 임의의 사용자 이름을 입력한 후 다음을 클릭합니다.
  2. 임의의 비밀번호를 입력한 후 로그인을 클릭합니다. 비밀번호는 무시되지만 인증이 완료되어 홈페이지로 이동하게 됩니다.
  3. 원하는 경우 표시 이름을 변경하세요. 초기 상태에서 할 수 있는 작업은 이것뿐입니다.
  4. 로그아웃을 클릭합니다.

이 상태에서는 사용자가 로그인할 때마다 비밀번호를 입력해야 합니다. 사용자가 기기의 화면 잠금 기능으로 로그인할 수 있도록 이 양식에 패스키 지원을 추가합니다. 최종 상태는 https://passkeys-codelab.glitch.me/에서 사용해 볼 수 있습니다.

패스키의 작동 방식에 대한 자세한 내용은 패스키의 작동 방식을 참고하세요.

3. 패스키 생성 기능 추가

사용자가 패스키로 인증하도록 하려면 패스키를 생성 및 등록하고 공개 키를 서버에 저장할 수 있는 권한을 부여해야 합니다.

패스키 생성 시 패스키 사용자 확인 대화상자가 표시됩니다.

사용자가 비밀번호로 로그인한 후 패스키 생성을 허용하고, 사용자가 패스키를 생성하고 /home 페이지에서 등록된 모든 패스키 목록을 볼 수 있도록 하는 UI를 추가하려고 합니다. 다음 섹션에서 패스키를 생성하고 등록하는 함수를 만들어 봅니다.

registerCredential() 함수 만들기

  1. Glitch에서 public/client.js 파일로 이동한 후 끝까지 스크롤합니다.
  2. 관련 주석 뒤의 다음 registerCredential() 함수를 추가합니다.

public/client. js

// TODO: Add an ability to create a passkey: Create the registerCredential() function.
export async function registerCredential() {

  // TODO: Add an ability to create a passkey: Obtain the challenge and other options from the server endpoint.

  // TODO: Add an ability to create a passkey: Create a credential.

  // TODO: Add an ability to create a passkey: Register the credential to the server endpoint.

};

이 함수는 서버에 패스키를 생성하고 등록합니다.

서버 엔드포인트에서 챌린지 및 기타 옵션 가져오기

패스키를 생성하기 전에 챌린지를 포함하여 서버에서 WebAuthn을 전달할 매개변수를 요청해야 합니다. WebAuthn은 사용자가 패스키를 생성하고 패스키로 사용자를 인증할 수 있는 브라우저 API입니다. 다행히 이 Codelab에는 이러한 매개변수로 응답하는 서버 엔드포인트가 이미 있습니다.

  • 서버 엔드포인트에서 챌린지 및 기타 옵션을 가져오려면 관련 주석 뒤의 registerCredential() 함수의 본문에 다음 코드를 추가합니다.

public/client.js

// TODO: Add an ability to create a passkey: Obtain the challenge and other options from the server endpoint.
const options = await _fetch('/auth/registerRequest');

다음 코드 스니펫에는 서버에서 수신하는 샘플 옵션이 포함되어 있습니다.

{
  challenge: *****,
  rp: {
    id: "example.com",
  },
  user: {
    id: *****,
    name: "john78",
    displayName: "John",
  },
  pubKeyCredParams: [{
    alg: -7, type: "public-key"
  },{
    alg: -257, type: "public-key"
  }],
  excludeCredentials: [{
    id: *****,
    type: 'public-key',
    transports: ['internal', 'hybrid'],
  }],
  authenticatorSelection: {
    authenticatorAttachment: "platform",
    requireResidentKey: true,
  }
}

서버와 클라이언트 간 프로토콜은 WebAuthn 사양의 일부가 아닙니다. 그러나 이 Codelab의 서버는 WebAuthn navigator.credentials.create() API에 전달된 PublicKeyCredentialCreationOptions 사전과 최대한 유사한 JSON을 반환하도록 설계되었습니다.

다음 표에 모든 내용이 포함되지는 않지만 PublicKeyCredentialCreationOptions 사전에 있는 중요한 매개변수가 포함되어 있습니다.

매개변수

설명

challenge

이 등록을 위한 ArrayBuffer 객체의 서버 생성 챌린지입니다. 이는 필수이지만 이 Codelab에서 다루지 않는 고급 주제인 증명을 수행하는 경우 외에는 등록 중에 사용되지 않습니다.

user.id

사용자의 고유 ID입니다. 이 값은 이메일 주소 또는 사용자 이름과 같은 개인 식별 정보를 포함하지 않는 ArrayBuffer 객체여야 합니다. 계정당 생성되는 임의의 16바이트 값이 좋습니다.

user.name

이 입력란에는 이메일 주소 또는 사용자 이름과 같이 사용자가 인식할 수 있는 계정의 고유 식별자가 있어야 합니다. 계정 선택기에 표시됩니다. (사용자 이름을 사용하는 경우 비밀번호 인증에서와 동일한 값을 사용하세요.)

user.displayName

이 입력란은 선택사항이며 사용자 친화적인 계정 이름입니다. 고유하지 않아도 되며 사용자가 선택한 이름일 수 있습니다. 웹사이트에 포함할 수 있는 적합한 값이 없는 경우 빈 문자열을 전달합니다. 브라우저에 따라 계정 선택기에 표시될 수 있습니다.

rp.id

신뢰 당사자(RP) ID는 도메인입니다. 웹사이트에서 도메인 또는 등록 가능한 접미사를 지정할 수 있습니다. 예를 들어 RP의 출처가 https://login.example.com:1337인 경우 RP ID는 login.example.com 또는 example.com일 수 있습니다. RP ID가 example.com으로 지정된 경우 사용자는 login.example.com 또는 example.com의 다른 하위 도메인에서 인증할 수 있습니다.

pubKeyCredParams

이 입력란은 RP에서 지원되는 공개 키 알고리즘을 지정합니다. [{alg: -7, type: "public-key"},{alg: -257, type: "public-key"}]로 설정하는 것이 좋습니다. P-256 및 RSA PKCS#1을 사용하는 ECDSA 지원을 지정하며, 지원을 통해 완전한 적용 범위를 제공합니다.

excludeCredentials

동일한 기기가 두 번 등록되는 것을 방지하기 위해 이미 등록된 사용자 인증 정보 ID 목록을 제공합니다. 제공되는 경우 transports 구성원에는 각 사용자 인증 정보를 등록하는 동안 getTransports() 함수를 호출한 결과가 포함되어야 합니다.

authenticatorSelection.authenticatorAttachment

"platform" 값으로 설정합니다. 이는 사용자에게 USB 보안 키 등을 삽입하라는 메시지가 표시되지 않도록 플랫폼 기기에 내장된 인증자가 필요하다는 뜻입니다.

authenticatorSelection.requireResidentKey

불리언 true 값으로 설정합니다. 검색 가능한 사용자 인증 정보(상주 키)는 서버에서 사용자 인증 정보 ID를 제공하지 않고도 사용할 수 있으므로 자동 완성과 호환됩니다.

authenticatorSelection.userVerification

"preferred" 값으로 설정하거나 기본값이므로 생략합니다. 기기의 화면 잠금을 사용하는 사용자 확인이 "required"인지, "preferred"인지, 또는 "discouraged"인지를 나타냅니다. "preferred" 값으로 설정하면 기기가 사용 가능할 때 사용자 확인을 요청합니다.

사용자 인증 정보 만들기

  1. 관련 주석 뒤의 registerCredential() 함수의 본문에서 Base64URL로 인코딩된 일부 매개변수, 특히 user.idchallenge 문자열과 excludeCredentials 배열에 포함된 id 문자열의 인스턴스를 다시 바이너리로 변환합니다.

public/client.js

// TODO: Add an ability to create a passkey: Create a credential.
// Base64URL decode some values.
options.user.id = base64url.decode(options.user.id);
options.challenge = base64url.decode(options.challenge);

if (options.excludeCredentials) {
  for (let cred of options.excludeCredentials) {
    cred.id = base64url.decode(cred.id);
  }
}
  1. 다음 줄에서 authenticatorSelection.authenticatorAttachment"platform"으로 설정하고 authenticatorSelection.requireResidentKeytrue로 설정합니다. 검색 가능한 사용자 인증 정보 기능이 있는 플랫폼 인증자(기기 자체)만 사용할 수 있습니다.

public/client.js

// Use platform authenticator and discoverable credential.
options.authenticatorSelection = {
  authenticatorAttachment: 'platform',
  requireResidentKey: true
}
  1. 다음 줄에서 navigator.credentials.create() 메서드를 호출하여 사용자 인증 정보를 만듭니다.

public/client.js

// Invoke the WebAuthn create() method.
const cred = await navigator.credentials.create({
  publicKey: options,
});

이 호출을 통해 브라우저에서 기기의 화면 잠금으로 사용자의 신원을 확인합니다.

서버 엔드포인트에 사용자 인증 정보 등록하기

사용자가 신원을 확인한 후 패스키가 생성되고 저장됩니다. 패스키를 등록하기 위해 서버로 보낼 수 있는 공개 키가 포함된 사용자 인증 정보 객체를 웹사이트에서 수신합니다.

다음 코드 스니펫에는 사용자 인증 정보 객체의 예시가 포함되어 있습니다.

{
  "id": *****,
  "rawId": *****,
  "type": "public-key",
  "response": {
    "clientDataJSON": *****,
    "attestationObject": *****,
    "transports": ["internal", "hybrid"]
  },
  "authenticatorAttachment": "platform"
}

다음 표에 모든 내용이 포함되지는 않지만 PublicKeyCredential 객체에 있는 중요한 매개변수가 포함되어 있습니다.

매개변수

설명

id

생성된 패스키의 Base64URL로 인코딩된 ID입니다. 이 ID는 인증 시 브라우저에서 기기에 일치하는 패스키가 있는지 확인하는 데 도움이 됩니다. 이 값은 백엔드의 데이터베이스에 저장해야 합니다.

rawId

사용자 인증 정보 ID의 ArrayBuffer 객체 버전입니다.

response.clientDataJSON

ArrayBuffer 객체로 인코딩된 클라이언트 데이터입니다.

response.attestationObject

ArrayBuffer로 인코딩된 증명 객체입니다. RP ID, 플래그, 공개 키와 같은 중요한 정보가 포함되어 있습니다.

response.transports

기기가 지원하는 전송 목록: "internal"은 기기가 패스키를 지원함을 의미합니다. "hybrid"다른 기기에서 인증도 지원함을 의미합니다.

authenticatorAttachment

이 사용자 인증 정보가 패스키 지원 기기에서 생성되면 "platform"을 반환합니다.

사용자 인증 정보 객체를 서버로 보내려면 다음 단계를 따르세요.

  1. 사용자 인증 정보의 바이너리 매개변수를 서버에 문자열로 전송할 수 있도록 Base64URL로 인코딩합니다.

public/client.js

// TODO: Add an ability to create a passkey: Register the credential to the server endpoint.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;

// The authenticatorAttachment string in the PublicKeyCredential object is a new addition in WebAuthn L3.
if (cred.authenticatorAttachment) {
  credential.authenticatorAttachment = cred.authenticatorAttachment;
}

// Base64URL encode some values.
const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
const attestationObject = base64url.encode(cred.response.attestationObject);

// Obtain transports.
const transports = cred.response.getTransports ? cred.response.getTransports() : [];

credential.response = {
  clientDataJSON,
  attestationObject,
  transports
};
  1. 다음 줄에서 객체를 서버로 전송합니다.

public/client.js

return await _fetch('/auth/registerResponse', credential);

프로그램을 실행하면 서버에서 사용자 인증 정보가 등록되었음을 나타내는 HTTP code 200을 반환합니다.

이제 완전한 registerCredential() 함수가 만들어졌습니다.

이 섹션의 솔루션 코드 검토

public/client.js

// TODO: Add an ability to create a passkey: Create the registerCredential() function.
export async function registerCredential() {

  // TODO: Add an ability to create a passkey: Obtain the challenge and other options from server endpoint.
  const options = await _fetch('/auth/registerRequest');

  // TODO: Add an ability to create a passkey: Create a credential.
  // Base64URL decode some values.

  options.user.id = base64url.decode(options.user.id);
  options.challenge = base64url.decode(options.challenge);

  if (options.excludeCredentials) {
    for (let cred of options.excludeCredentials) {
      cred.id = base64url.decode(cred.id);
    }
  }

  // Use platform authenticator and discoverable credential.
  options.authenticatorSelection = {
    authenticatorAttachment: 'platform',
    requireResidentKey: true
  }

  // Invoke the WebAuthn create() method.
  const cred = await navigator.credentials.create({
    publicKey: options,
  });

  // TODO: Add an ability to create a passkey: Register the credential to the server endpoint.
  const credential = {};
  credential.id = cred.id;
  credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
  credential.type = cred.type;

  // The authenticatorAttachment string in the PublicKeyCredential object is a new addition in WebAuthn L3.
  if (cred.authenticatorAttachment) {
    credential.authenticatorAttachment = cred.authenticatorAttachment;
  }

  // Base64URL encode some values.
  const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
  const attestationObject =
  base64url.encode(cred.response.attestationObject);

  // Obtain transports.
  const transports = cred.response.getTransports ?
  cred.response.getTransports() : [];

  credential.response = {
    clientDataJSON,
    attestationObject,
    transports
  };

  return await _fetch('/auth/registerResponse', credential);
};

4. 패스키 사용자 인증 정보를 등록하고 관리하기 위한 UI 빌드

이제 registerCredential() 함수를 사용할 수 있으므로 이를 호출하는 버튼이 필요합니다. 또한 등록된 패스키 목록을 표시해야 합니다.

/home 페이지에 표시되는 등록된 패스키

자리표시자 HTML 추가

  1. Glitch에서 views/home.html 파일로 이동합니다.
  2. 관련 주석 뒤의 패스키와 패스키 목록을 등록하는 버튼을 표시하는 UI 자리표시자를 추가합니다.

views/home.html

​​<!-- TODO: Add an ability to create a passkey: Add placeholder HTML. -->
<section>
  <h3 class="mdc-typography mdc-typography--headline6"> Your registered
  passkeys:</h3>
  <div id="list"></div>
</section>
<p id="message" class="instructions"></p>
<mwc-button id="create-passkey" class="hidden" icon="fingerprint" raised>Create a passkey</mwc-button>

div#list 요소는 목록의 자리표시자입니다.

패스키 지원 확인

패스키를 지원하는 기기를 사용하는 사용자에게만 패스키 생성 옵션을 표시하려면 먼저 WebAuthn을 사용할 수 있는지 확인해야 합니다. 그러면 패스키 생성 버튼을 표시하도록 hidden 클래스를 삭제해야 합니다.

환경에서 패스키를 지원하는지 확인하려면 다음 단계를 따르세요.

  1. 관련 주석 뒤의 views/home.html 파일 끝에 window.PublicKeyCredential, PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable, PublicKeyCredential.isConditionalMediationAvailabletrue인 경우 실행되는 조건문을 작성합니다.

views/home.html

// TODO: Add an ability to create a passkey: Check for passkey support.
const createPasskey = $('#create-passkey');
// Feature detections
if (window.PublicKeyCredential &&
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
    PublicKeyCredential.isConditionalMediationAvailable) {
  1. 조건문 본문에서 기기가 패스키를 생성할 수 있는지 확인한 후 양식 자동 완성에서 패스키를 제안할 수 있는지 확인합니다.

views/home.html

try {
  const results = await Promise.all([

    // Is platform authenticator available in this browser?
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),

    // Is conditional UI available in this browser?
    PublicKeyCredential.isConditionalMediationAvailable()
  ]);
  1. 모든 조건이 충족되면 패스키 생성 버튼을 표시합니다. 그렇지 않으면 경고 메시지를 표시합니다.

views/home.html

    if (results.every(r => r === true)) {

      // If conditional UI is available, reveal the Create a passkey button.
      createPasskey.classList.remove('hidden');
    } else {

      // If conditional UI isn't available, show a message.
      $('#message').innerText = 'This device does not support passkeys.';
    }
  } catch (e) {
    console.error(e);
  }
} else {

  // If WebAuthn isn't available, show a message.
  $('#message').innerText = 'This device does not support passkeys.';
}

등록된 패스키를 목록에 렌더링

  1. 등록된 패스키를 서버에서 가져와 목록으로 렌더링하는 renderCredentials() 함수를 정의합니다. 다행히 로그인한 사용자의 등록된 패스키를 가져오는 /auth/getKeys 서버 엔드포인트가 이미 있습니다.

views/home.html

// TODO: Add an ability to create a passkey: Render registered passkeys in a list.
async function renderCredentials() {
  const res = await _fetch('/auth/getKeys');
  const list = $('#list');
  const creds = html`${res.length > 0 ? html`
    <mwc-list>
      ${res.map(cred => html`
        <mwc-list-item>
          <div class="list-item">
            <div class="entity-name">
              <span>${cred.name || 'Unnamed' }</span>
          </div>
          <div class="buttons">
            <mwc-icon-button data-cred-id="${cred.id}"
            data-name="${cred.name || 'Unnamed' }" @click="${rename}"
            icon="edit"></mwc-icon-button>
            <mwc-icon-button data-cred-id="${cred.id}" @click="${remove}"
            icon="delete"></mwc-icon-button>
          </div>
         </div>
      </mwc-list-item>`)}
  </mwc-list>` : html`
  <mwc-list>
    <mwc-list-item>No credentials found.</mwc-list-item>
  </mwc-list>`}`;
  render(creds, list);
};
  1. 다음 줄에서 renderCredentials() 함수를 호출하여 사용자가 초기화로 /home 페이지에 도달하는 즉시 등록된 패스키를 표시합니다.

views/home.html

renderCredentials();

패스키 생성 및 등록

패스키를 생성하고 등록하려면 앞에서 구현한 registerCredential() 함수를 호출해야 합니다.

패스키 생성 버튼을 클릭할 때 registerCredential() 함수를 트리거하려면 다음 단계를 따르세요.

  1. 자리표시자 HTML 뒤의 파일에서 다음 import 문을 찾습니다.

views/home.html

import {
  $,
  _fetch,
  loading,
  updateCredential,
  unregisterCredential,
} from '/client.js';
  1. import 문의 본문 끝에 registerCredential() 함수를 추가합니다.

views/home.html

// TODO: Add an ability to create a passkey: Create and register a passkey.
import {
  $,
  _fetch,
  loading,
  updateCredential,
  unregisterCredential,
  registerCredential
} from '/client.js';
  1. 관련 주석 뒤의 파일 끝에 registerCredential() 함수와 로드 UI를 호출하고 등록 후 renderCredentials()를 호출하는 register() 함수를 정의합니다. 이렇게 하면 브라우저에서 패스키를 생성하고 문제 발생 시 오류 메시지를 표시합니다.

views/home.html

// TODO: Add an ability to create a passkey: Create and register a passkey.
async function register() {
  try {

    // Start the loading UI.
    loading.start();

    // Start creating a passkey.
    await registerCredential();

    // Stop the loading UI.
    loading.stop();

    // Render the updated passkey list.
    renderCredentials();
  1. register() 함수의 본문에서 예외를 포착합니다. 기기에 패스키가 이미 있으면 navigator.credentials.create() 메서드에서 InvalidStateError 오류가 발생합니다. excludeCredentials 배열로 이를 검사할 수 있습니다. 이 경우 사용자에게 관련 메시지를 표시합니다. 또한 사용자가 인증 대화상자를 취소하면 NotAllowedError 오류가 발생합니다. 이 경우 메시지를 자동으로 무시합니다.

views/home.html

  } catch (e) {

    // Stop the loading UI.
    loading.stop();

    // An InvalidStateError indicates that a passkey already exists on the device.
    if (e.name === 'InvalidStateError') {
      alert('A passkey already exists for this device.');

    // A NotAllowedError indicates that the user canceled the operation.
    } else if (e.name === 'NotAllowedError') {
      Return;

    // Show other errors in an alert.
    } else {
      alert(e.message);
      console.error(e);
    }
  }
};
  1. register() 함수 다음 줄에서 패스키 생성 버튼의 click 이벤트에 register() 함수를 연결합니다.

views/home.html

createPasskey.addEventListener('click', register);

이 섹션의 솔루션 코드 검토

views/home.html

​​<!-- TODO: Add an ability to create a passkey: Add placeholder HTML. -->
<section>
  <h3 class="mdc-typography mdc-typography--headline6"> Your registered
  passkeys:</h3>
  <div id="list"></div>
</section>
<p id="message" class="instructions"></p>
<mwc-button id="create-passkey" class="hidden" icon="fingerprint" raised>Create a passkey</mwc-button>

views/home.html

// TODO: Add an ability to create a passkey: Create and register a passkey.
import {
  $,
  _fetch,
  loading,
  updateCredential,
  unregisterCredential,
  registerCredential
} from '/client.js';

views/home.html

// TODO: Add an ability to create a passkey: Check for passkey support.
const createPasskey = $('#create-passkey');

// Feature detections
if (window.PublicKeyCredential &&
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
    PublicKeyCredential.isConditionalMediationAvailable) {
  try {
    const results = await Promise.all([

      // Is platform authenticator available in this browser?
      PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),

      // Is conditional UI available in this browser?
      PublicKeyCredential.isConditionalMediationAvailable()
    ]);
    if (results.every(r => r === true)) {

      // If conditional UI is available, reveal the Create a passkey button.
      createPasskey.classList.remove('hidden');
    } else {

      // If conditional UI isn't available, show a message.
      $('#message').innerText = 'This device does not support passkeys.';
    }
  } catch (e) {
    console.error(e);
  }
} else {

  // If WebAuthn isn't available, show a message.
  $('#message').innerText = 'This device does not support passkeys.';
}

// TODO: Add an ability to create a passkey: Render registered passkeys in a list.
async function renderCredentials() {
  const res = await _fetch('/auth/getKeys');
  const list = $('#list');
  const creds = html`${res.length > 0 ? html`
  <mwc-list>
    ${res.map(cred => html`
      <mwc-list-item>
        <div class="list-item">
          <div class="entity-name">
            <span>${cred.name || 'Unnamed' }</span>
          </div>
          <div class="buttons">
            <mwc-icon-button data-cred-id="${cred.id}" data-name="${cred.name || 'Unnamed' }" @click="${rename}" icon="edit"></mwc-icon-button>
            <mwc-icon-button data-cred-id="${cred.id}" @click="${remove}" icon="delete"></mwc-icon-button>
          </div>
        </div>
      </mwc-list-item>`)}
  </mwc-list>` : html`
  <mwc-list>
    <mwc-list-item>No credentials found.</mwc-list-item>
  </mwc-list>`}`;
  render(creds, list);
};

renderCredentials();

// TODO: Add an ability to create a passkey: Create and register a passkey.
async function register() {
  try {

    // Start the loading UI.
    loading.start();

    // Start creating a passkey.
    await registerCredential();

    // Stop the loading UI.
    loading.stop();

    // Render the updated passkey list.
    renderCredentials();
  } catch (e) {

    // Stop the loading UI.
    loading.stop();

    // An InvalidStateError indicates that a passkey already exists on the device.
    if (e.name === 'InvalidStateError') {
      alert('A passkey already exists for this device.');

    // A NotAllowedError indicates that the user canceled the operation.
    } else if (e.name === 'NotAllowedError') {
      Return;

    // Show other errors in an alert.
    } else {
      alert(e.message);
      console.error(e);
    }
  }
};

createPasskey.addEventListener('click', register);

직접 해 보기

지금까지의 모든 단계를 완료했다면 웹사이트에서 패스키를 생성, 등록, 표시하는 기능을 구현한 것입니다.

사용해 보려면 다음 단계를 따르세요.

  1. 미리보기 탭에서 임의의 사용자 이름과 비밀번호로 로그인합니다.
  2. 패스키 생성을 클릭합니다.
  3. 기기의 화면 잠금으로 본인 확인을 수행합니다.
  4. 웹페이지의 등록된 패스키 섹션에 패스키가 등록되고 표시되는지 확인합니다.

/home 페이지에 표시되는 등록된 패스키

등록된 패스키 이름 변경 및 삭제

목록에 등록된 패스키의 이름을 변경하거나 삭제할 수 있어야 합니다. Codelab에 포함되므로 코드에서 어떻게 작동하는지 확인할 수 있습니다.

Chrome에서는 데스크톱의 chrome://settings/passkeys 또는 Android 설정의 비밀번호 관리자에서 등록된 패스키를 삭제할 수 있습니다.

다른 플랫폼에서 등록된 패스키 이름을 변경 및 삭제하는 방법에 대해서는 해당 플랫폼별 지원 페이지를 참고하세요.

5. 패스키로 인증하는 기능 추가

이제 사용자가 패스키를 생성 및 등록할 수 있으며 웹사이트에 안전하게 인증하기 위한 수단으로 사용할 수 있습니다. 이제 웹사이트에 패스키 인증 기능을 추가해야 합니다.

authenticate() 함수 만들기

  • 관련 주석 뒤의 public/client.js 파일에서 사용자를 로컬에서 확인한 다음 서버에 대해 확인하는 authenticate() 함수를 만듭니다.

public/client.js

// TODO: Add an ability to authenticate with a passkey: Create the authenticate() function.
export async function authenticate() {

  // TODO: Add an ability to authenticate with a passkey: Obtain the challenge and other options from the server endpoint.

  // TODO: Add an ability to authenticate with a passkey: Locally verify the user and get a credential.

  // TODO: Add an ability to authenticate with a passkey: Verify the credential.

};

서버 엔드포인트에서 챌린지 및 기타 옵션 가져오기

사용자에게 인증을 요청하기 전에 챌린지를 포함하여 서버에서 WebAuthn을 전달할 매개변수를 요청해야 합니다.

  • 관련 주석 뒤의 authenticate() 함수의 본문에서 _fetch() 함수를 호출하여 서버에 POST 요청을 전송합니다.

public/client.js

// TODO: Add an ability to authenticate with a passkey: Obtain the challenge and other options from the server endpoint.
const options = await _fetch('/auth/signinRequest');

이 Codelab의 서버는 WebAuthn navigator.credentials.get() API에 전달된 PublicKeyCredentialRequestOptions 사전과 최대한 유사한 JSON을 반환하도록 설계되었습니다. 다음 코드 스니펫에는 수신해야 하는 예시 옵션이 포함되어 있습니다.

{
  "challenge": *****,
  "rpId": "passkeys-codelab.glitch.me",
  "allowCredentials": []
}

다음 표에 모든 내용이 포함되지는 않지만 PublicKeyCredentialRequestOptions 사전에 있는 중요한 매개변수가 포함되어 있습니다.

매개변수

설명

challenge

ArrayBuffer 객체의 서버 생성 챌린지입니다. 재전송 공격을 예방하기 위해 필요합니다. 답변에서 동일한 챌린지를 두 번 수락하지 마세요. 이것을 CSRF 토큰으로 간주합니다.

rpId

RP ID는 도메인입니다. 웹사이트에서 도메인 또는 등록 가능한 접미사를 지정할 수 있습니다. 이 값은 패스키 생성 시 사용된 rp.id 매개변수와 일치해야 합니다.

allowCredentials

이 속성은 이 인증에 적합한 인증자를 찾는 데 사용됩니다. 빈 배열을 전달하거나, 지정되지 않은 상태로 두면 브라우저에 계정 선택기가 표시됩니다.

userVerification

"preferred" 값으로 설정하거나 기본값이므로 생략합니다. 기기의 화면 잠금을 사용하는 사용자 확인이 "required"인지, "preferred"인지 또는 "discouraged"인지를 나타냅니다. "preferred" 값으로 설정하면 기기가 사용 가능할 때 사용자 확인을 요청합니다.

로컬에서 사용자 확인 및 사용자 인증 정보 가져오기

  1. 관련 주석 뒤의 authenticate() 함수의 본문에서 challenge 매개변수를 다시 바이너리로 변환합니다.

public/client.js

// TODO: Add an ability to authenticate with a passkey: Locally verify the user and get a credential.
// Base64URL decode the challenge.
options.challenge = base64url.decode(options.challenge);
  1. 사용자가 인증할 때 계정 선택기를 열 수 있도록 빈 배열을 allowCredentials 매개변수에 전달합니다.

public/client.js

// An empty allowCredentials array invokes an account selector by discoverable credentials.
options.allowCredentials = [];

계정 선택기는 패스키에 저장된 사용자 정보를 사용합니다.

  1. mediation: 'conditional' 옵션과 함께 navigator.credentials.get() 메서드를 호출합니다.

public/client.js

// Invoke the WebAuthn get() method.
const cred = await navigator.credentials.get({
  publicKey: options,

  // Request a conditional UI.
  mediation: 'conditional'
});

이 옵션은 양식 자동 완성의 일부로 조건부로 패스키를 추천하도록 브라우저에 지시합니다.

사용자 인증 정보 확인하기

사용자가 로컬에서 신원을 확인한 후에는 서버에서 확인할 수 있는 서명이 포함된 사용자 인증 정보 객체를 수신해야 합니다.

다음 코드 스니펫에는 PublicKeyCredential 객체의 예시가 포함되어 있습니다.

{
  "id": *****,
  "rawId": *****,
  "type": "public-key",
  "response": {
    "clientDataJSON": *****,
    "authenticatorData": *****,
    "signature": *****,
    "userHandle": *****
  },
  authenticatorAttachment: "platform"
}

다음 표에 모든 내용이 포함되지는 않지만 PublicKeyCredential 객체에 있는 중요한 매개변수가 포함되어 있습니다.

매개변수

설명

id

인증된 패스키 사용자 인증 정보의 Base64URL로 인코딩된 ID입니다.

rawId

사용자 인증 정보 ID의 ArrayBuffer 객체 버전입니다.

response.clientDataJSON

클라이언트 데이터의 ArrayBuffer 객체입니다. 이 입력란에는 챌린지 및 RP 서버가 확인해야 하는 출처 등의 정보가 포함됩니다.

response.authenticatorData

인증자 데이터의 ArrayBuffer 객체입니다. 이 입력란에는 RP ID와 같은 정보가 포함됩니다.

response.signature

서명의 ArrayBuffer 객체입니다. 이 값은 사용자 인증 정보의 핵심이며 서버에서 확인을 받아야 합니다.

response.userHandle

생성 시 설정된 사용자 ID를 포함하는 ArrayBuffer 객체입니다. 서버에서 사용하는 ID 값을 선택해야 하거나 백엔드에서 사용자 인증 정보 ID의 색인 생성을 피하려는 경우 사용자 인증 정보 ID 대신 이 값을 사용할 수 있습니다.

authenticatorAttachment

이 사용자 인증 정보를 로컬 기기에서 가져온 경우 "platform" 문자열을 반환합니다. 그렇지 않으면 특히 사용자가 휴대전화로 로그인할 때 "cross-platform" 문자열을 반환합니다. 사용자가 휴대전화로 로그인해야 하는 경우 로컬 기기에 패스키를 생성하라는 메시지를 표시해야 합니다.

사용자 인증 정보 객체를 서버로 보내려면 다음 단계를 따르세요.

  1. 관련 주석 뒤의 authenticate() 함수의 본문에서 사용자 인증 정보의 바이너리 매개변수를 인코딩하여 서버에 문자열로 전송할 수 있습니다.

public/client.js

// TODO: Add an ability to authenticate with a passkey: Verify the credential.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;

// Base64URL encode some values.
const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
const authenticatorData = base64url.encode(cred.response.authenticatorData);
const signature = base64url.encode(cred.response.signature);
const userHandle = base64url.encode(cred.response.userHandle);

credential.response = {
  clientDataJSON,
  authenticatorData,
  signature,
  userHandle,
};
  1. 객체를 서버로 전송합니다.

public/client.js

return await _fetch(`/auth/signinResponse`, credential);

프로그램을 실행하면 서버에서 사용자 인증 정보가 확인되었음을 나타내는 HTTP code 200을 반환합니다.

이제 전체 authentication() 함수가 만들어졌습니다.

이 섹션의 솔루션 코드 검토

public/client.js

// TODO: Add an ability to authenticate with a passkey: Create the authenticate() function.
export async function authenticate() {

  // TODO: Add an ability to authenticate with a passkey: Obtain the
  challenge and other options from the server endpoint.
  const options = await _fetch('/auth/signinRequest');

  // TODO: Add an ability to authenticate with a passkey: Locally verify
  the user and get a credential.
  // Base64URL decode the challenge.
  options.challenge = base64url.decode(options.challenge);

  // The empty allowCredentials array invokes an account selector
  by discoverable credentials.
  options.allowCredentials = [];

  // Invoke the WebAuthn get() function.
  const cred = await navigator.credentials.get({
    publicKey: options,

    // Request a conditional UI.
    mediation: 'conditional'
  });

  // TODO: Add an ability to authenticate with a passkey: Verify the credential.
  const credential = {};
  credential.id = cred.id;
  credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
  credential.type = cred.type;

  // Base64URL encode some values.
  const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
  const authenticatorData =
  base64url.encode(cred.response.authenticatorData);
  const signature = base64url.encode(cred.response.signature);
  const userHandle = base64url.encode(cred.response.userHandle);

  credential.response = {
    clientDataJSON,
    authenticatorData,
    signature,
    userHandle,
  };

  return await _fetch(`/auth/signinResponse`, credential);
};

6. 브라우저 자동 완성에 패스키 추가

사용자가 돌아오면 최대한 쉽고 안전하게 로그인하도록 합니다. 패스키로 로그인 버튼을 로그인 페이지에 추가하면 사용자가 버튼을 누르고 브라우저의 계정 선택기에서 패스키를 선택한 다음 화면 잠금을 사용하여 신원을 확인할 수 있습니다.

그러나 모든 사용자가 비밀번호에서 패스키로 한꺼번에 전환하지는 않습니다. 즉, 모든 사용자가 패스키로 전환할 때까지 비밀번호를 삭제할 수 없으므로 그때까지 비밀번호 기반 로그인 양식을 그대로 두어야 합니다. 하지만 비밀번호 양식과 패스키 버튼을 그대로 두면 사용자가 이 중에 어떤 로그인 방식을 사용할지 번거롭게 선택해야 합니다. 간단한 로그인 프로세스를 사용하는 것이 가장 좋습니다.

이때 사용하는 것이 조건부 UI입니다. 조건부 UI는 양식 입력란을 만들어 비밀번호 외에 자동 완성 항목의 일부로 패스키를 제안할 수 있는 WebAuthn 기능입니다. 사용자가 자동 완성 추천에서 패스키를 탭하면 로컬에서 신원을 확인하기 위해 기기의 화면 잠금을 사용하라는 메시지가 표시됩니다. 사용자 작업이 비밀번호 기반 로그인 작업과 거의 동일하기 때문에 이 동작으로 원활한 사용자 경험을 제공할 수 있습니다.

양식 자동 완성의 일부로 제안된 패스키

조건부 UI 사용 설정

조건부 UI를 사용 설정하려면 입력란의 autocomplete 속성에 webauthn 토큰을 추가하기만 하면 됩니다. 토큰이 설정되면 mediation: 'conditional' 문자열로 navigator.credentials.get() 메서드를 호출하여 화면 잠금 UI를 조건부로 트리거할 수 있습니다.

  • 조건부 UI를 사용 설정하려면 view/index.html 파일의 관련 주석 뒤의 기존 사용자 이름 입력란을 다음 HTML로 바꿔야 합니다.

view/index.html

<!-- TODO: Add passkeys to the browser autofill: Enable conditional UI. -->
<input
  type="text"
  id="username"
  class="mdc-text-field__input"
  aria-labelledby="username-label"
  name="username"
  autocomplete="username webauthn"
  autofocus />

기능 감지, WebAuthn 호출, 조건부 UI 사용 설정

  1. 관련 주석 뒤의 view/index.html 파일에서 기존 import 문을 다음 코드로 바꿉니다.

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
import {
  $,
  _fetch,
  loading,
  authenticate
} from "/client.js";

이 코드는 앞에서 구현한 authenticate() 함수를 가져옵니다.

  1. window.PulicKeyCredential 객체를 사용할 수 있고 PublicKeyCredential.isConditionalMediationAvailable() 메서드가 true 값을 반환하는지 확인한 후 authenticate() 함수를 호출합니다.

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
if (
  window.PublicKeyCredential &&
  PublicKeyCredential.isConditionalMediationAvailable
) {
  try {

    // Is conditional UI available in this browser?
    const cma =
      await PublicKeyCredential.isConditionalMediationAvailable();
    if (cma) {

      // If conditional UI is available, invoke the authenticate() function.
      const user = await authenticate();
      if (user) {

        // Proceed only when authentication succeeds.
        $("#username").value = user.username;
        loading.start();
        location.href = "/home";
      } else {
        throw new Error("User not found.");
      }
    }
  } catch (e) {
    loading.stop();

    // A NotAllowedError indicates that the user canceled the operation.
    if (e.name !== "NotAllowedError") {
      console.error(e);
      alert(e.message);
    }
  }
}

이 섹션의 솔루션 코드 검토

view/index.html

<!-- TODO: Add passkeys to the browser autofill: Enable conditional UI. -->
<input
  type="text"
  id="username"
  class="mdc-text-field__input"
  aria-labelledby="username-label"
  name="username"
  autocomplete="username webauthn"
  autofocus
/>

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
import {
  $,
  _fetch,
  loading,
  authenticate
} from '/client.js';

view/index.html

// TODO: Add passkeys to the browser autofill: Detect features, invoke WebAuthn, and enable a conditional UI.
// Is WebAuthn avaiable in this browser?
if (window.PublicKeyCredential &&
    PublicKeyCredential.isConditionalMediationAvailable) {
  try {

    // Is a conditional UI available in this browser?
    const cma= await PublicKeyCredential.isConditionalMediationAvailable();
    if (cma) {

      // If a conditional UI is available, invoke the authenticate() function.
      const user = await authenticate();
      if (user) {

        // Proceed only when authentication succeeds.
        $('#username').value = user.username;
        loading.start();
        location.href = '/home';
      } else {
        throw new Error('User not found.');
      }
    }
  } catch (e) {
    loading.stop();

    // A NotAllowedError indicates that the user canceled the operation.
    if (e.name !== 'NotAllowedError') {
      console.error(e);
      alert(e.message);
    }
  }
}

직접 해 보기

웹사이트에서 패스키의 생성, 등록, 표시, 인증을 구현했습니다.

사용해 보려면 다음 단계를 따르세요.

  1. 미리보기 탭으로 이동합니다.
  2. 필요한 경우 로그아웃합니다.
  3. 사용자 이름 텍스트 상자를 클릭합니다. 대화상자가 나타납니다.
  4. 로그인에 사용할 계정을 선택합니다.
  5. 기기의 화면 잠금으로 본인 확인을 수행합니다. /home 페이지로 리디렉션되고 로그인됩니다.

저장된 비밀번호 또는 패스키를 사용하여 신원을 확인하라는 대화상자

7. 수고하셨습니다.

이 Codelab을 완료했습니다. 궁금한 점이 있으면 FIDO-DEV 메일링 리스트 또는 StackOverflow에서 passkey 태그로 확인하세요.

자세히 알아보기