첫 번째 Android FIDO2 API

1. 소개

FIDO2 API란 무엇인가요?

FIDO2 API를 사용하면 Android 애플리케이션에서 사용자를 인증할 목적으로 강력한 증명된 공개 키 기반 사용자 인증 정보를 만들어 사용할 수 있습니다. API는 BLE, NFC, USB 로밍 인증자 (보안 키)는 물론 사용자가 지문이나 화면 잠금을 사용하여 인증할 수 있는 플랫폼 인증자의 사용을 지원하는 WebAuthn 클라이언트 구현을 제공합니다.

빌드할 항목...

이 Codelab에서는 지문 센서를 사용하는 간단한 재인증 기능이 포함된 Android 앱을 빌드합니다. '재인증' 사용자가 앱에 로그인한 후 앱으로 다시 전환하거나 앱의 중요한 섹션에 액세스하려고 할 때 다시 인증하는 경우입니다. 후자를 '단계별 인증'이라고도 합니다.

학습할 내용…

다양한 상황에 대응하기 위해 Android FIDO2 API를 호출하는 방법과 제공할 수 있는 옵션을 알아봅니다. 재인증 관련 권장사항도 알아봅니다.

필요한 항목…

  • 지문 센서가 없는 Android 기기 (지문 센서가 없어도 화면 잠금이 동등한 사용자 확인 기능을 제공할 수 있음)
  • 최신 업데이트가 설치된 Android OS 7.0 이상. 지문 (또는 화면 잠금)을 등록해야 합니다.

2. 설정

저장소 복제

GitHub 저장소를 확인하세요.

https://github.com/android/codelab-fido2

$ git clone https://github.com/android/codelab-fido2.git

무엇을 구현할까요?

  • 사용자가 '사용자 확인 플랫폼 인증자'를 등록하도록 허용 (지문 센서가 있는 Android 휴대전화 자체는 하나의 역할을 합니다.)
  • 사용자가 지문을 사용하여 앱을 다시 인증하도록 허용합니다.

여기에서 빌드할 항목을 미리 볼 수 있습니다.

Codelab 프로젝트 시작

완성된 앱은 https://webauthn-codelab.glitch.me의 서버에 요청을 전송합니다. 여기에서 동일한 앱의 웹 버전을 사용해 볼 수 있습니다.

c2234c42ba8a6ef1.png

자체 앱 버전을 개발하게 됩니다.

  1. 웹사이트의 수정 페이지(https://glitch.com/edit/#!/webauthn-codelab)로 이동합니다.
  2. '수정할 리믹스'를 찾습니다. 버튼을 클릭합니다. 버튼을 눌러 "포크"할 수 있습니다. 새 프로젝트 URL과 함께 자체 버전을 계속 사용할 수 있습니다. 9ef108869885e4ce.png
  3. 왼쪽 상단에서 프로젝트 이름을 복사합니다 (원하는 대로 수정할 수 있음). c91d0d59c61021a4.png
  4. .env 파일의 HOSTNAME 섹션에 붙여넣습니다. <ph type="x-smartling-placeholder">889b55b1cf74b894.png</ph>

3. 디지털 애셋 링크를 사용하여 앱과 웹사이트 연결

Android 앱에서 FIDO2 API를 사용하려면 웹사이트에 연결하고 웹사이트 간에 사용자 인증 정보를 공유하세요. 이렇게 하려면 디지털 애셋 링크를 활용합니다. 웹사이트에서 디지털 애셋 링크 JSON 파일을 호스팅하고 디지털 애셋 링크 파일의 링크를 앱의 매니페스트에 추가하여 연결을 선언할 수 있습니다.

도메인에 .well-known/assetlinks.json 호스팅

JSON 파일을 만들고 .well-known/assetlinks.json에 배치하여 앱과 웹사이트 간의 연결을 정의할 수 있습니다. 다행히 글리치의 .env 파일에 다음 환경 매개변수를 추가하기만 하면 assetlinks.json 파일을 자동으로 표시하는 서버 코드가 있습니다.

  • ANDROID_PACKAGENAME: 앱의 패키지 이름 (com.example.android.fido2)
  • ANDROID_SHA256HASH: 서명 인증서의 SHA256 해시

개발자 서명 인증서의 SHA256 해시를 가져오려면 아래 명령어를 사용하세요. 디버그 키 저장소의 기본 비밀번호는 'android'입니다.

$ keytool -exportcert -list -v -alias androiddebugkey -keystore ~/.android/debug.keystore

https://<your-project-name>.glitch.me/.well-known/assetlinks.json에 액세스하면 다음과 같은 JSON 문자열이 표시됩니다.

[{
  "relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"],
  "target": {
    "namespace": "web",
    "site": "https://<your-project-name>.glitch.me"
  }
}, {
  "relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.android.fido2",
    "sha256_cert_fingerprints": ["DE:AD:BE:EF:..."]
  }
}]

