KAYAK이 패스 키로 로그인 시간을 50% 줄이고 보안을 강화한 방법

10월 24, 2023
Kateryna Semenova Developer Relations Engineer, Android

소개

KAYAK은 사용자가 최적의 항공권, 호텔, 렌터카 상품을 찾도록 도와주는 세계 최고의 여행 검색 엔진으로 손꼽힙니다. 2023년에 KAYAK은 새로운 유형의 암호 없는 인증 방식인 패스 키를 자사의 Android와 웹 앱에 통합했습니다. 그 결과, KAYAK 사용자가 가입과 로그인에 걸리는 평균 시간이 50% 줄었을 뿐만 아니라 지원 티켓도 감소했습니다.

이 우수사례는 KAYAK이 Android에서 Credential Manager API 및 RxJava를 사용해 어떻게 구현했는지 설명합니다. 여러분의 앱에서 보안 및 사용자 경험을 개선하기 위해 Credential Manager 구현을 위한 본보기로 이 우수사례를 사용할 수도 있습니다.

간단한 요약본을 보려면 YouTube에서 도우미 동영상을 확인해 보세요.

Link to Youtube Video (visible only when JS is disabled)

문제

대부분의 기업과 마찬가지로, KAYAK도 과거에는 사용자 인증을 위해 비밀번호에 의존했습니다. 비밀번호는 사용자와 기업 모두에 똑같이 부담입니다. 비밀번호는 약하거나, 재사용되거나, 유출되거나, 또는 누군가에 의해 추측되거나 피싱 당하거나 해킹당할 수 있기 때문입니다.

“기업 입장에서 비밀번호 인증 제공은 많은 노력과 위험을 수반합니다. 공격자는 끊임없이 사용자 계정을 대상으로 무차별 암호 대입 공격을 시도하지만 모든 사용자가 강력한 암호의 필요성을 이해하는 것은 아닙니다. 그러나 강력한 비밀번호조차도 완전히 안전하지는 않으며 여전히 피싱에 당할 수 있습니다.” – Matthias Keller, KAYAK 기술 부문 최고 과학자 겸 SVP

KAYAK은 인증 보안을 강화하기 위해 이메일을 통해 '매직 링크'를 전송했습니다. 이 추가 단계는 보안 관점에서는 도움이 되었지만, 사용자가 로그인 프로세스를 완료하려면 다른 앱으로 전환해야 하므로 사용자 불편이 가중되었습니다. 피싱 공격의 위험을 완화하기 위해 추가 조치 도입이 필요했습니다.

해결 방법

이제 KAYAK의 Android 앱은 더 안전하고 사용자 친화적이며 빠른 인증 경험을 위해 패스 키를 사용합니다. 패스 키는 사용자의 기기에 저장되고 여러 기기에 걸쳐 동기화될 수 있는 고유하고 안전한 토큰입니다. 사용자는 단순히 기존 기기의 화면 잠금을 사용함으로써 패스 키로 KAYAK에 로그인할 수 있으므로 비밀번호를 입력할 때보다 로그인이 더 간단하고 안전해졌습니다.

"더 많은 사용자가 비밀번호 대신 패스 키를 사용할 수 있도록 Android 앱에 패스 키 지원을 추가했습니다. 또한 그 작업을 하면서 이전 Smartlock API 구현을 Credential Manager API에서 지원하는 Sign in with Google로 대체했습니다. 이제 사용자는 이메일 링크를 사용할 때보다 두 배 빠른 속도로 패스 키를 사용해 KAYAK에 가입하고 로그인할 수 있으며, 덕분에 완료율도 향상됩니다." – Matthias Keller, KAYAK 기술 부문 최고 과학자 겸 SVP

Credential Manager API 통합

KAYAK은 Android에서 패스 키를 통합하기 위해 Credential Manager API를 사용했습니다. Credential Manager는 비밀번호와 연합 인증 같은 전통적인 로그인 방법을 위한 지원과 Android 9(API 레벨 28)부터 시작하는 패스 키 지원을 단일한 사용자 인터페이스와 API로 통합한 Jetpack 라이브러리입니다.

kayak2
그림 1: Credential Manager의 패스 키 생성 화면.

앱에 대한 강력한 인증 흐름을 설계하는 것은 보안과 신뢰할 수 있는 사용자 경험을 보장하는 데 매우 중요합니다. 아래 도표는 KAYAK이 패스 키를 등록 및 인증 흐름에 통합하는 방법을 보여줍니다.

kayak3
그림 2: 등록 및 인증 흐름을 보여주는 KAYAK의 다이어그램.

