Go와 Svelte를 이용한 Passkey 인증 (2)

박재훈·2024년 10월 15일
1

GO

목록 보기
23/23
post-thumbnail
post-custom-banner

Frontend (Svelte)

참고했던 글에서는 그냥 HTML 파일 내에서 모든 걸 해결한다. 여기서도 원래는 그렇게 간단하게 하려고 했는데, 막상 그렇게 짜보니 웹앱의 URL이 localhost거나 도메인이 있어야 한다고 나오면서 안됐다.

그래서 프론트엔드를 이용하기로 했고, 리액트와 스벨트를 고민하다가 어차피 테스트용 페이지라 간단하고 빠르게 구현할 수 있는 스벨트를 이용하기로 했다.

먼저, 페이지는 대충 이렇게 생겼다. 이 한 페이지에서 회원가입, 로그인, 유저 데이터 불러오기를 모두 처리한다.

<style type="text/css">
    fieldset {
        margin: auto;
        margin-top: 5rem;
        padding: 1.5rem;
        width: 50%;
    }
    fieldset > input {
        margin-bottom: 0.5rem;
    }
</style>

<h1>Passkey Example</h1>

<div style="width: 100%;">
    <fieldset>
        <legend>Register</legend>
        <input bind:value={registerData.name} placeholder="name" /><br />
        <input bind:value={registerData.email} placeholder="email" /><br />
        <input bind:value={registerData.birthYear} placeholder="birth year" />
        <button on:click={() => register()}>submit</button>
    </fieldset>

    <fieldset>
        <legend>Login</legend>
        <input bind:value={loginData.name} placeholder="name" />
        <button on:click={() => login()}>submit</button>
    </fieldset>

    <fieldset>
        <legend>User Information</legend>
        <button on:click={() => requestUserData()}>request data</button>
        {#if userInfo}
            <br />
            <pre>{JSON.stringify(userInfo, null, 2)}</pre>
        {/if}
    </fieldset>
</div>

별다른 CSS 프레임워크 없이 정말 간단하게 구성했다.

Library

패스키를 브라우저에서 작동시키기 위해 @simplewebauthn/browser 라이브러리를 사용하였다.

본 시스템에서는 여기서 startRegistration, startAuthentication 이렇게 두 함수를 사용한다.

import { startRegistration, startAuthentication } from '@simplewebauthn/browser';

이렇게 임포트 했다.

Register

const registerData = {
    name: '',
    email: '',
    birthYear: '',
};

async function register() {
    const { name, email, birthYear } = registerData;
    if (!name.length || !email.length || !birthYear.length) {
        alert('All fields need to be set!');
        return;
    }

    // call backend /register/start
    const res = await fetch(`${backend}/api/passkey/register/start`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            name,
            email,
            birthYear: Number(birthYear),
        }),
    });

    if (!res.ok) {
        const msg = await res.json();
        throw new Error(msg);
    }

    const data = await res.json();
    const attestationResponse = await startRegistration(
        data.options.publicKey
    ).catch((err) => {
        alert(String(err));
        return undefined;
    });
    if (!attestationResponse) return;

    const verificationResponse = await fetch(
        `${backend}/api/passkey/register/finish`,
        {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                'X-Session-Id': data['sid'],
            },
            body: JSON.stringify(attestationResponse),
        },
    );

    const msg = await verificationResponse.json();
    alert(JSON.stringify(msg, null, 2));
}

내용도 정말 간단하다. /api/passkey/register/start API를 먼저 유저 데이터와 함께 호출해서, 거기서 나온 결과를 가지고 startRegistration 함수를 실행시킨다. 그러면 브라우저의 패스키 이벤트가 작동하는데, 거기서 자신의 환경에 맞게끔 인증을 하면 된다.

그렇게 인증을 마치면 attestation 데이터가 생성이 되고, 이걸 /api/passkey/register/finish/ API에 body로 보내면 된다. 헤더에는 아까 start API 실행시켰을 때 받은 세션ID를 커스텀 세션ID 헤더 X-Session-Id에 담는다.

그럼 서버에서 검증을 마친 후 결과를 최종적으로 전달해준다.

Login

백엔드에서 그러했듯, 프론트엔드에서도 마찬가지로 로그인은 회원가입과 과정이 매우 유사하다.

const loginData = { name: '' };

async function login() {
    const { name } = loginData;
    if (!name.length) {
        alert('Name needs to be set!');
        return;
    }

    // call backend /login/start
    const res = await fetch(`${backend}/api/passkey/login/start`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
    });

    if (!res.ok) {
        const msg = await res.json();
        throw new Error(msg);
    }

    const data = await res.json();
    const attestationResponse = await startAuthentication(
        data.options.publicKey
    ).catch((err) => {
        alert(String(err));
        return undefined;
    });
    if (!attestationResponse) return;

    const verificationResponse = await fetch(
        `${backend}/api/passkey/login/finish`,
        {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                'X-Session-Id': data['sid'],
            },
            body: JSON.stringify(attestationResponse),
        },
    );

    const msg = await verificationResponse.json();
    alert(JSON.stringify(msg, null, 2));
    sessionStorage.setItem('sid', msg.sid);
}

/api/passkey/login/start API로부터 나온 데이터를 가지고 startAuthentication 함수를 실행시켜서 attestation을 얻은 뒤 (이때도 브라우저에서 인증을 해야 한다) 회원가입때처럼 그걸 body에 보내고 헤더에 세션ID를 담는다.

이렇게 만들어진 데이터를 /api/passkey/login/finish API에 보내면 검증을 마친 후 최종적으로 로그인 세션ID를 보내준다. 이걸 세션 스토리지에 저장한다. 그럼 앞으로 이 세션ID를 요청마다 헤더 X-Session-Id에 담아서 보내면 된다.

Forbidden

let userInfo: any = undefined;

async function requestUserData() {
    const res = await fetch(`${backend}/api/forbidden`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-Session-Id': String(sessionStorage.getItem('sid')),
        },
    });
    userInfo = await res.json();
}

백엔드 파트에서도 그랬지만 이 부분은 패스키랑 별로 연관 없다. 그냥 API에 세션ID를 가지고 요청해서 나온 데이터를 출력하는 것이다.

실행

영상에서는 전체적인 실행 과정을 담았다. 성공 케이스와 실패 케이스(가입이 안된 회원으로 로그인 하거나 로그인이 되지 않은 채로 유저 정보를 불러오기)를 고루 넣었다.

맥북으로 개발했기 때문에 Touch ID를 통해서 인증하는 과정이 등장한다.

결론

Passwordless계의 혁신이 아닐까 싶지만, 생각보다 하드웨어와의 연관이 깊어 호환성이 좀 떨어질 수도 있겠다는 생각이 든다. 좌우지간 FIDO의 패스키 설명대로 비밀번호라는 인증 방식은 빨리 없어져야 하는 게 맞다고 본다.
실제로 많은 테크기업에서 패스키를 적용하는 중이고, 앞으로도 많이 흥했으면 좋겠다.

Reference

profile
생각대로 되지 않을 때, 비로소 코딩은 재미있는 법.
post-custom-banner

0개의 댓글