[Compose] SMS Retriever API - SMS 인증번호 자동입력

Google Play Service에는 SMS 기반 확인 프로세스를 간소화하는 데 사용할 수 있는 두 가지 API가 있습니다.

  • SMS Retriever API
  • SMS User Consent API

 

SMS Retriever API는 정말 자동화된 사용자 경험을 제공하므로 가능한 경우 사용하는 것이 좋습니다.

하지만 메시지 본문에 사용자 정의 해시 코드를 입력해야 하며, 해당 메시지를 보낸 사람이 아니라면 이 작업을 하는 것이 어려울 수 있습니다.

 

메시지의 내용을 제어할 수 없는 경우(예: 앱이 앱 내에서 결제 거래를 승인하기 전에 사용자 전화번호를 인증할 수 있는 금융 기관과 협력하는 경우) 커스텀 해시 코드가 필요하지 않은 SMS User Consent API를 사용할 수 있습니다. 

그러나 사용자에게 앱의 인증 코드가 포함된 메시지에 대한 액세스 요청은 승인해야 합니다. 

 

사용자에게 잘못된 메시지가 표시될 가능성을 최소화하기 위해 SMS 사용자 동의 기능은 메시지에 적어도 하나의 숫자를 포함한 4~10자리의 영숫자 코드가 포함되어 있는지 확인합니다.

 

 

 

자동 SMS 인증 흐름

 

SMS Retriever API를 사용하면 사용자가 직접 인증 코드를 입력하지 않아도 되며, 추가 앱 권한이 없어도 Android 앱에서 SMS 기반 사용자 인증을 자동으로 실행할 수 있습니다.

 

앱에서 자동 SMS 인증을 구현하면 인증 흐름은 다음과 같습니다.

 

간단히 흐름을 정리하자면,


1. 전화번호 인증 요청

사용자가 인증할 전화번호를 서버에 전달하며 인증번호 발송을 요청합니다.

2. SMS Retriever API 사용
동시에 앱은 SMS Retriever API를 사용하여 SMS를 리슨 합니다.

3. 서버에서 SMS 발송
서버는 사용자에게 일회용 코드와 앱을 식별하는 해시가 포함된 SMS 메시지를 발송합니다.

4. SMS 수신 및 확인
사용자의 기기에서 SMS 메시지를 수신하면 Google Play 서비스는 앱 해시를 사용하여 메시지가 앱을 대상으로 하는 것인지 확인하고, SMS Retriever API를 통해 메시지 텍스트를 제공합니다.

5. 인증 코드 파싱
앱은 메시지 텍스트에서 일회용 코드(인증 코드)를 파싱하여 서버로 인증 요청을 합니다.

6. 인증 확인  
서버는 앱에서 전달받은 일회용 코드(인증 코드)를 확인한 후 사용자가 계정을 성공적으로 인증했음을 기록합니다.

 

 

구현 방법

필요 버전

minSdkVersion 19 이상
compileSdkVersion 28 또는 이후 버전

 

Dependency

implementation("com.google.android.gms:play-services-auth:21.2.0")
implementation("com.google.android.gms:play-services-auth-api-phone:18.1.0")

 

SMS 규칙

1. SMS 내용은 140byte 이하여야 합니다.
2. SMS 맨 앞은 <#>라는 Text로 시작해야 합니다.
3. SMS 맨 마지막은 앱을 식별하는 11글자 해시 문자열로 끝나야 합니다.

ex) <#> [앱 이름] 인증번호는 [123456] 입니다. bzXF9SEFPHs

규칙 1, 2, 3을 제외한 나머지 부분은 변경해도 상관없습니다.

ex) <#> 인증번호: 123456 bzXF9SEFPHs

 

해시키 구하는 코드

class AppSignatureHelper(context: Context?) : ContextWrapper(context) {

    val appSignatures: List<String>
        get() {
            val appCodes = mutableListOf<String>()

            try {
                val packageName = packageName
                val signatures = getPackageSignatures(packageName)

                signatures.forEach { signature ->
                    hashSignature(packageName, signature)?.let { appCodes.add(it) }
                }
            } catch (e: PackageManager.NameNotFoundException) {
                Log.e(TAG, "패키지를 찾을 수 없어 해시를 가져올 수 없습니다.", e)
            }

            return appCodes
        }


    private fun getPackageSignatures(packageName: String): Array<Signature> {
        val packageManager = packageManager
        return packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures
    }

    private fun hashSignature(packageName: String, signature: Signature): String? {
        return hash(packageName, signature.toCharsString())
    }

    private fun hash(packageName: String, signature: String): String? {
        val appInfo = "$packageName $signature"
        return try {
            val messageDigest = MessageDigest.getInstance(HASH_TYPE)
            messageDigest.update(appInfo.toByteArray(StandardCharsets.UTF_8))
            val hashSignature = messageDigest.digest().take(NUM_HASHED_BYTES).toByteArray()
            encodeToBase64(hashSignature)
        } catch (e: NoSuchAlgorithmException) {
            Log.e(TAG, "해시 알고리즘을 찾을 수 없습니다.", e)
            null
        }
    }

    private fun encodeToBase64(hashSignature: ByteArray): String {
        val base64Hash = Base64.encodeToString(hashSignature, Base64.NO_PADDING or Base64.NO_WRAP)
        return base64Hash.take(NUM_BASE64_CHAR)
    }

    companion object {
        private val TAG: String = AppSignatureHelper::class.java.simpleName
        private const val HASH_TYPE = "SHA-256"
        private const val NUM_HASHED_BYTES = 9
        private const val NUM_BASE64_CHAR = 11
    }
}

 