등록 시 사용자에게 패스 키를 만들 기회가 주어집니다. 등록이 완료되면 사용자는 패스 키, Sign in with Google 또는 비밀번호를 사용하여 로그인할 수 있습니다. Credential Manager는 UI를 자동으로 실행하므로 네트워크 호출과 같은 예기치 않은 대기 시간이 발생하지 않도록 주의해야 합니다. 앱 세션을 시작할 때 항상 일회성 인증 질문 및 기타 패스 키 구성(예: RP ID)을 가져오세요.

KAYAK 팀은 현재 코루틴에 많은 투자를 하고 있지만, 초기 통합에서는 RxJava를 사용하여 Credential Manager API와 통합했습니다. 다음과 같이 Credential Manager 호출을 RxJava로 래핑했습니다.

override fun createCredential(request: CreateCredentialRequest, activity: Activity): Single<CreateCredentialResponse> {
   return Single.create { emitter ->
       // 자격 증명 생성 흐름 트리거
       credentialManager.createCredentialAsync(
           request = request,
           activity = activity,
           cancellationSignal = null,
           executor = Executors.newSingleThreadExecutor(),
           callback = object : CredentialManagerCallback<CreateCredentialResponse, CreateCredentialException> {
               override fun onResult(result: CreateCredentialResponse) {
                   emitter.onSuccess(result)
               }
 
               override fun onError(e: CreateCredentialException) {
                   emitter.tryOnError(e)
               }
           }
       )
   }
}

이 예시에서는 사용자가 제공한 자격 증명을 CreateCredentialResponse 유형의 RxJava Single로 반환하는 createCredential()이라는 Kotlin 함수를 정의합니다. createCredential() 함수는 RxJava Single 클래스를 사용하여 자격 증명 등록의 비동기 프로세스를 반응형 프로그래밍 스타일로 캡슐화합니다.

코루틴을 사용한 이 프로세스의 Kotlin 구현을 보려면 Credential Manager로 사용자 로그인 가이드를 읽어보세요.

신규 사용자 등록 가입 흐름

이 예시는 KAYAK이 새 자격 증명을 등록하기 위해 사용한 접근 방식을 보여주며, 여기서 Credential Manager가 Rx 원시 형식으로 래핑되었습니다.

webAuthnRetrofitService
  .getClientParams(username = /** email address **/)
  .flatMap { response ->
      // 일회성 인증 질문을 포함하는 클라이언트 매개변수로부터 패스 키 요청 생성
      CreatePublicKeyCredentialOption(/** 응답에서 JSON 생성 **/)
  }
  .subscribeOn(schedulers.io())
  .flatMap { request ->
      // Credential Manager UI를 호출하여 새 패스 키 자격 증명을 등록하는
      // 이전에 정의된 래퍼 호출
      credentialManagerRepository
      .createCredential(
           request = request,
           activity = activity
       )
  }
  .flatMap {
     // 인증 서버로 자격 증명 전송
  }
  .observeOn(schedulers.main())
  .subscribe(
      { /** 성공적인 로그인, 업데이트 UI 등 처리 **/ },
      { /** 프로세스 오류, 로거로 전송 **/ }
  )

KAYAK은 Rx를 통해 Credential Manager와의 여러 상호 작용이 수반될 수 있는 더 복잡한 파이프라인을 생성할 수 있었습니다.

기존 사용자 로그인

KAYAK은 다음 단계를 사용하여 로그인 흐름을 시작했습니다. 이 프로세스에서는 하단 시트 UI 요소를 실행하여 사용자가 Google ID와 기존 패스 키 또는 저장된 비밀번호를 사용하여 로그인할 수 있도록 합니다.

kayak4
그림 3: 패스 키 인증을 위한 하단 시트.

개발자는 로그인 흐름을 설정할 때 다음 단계를 따라야 합니다.

  1. 하단 시트가 자동으로 실행되므로 네트워크 호출 같이 UI에 예기치 않은 대기 시간이 발생하지 않도록 주의하세요. 앱 세션을 시작할 때 항상 일회성 인증 질문 및 기타 패스 키 구성(예: RP ID)을 가져오세요.
  2. Credential Manager API를 통한 Google 로그인 기능을 제공할 때, 코드는 처음에는 앱에서 이미 사용된 Google 계정을 찾아야 합니다. 이를 처리하려면 setFilterByAuthorizedAccounts 매개변수를 true로 설정하여 API를 호출하세요.
  3. 결과가 사용 가능한 자격 증명 목록을 반환하면 앱은 사용자에게 하단 시트 인증 UI를 표시합니다.
  4. NoCredentialException이 나타난다면 자격 증명을 찾을 수 없다는 뜻입니다. 즉, Google 계정도, 패스 키도, 저장된 비밀번호도 없습니다. 이때 앱은 API를 재호출하고 setFilterByAuthorizedAccounts를 false로 설정하여 Sign up with Google 흐름을 시작해야 합니다.
  5. Credential Manager에서 반환된 자격 증명을 처리하세요.
