서버 측 패스키 인증

개요

다음은 패스키 인증과 관련된 주요 단계에 대한 간략한 개요입니다.

패스키 인증 흐름

  • 패스키로 인증하는 데 필요한 본인 확인 질문과 기타 옵션을 정의합니다. 패스키 인증 호출 (웹의 navigator.credentials.get)에 전달할 수 있도록 클라이언트로 전송합니다. 사용자가 패스키 인증을 확인하면 패스키 인증 호출이 확인되고 사용자 인증 정보 (PublicKeyCredential)가 반환됩니다. 사용자 인증 정보에는 인증 어설션이 포함됩니다.
  • 인증 어설션을 확인합니다.
  • 인증 어설션이 유효하면 사용자를 인증합니다.

다음 섹션에서는 각 단계의 구체적인 내용을 자세히 설명합니다.

챌린지 만들기

실제로 챌린지는 ArrayBuffer 객체로 표현되는 무작위 바이트 배열입니다.

// Example challenge, base64URL-encoded
weMLPOSx1VfSnMV6uPwDKbjGdKRMaUDGxeDEUTT5VN8

챌린지의 목적을 달성하려면 다음을 수행해야 합니다.

  1. 동일한 챌린지를 두 번 이상 사용하지 않습니다. 로그인할 때마다 새로운 본인 확인 요청을 생성합니다. 로그인 시도 성공 여부와 관계없이 매번 로그인 시도 후 챌린지를 삭제합니다. 일정 시간이 지나면 챌린지를 삭제하세요. 답변에서 같은 챌린지를 두 번 이상 수락하지 마세요.
  2. 챌린지가 암호화 방식으로 안전한지 확인합니다. 챌린지는 사실상 추측하기가 불가능해야 합니다. 서버 측에서 암호화 방식으로 안전한 챌린지를 만들려면 신뢰할 수 있는 FIDO 서버 측 라이브러리를 사용하는 것이 가장 좋습니다. 대신 직접 챌린지를 만드는 경우 기술 스택에서 제공되는 기본 제공 암호화 기능을 사용하거나 암호화 사용 사례를 위해 설계된 라이브러리를 찾아보세요. Node.js의 iso-crypto 또는 Python의 secrets를 예로 들 수 있습니다. 사양에 따라 챌린지가 안전한 것으로 간주되려면 길이가 16바이트 이상이어야 합니다.

챌린지를 만들었으면 나중에 확인할 수 있도록 사용자의 세션에 저장하세요.

사용자 인증 정보 요청 옵션 만들기

publicKeyCredentialRequestOptions 객체로 사용자 인증 정보 요청 옵션을 만듭니다.

이렇게 하려면 FIDO 서버 측 라이브러리를 사용하세요. 일반적으로 이러한 옵션을 만들 수 있는 유틸리티 함수를 제공합니다. SimpleWebAuthn은 다음과 같은 서비스를 제공합니다. generateAuthenticationOptions

publicKeyCredentialRequestOptions에는 패스키 인증에 필요한 모든 정보가 포함되어야 합니다. 이 정보를 publicKeyCredentialRequestOptions 객체 생성을 담당하는 FIDO 서버 측 라이브러리의 함수에 전달합니다.

