보안 키를 사용하여 2단계 인증으로 사이트 보호 (WebAuthn)

1. 빌드할 항목

비밀번호 기반 로그인을 지원하는 기본 웹 애플리케이션으로 시작합니다.

그런 다음 WebAuthn을 기반으로 보안 키를 통해 2단계 인증 지원을 추가합니다. 이를 위해 다음을 구현합니다.

  • 사용자가 WebAuthn 사용자 인증 정보를 등록하는 방법.
  • WebAuthn 사용자 인증 정보를 등록하는 사용자에게 2단계 인증(2단계 인증 절차)을 요청하는 2단계 인증 흐름
  • 사용자 인증 정보 관리 인터페이스: 사용자가 사용자 인증 정보의 이름을 바꾸거나 삭제할 수 있는 사용자 인증 정보 목록입니다.

16ce77744061c5f7.png

완성된 웹 앱을 살펴보고 사용해 보세요.

2. WebAuthn 정보

WebAuthn 기본사항

WebAuthn을 사용해야 하는 이유

피싱은 웹에서 엄청난 보안 문제를 야기합니다. 대부분의 계정 위반은 사이트에서 재사용되는 취약하거나 도용된 비밀번호를 활용합니다. 이 문제에 대한 업계의 총체적인 대응은 다단계 인증이지만, 단편화되어 있어서 대다수가 여전히 피싱을 적절히 해결할 수 없습니다.

Web Authentication API 또는 WebAuthn은 모든 웹 애플리케이션에서 사용할 수 있는 표준화된 피싱 방지 프로토콜입니다.

작동 원리

출처: webauthn.guide

WebAuthn을 사용하면 서버에서 비밀번호 대신 공개 키 암호화를 사용하여 사용자를 등록하고 인증할 수 있습니다. 웹사이트는 비공개-공개 키 쌍으로 구성된 사용자 인증 정보를 만들 수 있습니다.

  • 비공개 키가 사용자 기기에 안전하게 저장됩니다.
  • 보관을 위해 공개 키 및 무작위로 생성된 사용자 인증 정보 ID가 서버로 전송됩니다.

공개 키는 서버에서 사용자 ID를 증명하는 데 사용합니다. 해당 비공개 키 없이는 소용이 없기 때문에 비밀이 아닙니다.

이점

WebAuthn에는 다음과 같은 두 가지 주요 이점이 있습니다.

  • 공유 비밀번호 없음: 서버에 보안 비밀이 저장되지 않습니다. 공개 키는 유용하지 않기 때문에 해커의 데이터베이스를 덜 매력적으로 만들 수 있습니다.
  • 범위 사용자 인증 정보: site.example에 등록된 사용자 인증 정보는 evil-site.example에서 사용할 수 없습니다. 따라서 WebAuthn 피싱을 방지할 수 있습니다.

사용 사례

WebAuthn의 한 가지 사용 사례는 보안 키를 사용하는 2단계 인증입니다. 이는 특히 엔터프라이즈 웹 애플리케이션과 관련이 있을 수 있습니다.

브라우저 지원

이 게시물은 W3C 및 FIDO에서 작성했으며 Google, Mozilla, Microsoft, Yubico 및 다른 업체에서 참여합니다.

용어집

  • OTP: 사용자를 등록하고 나중에 등록된 사용자 인증 정보의 소유권을 주장할 수 있는 소프트웨어 또는 하드웨어 항목입니다. 인증에는 다음과 같은 두 가지 유형이 있습니다.
  • 로밍 OTP: 사용자가 로그인하려는 모든 기기에서 사용할 수 있는 OTP입니다. 예: USB 보안 키, 스마트폰
  • 플랫폼 인증자: 사용자 기기에 내장된 인증자입니다. 예: Apple의 Touch ID입니다.
  • 사용자 인증 정보: 비공개-공개 키 쌍
  • 신뢰 당사자: 사용자를 인증하려는 웹사이트의 서버
  • FIDO 서버: 인증에 사용되는 서버 FIDO는 FIDO 얼라이언스에서 개발한 프로토콜 모음으로, WebAuthn이 여기에 해당합니다.

이 워크숍에서는 로밍 OTP를 사용합니다.

3. 시작하기 전에

필요한 항목

이 Codelab을 완료하려면 다음이 필요합니다.

  • WebAuthn에 관한 기본적인 이해
  • 자바스크립트 및 HTML에 대한 기본 지식
  • WebAuthn을 지원하는 최신 브라우저
  • U2F 호환 보안 키.

다음 중 하나를 보안 키로 사용할 수 있습니다.

  • Chrome을 실행하는 Android>=7 (Nougat)을 사용하는 Android 휴대전화 이 경우 블루투스가 지원되는 Windows, macOS 또는 Chrome OS 머신도 필요합니다.
  • YubiKey와 같은 USB 키

6539dc7ffec2538c.png

출처: https://www.yubico.com/products/security-key/

dd56e2cfe0f7ced2.png

학습할 내용

학습할 내용 ✅

  • WebAuthn 인증의 2단계 인증 방법으로 보안 키를 등록하고 사용하는 방법
  • 이 프로세스를 사용자 친화적으로 만드는 방법