Android 스튜디오에서 프로젝트 열기

'Open an existing Android Studio project'를 클릭합니다. Android 스튜디오의 시작 화면에 표시됩니다.

'android'를 선택합니다. 폴더 안에 있는 폴더를 선택합니다.

1062875cf11ffb95.png

앱을 리믹스와 연결

gradle.properties 파일을 엽니다. 파일 하단에서 호스트 URL을 방금 만든 Glitch 리믹스로 변경합니다.

// ...

# The URL of the server
host=https://<your-project-name>.glitch.me

이제 디지털 애셋 링크 구성이 모두 설정되어 있어야 합니다.

4. 현재 앱 작동 방식 보기

먼저 앱이 어떻게 작동하는지 살펴보겠습니다. 실행 구성 콤보 상자에서 'app-start'를 선택해야 합니다. '실행'을 클릭합니다. (콤보박스 옆의 녹색 삼각형)을 클릭하여 연결된 Android 기기에서 앱을 실행합니다.

29351fb97062b43c.png

앱을 실행하면 화면에 사용자 이름을 입력하라는 메시지가 표시됩니다. UsernameFragment입니다. 시연을 위해 앱과 서버는 모든 사용자 이름을 수락합니다. 내용을 입력하고 '다음'을 누르세요.

bd9007614a9a3644.png

다음 화면은 AuthFragment입니다. 여기에서 사용자는 비밀번호로 로그인할 수 있습니다. 향후 여기에 FIDO2로 로그인하는 기능이 추가될 예정입니다. 다시 말하지만, 시연을 위해 앱과 서버는 모든 비밀번호를 허용합니다. 내용을 입력하고 '로그인'을 누르세요.

d9caba817a0a99bd.png

이 앱의 마지막 화면인 HomeFragment입니다. 지금은 여기에 빈 사용자 인증 정보 목록만 표시됩니다. '재인증' 누르기 AuthFragment(으)로 돌아갑니다. '로그아웃' 누르기 UsernameFragment(으)로 돌아갑니다. '+'가 있는 플로팅 작업 버튼 아무 것도 하지 않지만

새 사용자 인증 정보를 제공합니다.

1cfcc6c884020e37.png

코딩을 시작하기 전에 유용한 기법을 살펴보겠습니다. Android 스튜디오에서 'TODO'를 누릅니다. 을 클릭합니다. 그러면 이 Codelab의 모든 TODO 목록이 표시됩니다. 다음 섹션에서 첫 번째 TODO부터 시작하겠습니다.

e5a811bbc7cd7b30.png

5. 지문을 사용하여 사용자 인증 정보 등록

지문을 사용하여 인증을 사용 설정하려면 먼저 사용자가 확인하는 플랫폼 인증자가 생성한 사용자 인증 정보를 등록해야 합니다. 플랫폼 인증자는 기기에 내장된 인증자로, 지문 센서와 같은 생체 인식을 사용하여 사용자를 확인합니다.

37ce78fdf2759832.png

이전 섹션에서 확인했듯이 플로팅 작업 버튼은 이제 아무 작업도 하지 않습니다. 새 사용자 인증 정보를 등록하는 방법을 알아보겠습니다.

서버 API 호출: /auth/registerRequest

AuthRepository.kt를 열고 TODO(1)를 찾습니다.

여기서 registerRequest는 FAB를 누를 때 호출되는 메서드입니다. 이 메서드가 서버 API /auth/registerRequest를 호출하도록 하려고 합니다. API는 클라이언트가 새 사용자 인증 정보를 생성하는 데 필요한 모든 PublicKeyCredentialCreationOptions가 포함된 ApiResult를 반환합니다.

