패스키 관리

이 가이드에서는 Signal API를 사용하여 서버와 사용자의 비밀번호 관리자 간에 저장된 사용자 인증 정보와 사용자 프로필 세부정보를 동기화하면서 사용자가 등록된 패스키를 보고, 이름을 바꾸고, 삭제할 수 있도록 하는 방법을 자세히 설명합니다.

서버 측 작업

백엔드 데이터베이스 레이어와 엔드포인트는 등록된 사용자 인증 정보에 대한 일반적인 CRUD 작업과 사용자 프로필 세부정보 업데이트를 지원해야 합니다(MUST). 프레임워크별 라이브러리와 분리된 서버는 다음을 위해 엔드포인트를 노출합니다.

  1. 모든 사용자 인증 정보 나열: 로그인한 사용자의 ID와 일치하는 모든 StoredPasskeyCredential 레코드를 가져옵니다.
  2. 사용자 인증 정보 이름 업데이트: 특정 사용자 인증 정보 ID의 새 맞춤 닉네임을 수락하고 서버에서 업데이트를 유지합니다.
  3. 사용자 인증 정보 삭제: 데이터베이스에서 특정 사용자 인증 정보 ID를 삭제합니다.
  4. 사용자 계정 세부정보 업데이트: 클라이언트가 패스키 제공업체와 동기화할 수 있도록 로그인한 사용자의 프로필에 대해 업데이트된 name (사용자 이름) 및 displayName 값을 허용합니다.
// Node.js routing example for credential CRUD and user profile rename
router.get('/api/credentials', checkUserAuthenticated, async (req, res) => {
  const list = await db.findCredentialsByUserId(req.user.id);
  return res.json(list);
});

router.put('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const { name } = req.body;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  cred.name = name;
  await db.saveCredential(cred);
  return res.json(cred);
});

router.delete('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  await db.deleteCredential(id);
  return res.json({ success: true });
});

router.put('/api/user', checkUserAuthenticated, async (req, res) => {
  const { name, displayName } = req.body;
  const updatedUser = await db.updateUser(req.user.id, { name, displayName });
  return res.json(updatedUser);
});

클라이언트 측 관리 UI

사용자가 등록된 인증 옵션과 계정 프로필 세부정보를 쉽게 감사하고 관리할 수 있는 전용 설정 패널을 렌더링합니다.

  1. 저장된 목록 표시: 엔드포인트에서 목록을 가져와 개별 사용자 인증 정보 행을 렌더링합니다. 응답이 비어 있으면 유용한 빈 상태 메시지 (예: '패스키를 찾을 수 없음')를 렌더링합니다.
  2. AAGUID 메타데이터 매핑: 각 패스키에 대해 로컬 레지스트리에서 aaguid 속성을 조회하여 제공업체 세부정보를 렌더링합니다. 자세한 내용은 AAGUID에서 패스키 제공업체 확인 섹션을 참고하세요.
  3. 항목별 UI 요구사항: 목록 컨테이너 내부의 모든 행은 다음을 렌더링해야 합니다(MUST).
    • 제공업체 아이콘: AAGUID에서 파생된 이미지 또는 데이터 URI입니다.
    • 제공업체/맞춤 이름: AAGUID에서 파생된 이름 또는 사용자가 이름을 바꾼 문자열입니다.
    • 등록일: 클라이언트 표시를 위해 인간이 읽을 수 있는 날짜로 형식이 지정된 데이터베이스에 유지되는 원시 에포크 타임스탬프 registeredAt입니다.
    • 마지막 사용 날짜: 클라이언트 표시를 위해 사람이 읽을 수 있는 날짜 (있는 경우)로 형식이 지정된 데이터베이스 지속 원시 에포크 타임스탬프 lastUsedAt입니다.
    • 이름 바꾸기 버튼: 이름 바꾸기 작업을 트리거하여 서버에서 사용자 인증 정보의 맞춤 닉네임을 업데이트합니다 (PUT /api/credential/:id). 참고: 개별 패스키의 닉네임 이름을 바꾸는 것은 서버 측 라벨 변경이며 signalCurrentUserDetails()를 호출하지 않습니다.
    • 삭제 버튼: 사용자 인증 정보 (DELETE /api/credential/:id) 삭제를 트리거하고 signalAllAcceptedCredentials()를 통해 나머지 사용자 인증 정보 ID를 동기화합니다.
  4. 사용자 계정 이름 바꾸기 UI:
    • 사용자가 계정 사용자 이름 (name) 또는 displayName (PUT /api/user)을 업데이트할 수 있는 컨트롤을 제공하고 업데이트된 사용자 세부정보를 PublicKeyCredential.signalCurrentUserDetails()를 통해 비밀번호 관리자와 즉시 동기화합니다.
  5. 조건부 '패스키 만들기' 버튼:
    • 관리 페이지에 눈에 잘 띄는 '패스키 생성' 등록 트리거 버튼을 제공합니다. 이 UI 요소를 렌더링하기 전에 페이지는 PublicKeyCredential.getClientCapabilities()를 사용하여 기능을 감지하여 플랫폼 인증자가 지원되는지 확인해야 합니다(MUST). 패스키가 지원되지 않는 경우 이 버튼을 숨기고 대신 표준 MFA 등록을 유도합니다.
    • navigator.credentials.create() 호출에서 authenticatorSelection.authenticatorAttachment를 생략하여 보안 키를 등록할 수 있습니다.