학습에서 승리했습니다. 😈

  • 인증에 사용하는 서버인 FIDO 서버를 빌드하는 방법 일반적으로 웹 애플리케이션이나 사이트 개발자는 기존 FIDO 서버 구현을 활용하기 때문에 문제가 되지 않습니다. 사용하는 서버 구현의 기능과 품질을 항상 확인해야 합니다. 이 Codelab에서 FIDO 서버는 SimpleWebAuthn을 사용합니다. 다른 옵션은 FIDO Alliance 공식 페이지를 참고하세요. 오픈소스 라이브러리는 webauthn.io 또는 AwesomeWebAuthn을 참고하세요.

면책조항

사용자가 로그인하려면 비밀번호를 입력해야 합니다. 하지만 이 Codelab에서는 편의상 비밀번호를 저장하거나 확인하지 않습니다. 실제 애플리케이션에서는 서버 측에서 올바른지 확인할 수 있습니다.

이 Codelab에서는 CSRF 검사, 세션 유효성 검사, 입력 완전 삭제와 같은 기본 보안 확인을 구현합니다. 그러나 무차별 대입 공격을 방지하기 위해 비밀번호에 대한 입력 제한은 없는 등 여러 보안 조치가 지원되지 않습니다. 비밀번호가 저장되지 않으므로 여기에서는 중요하지 않지만 이 코드를 프로덕션에서 그대로 사용하면 안 됩니다.

4. 인증자 설정

Android 휴대전화를 인증자로 사용하는 경우

  • 데스크톱과 휴대전화 모두에서 Chrome이 최신 버전인지 확인합니다.
  • 데스크톱과 휴대전화에서 모두 Chrome을 열고 이 워크숍에 사용할 프로필과 동일한 프로필(⏤)으로 로그인합니다.
  • 데스크톱휴대전화에서 이 프로필의 동기화를 사용 설정합니다. 대신 chrome://settings/syncSetup을 사용하세요.
  • 데스크톱과 휴대전화에서 블루투스를 사용 설정합니다.
  • Chrome 데스크톱에 동일한 프로필로 로그인한 상태에서 webauthn.io를 엽니다.
  • 간단한 사용자 이름을 입력합니다. 증명 유형인증자 유형없음지정되지 않음(기본값) 값으로 둡니다. 등록을 클릭합니다.

6b49ff0298f5a0af.png

  • 신원 확인을 요청하는 브라우저 창이 열립니다. 목록에서 휴대전화를 선택합니다.

ffebe58ac826eaf2.png 852de328fcd4eb42.png

  • 휴대전화에 본인 확인이라는 알림이 표시됩니다. 탭합니다.
  • 휴대전화에서 휴대전화의 PIN 코드를 입력하거나 지문 센서를 터치하라는 메시지가 표시됩니다. 비밀번호를 입력합니다.
  • 데스크톱의 webauthn.io에 '성공' 표시기가 표시됩니다.

fc0acf00a4d412fa.png

  • 데스크톱의 webauthn.io에서 로그인 버튼을 클릭합니다.
  • 다시 브라우저 창이 열리면 목록에서 내 휴대전화를 선택합니다.
  • 휴대전화에서 팝업 알림을 탭한 후 PIN을 입력하거나 지문 센서를 터치하세요.
  • webauthn.io가 로그인되어 있다고 알려줍니다. 휴대전화가 보안 키로 정상적으로 작동하고 있습니다. 워크숍이 모두 준비되었습니다.

USB 보안 키를 인증자로 사용하는 경우

  • Chrome 데스크톱에서 webauthn.io를 엽니다.
  • 간단한 사용자 이름을 입력합니다. 증명 유형인증자 유형없음지정되지 않음 (기본값) 값으로 둡니다. 등록을 클릭합니다.
  • 신원 확인을 요청하는 브라우저 창이 열립니다. 목록에서 USB 보안 키를 선택합니다.

ffebe58ac826eaf2.png 9fe75f04e43da035.png

  • 데스크톱에 보안 키를 삽입하고 터치합니다.

923d5adb8aa8286c.png

  • 데스크톱의 webauthn.io에 '성공' 표시기가 표시됩니다.

fc0acf00a4d412fa.png

  • 데스크톱의 webauthn.io에서 로그인 버튼을 클릭합니다.
  • 브라우저 창이 다시 열리고 목록에서 USB 보안 키를 선택합니다.
  • 키를 터치합니다.
  • Webauthn.io에서 로그인 상태를 확인해야 합니다. USB 보안 키가 제대로 작동하고 있습니다. 워크숍이 준비되었습니다.

7e1c0bb19c9f3043.png

5. 설정

이 Codelab에서는 코드를 자동으로 즉시 배포하는 온라인 코드 편집기인 Glitch를 사용합니다.

시작 코드 포크

시작 프로젝트를 엽니다.

리믹스 버튼을 클릭합니다.

이렇게 하면 시작 코드의 사본이 생성됩니다. 이제 수정할 코드가 있습니다. 포크 (Glquo에서 "remix"라고 함)는 이 Codelab의 모든 작업을 실행합니다.

cf2b9f552c9809b6.png

시작 코드 탐색

방금 포크한 시작 코드를 살펴보세요.

libs 아래에 auth.js라는 라이브러리가 이미 제공되어 있습니다. 서버 측 인증 로직을 처리하는 맞춤 라이브러리입니다. fido 라이브러리를 종속 항목으로 사용합니다.

6. 사용자 인증 정보 등록 구현

사용자 인증 정보 등록 구현

보안 키로 2단계 인증을 설정하기 위해 가장 먼저 필요한 것은 사용자가 사용자 인증 정보를 생성하도록 하는 것입니다.

먼저 클라이언트 측 코드에서 이 작업을 실행하는 함수를 추가해 보겠습니다.