BroadcastReceiver

첫 번째로 SMS 수신을 확인하기 위해 BroadcastReceiver 코드를 작성.

본인의 SMS 형식에 맞춰 파싱 함수를 수정해야 합니다.

class AuthSmsReceiver : BroadcastReceiver() {

    private var otpReceiver: OtpReceiveListener? = null

    interface OtpReceiveListener {
        fun onSmsReceived(otp: String)
    }

    private fun extractVerificationCode(message: String): String? {
        if (message.contains("app_name")) {
            val regex = """\d{6}""".toRegex()
            return regex.find(message)?.value
        }
        return null
    }

    override fun onReceive(context: Context, intent: Intent) {
        if (SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) {
            intent.extras?.let { bundle ->
                val status = bundle.get(SmsRetriever.EXTRA_STATUS) as Status

                when (status.statusCode) {
                    CommonStatusCodes.SUCCESS -> {
                        val otpSms = bundle.getString(SmsRetriever.EXTRA_SMS_MESSAGE, "")

                        if (otpReceiver != null && otpSms.isNotEmpty()) {
                            val otp = extractVerificationCode(otpSms)
                            if (!otp.isNullOrEmpty()) {
                                otpReceiver!!.onSmsReceived(otp)
                            }
                        }
                    }
                }
            }
        }
    }

    fun setSmsListener(receiver: OtpReceiveListener) {
        this.otpReceiver = receiver
    }

    fun doFilter() = IntentFilter().apply {
        addAction(SmsRetriever.SMS_RETRIEVED_ACTION)
    }
}

 

View & ViewModel

많은 예제들이 Activity에서 구현하고 있지만, 저는 프로젝트에서 특정 뷰에서만 사용하기를 원했고

Multi Module + Single Activity 구조의 프로젝트였기에 app 모듈에 있는 MainActivity를 가져오기 힘들었습니다.

때문에 LocalContext.current를 통해 Context를 가져와서 activity로 캐스팅하고 ViewModel에 매개변수로 넘겨 사용했습니다.

 

DisposableEffect를 사용해 activity의 smsReceiver를 생성 및 제거했습니다.

DisposableEffect(LocalLifecycleOwner.current) {
    (context as? Activity)?.let { viewModel.startSmsRetriever(it) }
    onDispose { (context as? Activity)?.let { viewModel.stopSmsRetriever() } }
}

 

 

ViewModel에서는 AuthSmsReceiver.OtpReceiveListener를 상속받아 구현했습니다.

그냥 apply에서 this.setSmsListener(object: AuthSmsReceiver.OtpReceiveListener { /* override fun onSmsReceived... */})로 작성해도 상관없습니다.

    private var smsReceiver: AuthSmsReceiver? = null
    private var weakActivity: WeakReference<Activity>? = null

    @SuppressLint("UnspecifiedRegisterReceiverFlag")
    fun startSmsRetriever(activity: Activity) {
        weakActivity = WeakReference(activity)
        SmsRetriever.getClient(activity).startSmsRetriever().also { task ->
            task.addOnSuccessListener {
                if (smsReceiver == null) {
                    smsReceiver = AuthSmsReceiver().apply {
                        setSmsListener(this@MobileSendAuthViewModel)
                    }
                }

                if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE){
                    weakActivity?.get()?.registerReceiver(smsReceiver, smsReceiver!!.doFilter(), RECEIVER_EXPORTED)
                } else {
                    weakActivity?.get()?.registerReceiver(smsReceiver, smsReceiver!!.doFilter())
                }
            }

            task.addOnFailureListener {
                setEffect { AuthEffect.ShowToast(
                    message = it.message.toString(),
                    type = SnackBarType.ERROR
                ) }
            }
        }
    }

    override fun onSmsReceived(otp: String) {
        setAction(AuthAction.AutoFillCode(otp))
        stopSmsRetriever()
    }

    fun stopSmsRetriever() {
        weakActivity?.get()?.let { activity ->
            if (smsReceiver != null) {
                activity.unregisterReceiver(smsReceiver)
                smsReceiver = null
            }
        }
    }

 

 

여기서 주의할 점은 API 34부터 registerReceiver를 사용할 때

RECEIVER_EXPORTED 또는 RECEIVER_NOT_EXPORTED를 넣어줘야 합니다.

해당 값을 넣어주지 않으면 아래와 같은 Exception이 발생한다고 합니다.

java.lang.SecurityException: package: One of RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED should be specified when a receiver isn’t being registered exclusively for system broadcasts

해당 내용에 대한 자세한 설명은 여기를 참고해 주세요.

 

 

예시 화면

 

인증 코드 재전송 시 다시 Receive를 받기 위해서는 기존 receiver를 제거하고 새로운 receiver를 등록해야지만 정상적으로 동작합니다.

 

 

또 다른 방법으로는 AndroidManifest.xml에서 아래처럼 Receiver를 정적으로 등록해도 동작합니다.

다만, 특정 뷰에서만 해당 기능이 필요하기 때문에 동적으로 Receiver를 등록하는 게 좋을 것 같습니다.

<receiver
    android:name="com.weave.intro.utils.AuthSmsReceiver"
    android:exported="true"
    android:permission="com.google.android.gms.auth.api.phone.permission.SEND">
    <intent-filter>
        <action android:name="com.google.android.gms.auth.api.phone.SMS_RETRIEVED"/>
    </intent-filter>
</receiver>