신호 API 동기화

Signal API를 사용하면 애플리케이션이 사용자 인증 정보 상태를 비밀번호 관리자에 전달하여 사용자의 동기화된 저장소와 백엔드 데이터베이스를 동기화할 수 있습니다.

  • 특성 감지 및 매개변수 인코딩 규칙:
    • Signal API 메서드를 호출하기 전에 항상 기능 감지 (if (PublicKeyCredential.signalAllAcceptedCredentials) 및 if (PublicKeyCredential.signalCurrentUserDetails))를 실행하세요.
    • Signal API 메서드(signalAllAcceptedCredentials, signalCurrentUserDetails)에 전달되는 모든 userId 및 사용자 인증 정보 ID 매개변수는 Base64URL로 인코딩된 문자열이어야 합니다(MUST).
  • 페이지 로드 동기화 시작:
    • 애플리케이션은 관리 페이지가 로드될 때(예: DOMContentLoaded 이벤트 리스너, 모듈 초기화 또는 useEffect와 같은 프레임워크 구성요소 마운트 후크 내에서) signalAllAcceptedCredentials()를 자동으로 호출해야 합니다(MUST).
  • 관리 업데이트 동기화:
    • 애플리케이션은 사용자 인증 정보 삭제 클릭 핸들러 가져오기 후 즉시 signalAllAcceptedCredentials()를 호출해야 합니다(MUST).
    • 애플리케이션은 사용자 계정 이름 바꾸기(name 또는 displayName) 클릭 핸들러 가져오기 후 즉시 signalCurrentUserDetails()를 호출해야 합니다(MUST).
// Client-side management synchronization ES module
import { listFetch, renameCredentialFetch, updateUserFetch, deleteFetch } from './api.js';

// Base64URL-encoded User ID string (illustration only)
const base64UrlUserId = "M2YPl-KGnA8";

async function syncAcceptedCredentials(currentCredentialsList) {
  if (!window.PublicKeyCredential || !PublicKeyCredential.signalAllAcceptedCredentials) return;
  try {
    const credentialIds = currentCredentialsList.map(c => c.id); // Array of Base64URL credential ID strings
    
    await PublicKeyCredential.signalAllAcceptedCredentials({
      rpId, // RP ID must match the one defined on the server
      userId: base64UrlUserId, // User ID Base64URL-encoded string
      allAcceptedCredentialIds: credentialIds
    });
  } catch (e) {
    console.error('SignalAllAcceptedCredentials sync failure:', e);
  }
}

async function loadManagementPanel() {
  const response = await listFetch();
  const list = await response.json();
  
  renderUI(list);
  // Sync accepted credentials on initial load
  await syncAcceptedCredentials(list);
}

async function performDelete(credentialId) {
  const response = await deleteFetch(credentialId);
  if (response.ok) {
    const updatedResponse = await listFetch();
    const updatedList = await updatedResponse.json();
    
    renderUI(updatedList);
    // Sync remaining accepted credentials after deletion
    await syncAcceptedCredentials(updatedList);
  }
}