publicKeyCredentialRequestOptions의 필드 중 일부는 상수일 수 있습니다. 그 외는 서버에서 동적으로 정의해야 합니다.

  • rpId: 사용자 인증 정보가 연결될 RP ID입니다(예: example.com). 여기에 입력한 RP ID가 사용자 인증 정보에 연결된 RP ID와 일치하는 경우에만 인증이 완료됩니다. RP ID를 채우려면 사용자 인증 정보 등록 중에 publicKeyCredentialCreationOptions에서 설정한 RP ID와 동일한 값을 사용하세요.
  • challenge: 인증 요청 시 사용자가 패스키를 보유하고 있음을 증명하기 위해 패스키 제공업체가 서명하는 데이터입니다. 챌린지 만들기에서 세부정보를 검토하세요.
  • allowCredentials: 이 인증에 허용되는 사용자 인증 정보의 배열입니다. 사용자가 브라우저에 표시된 목록에서 사용 가능한 패스키를 선택할 수 있도록 빈 배열을 전달합니다. 자세한 내용은 RP 서버에서 본인 확인 요청 가져오기검색 가능한 사용자 인증 정보 자세히 알아보기를 참고하세요.
  • userVerification: 기기 화면 잠금을 사용하는 사용자 인증이 '필수', '권장' 또는 '사용하지 않음'인지를 나타냅니다. RP 서버에서 챌린지 가져오기를 검토하세요.
  • timeout: 사용자가 인증을 완료하는 데 걸릴 수 있는 시간 (밀리초)입니다. 충분히 관대하고 challenge의 전체 기간보다 짧아야 합니다. 권장되는 기본값은 5분이지만 최대 10분까지 늘릴 수 있지만 권장 범위 이내입니다. 사용자가 하이브리드 워크플로를 사용할 것으로 예상되는 경우 긴 제한 시간이 적합합니다. 일반적으로 이 워크플로는 조금 더 오래 걸립니다. 작업 시간이 초과되면 NotAllowedError이 발생합니다.

publicKeyCredentialRequestOptions를 만든 후 클라이언트로 전송합니다.

서버에서 전송된 publicKeyCredentialCreationOptions
서버에서 전송하는 옵션입니다. challenge 디코딩은 클라이언트 측에서 발생합니다.

코드 예시: 사용자 인증 정보 요청 옵션 만들기

이 예에서는 SimpleWebAuthn 라이브러리를 사용합니다. 여기서는 사용자 인증 정보 요청 옵션 생성을 generateAuthenticationOptions 함수에 넘깁니다.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';

router.post('/signinRequest', csrfCheck, async (req, res) => {

  // Ensure you nest calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // Use the generateAuthenticationOptions function from SimpleWebAuthn
    const options = await generateAuthenticationOptions({
      rpID: process.env.HOSTNAME,
      allowCredentials: [],
    });
    // Save the challenge in the user session
    req.session.challenge = options.challenge;

    return res.json(options);
  } catch (e) {
    console.error(e);
    return res.status(400).json({ error: e.message });
  }
});

사용자 확인 및 로그인

navigator.credentials.get가 클라이언트에서 성공적으로 확인되면 PublicKeyCredential 객체를 반환합니다.

서버에서 전송한 PublicKeyCredential 객체
navigator.credentials.getPublicKeyCredential을 반환합니다.

responseAuthenticatorAssertionResponse입니다. RP에서 패스키로 인증을 시도하고 인증하는 데 필요한 항목을 생성하라는 클라이언트의 명령에 대한 패스키 제공업체의 응답을 나타냅니다. 여기에는 다음이 포함됩니다.

  • response.authenticatorDataresponse.clientDataJSON(패스키 등록 단계 등)
  • response.signature - 이러한 값의 서명이 포함되어 있습니다.

PublicKeyCredential 객체를 서버로 전송합니다.

서버에서 다음을 수행합니다.