public/auth.client.js에는 아직 아무것도 하지 않는 registerCredential()이라는 함수가 있습니다. 다음 코드를 파일에 추가합니다.

async function registerCredential() {
  // Fetch the credential creation options from the backend
  const credentialCreationOptionsFromServer = await _fetch(
    "/auth/credential-options",
    "POST"
  );
  // Decode the credential creation options
  const credentialCreationOptions = decodeServerOptions(
    credentialCreationOptionsFromServer
  );
  // Create a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
  const credential = await navigator.credentials.create({
    publicKey: {
      ...credentialCreationOptions,
    }
  });
  // Encode the newly created credential to send it to the backend
  const encodedCredential = encodeCredential(credential);
  // Send the encoded credential to the backend for storage
  return await _fetch("/auth/credential", "POST", encodedCredential);
}

이 함수는 이미 내보내졌습니다.

registerCredential의 기능은 다음과 같습니다.

  • 서버(/auth/credential-options)에서 사용자 인증 정보 생성 옵션을 가져옵니다.
  • 서버 옵션은 다시 인코딩되기 때문에 유틸리티 함수 decodeServerOptions을 사용하여 디코딩합니다.
  • 웹 API navigator.credential.create를 호출하여 사용자 인증 정보를 생성합니다. navigator.credential.create가 호출되면 브라우저가 인계받아 보안 키를 선택하라는 메시지가 사용자에게 표시됩니다.
  • 새로 만든 사용자 인증 정보를 디코딩합니다.
  • 이 API는 인코딩된 사용자 인증 정보가 포함된 /auth/credential에 요청을 보내 서버 측에 새 사용자 인증 정보를 등록합니다.

부수: 서버 코드 살펴보기

registerCredential()는 서버를 2번 호출하므로 백엔드에서 어떤 일이 일어나는지 잠시 살펴보겠습니다.

사용자 인증 정보 생성 옵션

클라이언트에서 /auth/credential-options 요청을 보내면 서버에서 옵션 객체를 생성하여 클라이언트에 다시 전송합니다.

이후 이 객체는 클라이언트가 실제 사용자 인증 정보 생성 호출에서 사용됩니다.

