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)에서 노트북에 이 저장소를 클론합니다.
  2. CredentialManager 모듈로 이동하여 Android 스튜디오에서 프로젝트를 엽니다.

앱의 초기 상태 확인

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

  1. 앱을 실행합니다.
  2. 가입 및 로그인 버튼이 있는 기본 화면이 표시됩니다.
  3. 가입 버튼을 누르면 패스키나 비밀번호를 사용하여 가입할 수 있습니다.
  4. 로그인 버튼을 누르면 패스키 및 저장된 비밀번호를 사용하여 로그인할 수 있습니다.

8c0019ff9011950a.jpeg

패스키의 정의와 작동 방식을 알아보려면 패스키 작동 방식을 참고하세요.

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

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

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

패스키로 가입

인증 관리자 내에서 app -> main -> java -> SignUpFragment.kt로 이동하면 'username' 텍스트 필드와 패스키로 가입하는 버튼이 표시됩니다.

dcc5c529b310f2fb.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()는 애셋에서 등록 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을 읽습니다.
  • 이 json에서는 4개 필드를 교체해야 합니다.
  • UserId는 고유해야 하므로 사용자가 여러 패스키를 만들 수 있습니다(필요한 경우). <userId>를 생성된 userId로 교체합니다.
  • <challenge>도 고유해야 하므로 임의의 고유한 챌린지를 생성하게 됩니다. 이 메서드는 이미 코드에 있습니다.

다음 코드 스니펫에는 서버에서 수신하는 샘플 옵션이 포함되어 있습니다.

{
  "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

신뢰 당사자 엔티티는 애플리케이션 세부정보에 해당하며 다음 내용이 필요합니다.

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

pubKeyCredParams

공개 키 사용자 인증 정보 매개변수는 허용된 알고리즘 및 키 유형 목록입니다. 목록에는 하나 이상의 요소가 포함되어야 합니다.

excludeCredentials

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

authenticatorSelection.authenticatorAttachment

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

residentKey

패스키를 만드는 데 '필요한' 값을 나타냅니다.

사용자 인증 정보 만들기

  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 디버그 명령어를 통해 전체 오류 로그를 확인할 수 있습니다.

93022cb87c00f1fc.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로 설정하여 패스키를 통해 로그인한다고 나타냅니다.
  • 로그인한 후에는 사용자를 홈 화면으로 리디렉션합니다.

다음 코드 스니펫에는 수신해야 하는 옵션 예가 포함되어 있습니다.

{
  "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. 사용자 인증 정보 제공업체에 비밀번호 저장

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

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

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

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

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

629001f4a778d4fb.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을 반환하여 이 사용자 계정과 연결된 패스키를 모두 가져오는 메서드입니다.

두 번째 매개변수인 clientDataHash는 신뢰 당사자 신원을 확인하는 데 사용되는 해시이며 GetCredentialRequest.origin이 설정된 경우에만 설정합니다. 샘플 앱에서는 null입니다.

세 번째 매개변수는 원격 사용자 인증 정보 검색으로 대체하는 대신 사용자 인증 정보를 사용할 수 없을 때 즉시 반환되는 작업을 선호하는 경우 true이고 그렇지 않은 경우 false(기본값)입니다.

  • 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()에 전달합니다. 그러면 사용자 인증 정보 옵션 목록과 활동 컨텍스트를 사용하여 컨텍스트 하단 시트의 옵션을 렌더링합니다.
  • 요청이 성공하면 연결된 계정에 관해 생성된 모든 사용자 인증 정보가 화면의 하단 시트에 나열됩니다.
  • 이제 사용자가 생체 인식이나 화면 잠금 등을 통해 신원을 확인하여, 선택한 사용자 인증 정보를 인증할 수 있습니다.
  • setSignedInThroughPasskeys 플래그를 true로 설정하여 패스키를 통해 로그인한다고 나타냅니다. 그렇지 않은 경우에는 false로 설정합니다.
  • 렌더링된 뷰 공개 상태를 처리하고 어떤 이유로 인해 요청이 실패하는 경우 예외를 처리합니다. 여기서 오류 메시지가 기록되고 앱의 오류 대화상자에 표시됩니다. Android 스튜디오나 adb 디버그 명령어를 통해 전체 오류 로그를 확인할 수 있습니다.
  • 끝으로 공개 키 사용자 인증 정보를 서버에 전송하고 사용자 로그인을 허용하여 등록 프로세스를 완료해야 합니다. 패스키를 통해 인증하기 위해 서버로 보낼 수 있는 공개 키가 포함된 사용자 인증 정보 객체를 앱에서 수신합니다.

여기서는 모의 서버를 사용했으므로 서버에서 공개 키를 검증했음을 나타내는 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를 반환합니다.
  • 로그인한 후에는 사용자를 홈 화면으로 리디렉션합니다.

다음 코드 스니펫에는 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 대신 이 값을 사용할 수 있습니다.

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

직접 해 보기

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

6. 축하합니다

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

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

자세히 알아보기