그런 다음 옵션을 사용하여 getRegisterPendingIntent를 호출할 수 있습니다. 이 FIDO2 API는 Android PendingIntent를 반환하여 지문 대화상자를 열고 새 사용자 인증 정보를 생성합니다. 그러면 호출자에게 PendingIntent를 반환할 수 있습니다.

그러면 메서드가 아래와 같이 표시됩니다.

suspend fun registerRequest(): PendingIntent? {
  fido2ApiClient?.let { client ->
    try {
      val sessionId = dataStore.read(SESSION_ID)!!
      when (val apiResult = api.registerRequest(sessionId)) {
        ApiResult.SignedOutFromServer -> forceSignOut()
        is ApiResult.Success -> {
          if (apiResult.sessionId != null) {
            dataStore.edit { prefs ->
              prefs[SESSION_ID] = apiResult.sessionId
            }
          }
          val task = client.getRegisterPendingIntent(apiResult.data)
          return task.await()
        }
      }
    } catch (e: Exception) {
      Log.e(TAG, "Cannot call registerRequest", e)
    }
  }
  return null
}

등록을 위한 지문 대화상자 열기

HomeFragment.kt를 열고 TODO(2)를 찾습니다.

여기서 UI는 AuthRepository에서 인텐트를 다시 가져옵니다. 여기에서는 createCredentialIntentLauncher 멤버를 사용하여 이전 단계의 결과로 가져온 PendingIntent를 실행합니다. 그러면 사용자 인증 정보 생성을 위한 대화상자가 열립니다.

binding.add.setOnClickListener {
  lifecycleScope.launch {
    val intent = viewModel.registerRequest()
    if (intent != null) {
      createCredentialIntentLauncher.launch(
        IntentSenderRequest.Builder(intent).build()
      )
    }
  }
}

새 사용자 인증 정보로 ActivityResult 수신

HomeFragment.kt를 열고 TODO(3)를 찾습니다.

handleCreateCredentialResult 메서드는 지문 대화상자가 닫힌 후에 호출됩니다. 사용자 인증 정보가 성공적으로 생성된 경우 ActivityResult의 data 멤버에 사용자 인증 정보 정보가 포함됩니다.

먼저 data에서 PublicKeyCredential을 추출해야 합니다. 데이터 인텐트에는 Fido.FIDO2_KEY_CREDENTIAL_EXTRA 키가 있는 추가 바이트 배열 필드가 있습니다. PublicKeyCredential에서 deserializeFromBytes라는 정적 메서드를 사용하여 바이트 배열을 PublicKeyCredential 객체로 변환할 수 있습니다.

다음으로 이 사용자 인증 정보 객체의 response 구성원이 AuthenticationErrorResponse인지 확인합니다. 사용할 수 있는 경우 사용자 인증 정보를 생성하는 중에 오류가 발생한 것입니다. 그렇지 않으면 백엔드로 사용자 인증 정보를 보낼 수 있습니다.

완성된 메서드는 다음과 같습니다.

private fun handleCreateCredentialResult(activityResult: ActivityResult) {
  val bytes = activityResult.data?.getByteArrayExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA)
  when {
    activityResult.resultCode != Activity.RESULT_OK ->
      Toast.makeText(requireContext(), R.string.cancelled, Toast.LENGTH_LONG).show()
    bytes == null ->
      Toast.makeText(requireContext(), R.string.credential_error, Toast.LENGTH_LONG)
        .show()
    else -> {
      val credential = PublicKeyCredential.deserializeFromBytes(bytes)
      val response = credential.response
      if (response is AuthenticatorErrorResponse) {
        Toast.makeText(requireContext(), response.errorMessage, Toast.LENGTH_LONG)
          .show()
      } else {
        viewModel.registerResponse(credential)
      }
    }
  }
}

서버 API 호출: /auth/registerResponse

AuthRepository.kt를 열고 TODO(4)를 찾습니다.

registerResponse 메서드는 UI에서 새 사용자 인증 정보를 성공적으로 생성한 후에 호출되며 이는 다시 서버로 전송하려고 합니다.

PublicKeyCredential 객체에는 내부에 새로 생성된 사용자 인증 정보에 관한 정보가 있습니다. 이제 서버에 등록된 다른 키와 구별할 수 있도록 로컬 키의 ID를 기억하려고 합니다. PublicKeyCredential 객체에서 rawId 속성을 가져와 toBase64를 사용하여 로컬 문자열 변수에 배치합니다.