navigator.credentials.create({
    publicKey: {
    // Options generated server-side
    ...credentialCreationOptions
// ...
}

그렇다면 이 credentialCreationOptions의 정보는 이전 단계에서 구현한 클라이언트 측 registerCredential에 사용된 것인가요?

router.post("/credential-options", ...)에서 서버 코드를 확인합니다.

모든 속성을 살펴보지는 않지만 fido2 라이브러리를 사용하여 생성되고 최종적으로 클라이언트에 반환된 서버 코드의 흥미로운 객체를 몇 가지 소개합니다.

  • rpNamerpId는 사용자를 등록하고 인증하는 조직을 설명합니다. WebAuthn에서 사용자 인증 정보는 특정 도메인으로 범위가 지정되므로 보안상의 이점이 있습니다. 여기서 rpNamerpId는 사용자 인증 정보의 범위를 지정하는 데 사용됩니다. 예를 들어 유효한 rpId는 사이트의 호스트 이름입니다. 시작 프로젝트를 포크할 때 자동으로 업데이트되는 내용을 확인하세요. 🧘 ♀️
  • excludeCredentials은 사용자 인증 정보 목록입니다. 새 사용자 인증 정보를 excludeCredentials에 나열된 사용자 인증 정보 중 하나를 포함하는 인증자에서 만들 수 없습니다. Codelab에서 excludeCredentials는 이 사용자의 기존 사용자 인증 정보 목록입니다. user.id를 사용하면 사용자가 만드는 각 사용자 인증 정보가 다른 인증자 (보안 키)에 상주하도록 보장합니다. 이 방법은 사용자가 여러 개의 사용자 인증 정보를 등록한 경우 여러 인증자 (보안 키)를 사용하게 되므로 하나의 보안 키를 분실하면 사용자가 계정에 액세스하지 못하게 되므로 주의해야 합니다.
  • authenticatorSelection는 웹 애플리케이션에서 허용할 인증자의 유형을 정의합니다. authenticatorSelection를 자세히 살펴보겠습니다.
    • residentKey: preferred은 이 애플리케이션이 클라이언트 측 검색 가능한 사용자 인증 정보를 적용하지 않음을 의미합니다. 클라이언트 측 검색 가능한 사용자 인증 정보는 먼저 사용자를 식별하지 않고도 사용자를 인증할 수 있게 해주는 특수한 유형의 사용자 인증 정보입니다. 이 Codelab에서는 기본 구현에 중점을 두므로 preferred를 설정했습니다. 검색 가능한 사용자 인증 정보는 고급 흐름에 사용됩니다.
    • requireResidentKey는 WebAuthn v1과의 이전 버전과의 호환성을 위해서만 제공됩니다.
    • userVerification: preferred는 인증자가 사용자 확인을 지원하는 경우(예: 생체 인식 보안 키나 내장된 PIN 기능이 있는 키) 사용자 인증 정보를 생성할 때 인증자가 이를 요청하는 것을 의미합니다. 인증자가 기본 보안 키인 경우 사용자 인증을 요청하지 않습니다.
  • ​​pubKeyCredParam는 선호도에 따라 사용자 인증 정보의 원하는 암호화 속성을 설명합니다.

모든 옵션은 웹 애플리케이션에서 보안 모델을 위해 결정해야 합니다. 서버에서 이러한 옵션은 단일 authSettings 객체에 정의됨을 확인합니다.

당면 과제

또 다른 흥미로운 부분은 req.session.challenge = options.challenge;입니다.

WebAuthn은 암호화 프로토콜이므로 공격자가 공격을 시도하여 인증을 재생성하거나 인증을 사용 설정할 비공개 키의 소유자가 아닌 경우 무작위로 공격을 시도하여 공격을 피해야 합니다.

이 문제를 완화하기 위해 챌린지가 서버에서 생성되고, 즉시 서명됩니다. 그런 다음 서명이 예상한 것과 비교됩니다. 이렇게 하면 사용자 인증 정보 생성 시 사용자가 비공개 키를 유지할 수 있습니다.

사용자 인증 정보 등록 코드

router.post("/credential" ...)에서 서버 코드를 확인합니다.

여기에서 사용자 인증 정보가 서버 측에 등록됩니다.

이제 어떻게 해야 할까요?

이 코드에서 가장 주목할 만한 비트 중 하나는 fido2.verifyAttestationResponse를 통한 인증 호출입니다.

  • 서명된 챌린지가 확인되고, 이는 생성 시 실제로 비공개 키를 유지한 사람이 사용자 인증 정보를 생성했음을 보장합니다.
  • 출처에 바인딩된 신뢰 당사자 ID도 인증됩니다. 이렇게 하면 사용자 인증 정보가 이 웹 애플리케이션에 바인딩됩니다 (이 웹 애플리케이션만).

UI에 이 기능 추가

이제 사용자 인증 정보를 만드는 함수인 `registerCredential(),이 준비되었으므로 사용자에게 제공할 수 있습니다.

이 작업은 일반적으로 인증 관리를 위한 위치이므로 계정 페이지에서 이 작업을 수행합니다.

account.html 마크업의 사용자 이름 아래에 레이아웃 클래스 class="flex-h-between"가 있는 비어 있는 div가 있습니다. 2FA 기능과 관련된 UI 요소에 이 div를 사용합니다.

이 div를 추가합니다.

  • '2단계 인증'으로 표시된 제목
  • 사용자 인증 정보를 만드는 버튼
 <div class="flex-h-between">
    <h3>
        Two-factor authentication
    </h3>
    <button class="create" id="registerButton" raised>
        ➕ Add a credential
    </button>
</div>

이 div 아래에 나중에 필요한 사용자 인증 정보 div를 추가합니다.

<div class="flex-h-between">
(HTML you've just added)
</div>
<div id="credentials"></div>

account.html 인라인 스크립트에서 방금 만든 함수를 가져와 이 함수를 호출하는 함수 register와 방금 만든 버튼에 연결된 이벤트 핸들러를 추가합니다.

// Set up the handler for the button that registers credentials
const registerButton = document.querySelector('#registerButton');
registerButton.addEventListener('click', register);

// Register a credential
async function register() {
  let user = {};
  try {
    const user = await registerCredential();
  } catch (e) {
    // Alert the user that something went wrong
    if (Array.isArray(e)) {
      alert(
        // `msg` not `message`, this is the key's name as per the express validator API
        `Registration failed. ${e.map((err) => `${err.msg} (${err.param})`)}`
      );
    } else {
      alert(`Registration failed. ${e}`);
    }
  }
}

사용자가 볼 수 있는 사용자 인증 정보 표시

사용자 인증 정보를 만드는 기능이 추가되었으므로 이제 사용자가 추가한 사용자 인증 정보를 볼 방법이 필요합니다.

계정 페이지를 사용하면 됩니다.

account.html에서 updateCredentialList()라는 함수를 찾습니다.

백엔드에 호출하여 현재 로그인한 사용자의 등록된 모든 사용자 인증 정보를 가져오고 반환된 사용자 인증 정보를 표시하는 코드를 추가합니다.

// Update the list that displays credentials
async function updateCredentialList() {
  // Fetch the latest credential list from the backend
  const response = await _fetch('/auth/credentials', 'GET');
  const credentials = response.credentials || [];
  // Generate the credential list as HTML and pass remove/rename functions as args
  const credentialListHtml = getCredentialListHtml(
    credentials,
    removeEl,
    renameEl
  );
  // Display the list of credentials in the DOM
  const list = document.querySelector('#credentials');
  render(credentialListHtml, list);
}    

지금은 removeElrenameEl에 신경 쓰지 않아도 됩니다. 이 Codelab의 뒷부분에서 자세히 알아봅니다.

인라인 스크립트 시작 부분의 account.html 내에서 updateCredentialList 호출을 1번 추가합니다. 이 호출에서는 사용자가 계정 페이지를 방문할 때 사용 가능한 사용자 인증 정보를 가져옵니다.

<script type="module">
    // ... (imports)
    // Initialize the credential list by updating it once on page load
    updateCredentialList();

이제 registerCredential가 성공적으로 완료되면 updateCredentialList를 호출하여 목록에 새로 만든 사용자 인증 정보가 표시됩니다.

async function register() {
  let user = {};
  try {
    // ...
  } catch (e) {
    // ...
  }
  // Refresh the credential list to display the new credential
  await updateCredentialList();
}

직접 시험해 보세요. 👩 💻

사용자 인증 정보 등록을 완료했습니다. 이제 사용자가 보안 키 기반 사용자 인증 정보를 만들고 계정 페이지에서 시각화할 수 있습니다.

사용해 보기:

  • 로그아웃을 클릭합니다.
  • 모든 사용자 및 비밀번호로 로그인합니다. 앞서 언급했듯이 이 Codelab에서는 작업을 단순화하기 위해 비밀번호가 실제로 정확한지 검사하지 않습니다. 비어 있지 않은 비밀번호를 입력하세요.
  • 계정 페이지에서 사용자 인증 정보 추가를 클릭합니다.
  • 보안 키를 삽입하고 터치하라는 메시지가 표시됩니다. 잊지 말고 가치를 할당하세요.
  • 사용자 인증 정보가 생성되면 계정 페이지에 사용자 인증 정보가 표시됩니다.
  • 계정 페이지를 새로고침합니다. 사용자 인증 정보가 표시되어야 합니다.
  • 사용 가능한 키가 2개 있는 경우 두 개의 보안 키를 사용자 인증 정보로 추가해 봅니다. 둘 다 표시되어야 합니다.
  • 동일한 인증자 (키)로 사용자 인증 정보를 두 개 만들어 보세요. 지원되지 않는 것을 확인할 수 있습니다. 이는 의도적인 결과입니다. 백엔드에서 excludeCredentials을 사용하기 때문입니다.

7. 2단계 인증 사용 설정

사용자가 사용자 인증 정보를 등록하거나 등록 취소할 수 있지만, 사용자 인증 정보는 표시되어서 아직 실제로 사용되지는 않았습니다.

이제 실제로 사용하고 2단계 인증을 설정할 차례입니다.

이 섹션에서는 웹 애플리케이션의 인증 흐름을 이 기본 흐름에서 변경합니다.

6ff49a7e520836d0.png

이 2단계 인증을 구매하는 방법은 다음과 같습니다.

e7409946cd88efc7.png

2단계 인증 구현

먼저 필요한 기능을 추가하고 백엔드와의 통신을 구현하겠습니다. 다음 단계에서는 프런트엔드에 추가합니다.

여기서 구현해야 하는 작업은 사용자 인증 정보로 사용자를 인증하는 함수입니다.

public/auth.client.js에서 빈 함수 authenticateTwoFactor를 찾아 다음 코드에 추가합니다.

async function authenticateTwoFactor() {
  // Fetch the 2F options from the backend
  const optionsFromServer = await _fetch("/auth/two-factor-options", "POST");
  // Decode them
  const decodedOptions = decodeServerOptions(optionsFromServer);
  // Get a credential via the browser API; this will prompt the user to touch their security key or tap a button on their phone
  const credential = await navigator.credentials.get({
    publicKey: decodedOptions
  });
  // Encode the credential
  const encodedCredential = encodeCredential(credential);
  // Send it to the backend for verification
  return await _fetch("/auth/authenticate-two-factor", "POST", {
    credential: encodedCredential
  });
}

이 함수는 이미 내보내져 있습니다. 다음 단계에서 필요합니다.

authenticateTwoFactor의 기능은 다음과 같습니다.

  • 서버에서 2단계 인증 옵션을 요청합니다. 이전에 본 사용자 인증 정보 생성 옵션과 마찬가지로 이는 서버에서 정의되며 웹 애플리케이션의 보안 모델에 따라 달라집니다. 자세한 내용은 router.post("/two-factors-options", ...의 서버 코드를 자세히 알아보세요.
  • navigator.credentials.get를 호출하면 브라우저가 우선 조치되고 이전에 등록된 키를 삽입하고 터치하라는 메시지가 표시됩니다. 이렇게 하면 이 특정 2단계 인증 작업의 사용자 인증 정보가 선택됩니다.
  • 선택한 사용자 인증 정보가 가져오기 요청을 통해("/auth/authenticate-two-factor"`) 전달됩니다. 해당 사용자에게 사용자 인증 정보가 유효하면 사용자가 인증됩니다.

부수: 서버 코드 살펴보기

server.js는 이미 일부 탐색 및 액세스를 처리하고 있으므로 인증된 사용자만 계정 페이지에 액세스할 수 있으며 필요한 리디렉션을 수행합니다.

이제 router.post("/initialize-authentication", ... 아래의 서버 코드를 살펴보겠습니다.

여기에는 두 가지 흥미로운 점이 있습니다.

  • 이 단계에서 비밀번호와 사용자 인증 정보가 동시에 확인됩니다. 이는 보안 조치입니다. 2단계 인증을 설정한 사용자의 경우 비밀번호 절차가 올바른지에 따라 UI 흐름이 다르게 보이지 않기를 바랍니다. 이 단계에서 비밀번호와 사용자 인증 정보를 동시에 확인합니다.
  • 비밀번호와 사용자 인증 정보가 모두 유효하면 completeAuthentication(req, res);를 호출하여 인증을 완료합니다. 즉, 사용자가 아직 인증되지 않은 임시 auth 세션에서 사용자가 인증된 기본 세션 main으로 세션을 전환한다는 의미입니다.

사용자 플로우에 2단계 인증 페이지를 포함합니다.

views 폴더에서 새 페이지 second-factor.html를 확인합니다.

이 버튼에는 보안 키 사용이라고 표시된 버튼이 있지만 지금은 아무것도 하지 않습니다.

이 버튼을 클릭하면 authenticateTwoFactor()을 호출합니다.

  • authenticateTwoFactor()에 성공하면 사용자가 계정 페이지로 리디렉션됩니다.
  • 해결되지 않으면 사용자에게 오류가 발생했다고 알립니다. 실제 애플리케이션에서는 보다 유용한 오류 메시지를 구현합니다. 이 데모에서는 편의를 위해 창 알림만 사용합니다.
    <main>
...
    </main>
    <script type="module">
      import { authenticateTwoFactor, authStatuses } from "/auth.client.js";

      const button = document.querySelector("#authenticateButton");
      button.addEventListener("click", async e => {
        try {
          // Ask the user to authenticate with the second factor; this will trigger a browser prompt
          const response = await authenticateTwoFactor();
          const { authStatus } = response;
          if (authStatus === authStatuses.COMPLETE) {
            // The user is properly authenticated => Navigate to the Account page
            location.href = "/account";
          } else {
            throw new Error("Two-factor authentication failed");
          }
        } catch (e) {
          // Alert the user that something went wrong
          alert(`Two-factor authentication failed. ${e}`);
        }
      });
    </script>
  </body>
</html>

2단계 인증 사용

이제 2단계 인증 단계를 추가할 수 있습니다.

이제 2단계 인증을 구성한 사용자를 위해 index.html에 이 단계를 추가하기만 하면 됩니다.

322a5c49d865a0d8.png

index.htmllocation.href = "/account"; 아래에 2단계 인증을 설정한 경우 사용자를 2단계 인증 페이지로 조건부로 탐색하는 코드를 추가합니다.

이 Codelab에서 사용자 인증 정보를 만들면 사용자가 자동으로 2단계 인증을 선택합니다.

또한 server.js에서는 인증된 사용자만 account.html에 액세스할 수 있도록 서버 측 세션 확인을 구현합니다.

const { authStatus } = response;
if (authStatus === authStatuses.COMPLETE) {
  // The user is properly authenticated => navigate to account
  location.href = '/account';
} else if (authStatus === authStatuses.NEED_SECOND_FACTOR) {
  // Navigate to the two-factor-auth page because two-factor-auth is set up for this user
  location.href = '/second-factor';
}

직접 시험해 보세요. 👩 💻

  • 새 사용자 johndoe로 로그인합니다.
  • 로그아웃합니다.
  • johndoe로 계정에 로그인합니다. 비밀번호만 입력하면 됩니다.
  • 사용자 인증 정보를 만듭니다. 이는 johndoe가 2단계 인증을 활성화했음을 의미합니다.
  • 로그아웃합니다.
  • 사용자 이름 johndoe와 비밀번호를 삽입합니다.
  • 2단계 인증 페이지로 자동 이동하는 방법을 알아보세요.
  • /account에서 계정 페이지에 액세스해 봅니다. 완전히 인증되지 않았으므로 색인 페이지로 리디렉션되는 방법을 확인할 수 있습니다. 두 번째 요소가 누락되어 있습니다.
  • 2단계 인증 페이지로 돌아가서 보안 키 사용을 클릭하여 2단계 인증을 합니다.
  • 로그인되었으며 계정 페이지가 표시됩니다.

8. 사용자 인증 정보를 더 쉽게 사용

보안 키를 사용하여 2단계 인증의 기본 기능을 이용할 수 있습니다. 🚀

하지만... 눈치채셨나요?

현재 사용자 인증 정보 목록이 매우 편리하지 않습니다. 사용자 인증 정보 ID와 공개 키는 사용자 인증 정보를 관리하는 데 유용하지 않은 긴 문자열입니다. 긴 문자열과 숫자로는 인간이 바람직하지 않습니다 🚶

이를 개선하고, 사람이 읽을 수 있는 문자열로 사용자 인증 정보의 이름을 지정하고 이름을 변경하는 기능을 추가해 보겠습니다.

이름 사용자 인증 정보 살펴보기

이 기능을 구현하는 데 걸리는 시간을 크게 단축하기 위해 auth.client.js에서 사용자 인증 정보 이름을 변경하는 함수를 시작 코드에 추가했습니다.

async function renameCredential(credId, newName) {
  const params = new URLSearchParams({
    credId,
    name: newName
  });
  return _fetch(
    `/auth/credential?${params}`,
    "PUT"
  );
}

이 메서드는 일반 데이터베이스 업데이트 호출입니다. 클라이언트는 사용자 인증 정보 ID와 해당 사용자 인증 정보의 새 이름을 사용하여 PUT 요청을 백엔드로 보냅니다.

커스텀 사용자 인증 정보 이름 구현

account.html에서 빈 함수 rename를 확인합니다.

다음 코드를 파일에 추가합니다.

// Rename a credential
async function rename(credentialId) {
  // Let the user input a new name
  const newName = window.prompt(`Name this credential:`);
  // Rename only if the user didn't cancel AND didn't enter an empty name
  if (newName && newName.trim()) {
    try {
      // Make the backend call to rename the credential (the name is sanitized) server-side
      await renameCredential(credentialId, newName);
    } catch (e) {
      // Alert the user that something went wrong
      if (Array.isArray(e)) {
        alert(
          // `msg` not `message`, this is the key's name as per the express validator API
          `Renaming failed. ${e.map((err) => `${err.msg} (${err.param})`)}`
        );
      } else {
        alert(`Renaming failed. ${e}`);
      }
    }
    // Refresh the credential list to display the new name
    await updateCredentialList();
  }
}

사용자 인증 정보가 성공적으로 만들어진 후에만 사용자 인증 정보의 이름을 지정하는 것이 좋습니다. 이름이 없는 사용자 인증 정보를 만든 다음, 만들고 나면 사용자 인증 정보의 이름을 변경합니다. 그러나 이 경우 백엔드 호출이 두 번 발생합니다.

등록 시 사용자가 사용자 인증 정보의 이름을 지정할 수 있도록 register()에서 rename 함수를 사용합니다.

async function register() {
  let user = {};
  try {
    const user = await registerCredential();
    // Get the latest credential's ID (newly created credential)
    const allUserCredentials = user.credentials;
    const newCredential = allUserCredentials[allUserCredentials.length - 1];
    // Rename it
    await rename(newCredential.credId);
  } catch (e) {
    // ...
  }
  // Refresh the credential list to display the new credential
  await updateCredentialList();
}

백엔드에서 사용자 입력이 검증 및 정리됩니다.

  check("name")
    .trim()
    .escape()

사용자 인증 정보 이름 표시

templates.jsgetCredentialHtml로 이동합니다.

사용자 인증 정보 카드 상단에 사용자 인증 정보 이름을 표시하는 코드가 이미 있습니다.

// Register credential
const getCredentialHtml = (credential, removeEl, renameEl) => {
 const { name, credId, publicKey } = credential;
 return html`
    <div class="credential-card">
      <div class="credential-name">
        ${name
          ? html`
              ${name}
            `
          : html`
              <span class="unnamed">(Unnamed)</span>
            `}
      </div>
     // ...
    </div>
  `;
};

직접 시험해 보세요. 👩 💻

  • 사용자 인증 정보를 만듭니다.
  • 이름을 지정하라는 메시지가 표시됩니다.
  • 새 이름을 입력하고 확인을 클릭합니다.
  • 이제 사용자 인증 정보의 이름이 변경되었습니다.
  • 이름 입력란을 비워두면 반복하고 원활하게 작동하는지 확인합니다.

사용자 인증 정보 이름 변경 사용 설정

사용자가 사용자 인증 정보의 이름을 변경해야 할 수도 있습니다. 예를 들어 두 번째 키를 추가하고 첫 번째 키의 이름을 변경하여 더 쉽게 구분할 수 있습니다.

account.html에서 so-far-빈 함수 renameEl를 찾아 다음 코드에 추가합니다.

// Rename a credential via HTML element
async function renameEl(el) {
  // Define the ID of the credential to update
  const credentialId = el.srcElement.dataset.credentialId;
  // Rename the credential
  await rename(credentialId);
  // Refresh the credential list to display the new name
  await updateCredentialList();
}

이제 templates.jsgetCredentialHtml에서 class="flex-end" div 내에 다음 코드를 추가합니다. 이 코드는 사용자 인증 정보 카드 템플릿에 이름 바꾸기 버튼을 추가합니다. 버튼을 클릭하면 방금 만든 renameEl 함수가 호출됩니다.

const getCredentialHtml = (credential, removeEl, renameEl) => {
// ...
 <div class="flex-end">
  <button
    data-credential-id="${credId}"
    @click="${renameEl}"
    class="secondary right"
  >
   Rename
  </button>
 </div>
 // ...
  `;
};

직접 시험해 보세요. 👩 💻

  • 이름 바꾸기를 클릭합니다.
  • 메시지가 표시되면 새 이름을 입력합니다.
  • 확인을 클릭합니다.
  • 사용자 인증 정보 이름을 변경하면 목록이 자동으로 업데이트됩니다.
  • 페이지를 새로고침해도 새 이름이 계속 표시됩니다 (새 이름이 서버 측에서 유지됨).

사용자 인증 정보 생성 날짜 표시

생성 날짜가 navigator.credential.create()을(를) 통해 생성된 사용자 인증 정보에 표시되지 않습니다.

그러나 이 정보는 사용자가 사용자 인증 정보를 구분하는 데 유용할 수 있으므로 시작 코드의 서버 측 라이브러리를 조정하고 새 사용자 인증 정보를 저장할 때 creationDate와 동일한 creationDate 필드를 추가했습니다.

templates.jsclass="creation-date" div 내에 다음을 추가하여 생성 날짜 정보를 사용자에게 표시합니다.

<div class="creation-date">
  <label>Created:</label>
  <div class="info">
    ${new Date(creationDate).toLocaleDateString()}
    ${new Date(creationDate).toLocaleTimeString()}
  </div>
</div>

9. 미래형 코드 만들기

지금까지는 사용자에게 간단한 로밍 인증자를 등록해 달라고 요청했고, 그 이후에는 로그인 시 2단계 인증으로 사용되었습니다.

한 가지 고급 접근 방식은 더 강력한 유형의 인증자인 사용자 확인 로밍 인증자 (UVRA)를 사용하는 것입니다. UVRA는 단일 인증 절차에서 두 가지 인증 요소 및 피싱 방지 기능을 제공할 수 있습니다.

두 가지 접근방식을 모두 지원하는 것이 이상적입니다. 이를 위해 다음 단계에 따라 사용자 환경을 맞춤설정해야 합니다.

  • 사용자가 간단한 (사용자가 확인되지 않은) 로밍 인증자만 있는 경우 이를 사용하여 피싱 방지 계정 부트스트랩을 달성하지만 사용자 이름과 비밀번호도 입력해야 합니다. 이 Codelab은 이미 이 작업을 하고 있습니다.
  • 다른 사용자가 고급 사용자 확인 로밍 인증자를 보유한 경우 계정 부트스트랩 시 비밀번호 단계를 건너뛰거나 사용자 이름 단계를 건너뛰어도 됩니다.

자세한 내용은 비밀번호가 없는 로그인 옵션을 사용하는 피싱 방지 계정 부트스트랩에서 확인하세요.

이 Codelab에서는 실제로 사용자 환경을 맞춤설정하지 않지만, 사용자 환경을 맞춤설정하는 데 필요한 데이터를 얻을 수 있도록 코드베이스를 설정합니다.

다음 두 가지가 필요합니다.

  • 백엔드 설정에서 residentKey: preferred를 설정하세요. 이 작업은 이미 완료되어 있습니다.
  • 검색 가능한 사용자 인증 정보 (거주 키라고도 함)가 생성되었는지 확인할 방법을 설정합니다.

검색 가능한 사용자 인증 정보가 생성되었는지 확인하려면 다음 안내를 따르세요.

  • 사용자 인증 정보 생성 시 credProps (credProps: true) 값을 쿼리합니다.
  • 사용자 인증 정보 생성 시 transports의 값을 쿼리합니다. 이를 통해 기본 플랫폼이 UVRA 기능(예: 휴대전화)을 지원하는지 확인할 수 있습니다.
  • 백엔드에 credPropstransports 값을 저장합니다. 시작 코드에서 이 작업은 이미 완료되었습니다. 궁금하다면 auth.js을(를) 살펴보세요.

credPropstransports의 값을 가져와 백엔드로 전송합니다. auth.client.js에서 다음과 같이 registerCredential를 수정합니다.

  • navigator.credentials.create 호출 시 extensions 필드 추가
  • 스토리지의 백엔드에 사용자 인증 정보를 전송하기 전에 encodedCredential.transportsencodedCredential.credProps를 설정합니다.

registerCredential는 다음과 같아야 합니다.

async function registerCredential() {
  // Fetch the credential creation options from the backend
  const credentialCreationOptionsFromServer = await _fetch(
    '/auth/credential-options',
    'POST'
  );
  // Decode the credential creation options
  const credentialCreationOptions = decodeServerOptions(
    credentialCreationOptionsFromServer
  );
  // Create a credential via the browser API; this will prompt the user
  const credential = await navigator.credentials.create({
    publicKey: {
      ...credentialCreationOptions,
      extensions: {
        credProps: true,
      },
    },
  });
  // Encode the newly created credential to send it to the backend
  const encodedCredential = encodeCredential(credential);
  // Set transports and credProps for more advanced user flows
  encodedCredential.transports = credential.response.getTransports();
  encodedCredential.credProps =
    credential.getClientExtensionResults().credProps;
  // Send the encoded credential to the backend for storage
  return await _fetch('/auth/credential', 'POST', encodedCredential);
}

10. 교차 브라우저 지원 보장

Chromium 이외의 브라우저 지원

public/auth.client.jsregisterCredential 함수에서 새로 만든 사용자 인증 정보에 대한 credential.response.getTransports()를 호출하여 최종적으로 이 정보를 백엔드에 힌트로 저장합니다.

그러나 getTransports()이 현재 모든 브라우저에서 지원되는 getClientExtensionResults와 달리 구현되지 않습니다. getTransports()을 호출하면 Firefox와 Safari에서 오류가 발생하므로 브라우저에서 사용자 인증 정보를 생성할 수 없습니다.

코드가 모든 주요 브라우저에서 실행되도록 하려면 조건에서 encodedCredential.transports 호출을 래핑합니다.

if (credential.response.getTransports) {
  encodedCredential.transports = credential.response.getTransports();
}

서버에서 transportstransports || []로 설정됩니다. Firefox 및 Safari에서 transports 목록이 undefined가 아니고 빈 목록 []가 있어 오류를 방지합니다.

WebAuthn을 지원하지 않는 브라우저를 사용하는 사용자에게 경고

1e9c1be837d66ce8.png

WebAuthn이 모든 주요 브라우저에서 지원되지만 WebAuthn을 지원하지 않는 브라우저에 경고를 표시하는 것이 좋습니다.

index.html에서 다음 div가 있는지 확인합니다.

<div id="warningbanner" class="invisible">
⚠️ Your browser doesn't support WebAuthn. Open this demo in Chrome, Edge, Firefox or Safari.
</div>

index.html 인라인 스크립트에서 다음 코드를 추가하여 WebAuthn을 지원하지 않는 브라우저에 배너를 표시합니다.

// Display a banner in browsers that don't support WebAuthn
if (!window.PublicKeyCredential) {
  document.querySelector('#warningbanner').classList.remove('invisible');
}

실제 웹 애플리케이션에서는 보다 정교한 작업을 수행하고 이러한 브라우저에 적절한 대체 메커니즘을 제공하지만, WebAuthn 지원을 확인하는 방법을 살펴보겠습니다.

11. 잘하셨습니다.

보안 키를 사용하여 2단계 인증을 구현했습니다.

이 Codelab에서는 기본사항을 다뤘습니다. 2단계 인증을 위한 WebAuthn을 자세히 살펴보려면 다음에 시도해 볼 수 있는 다음 아이디어를 참고해 보세요.

  • 사용자 인증 정보 카드에 \'마지막으로 사용한 정보’ 정보를 추가합니다. 이는 사용자가 주어진 보안 키의 사용 여부를 결정할 때 특히 도움이 되는 정보입니다(특히 여러 개의 키를 등록한 경우).
  • 더 강력한 오류 처리 및 보다 정확한 오류 메시지를 구현합니다.
  • auth.js을 살펴보고 일부 authSettings가 변경되면 어떻게 되는지, 특히 사용자 인증을 지원하는 키를 사용할 때 어떤 일이 일어나는지 살펴보세요.