본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Android] SMS Retriever API - SMS 권한 없이 인증번호 읽어오기!

by 돼지왕 왕돼지 2019. 1. 18.
반응형

[Android] SMS Retriever API - SMS 권한 없이 인증번호 읽어오기!


https://developers.google.com/identity/sms-retriever/

accounttype, CommonStatusCodes, credentialrequest, credentialsapi, cretential, Example, EXTRA_SMS_MESSAGE, EXTRA_STATUS, get phonenumber without permission, gethintpickerintent, google play service, google play service version, gps version, hash, hash condition, hash string, hasresolution, hintrequest, keytool, Name, onsuccesslistener, package name, package name and sign key, phonenumber, Prerequisites, public key cert, read sms without permission, READ_PHONE_STATE, READ_SMS, request, sample, Sample Code, Save, setaccounttype, setphonenumberidentifiersupported, sign key, sms permission, sms retriever api, sms verification without permission, smsbroadcastreceiver, smsretrieverclient, SMS_RETRIEVED_ACTION, startresolutionforresult, startsmsretriever, timeout, user selection, [Android] SMS Retriever API - SMS 권한 없이 인증번호 읽어오기!


-

SMS Retriever API 를 사용하면 SMS Permission 이 없어도 인증번호를 읽어올 수 있다.

덧붙여 SMS Retriever API 는 SMS Verification 에 대한 녀석으로 권한 없이 “PhoneNumber” 까지도 얻어올 수 있다.

그러나 예상했겠지만, 아무것도 하지 않고 그냥 읽어올 수 있는 것은 아니다.


우선 SMS 정보 읽어오기의 Key Idea 는.. 

Server 에서 인증코드를 내려줄 때 특정 Hash 값을 함께 내려주고, ( app 의 package name 과 sign key 조합 )

Client 에서는 Server 에 SMS 를 요청한 후 SMS Retriever API 를 사용하면, 내부에서 자체적으로 hash 값을 구한다.

그럼 SMS Retriever 에서는 해당 Hash 값이 있는 문자에 대해서만 Reading 을 가능하게 하는 그런 것이다.


PhoneNumber 읽어오기의 Key Idea 는..

우리가 맘대로 읽어오고 끝이 아니라, User 에게 Selection 을 제공하는 형태이다.



-

가입동선에서 SMS Verification 을 하는 경우가 많은데, 이 목적을 위해서만 READ_PHONE_STATE 나 READ_SMS Permission 을 요청하는 것은 부담스러울 수 있다.

그렇다고 User 가 전화번호를 직접 Typing 하거나, 문자를 수신한 후 문자를 보고 직접 Typing 하도록 하는 것도 꽤 별로인 상황인지라 이 녀석을 이용하면 좋을 것 같다.



-

이 녀석의 Prerequisites 는 눈여겨볼만하다.

Android 단말에 Google Play Service Version 10.2 이상이 설치되어 있어야 한다.

https://developers.google.com/android/guides/releases 에 따르면 2017년 2월에 10.2 버전이 Release 되었다.


그러나 모든 단말에서 10.2 이상이 깔려 있을것이란 보장을 할 수 없고,

AOSP ( 중국 샤오미 단말 등 ) 단말들에는 Google 관련된 기능이 설치가 되어 있지 않을 확률이 아주 높다.



-

Phone Number 얻어오기 -> User 가 PhoneNumber 를 선택하는 Popup 이 뜬다.

이를 통해 직접 입력하는 수고가 덜어진다. (직접 입력하며 typo 가 발생하는 상황도 방지한다.)

private void requestHint() {
    HintRequest hintRequest = new HintRequest.Builder()
           .setPhoneNumberIdentifierSupported(true)
           .build();
    PendingIntent intent = Auth.CredentialsApi.getHintPickerIntent(apiClient, hintRequest);
    startIntentSenderForResult(intent.getIntentSender(), RESOLVE_HINT, null, 0, 0, 0);
}

// Obtain the phone number from the result
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
  super.onActivityResult(requestCode, resultCode, data);
  if (requestCode == RESOLVE_HINT) {
      if (resultCode == RESULT_OK) {
          Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY);
          String phoneNumber = credential.getId(); // string
      }
  }
}


-

SMS 읽어오는 Callback 등록

private void startSmsRetriever(){
  // Get an instance of SmsRetrieverClient, used to start listening for a matching SMS message.
  SmsRetrieverClient client = SmsRetriever.getClient(this); // this = context
  
  // Starts SmsRetriever, which waits for ONE matching SMS message until timeout
  // (5 minutes). The matching SMS message will be sent via a Broadcast Intent with
  // action SmsRetriever#SMS_RETRIEVED_ACTION.
  Task<Void> task = client.startSmsRetriever();
  
  // Listen for success/failure of the start Task. If in a background thread, this
  // can be made blocking using Tasks.await(task, [timeout]);
  task.addOnSuccessListener(new OnSuccessListener<Void>() {
    @Override
    public void onSuccess(Void aVoid) {
      // Successfully started retriever, expect broadcast intent
      registerSmsBroadcastReceiver();
    }
  });
  
  task.addOnFailureListener(new OnFailureListener() {
    @Override
    public void onFailure(@NonNull Exception e) {
      // Failed to start retriever, inspect Exception for more details
      // ...
    }
  });
}