// Renaming a passkey's nickname updates the server record only
async function performCredentialRename(credentialId, newCredentialName) {
  const response = await renameCredentialFetch(credentialId, { name: newCredentialName });
  if (response.ok) {
    const updatedResponse = await listFetch();
    renderUI(await updatedResponse.json());
  }
}

// Renaming the user's account username or displayName triggers signalCurrentUserDetails
async function performUserRename(rpId, userId, updatedName, updatedDisplayName) {
  const response = await updateUserFetch({ name: updatedName, displayName: updatedDisplayName });
  if (response.ok && window.PublicKeyCredential && PublicKeyCredential.signalCurrentUserDetails) {
    try {
      await PublicKeyCredential.signalCurrentUserDetails({
        rpId, // RP ID must match the one defined on the server
        userId, // Base64URL-encoded user ID
        name: updatedName, // Updated username
        displayName: updatedDisplayName // Updated display name
      });
    } catch (e) {
      console.error('SignalCurrentUserDetails sync failure:', e);
    }
  }
}

window.addEventListener('DOMContentLoaded', loadManagementPanel);

AAGUID에서 패스키 제공업체 확인

AAGUID (인증자 증명 전역 고유 식별자)는 특정 인스턴스가 아닌 인증자 모델을 나타내는 128비트 식별자입니다. 패스키 등록 중에 인증자 데이터에 포함되며, 어떤 패스키 제공업체 (예: Google 비밀번호 관리자, iCloud 키체인, 1Password)가 사용자 인증 정보를 생성했는지 확인하는 데 사용할 수 있습니다.

AAGUID는 사용자의 패스키 관리를 지원하는 데만 사용해야 합니다. 암호화 증명이 없는 한 수정할 수 있으며, 현재 플랫폼 패스키는 이를 지원하지 않습니다.

1. AAGUID 레지스트리

커뮤니티에서 관리하는 AAGUID와 제공업체 이름 및 아이콘의 JSON 매핑은 다음에서 확인할 수 있습니다.

https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/refs/heads/main/combined_aaguid.json

각 항목에는 다음 스키마가 있습니다.

{
  "<aaguid-uuid>": {
    "name": "Provider Name",
    "icon_light": "data:image/png;base64,...",
    "icon_dark": "data:image/png;base64,..."
  }
}

2. 등록 후 AAGUID 사용

등록 응답을 확인한 후 등록 결과에서 aaguid를 읽고 레지스트리에서 조회하여 사용자 인증 정보의 name 및 providerIcon를 채웁니다.

레지스트리에서 AAGUID를 조회하기 전에 '00000000-0000-0000-0000-000000000000'와 같은지 확인합니다. 이 경우 레지스트리 조회를 건너뛰고 name를 대체 (예: 사용자 에이전트의 기기 이름 또는 '알 수 없는 패스키 제공업체')로 설정하고 providerIcon를 undefined로 설정합니다. 0이 아닌 AAGUID의 레지스트리만 조회합니다.

import aaguids from './aaguids.json' with { type: 'json' };

const { aaguid } = registrationInfo;
if (aaguid === '00000000-0000-0000-0000-000000000000') {
  // use the device name as the passkey provider based on
  // the information derived from the user agent string,
  // or just say "Unknown passkey provider"
} else {
  const provider = aaguids[aaguid];
  const credential = {
    // ...other fields
    aaguid,
    name: provider?.name || 'Unknown passkey provider',
    providerIcon: provider?.icon_light,
  };
}

대체 전략

패스키 기능 감지 대체

항상 'webauthn-polyfills'를 설치하고 컨텍스트에서 가져옵니다. PublicKeyCredential가 지원되는 한 PublicKeyCredential.getClientCapabilities도 지원됩니다.

import 'webauthn-polyfills';

신호 API 동기화 대체

브라우저가 PublicKeyCredential.parseRequestOptionsFromJSON를 지원하지 않는 경우 'webauthn-polyfills'를 사용합니다.

<script type="module"> 
  if (!PublicKeyCredential.parseRequestOptionsFromJSON) { 
     await import('https://unpkg.com/webauthn-polyfills'); 
   } 
 </script> 

이렇게 하면 PublicKeyCredential.prototype.toJSON에 대한 지원도 추가됩니다.