이제 정보를 서버로 보낼 준비가 되었습니다. api.registerResponse를 사용하여 서버 API를 호출하고 응답을 다시 보냅니다. 반환된 값에는 새 사용자 인증 정보를 포함하여 서버에 등록된 모든 사용자 인증 정보 목록이 포함됩니다.

마지막으로 결과를 DataStore에 저장할 수 있습니다. 사용자 인증 정보 목록은 CREDENTIALS 키를 사용하여 StringSet로 저장해야 합니다. toStringSet를 사용하여 사용자 인증 정보 목록을 StringSet로 변환할 수 있습니다.

또한 LOCAL_CREDENTIAL_ID 키로 사용자 인증 정보 ID를 저장합니다.

suspend fun registerResponse(credential: PublicKeyCredential) {
  try {
    val sessionId = dataStore.read(SESSION_ID)!!
    val credentialId = credential.rawId.toBase64()
    when (val result = api.registerResponse(sessionId, credential)) {
      ApiResult.SignedOutFromServer -> forceSignOut()
      is ApiResult.Success -> {
        dataStore.edit { prefs ->
          result.sessionId?.let { prefs[SESSION_ID] = it }
          prefs[CREDENTIALS] = result.data.toStringSet()
          prefs[LOCAL_CREDENTIAL_ID] = credentialId
        }
      }
    }
  } catch (e: ApiException) {
    Log.e(TAG, "Cannot call registerResponse", e)
  }
}

앱을 실행하면 FAB를 클릭하여 새 사용자 인증 정보를 등록할 수 있습니다.

7d64d9289c5a3cbc.png

6. 지문으로 사용자 인증

이제 앱과 서버에 사용자 인증 정보가 등록되었습니다. 이제 이를 사용하여 사용자가 로그인하도록 허용할 수 있습니다. AuthFragment에 지문 로그인 기능이 추가됩니다. 사용자가 마우스를 가져가면 지문 대화상자가 표시됩니다. 인증에 성공하면 사용자는 HomeFragment로 리디렉션됩니다.

서버 API 호출: /auth/signinRequest

AuthRepository.kt를 열고 TODO(5)를 찾습니다.

signinRequest 메서드는 AuthFragment가 열릴 때 호출됩니다. 여기서는 서버를 요청하고 사용자가 FIDO2로 로그인하도록 허용할 수 있는지 확인하려고 합니다.

먼저 서버에서 PublicKeyCredentialRequestOptions를 가져와야 합니다. api.signInRequest를 사용하여 서버 API를 호출합니다. 반환된 ApiResult에는 PublicKeyCredentialRequestOptions가 포함됩니다.

PublicKeyCredentialRequestOptions를 사용하면 FIDO2 API getSignIntent를 사용하여 지문 대화상자를 여는 PendingIntent를 만들 수 있습니다.

마지막으로 PendingIntent를 UI로 다시 반환할 수 있습니다.

suspend fun signinRequest(): PendingIntent? {
  fido2ApiClient?.let { client ->
    val sessionId = dataStore.read(SESSION_ID)!!
    val credentialId = dataStore.read(LOCAL_CREDENTIAL_ID)
    if (credentialId != null) {
      when (val apiResult = api.signinRequest(sessionId, credentialId)) {
        ApiResult.SignedOutFromServer -> forceSignOut()
        is ApiResult.Success -> {
          val task = client.getSignPendingIntent(apiResult.data)
          return task.await()
        }
      }
    }
  }
  return null
}

어설션을 위한 지문 대화상자 열기

AuthFragment.kt를 열고 TODO(6)를 찾습니다.

이 작업은 등록을 위해 한 것과 거의 동일합니다. signIntentLauncher 멤버로 지문 대화상자를 실행할 수 있습니다.

viewLifecycleOwner.lifecycleScope.launchWhenStarted {
  launch {
    viewModel.signinRequests.collect { intent ->
      signIntentLauncher.launch(
        IntentSenderRequest.Builder(intent).build()
      )
    }
  }
  launch {
    ...
  }
}

ActivityResult 처리

AuthFragment.kt를 열고 TODO(7)를 찾습니다.

