React + Vite + WebAuthn 패스키 구현 — 비밀번호 없는 어드민 로그인

왜 패스키인가

iEvent 어드민 패널은 소수 운영자만 사용합니다. 비밀번호 관리의 번거로움을 없애고 피싱 내성을 높이기 위해 WebAuthn 패스키를 도입했습니다. Touch ID / Face ID로 로그인합니다.

패스키 등록 흐름 (Registration)

# 1. 서버: challenge 생성
@router.post("/auth/passkey/register/options")
def get_registration_options(current_user = Depends(get_current_user), db = Depends(get_db)):
    challenge = secrets.token_bytes(32)
    # challenge를 세션/Redis에 임시 저장
    redis_client.setex(f"passkey_challenge:{current_user.id}", 60, challenge.hex())

    return {
        "challenge": base64.b64encode(challenge).decode(),
        "rp": {"name": "iEvent Admin", "id": "admin.ivent.com"},
        "user": {
            "id": base64.b64encode(current_user.id.encode()).decode(),
            "name": current_user.email,
            "displayName": current_user.name
        },
        "pubKeyCredParams": [{"type": "public-key", "alg": -7}],  # ES256
        "authenticatorSelection": {
            "userVerification": "required",
            "residentKey": "required"
        }
    }

프론트엔드: 패스키 등록

# admin/src/services/passkey.ts
function base64urlToBuffer(base64url: string): ArrayBuffer {
  const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
  const binary = atob(base64);
  return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}

function bufferToBase64url(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  bytes.forEach(b => binary += String.fromCharCode(b));
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

async function registerPasskey() {
  // 1. 서버에서 옵션 가져오기
  const options = await fetch('/api/auth/passkey/register/options', {
    method: 'POST', credentials: 'include'
  }).then(r => r.json());

  // 2. challenge를 ArrayBuffer로 변환
  const publicKey: PublicKeyCredentialCreationOptions = {
    ...options,
    challenge: base64urlToBuffer(options.challenge),
    user: {
      ...options.user,
      id: base64urlToBuffer(options.user.id)
    }
  };

  // 3. 패스키 생성 (Touch ID / Face ID 프롬프트)
  const credential = await navigator.credentials.create({ publicKey });
  const cred = credential as PublicKeyCredential;
  const response = cred.response as AuthenticatorAttestationResponse;

  // 4. 서버에 등록
  await fetch('/api/auth/passkey/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({
      id: cred.id,
      rawId: bufferToBase64url(cred.rawId),
      response: {
        attestationObject: bufferToBase64url(response.attestationObject),
        clientDataJSON: bufferToBase64url(response.clientDataJSON)
      }
    })
  });
}

패스키 인증 (Authentication)

# 프론트엔드
async function authenticateWithPasskey() {
  const options = await fetch('/api/auth/passkey/auth/options', {
    method: 'POST', credentials: 'include'
  }).then(r => r.json());

  const publicKey: PublicKeyCredentialRequestOptions = {
    ...options,
    challenge: base64urlToBuffer(options.challenge),
    allowCredentials: options.allowCredentials?.map((c: any) => ({
      ...c,
      id: base64urlToBuffer(c.id)
    }))
  };

  const credential = await navigator.credentials.get({ publicKey });
  const cred = credential as PublicKeyCredential;
  const response = cred.response as AuthenticatorAssertionResponse;

  const result = await fetch('/api/auth/passkey/auth/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({
      id: cred.id,
      rawId: bufferToBase64url(cred.rawId),
      response: {
        authenticatorData: bufferToBase64url(response.authenticatorData),
        clientDataJSON: bufferToBase64url(response.clientDataJSON),
        signature: bufferToBase64url(response.signature)
      }
    })
  }).then(r => r.json());

  if (result.token) {
    localStorage.setItem('admin_token', result.token);
  }
}

교훈

WebAuthn의 핵심은 base64url 인코딩입니다. 브라우저 navigator.credentials API는 ArrayBuffer를 받고 반환하므로, 서버와의 통신에서 base64url 변환이 필수입니다. py_webauthn 라이브러리를 사용하면 서버 측 검증을 안전하게 구현할 수 있습니다.

글쓴이

admin

https://ivent-admin.storyqbe.top/ 를 운영하고 있는 강프로입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다