Single.fromSupplier<GetPublicKeyCredentialOption> {
  GetPublicKeyCredentialOption(/** Insert challenge and RP ID that was fetched earlier **/)
}
  .flatMap { response ->
      // 패스 키 요청 생성
      GetPublicKeyCredentialOption(response.toGetPublicKeyCredentialOptionRequest())
  }
  .subscribeOn(schedulers.io())
  .map {  publicKeyCredentialOption ->
    // Google 로그인 및 저장된 비밀번호같이 원하는 다른 옵션과 함께
    // 패스 키 요청을 병합합니다.
  }
  .flatMap { request ->
    // Credential Manager 시스템 UI 트리거
    credentialManagerRepository.getCredential(
      request = request,
      activity = activity
    )
  }
  .onErrorResumeNext { throwable ->
      // Google 로그인을 제공할 때 앱에서 이미 사용된 Google 계정만
      // 먼저 찾는 것이 좋습니다. 이러한 Google 계정, 패스 키,
      // 저장된 비밀번호가 없는 경우 Google 로그인을 한 번 더 찾아봅니다.
      if (throwable is NoCredentialException) {
        return@onErrorResumeNext credentialManagerRepository.getCredential(
          request = GetCredentialRequest(/* filterByAuthorizedOnly = false인 Google ID */),
          activity = activity
        )
      }
    Single.error(throwable)
  }
  .flatMapCompletable {
    // 1단계: Retrofit 서비스를 사용하여 유효성 검사를 위해 자격 증명을 서버로 보냅니다.
    // 서버 대기는 subscribeOn(schedulers.io())를 사용하여 IO 스레드에서 처리됩니다.
    // 2단계: UI에 결과를 표시합니다. 여기에는
    // 프로필 사진 로드, 개인 설정 인사말 업데이트, 회원 전용 영역 활성화, 로그인 대화 상자 숨기기 등의
    // 변경 사항이 포함됩니다. 2단계의 활동은 주 스레드에서 실행됩니다.
  }
  .observeOn(schedulers.main())
  .subscribe(
    // 오류를 처리합니다(예: 로그 수집 서비스로 전송). 
    // 사용자 설정 문제 같이 사용자에게 표시되는 예외 중
    // 일부도 도움이 될 수 있습니다. 
    // https://developer.android.com/training/sign-in/passkeys#troubleshoot에서
    // 일반적인 오류의 문제 해결에 대한 자세한 정보를 확인하세요.
  )
“일단 Credential Manager API가 전반적으로 구현되면 다른 인증 방법도 매우 쉽게 추가할 수 있습니다. 패스 키를 추가한 후에는 Google One-Tap Sign In을 추가하는 일이 거의 0에 수렴할 정도로 없었습니다.” – Matthias Keller

자세히 알아보려면 Credentials Manager API를 통합하는 방법과 Credential Manager를 Sign in with Google과 통합하는 방법에 대한 가이드를 따르세요.

UX 고려 사항

패스 키로 전환할 때 KAYAK은 주요 사용자 경험 고려 사항 중 사용자가 패스 키를 삭제할 수 있어야 하는지 혹은 둘 이상의 패스 키를 생성할 수 있어야 하는지를 고심했습니다.

패스 키에 대한 UX 가이드는 패스 키를 취소하는 옵션을 마련하고 사용자가 동일한 비밀번호 관리자에서 동일한 사용자 이름에 대해 중복된 패스 키를 만들지 않도록 할 것을 권장합니다.

kayak5
그림 4: 패스 키 관리를 위한 KAYAK의 UI.

동일한 계정에 대한 여러 자격 증명 등록을 방지하기 위해 KAYAK은 사용자에 대해 이미 등록된 자격 증명을 나열하는 excludeCredentials 속성을 사용했습니다. 다음 예시에서 중복을 생성하지 않고 Android에서 새 자격 증명을 만드는 방법을 보여줍니다.

fun WebAuthnClientParamsResponse.toCreateCredentialRequest(): String {
        val credentialRequest = WebAuthnCreateCredentialRequest(
            challenge = this.challenge!!.asSafeBase64,
            relayingParty = this.relayingParty!!,
            pubKeyCredParams = this.pubKeyCredParams!!,
            userEntity = WebAuthnUserEntity(
              id = this.userEntity!!.id.asSafeBase64,
                   name = this.userEntity.name,
                   displayName = this.userEntity.displayName
            ),
            authenticatorSelection = WebAuthnAuthenticatorSelection(
                  authenticatorAttachment = "platform",
                  residentKey = "preferred"
             ),
            // 여기서 기존 자격 증명을 미리 설정하면
            // 동일한 키체인/비밀번호 관리자에 여러 개의 패스 키 생성이 방지됨
            excludeCredentials = this.allowedCredentials!!.map { it.copy(id = it.id.asSafeBase64) },
        )
        return GsonBuilder().disableHtmlEscaping().create().toJson(credentialRequest)
}