/**
 * BroadcastReceiver to wait for SMS messages. This can be registered either
 * in the AndroidManifest or at runtime.  Should filter Intents on
 * SmsRetriever.SMS_RETRIEVED_ACTION.
 */
public class MySMSBroadcastReceiver extends BroadcastReceiver {

  @Override
  public void onReceive(Context context, Intent intent) {
    if (SmsRetriever.SMS_RETRIEVED_ACTION.equals(intent.getAction())) {
      Bundle extras = intent.getExtras();
      Status status = (Status) extras.get(SmsRetriever.EXTRA_STATUS);

      switch(status.getStatusCode()) {
        case CommonStatusCodes.SUCCESS:
          // Get SMS message contents
          String message = (String) extras.get(SmsRetriever.EXTRA_SMS_MESSAGE);
          // Extract one-time code from the message and complete verification
          // by sending the code back to your server.
          break;
        case CommonStatusCodes.TIMEOUT:
          // Waiting for SMS timed out (5 minutes)
          // Handle the error ...
          break;
      }
    }
  }
}

private registerSmsBroadcastReceiver(){
    // in real impl, for unregister, MySMSBroadcastReceiver should be member variable assigned
    registerBroadcastReceiver(new MySMSBroadcastReceiver(), new IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION));
}






-

전화번호를 받아오는 절차를 수행한 후 user 에게 SmartLock 에 저장할 지 prompt 를 띄울 수 있다.

1회에 한해 agree 를 받으면 이후에 동의 없이 해당 단말은 물론 다른 단말에서도 phoneNumber 를 쉽게 구해올 수 있다.

동의 없이 구해 오는 것은 앱 재설치는 물론, 다른 단말에 설치시에도 유효하다.

단 주의할 점은.. 받아온 전화번호를 다시 쓰는 것이기 때문에 USIM 이 다른 케이스(실제 전화번호가 바뀐 케이스)에도 유효한지는 테스트가 필요해 보인다.

// Save
Credential credential = new Credential.Builder(phoneNumberString)
        .setAccountType("https://signin.example.com")  // a URL specific to the app
        .setName(displayName)  // optional: a display name if available
        .build();
Auth.CredentialsApi.save(apiClient, credential).setResultCallback(
            new ResultCallback() {
                public void onResult(Result result) {
                    Status status = result.getStatus();
                    if (status.isSuccess()) {
                        Log.d(TAG, "SAVE: OK");  // already saved
                    } else if (status.hasResolution()) {
                        // Prompt the user to save
                        status.startResolutionForResult(this, RC_SAVE);
                    }
                }
            });

// On the next install, retrieve the phone number
mCredentialRequest = new CredentialRequest.Builder()
    .setAccountTypes("https://signin.example.com")  // the URL specific to the developer
    .build();
Auth.CredentialsApi.request(apiClient, mCredentialRequest).setResultCallback(
    new ResultCallback<CredentialRequestResult>() {
        public void onResult(CredentialRequestResult credentialRequestResult) {
            if (credentialRequestResult.getStatus().isSuccess()) {
                credentialRequestResult.getCredential().getId();  // this is the phone number
            }
        }
    });

// Then, initiate verification and sign the user in (same as original verification logic)


-

Server 사이드에서 할 것은 인증 메시지에 Hash 를 넣는 것이다.

그 메시지에는 조건이 있는데...


1. 140 bytes 이하

2. string 의 시작이 <#>(11.2  이상) 또는 zero-width space char(U+200B) 2개

3. one time code(인증코드) 를 포함할 것

4. 11자리의 hash string이 마지막에 붙어있어야 한다.


위의 조건이 만족되어야만 SMS Retriever 에서 제대로 파싱 할 수 있다.

ex)

<#> Your ExampleApp code is: 123ABC78

FA+9qCX9VSu



-

Hash String 은 package name 과 sign key ( public key cert ) 조합으로 생성된다.


1. 우선 app 의 public key cert 를 구해오는 것은 아래와 같이 한다

$ keytool -alias MyAndroidKey -exportcert -keystore MyProduction.keystore | xxd -p | tr -d "[:space:]”


2. packageName + singleSpace + publicKeyCert string 을 만든다.


3. 위의 조합을 SHA-256 sum 으로 계산한다.


4. SHA-256 sum 을 Base64 로 encoding 한다. 이 결과물이 11자리의 최종 hash 이다.


위의 과정은 아래 명령어 하나로도 생성 가능하다

$ keytool -exportcert -alias MyAndroidKey -keystore MyProductionKeys.keystore | xxd -p | tr -d "[:space:]" | echo -n com.example.myapp `cat` | sha256sum | tr -d "[:space:]-" | xxd -r -p | base64 | cut -c1-11



-

위의 과정을 거치지 않고 SMS Retriever 의 sample app 에 있는 AppSignatureHelper 를 통해서도 hash 값을 구할 수 있다.



-

끝!




반응형

댓글