다시 말하지만, 이는 등록을 위해 한 것과 동일합니다. PublicKeyCredential를 추출하고 오류를 확인한 후 ViewModel에 전달할 수 있습니다.

private fun handleSignResult(activityResult: ActivityResult) {
  val bytes = activityResult.data?.getByteArrayExtra(Fido.FIDO2_KEY_CREDENTIAL_EXTRA)
  when {
    activityResult.resultCode != Activity.RESULT_OK ->
      Toast.makeText(requireContext(), R.string.cancelled, Toast.LENGTH_SHORT).show()
    bytes == null ->
      Toast.makeText(requireContext(), R.string.auth_error, Toast.LENGTH_SHORT)
        .show()
    else -> {
      val credential = PublicKeyCredential.deserializeFromBytes(bytes)
      val response = credential.response
      if (response is AuthenticatorErrorResponse) {
        Toast.makeText(requireContext(), response.errorMessage, Toast.LENGTH_SHORT)
          .show()
      } else {
        viewModel.signinResponse(credential)
      }
    }
  }
}

서버 API 호출: /auth/signinResponse

AuthRepository.kt를 열고 TODO(8)를 찾습니다.

PublicKeyCredential 객체에는 keyHandle라는 사용자 인증 정보 ID가 있습니다. 등록 과정에서 했던 것과 마찬가지로 나중에 저장할 수 있도록 로컬 문자열 변수에 저장하겠습니다.

이제 api.signinResponse를 사용하여 서버 API를 호출할 준비가 되었습니다. 반환된 값에는 사용자 인증 정보 목록이 포함됩니다.

이제 로그인이 성공적으로 완료된 것입니다. 모든 결과는 DataStore에 저장해야 합니다. 사용자 인증 정보 목록은 CREDENTIALS 키를 포함한 StringSet으로 저장해야 합니다. 위에서 저장한 로컬 사용자 인증 정보 ID는 LOCAL_CREDENTIAL_ID 키가 있는 문자열로 저장해야 합니다.

마지막으로 UI에서 사용자를 HomeFragment로 리디렉션할 수 있도록 로그인 상태를 업데이트해야 합니다. 이렇게 하려면 SignInState.SignedIn 객체를 signInStateMutable라는 SharedFlow에 내보내면 됩니다. 또한 refreshCredentials를 호출하여 사용자 인증 정보를 가져와 UI에 나열하려고 합니다.

suspend fun signinResponse(credential: PublicKeyCredential) {
  try {
    val username = dataStore.read(USERNAME)!!
    val sessionId = dataStore.read(SESSION_ID)!!
    val credentialId = credential.rawId.toBase64()
    when (val result = api.signinResponse(sessionId, credential)) {
      ApiResult.SignedOutFromServer -> forceSignOut()
      is ApiResult.Success -> {
        dataStore.edit { prefs ->
          result.sessionId?.let { prefs[SESSION_ID] = it }
          prefs[CREDENTIALS] = result.data.toStringSet()
          prefs[LOCAL_CREDENTIAL_ID] = credentialId
        }
        signInStateMutable.emit(SignInState.SignedIn(username))
        refreshCredentials()
      }
    }
  } catch (e: ApiException) {
    Log.e(TAG, "Cannot call registerResponse", e)
  }
}

앱을 실행하고 'Reauth'를 클릭합니다. AuthFragment 앱을 엽니다. 이제 지문을 사용하여 로그인하라는 지문 대화상자가 표시됩니다.

45f81419f84952c8.png

축하합니다. Android에서 FIDO2 API를 사용하여 등록하고 로그인하는 방법을 알아보았습니다.

7. 축하합니다.

Codelab - 첫 번째 Android FIDO2 API를 완료했습니다.

학습한 내용

  • 사용자 확인 플랫폼 인증자를 사용하여 사용자 인증 정보를 등록하는 방법
  • 등록된 인증자를 사용하여 사용자를 인증하는 방법
  • 새 인증자를 등록하는 데 사용할 수 있는 옵션
  • 생체 인식 센서를 사용한 재인증을 위한 UX 권장사항

다음 단계

  • 웹사이트에서 유사한 환경을 빌드하는 방법을 알아봅니다.

이 기능은 첫 번째 WebAuthn Codelab을 통해 학습할 수 있습니다.

리소스

도움을 주신 FIDO Alliance의 유리 아커만 님께 감사드립니다.