왜 패스키인가
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 라이브러리를 사용하면 서버 측 검증을 안전하게 구현할 수 있습니다.