KAYAK이 웹 구현을 위해 excludeCredentials 기능을 구현한 방법은 다음과 같습니다.

var registrationOptions = {
   'publicKey': {
       'challenge': self.base64ToArrayBuffer(data.challenge),
       'rp': data.rp,
       'user': {
           'id': new TextEncoder().encode(data.user.id),
           'name': data.user.name,
           'displayName': data.user.displayName
       },
       'pubKeyCredParams': data.pubKeyCredParams,
       'authenticatorSelection': {
           'residentKey': 'required'
       }
   }
};
 
if (data.allowCredentials && data.allowCredentials.length > 0) {
   var excludeCredentials = [];
   for (var i = 0; i < data.allowCredentials.length; i++) {
       excludeCredentials.push({
           'id': self.base64ToArrayBuffer(data.allowCredentials[i].id),
           'type': data.allowCredentials[i].type
       });
   }
   registrationOptions.publicKey.excludeCredentials = excludeCredentials;
}
 
navigator.credentials.create(registrationOptions);

서버 측 구현

서버 측 부분은 인증 솔루션의 필수 구성요소입니다. KAYAK은 오픈소스 Java 라이브러리인 WebAuthn4J를 활용하여 기존 인증 백엔드에 패스 키 기능을 추가했습니다.

KAYAK은 서버 측 프로세스를 다음 단계로 세분화했습니다.

  1. 클라이언트가 서버에서 패스 키를 생성하거나 사용하는 데 필요한 매개변수를 요청합니다. 여기에는 인증 질문, 지원되는 암호화 알고리즘, 신뢰 당사자 ID 및 관련 항목이 포함됩니다. 클라이언트에 이미 사용자 이메일 주소가 있는 경우, 매개변수에는 등록할 사용자 객체와 패스 키 목록(있는 경우)이 포함됩니다.
  2. 클라이언트가 브라우저 또는 앱 흐름을 실행하여 패스 키 등록 또는 로그인을 시작합니다.
  3. 클라이언트가 검색된 자격 증명 정보를 서버로 전송합니다. 여기에는 클라이언트 ID, 인증자 데이터, 클라이언트 데이터, 기타 관련 항목이 포함됩니다. 이 정보는 계정을 만들거나 로그인을 확인하는 데 필요합니다.

KAYAK이 이 프로젝트를 진행할 당시에는 패스 키를 지원하는 타사 제품이 없었습니다. 그러나 이제 문서라이브러리 예제 등 많은 리소스를 사용해 패스 키 서버를 만들 수 있습니다.

결과

패스 키를 통합한 이후 KAYAK의 사용자 만족도가 크게 증가했습니다. 사용자는 길고 복잡한 문자열을 기억하거나 입력할 필요가 없으므로 패스 키가 비밀번호보다 훨씬 사용하기 쉽다는 의견을 주었습니다. KAYAK은 사용자가 가입하고 로그인하는 데 걸리는 평균 시간을 50% 줄였고, 비밀번호 분실과 관련된 지원 티켓이 감소했으며, 비밀번호 기반 공격에 대한 노출을 줄여 시스템을 더욱 안전하게 만들었습니다. 이러한 개선에 힘입어 KAYAK은 2023년 말까지 앱에서 비밀번호 기반 인증을 없앨 계획입니다.

“패스 키는 비밀번호를 생성할 필요성과 별도의 앱으로 이동해 링크나 코드를 받을 필요성을 제거함으로써 계정을 번개처럼 빠르게 만들 수 있게 해줍니다. 뿐만 아니라, 새로운 Credential Manager 라이브러리를 구현하여 패스 키, 비밀번호, Google 로그인을 모두 하나의 새로운 최신 UI에 넣음으로써 코드베이스의 기술적 부담도 줄였습니다. 실제로, 사용자는 이메일 링크보다 두 배 빠른 패스 키로 KAYAK에 가입하고 로그인할 수 있으며, 덕분에 완료율도 향상되었습니다." – Matthias Keller

결론

패스 키는 기존 비밀번호에 비해 큰 장점이 있는 새롭고 혁신적인 인증 솔루션입니다. KAYAK은 조직이 패스 키를 통합하여 인증 프로세스의 보안과 사용성을 개선하는 방법을 보여주는 훌륭한 예입니다. 보다 안전하고 사용자 친화적인 인증 환경을 모색 중이라면 Android의 Credential Manager API와 함께 패스 키를 사용하는 방안을 고려해 보세요.