데이터베이스 스키마
추천 데이터베이스 스키마입니다. 서버 측 패스키 등록에서 이 설계에 대해 자세히 알아보세요.
  • 어설션을 확인하고 사용자를 인증하는 데 필요한 정보를 수집합니다.
    • 인증 옵션을 생성할 때 세션에 저장한 예상 본인 확인 요청을 가져옵니다.
    • 예상 출처 및 RP ID를 가져옵니다.
    • 데이터베이스에서 사용자를 찾습니다. 검색 가능한 사용자 인증 정보의 경우 인증을 요청하는 사용자가 누구인지 알 수 없습니다. 다음 두 가지 방법으로 확인할 수 있습니다.
      • 옵션 1: PublicKeyCredential 객체에서 response.userHandle 사용 사용자 테이블에서 userHandle와 일치하는 passkey_user_id를 찾습니다.
      • 옵션 2: PublicKeyCredential 객체에 있는 사용자 인증 정보 id를 사용합니다. 공개 키 사용자 인증 정보 표에서 PublicKeyCredential 객체에 있는 사용자 인증 정보 id와 일치하는 사용자 인증 정보 id를 찾습니다. 그런 다음 Users 테이블에 대한 외래 키 passkey_user_id를 사용하여 해당 사용자를 찾습니다.
    • 수신한 인증 어설션과 일치하는 공개 키 사용자 인증 정보를 데이터베이스에서 찾습니다. 이렇게 하려면 공개 키 사용자 인증 정보 테이블에서 PublicKeyCredential 객체에 있는 사용자 인증 정보 id와 일치하는 사용자 인증 정보 id를 찾습니다.
  • 인증 어설션을 확인합니다. 이 인증 단계는 일반적으로 이러한 용도로 유틸리티 기능을 제공하는 FIDO 서버 측 라이브러리에 전달합니다. SimpleWebAuthn은 다음과 같은 서비스를 제공합니다. verifyAuthenticationResponse 부록: 인증 응답 확인에서 내부 과정을 알아봅니다.

  • 재전송 공격을 방지하려면 인증 성공 여부와 관계없이 챌린지를 삭제합니다.

  • 사용자를 로그인 처리합니다. 인증이 완료되면 세션 정보를 업데이트하여 사용자를 로그인한 것으로 표시합니다. 또한 프런트엔드가 새로 로그인한 사용자와 연결된 정보를 사용할 수 있도록 user 객체를 클라이언트에 반환할 수도 있습니다.

코드 예: 사용자 인증 및 로그인

이 예에서는 SimpleWebAuthn 라이브러리를 사용합니다. 여기서는 verifyAuthenticationResponse 함수에 인증 응답을 확인합니다.

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

router.post('/signinResponse', csrfCheck, async (req, res) => {
  const response = req.body;
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;

  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // Find the credential stored to the database by the credential ID
    const cred = Credentials.findById(response.id);
    if (!cred) {
      throw new Error('Credential not found.');
    }
    // Find the user - Here alternatively we could look up the user directly
    // in the Users table via userHandle
    const user = Users.findByPasskeyUserId(cred.passkey_user_id);
    if (!user) {
      throw new Error('User not found.');
    }
    // Base64URL decode some values
    const authenticator = {
      credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey),
      credentialID: isoBase64URL.toBuffer(cred.id),
      transports: cred.transports,
    };

    // Verify the credential
    const { verified, authenticationInfo } = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      authenticator,
      requireUserVerification: false,
    });

    if (!verified) {
      throw new Error('User verification failed.');
    }

    // Kill the challenge for this session.
    delete req.session.challenge;

    req.session.username = user.username;
    req.session['signed-in'] = 'yes';

    return res.json(user);
  } catch (e) {
    delete req.session.challenge;

    console.error(e);
    return res.status(400).json({ error: e.message });
  }
});

부록: 인증 응답 확인

인증 응답 확인은 다음 검사로 구성됩니다.

  • RP ID가 사이트와 일치하는지 확인합니다.
  • 요청의 출처가 사이트의 로그인 출처와 일치하는지 확인합니다. Android 앱의 경우 출처 확인을 검토하세요.
  • 기기에서 보안문자를 입력할 수 있는지 확인합니다.
  • 인증 과정에서 사용자가 RP로 지정된 요구사항을 준수했는지 확인합니다. 사용자 확인이 필요한 경우 authenticatorDatauv (사용자 확인됨) 플래그가 true인지 확인합니다. 패스키에는 사용자 존재가 항상 필요하므로 authenticatorDataup (사용자 존재) 플래그가 true인지 확인합니다.
  • 서명을 확인합니다. 서명을 확인하려면 다음이 필요합니다.
    • 서명된 챌린지인 서명: response.signature
    • 서명을 확인할 공개 키입니다.
    • 원래 서명된 데이터입니다. 서명을 확인할 데이터입니다.
    • 서명을 만드는 데 사용된 암호화 알고리즘입니다.

이 단계에 대해 자세히 알아보려면 SimpleWebAuthn의 verifyAuthenticationResponse 소스 코드를 확인하거나 사양에서 전체 인증 목록을 살펴보세요.