버전 2024년 4분기: Android 앱에서 Credential Manager API를 사용하여 인증 과정을 간소화하는 방법 알아보기

1. 시작하기 전에

기존 인증 솔루션은 여러 보안 및 사용성 문제가 있습니다.

비밀번호는 널리 사용되지만 다음과 같은 문제가 있습니다.

  • 쉽게 잊어버림
  • 안전한 비밀번호를 만들려면 사용자에게 지식이 요구됨
  • 공격자가 피싱하고 수집하고 재생하기 쉬움

Android에서는 비밀번호 없는 인증의 차세대 업계 표준인 패스키를 지원하여 로그인 환경을 간소화하고 보안 위험을 해결하는 Credential Manager API를 만들기 위해 노력했습니다.

인증 관리자는 패스키 지원을 통합하고 이를 비밀번호, Google 계정으로 로그인 등 기존 인증 방법과 결합합니다.

사용자는 패스키를 만들어 Google 비밀번호 관리자에 저장할 수 있으며 Google 비밀번호 관리자는 이러한 패스키를 사용자가 로그인하는 다양한 Android 기기에서 동기화합니다. 패스키를 생성하여 사용자 계정과 연결해야 하며, 공개 키를 서버에 저장해야 사용자가 로그인할 수 있습니다.

이 Codelab에서는 Credential Manager API를 사용하여 패스키와 비밀번호로 가입하고 이를 향후 인증용으로 사용하는 방법을 알아봅니다. 다음 두 가지 흐름이 있습니다.

  • 가입: 패스키와 비밀번호 사용
  • 로그인: 패스키와 저장된 비밀번호 사용

기본 요건

  • Android 스튜디오에서 앱을 실행하는 방법에 관한 기본 이해
  • Android 앱의 인증 흐름에 관한 기본 이해
  • 패스키에 관한 기본 이해

학습할 내용

  • 패스키를 생성하는 방법
  • 비밀번호 관리자에 비밀번호를 저장하는 방법
  • 패스키나 저장된 비밀번호로 사용자를 인증하는 방법

필요한 항목

다음 기기 조합 중 하나:

  • Android 9 이상(패스키용) 및 Android 4.4 이상(Credential Manager API를 통한 비밀번호 인증용)을 실행하는 Android 기기
  • 생체 인식 센서가 적용된 기기면 더 좋음
  • 생체 인식(또는 화면 잠금)을 등록해야 함
  • Kotlin 플러그인 버전: 1.8.10

2. 설정

  1. credman_codelab 브랜치(https://github.com/android/identity-samples/tree/credman_codelab)에서 노트북에 이 저장소를 클론합니다.
git clone -b credman_codelab https://github.com/android/identity-samples.git
  1. CredentialManager 모듈로 이동하여 Android 스튜디오에서 프로젝트를 엽니다.

앱의 초기 상태 확인

앱의 초기 상태가 어떻게 작동하는지 확인하려면 다음 단계를 따르세요.

  1. 앱을 실행합니다.
  2. 가입 및 로그인 버튼이 있는 기본 화면이 표시됩니다. 이 버튼은 아직 아무것도 하지 않지만 다음 섹션에서 기능을 사용 설정할 예정입니다.

7a6fe80f4cf877a8.jpeg

3. 패스키를 사용한 가입 기능 추가

Credential Manager API를 사용하는 Android 앱에서 새 계정에 가입할 때 사용자는 계정의 패스키를 만들 수 있습니다. 이 패스키는 사용자가 선택한 사용자 인증 정보 제공업체에 안전하게 저장되어 향후 로그인에 사용되므로 사용자가 매번 비밀번호를 입력하지 않아도 됩니다.

이제 패스키를 만들고 생체 인식/화면 잠금을 사용하여 사용자 인증 정보를 등록합니다.

패스키로 가입

Credential Manager/app/main/java/SignUpFragment.kt 내의 코드는 'username' 텍스트 필드와 패스키로 가입하는 버튼을 정의합니다.

1f4c50daa2551f1.jpeg

챌린지와 기타 json 응답을 createPasskey() 호출에 전달

패스키를 만들기 전에 createCredential 호출 중 Credential Manager API에 전달할 필요한 정보를 서버에 요청해야 합니다.

이 Codelab에서 필요한 매개변수를 반환하는 모의 응답(RegFromServer.txt)이 프로젝트의 애셋에 이미 있습니다.

  • 앱에서 SignUpFragment.kt로 이동하여 signUpWithPasskeys 메서드를 찾습니다. 여기서 패스키 생성 및 사용자 가입을 위한 로직을 작성합니다. 동일한 클래스에서 이 메서드를 찾을 수 있습니다.
  • createPasskey()를 호출하는 주석이 있는 else 블록을 확인하고 다음 코드로 바꿉니다.

SignUpFragment.kt

//TODO : Call createPasskey() to signup with passkey

val data = createPasskey()

이 메서드는 유효한 사용자 이름이 화면에 입력되면 호출됩니다.

  • createPasskey() 메서드 내에서 필요한 매개변수가 반환된 CreatePublicKeyCredentialRequest()를 만들어야 합니다.

SignUpFragment.kt

//TODO create a CreatePublicKeyCredentialRequest() with necessary registration json from server

val request = CreatePublicKeyCredentialRequest(fetchRegistrationJsonFromServer())

fetchRegistrationJsonFromServer() 메서드는 애셋에서 에뮬레이션된 서버 PublicKeyCredentialCreationOptions JSON 응답을 읽고 패스키를 생성하는 동안 전달할 등록 JSON을 반환합니다.

  • fetchRegistrationJsonFromServer() 메서드를 찾고 TODO를 다음 코드로 대체하여 JSON을 반환하고 빈 문자열 return 문도 삭제합니다.

SignUpFragment.kt

//TODO fetch registration mock response

val response = requireContext().readFromAsset("RegFromServer")

//Update userId,challenge, name and Display name in the mock
return response.replace("<userId>", getEncodedUserId())
   .replace("<userName>", binding.username.text.toString())
   .replace("<userDisplayName>", binding.username.text.toString())
   .replace("<challenge>", getEncodedChallenge())
  • 이 JSON은 불완전하며 교체해야 할 필드가 4개 있습니다.
  • UserId는 고유해야 하므로 사용자가 여러 패스키를 만들 수 있습니다(필요한 경우). <userId>를 생성된 userId 값으로 바꿉니다.
  • <challenge>도 고유해야 하므로 임의의 고유한 챌린지를 생성하게 됩니다. 이 메서드는 이미 코드에 있습니다.

실제 서버 PublicKeyCredentialCreationOptions 응답은 더 많은 옵션을 반환할 수 있습니다. 이러한 필드의 예는 다음과 같습니다.

{
  "challenge": String,
  "rp": {
    "name": String,
    "id": String
  },
  "user": {
    "id": String,
    "name": String,
    "displayName": String
  },
  "pubKeyCredParams": [
    {
      "type": "public-key",
      "alg": -7
    },
    {
      "type": "public-key",
      "alg": -257
    }
  ],
  "timeout": 1800000,
  "attestation": "none",
  "excludeCredentials": [],
  "authenticatorSelection": {
    "authenticatorAttachment": "platform",
    "requireResidentKey": true,
    "residentKey": "required",
    "userVerification": "required"
  }
}

다음 표에서는 PublicKeyCredentialCreationOptions 객체의 몇 가지 중요한 매개변수를 설명합니다.

매개변수

설명

challenge

엔트로피를 충분히 포함하여 추측을 불가능하게 하는 서버 생성 임의 문자열입니다. 길이는 16바이트 이상이어야 합니다. 이는 필수이지만 증명을 실행하지 않는 한 등록 중에 사용되지 않습니다.

user.id

사용자의 고유 ID입니다. 이 값에는 이메일 주소나 사용자 이름 등 개인 식별 정보가 포함되면 안 됩니다. 계정당 생성된 임의의 16바이트 값이 좋습니다.

user.name

이 필드에는 이메일 주소나 사용자 이름 등 사용자가 인식할 계정의 고유 식별자가 있어야 합니다. 이것은 계정 선택기에 표시됩니다. 사용자 이름을 사용하는 경우 비밀번호 인증에서와 동일한 값을 사용하세요.

user.displayName

이 필드는 선택사항이며 좀 더 사용자 친화적인 계정 이름입니다.

rp.id

신뢰 당사자 엔티티는 애플리케이션 세부정보에 해당합니다. 다음과 같은 속성이 있습니다.

  • name (필수): 애플리케이션 이름
  • ID (선택사항): 도메인 또는 하위 도메인에 해당합니다. 없으면 현재 도메인이 사용됩니다.
  • icon(선택사항):

pubKeyCredParams

허용된 알고리즘 및 키 유형 목록 목록에는 하나 이상의 요소가 포함되어야 합니다.

excludeCredentials

기기를 등록하려는 사용자가 다른 기기를 등록했을 수 있습니다. 단일 인증자에서 동일한 계정의 사용자 인증 정보를 여러 개 생성하는 것을 제한하려면 이러한 기기를 무시하면 됩니다. 제공되는 경우 transports 구성원에는 각 사용자 인증 정보를 등록하는 동안 getTransports()를 호출한 결과가 포함되어야 합니다.

authenticatorSelection.authenticatorAttachment

기기를 플랫폼에 연결해야 하는지 또는 이에 관한 요구사항이 없는지 나타냅니다. 이 값을 platform로 설정합니다. 이는 플랫폼 기기에 삽입된 인증자가 필요하며 사용자에게 USB 보안 키 등을 삽입하라는 메시지가 표시되지 않음을 나타냅니다.

residentKey

패스키를 만들려면 값 required를 나타냅니다.

사용자 인증 정보 만들기

  1. CreatePublicKeyCredentialRequest()를 만든 후에는 생성된 요청과 함께 createCredential()를 호출해야 합니다.

SignUpFragment.kt

//TODO call createCredential() with createPublicKeyCredentialRequest

try {
   response = credentialManager.createCredential(
       requireActivity(),
       request
   ) as CreatePublicKeyCredentialResponse
} catch (e: CreateCredentialException) {
   configureProgress(View.INVISIBLE)
   handlePasskeyFailure(e)
}
  • 필수 정보를 createCredential()에 전달합니다.
  • 요청이 성공하면 패스키를 만들라는 메시지가 화면 하단 시트에 표시됩니다.
  • 이제 사용자가 생체 인식이나 화면 잠금 등을 통해 신원을 확인할 수 있습니다.
  • 렌더링된 뷰 공개 상태를 처리하고 어떤 이유로 인해 요청이 실패하는 경우 예외를 처리합니다. 여기서 오류 메시지가 기록되고 앱의 오류 대화상자에 표시됩니다. Android 스튜디오나 adb debug 명령어를 통해 전체 오류 로그를 확인할 수 있습니다.

1ea8ace66135de1e.png

  1. 마지막으로 등록 절차를 완료해야 합니다. 앱은 공개 키 사용자 인증 정보를 서버로 전송하고 서버는 이를 현재 사용자에게 등록합니다.

여기서는 모의 서버를 사용했으므로 서버가 등록된 공개 키를 향후 인증 및 검증용으로 저장했음을 나타내는 true를 반환합니다. 자체 구현을 위해 서버 측 패스키 등록에 대해 자세히 알아보세요.

signUpWithPasskeys() 메서드 내에서 관련 주석을 찾아 다음 코드로 바꿉니다.

SignUpFragment.kt

//TODO : complete the registration process after sending public key credential to your server and let the user in

data?.let {
   registerResponse()
   DataProvider.setSignedInThroughPasskeys(true)
   listener.showHome()
}
  • registerResponse()는 모의 서버가 향후 사용을 위해 공개 키를 저장했음을 나타내는 true를 반환합니다.
  • setSignedInThroughPasskeys 플래그를 true로 설정합니다.
  • 로그인한 후에는 사용자를 홈 화면으로 리디렉션합니다.

실제 PublicKeyCredential에는 더 많은 필드가 포함될 수 있습니다. 이러한 필드의 예는 다음과 같습니다.

{
  "id": String,
  "rawId": String,
  "type": "public-key",
  "response": {
    "clientDataJSON": String,
    "attestationObject": String,
  }
}

다음 표에서는 PublicKeyCredential 객체의 몇 가지 중요한 매개변수를 설명합니다.

매개변수

설명

id

생성된 패스키의 Base64URL로 인코딩된 ID입니다. 이 ID는 인증 시 브라우저에서 기기에 일치하는 패스키가 있는지 확인하는 데 도움이 됩니다. 이 값은 백엔드의 데이터베이스에 저장해야 합니다.

rawId

사용자 인증 정보 ID의 ArrayBuffer 객체 버전입니다.

response.clientDataJSON

ArrayBuffer 객체로 인코딩된 클라이언트 데이터입니다.

response.attestationObject

ArrayBuffer로 인코딩된 증명 객체입니다. RP ID, 플래그, 공개 키와 같은 중요한 정보가 포함되어 있습니다.

앱을 실행하면 패스키로 가입 버튼을 클릭하여 패스키를 만들 수 있습니다.

4. 사용자 인증 정보 제공업체에 비밀번호 저장

이 앱의 가입 화면에는 이미 사용자 이름과 비밀번호를 사용한 가입이 데모용으로 구현되어 있습니다.

비밀번호 제공업체와 함께 사용자 비밀번호 사용자 인증 정보를 저장하려면 CreatePasswordRequest를 구현하여 createCredential()에 전달하여 비밀번호를 저장합니다.

  • signUpWithPassword() 메서드를 찾아 TODO를 createPassword 호출로 바꿉니다.

SignUpFragment.kt

//TODO : Save the user credential password with their password provider

createPassword()
  • createPassword() 메서드 내에서 이와 같은 비밀번호 요청을 만들고 TODO를 다음 코드로 바꿔야 합니다.

SignUpFragment.kt

//TODO : CreatePasswordRequest with entered username and password

val request = CreatePasswordRequest(
   binding.username.text.toString(),
   binding.password.text.toString()
)
  • 그런 다음 createPassword() 메서드 내에서 비밀번호 생성 요청으로 사용자 인증 정보를 만들고 사용자 비밀번호 사용자 인증 정보를 비밀번호 제공업체와 함께 저장합니다. TODO를 다음 코드로 바꿉니다.

SignUpFragment.kt

//TODO : Create credential with created password request


try {
   credentialManager.createCredential(requireActivity(), request) as CreatePasswordResponse
} catch (e: Exception) {
   Log.e("Auth", " Exception Message : " + e.message)
}
  • 이제 탭 한 번으로 비밀번호로 인증하도록 사용자의 비밀번호 제공업체와 함께 비밀번호 사용자 인증 정보를 저장했습니다.

5. 패스키나 비밀번호를 사용한 인증 기능 추가

이제 앱에 안전하게 인증하기 위한 방법으로 이를 사용할 수 있습니다.

76e81460b26f9798.png

챌린지와 기타 옵션을 획득하여 getPasskey() 호출에 전달

사용자에게 인증을 요청하기 전에 챌린지를 포함하여 서버에서 WebAuthn JSON을 전달할 매개변수를 요청해야 합니다.

이 Codelab에서는 이러한 매개변수를 반환하는 모의 응답이 애셋 (AuthFromServer.txt)에 이미 있습니다.

  • 앱에서 SignInFragment.kt로 이동하여 signInWithSavedCredentials 메서드를 찾아 저장된 패스키나 비밀번호를 통해 인증하는 로직을 작성해 사용자의 로그인을 허용합니다.
  • createPasskey()를 호출하는 주석이 있는 else 블록을 확인하고 다음 코드로 바꿉니다.

SignInFragment.kt

//TODO : Call getSavedCredentials() method to signin using passkey/password

val data = getSavedCredentials()
  • getSavedCredentials() 메서드 내에서 사용자 인증 정보 제공업체에서 사용자 인증 정보를 가져오는 데 필요한 매개변수를 사용하여 GetPublicKeyCredentialOption()를 만들어야 합니다.

SigninFragment.kt

//TODO create a GetPublicKeyCredentialOption() with necessary registration json from server

val getPublicKeyCredentialOption =
   GetPublicKeyCredentialOption(fetchAuthJsonFromServer(), null)

fetchAuthJsonFromServer() 메서드는 애셋에서 인증 JSON 응답을 읽고 인증 JSON을 반환하여 이 사용자 계정과 연결된 모든 패스키를 가져옵니다.

GetPublicKeyCredentialOption()의 두 번째 매개변수인 clientDataHash는 신뢰 당사자 ID를 확인하는 데 사용되는 해시입니다. GetCredentialRequest.origin를 설정한 경우에만 설정합니다. 샘플 앱에서는 null로 설정됩니다.

  • fetchAuthJsonFromServer() 메서드를 찾고 TODO를 다음 코드로 대체하여 json을 반환하고 빈 문자열 return 문도 삭제합니다.

SignInFragment.kt

//TODO fetch authentication mock json

return requireContext().readFromAsset("AuthFromServer")

참고: 이 Codelab의 서버는 API의 getCredential() 호출에 전달된 PublicKeyCredentialRequestOptions 사전과 최대한 유사한 JSON을 반환하도록 설계되었습니다. 다음 코드 스니펫에는 실제 응답에서 수신할 수 있는 옵션 예가 포함되어 있습니다.

{
  "challenge": String,
  "rpId": String,
  "userVerification": "",
  "timeout": 1800000
}

다음 표에서는 PublicKeyCredentialRequestOptions 객체의 몇 가지 중요한 매개변수를 설명합니다.

매개변수

설명

challenge

ArrayBuffer 객체의 서버 생성 챌린지입니다. 재전송 공격을 예방하기 위해 필요합니다. 답변에서 동일한 챌린지를 두 번 수락하지 마세요. 이것을 CSRF 토큰으로 간주합니다.

rpId

RP ID는 도메인입니다. 웹사이트에서 도메인 또는 등록 가능한 접미사를 지정할 수 있습니다. 이 값은 패스키 생성 시 사용된 rp.id 매개변수와 일치해야 합니다.

  • 이제 PasswordOption() 객체를 만들어 이 사용자 계정의 Credential Manager API를 통해 비밀번호 제공업체에 저장된 모든 비밀번호를 가져와야 합니다. getSavedCredentials() 메서드 내에서 TODO를 찾아 다음과 같이 바꿉니다.

SigninFragment.kt

//TODO create a PasswordOption to retrieve all the associated user's password

val getPasswordOption = GetPasswordOption()

사용자 인증 정보 가져오기

  • 이제 위의 모든 옵션과 함께 getCredential() 요청을 호출하여 연결된 사용자 인증 정보를 가져와야 합니다.

SignInFragment.kt

//TODO call getCredential() with required credential options

val result = try {
   credentialManager.getCredential(
       requireActivity(),
       GetCredentialRequest(
           listOf(
               getPublicKeyCredentialOption,
               getPasswordOption
           )
     )
   )
} catch (e: Exception) {
   configureViews(View.INVISIBLE, true)
   Log.e("Auth", "getCredential failed with exception: " + e.message.toString())
   activity?.showErrorAlert(
       "An error occurred while authenticating through saved credentials. Check logs for additional details"
   )
   return null
}

if (result.credential is PublicKeyCredential) {
   val cred = result.credential as PublicKeyCredential
   DataProvider.setSignedInThroughPasskeys(true)
   return "Passkey: ${cred.authenticationResponseJson}"
}
if (result.credential is PasswordCredential) {
   val cred = result.credential as PasswordCredential
   DataProvider.setSignedInThroughPasskeys(false)
   return "Got Password - User:${cred.id} Password: ${cred.password}"
}
if (result.credential is CustomCredential) {
   //If you are also using any external sign-in libraries, parse them here with the utility functions provided.
}

  • 필수 정보를 getCredential()에 전달합니다. 그러면 사용자 인증 정보 옵션 목록과 활동 컨텍스트를 사용하여 컨텍스트 하단 시트의 옵션을 렌더링합니다.
  • 요청이 성공하면 연결된 계정에 관해 생성된 모든 사용자 인증 정보가 화면의 하단 시트에 나열됩니다.
  • 이제 사용자가 생체 인식이나 화면 잠금 등을 통해 신원을 확인하여, 선택한 사용자 인증 정보를 인증할 수 있습니다.
  • 선택한 사용자 인증 정보가 PublicKeyCredential인 경우 setSignedInThroughPasskeys 플래그를 true로 설정합니다. 그렇지 않으면 false로 설정합니다.

다음 코드 스니펫에는 PublicKeyCredential 객체의 예시가 포함되어 있습니다.

{
  "id": String
  "rawId": String
  "type": "public-key",
  "response": {
    "clientDataJSON": String
    "authenticatorData": String
    "signature": String
    "userHandle": String
  }
}

다음 표에 모든 내용이 포함되지는 않지만 PublicKeyCredential 객체에 있는 중요한 매개변수가 포함되어 있습니다.

매개변수

설명

id

인증된 패스키 사용자 인증 정보의 Base64URL로 인코딩된 ID입니다.

rawId

사용자 인증 정보 ID의 ArrayBuffer 객체 버전입니다.

response.clientDataJSON

클라이언트 데이터의 ArrayBuffer 객체입니다. 이 입력란에는 챌린지 및 RP 서버가 확인해야 하는 출처 등의 정보가 포함됩니다.

response.authenticatorData

인증자 데이터의 ArrayBuffer 객체입니다. 이 입력란에는 RP ID와 같은 정보가 포함됩니다.

response.signature

서명의 ArrayBuffer 객체입니다. 이 값은 사용자 인증 정보의 핵심이며 서버에서 확인을 받아야 합니다.

response.userHandle

생성 시 설정된 사용자 ID를 포함하는 ArrayBuffer 객체입니다. 서버에서 사용하는 ID 값을 선택해야 하거나 백엔드에서 사용자 인증 정보 ID의 색인 생성을 피하려는 경우 사용자 인증 정보 ID 대신 이 값을 사용할 수 있습니다.

  • 마지막으로 인증 절차를 완료해야 합니다. 일반적으로 사용자가 패스키 인증을 완료하면 앱은 인증 어설션이 포함된 공개 키 사용자 인증 정보를 서버로 전송하여 어설션을 확인하고 사용자를 인증합니다.

여기서는 모의 서버를 사용했으므로 서버에서 어설션을 확인했음을 나타내는 true를 반환합니다. 자체 구현을 위해 서버 측 패스키 인증에 대해 자세히 알아보세요.

signInWithSavedCredentials() 메서드 내에서 관련 주석을 찾아 다음 코드로 바꿉니다.

SignInFragment.kt

//TODO : complete the authentication process after validating the public key credential to your server and let the user in.

data?.let {
   sendSignInResponseToServer()
   listener.showHome()
}
  • sendSigninResponseToServer()는 (모의) 서버에서 향후 사용을 위해 공개 키를 검증했음을 나타내는 true를 반환합니다.
  • 로그인한 후에는 사용자를 홈 화면으로 리디렉션합니다.

앱을 실행하고 로그인 > 패스키/저장된 비밀번호로 로그인으로 이동하여 저장된 사용자 인증 정보를 사용하여 로그인해 보세요.

직접 해 보기

패스키를 생성하고 인증 관리자에 비밀번호를 저장하고 Android 앱에서 Credential Manager API를 사용하여 패스키나 저장된 비밀번호를 통해 인증하는 작업을 구현했습니다.

6. 축하합니다

이 Codelab을 완료했습니다. 최종 해결 방법은 https://github.com/android/identity-samples/tree/main/CredentialManager에서 확인할 수 있습니다.

궁금한 점이 있다면 StackOverflow에서 passkey 태그를 사용하여 질문해 주세요.

자세히 알아보기