ในทุกวันนี้แอปส่วนใหญ่นั้นจะมาพร้อมกับระบบ Authentication ที่มีรูปแบบแตกต่างกันออกไป ไม่ว่าจะเป็นการใช้ Username และ Password หรือใช้ Social Account ต่างๆในการยืนยันตัวตน และอีกหนึ่งวิธีที่นิยมกันก็คือการใช้เบอร์โทรศัพท์เพื่อยืนยันตัวตนด้วยรหัส OTP

ด้วยรูปแบบของ OTP ทำให้สะดวกต่อการประยุกต์ใช้กับระบบ Authentication ในรูปแบบต่างๆ ไม่ว่าจะเป็นการใช้หมายเลขโทรศัพท์ในการเข้าใช้งาน​ โดยยืนยันตัวตนผ่าน OTP, การใช้ OTP สำหรับ 2-factor Authentication ก็ตาม หรือแม้แต่การใช้ OTP เพื่อยืนยันการทำงานในขั้นตอนที่สำคัญของแอป ซึ่งเราจะเรียกรวมๆว่า SMS Verification

บทความนี้เหมาะสำหรับแอปที่มีระบบ Authentication และรองรับการยืนยันตัวตนด้วย OTP อยู่แล้ว แต่ถ้ากำลังมองหาระบบ Authentication เพื่อนำไปใช้งาน ก็ขอแนะนำ Firebase Authentication เลยจ้า
Firebase Authentication | Simple, free multi-platform sign-in
Firebase is Google’s mobile platform that helps you quickly develop high-quality apps and grow your business.

เมื่อไม่อยากให้ผู้ใช้ต้องกรอก OTP เอง

โจทย์อย่างหนึ่งที่นักพัฒนาส่วนใหญ่จะเจอคล้ายๆกันก็คืออยากจะลดขั้นตอนในการกรอก OTP ให้ไวขึ้น โดยมีเงื่อนไขว่าถ้าเบอร์โทรศัพท์ของเครื่องที่ใช้งานแอปอยู่นั้นตรงกับของ Account นั้นๆ ก็จะให้ใส่รหัส OTP ในแอปให้โดยอัตโนมัติเลย

แต่ทว่าเอาเข้าจริงกลับไม่ได้ง่ายอย่างที่คิด เพราะว่า

  • ไม่สามารถดึงเบอร์โทรศัพท์จากบาง SIM ผ่านโค้ดได้
  • การจะอ่าน OTP จาก SMS ต้องขอ Permission สำหรับ SMS ซึ่งจะทำให้ผู้ใช้รู้สึกไม่ปลอดภัย เพราะเป็น Permission ที่สามารถเข้าถึง SMS ได้ทั้งหมดในเครื่อง

จึงทำให้ Google Play Services เพิ่มความสามารถหนึ่งเข้ามาเพื่อช่วยแก้ปัญหานี้ให้นักพัฒนา

รับ OTP แบบอัตโนมัติได้ง่ายๆด้วย Auth API จาก Google Play Services

ต้องบอกก่อนเลยว่าจริงๆแล้ว Google Play Services เนี่ย ถือว่าเป็นแอปที่ค่อนข้างขี้โกงกว่าชาวบ้านพอสมควร เพราะตัวมันเองทำงานเป็น System App ทำให้เข้าถึงการทำงานหลายๆอย่างที่แอปทั่วๆไปไม่สามารถทำได้

รวมไปถึง Automatic SMS Verification หรือการรับ OTP แบบอัตโนมัติ โดยเรียกผ่าน API ที่ชื่อว่า SMS Retriever API ซึ่งเป็นหนึ่งใน Auth API ของ Google Play Services

เตรียมให้พร้อมก่อนจะใช้งาน Auth API

SMS Retriever API นั้นจะอยู่ใน Auth API ของ Google Play Services อีกทีหนึ่ง โดยให้เพิ่ม Dependency ทั้ง 2 ตัวของ Auth API เข้าไปใน build.gradle แบบนี้

implementation 'com.google.android.gms:play-services-auth:19.0.0'
implementation 'com.google.android.gms:play-services-auth-api-phone:17.5.0'

และเตรียม GoogleApiClient สำหรับ Auth API ไว้ใน Activity ที่ต้องการเรียกใช้งานให้พร้อม

private val apiClient: GoogleApiClient by lazy {
    GoogleApiClient.Builder(this)
        .enableAutoManage(this, null)
        .addApi(Auth.CREDENTIALS_API).build()
}

แสดงเบอร์โทรศัพท์ที่ผูกอยู่กับ Google Account นั้นๆ

Google Play Services จะคอยจำเบอร์โทรศัพท์ของผู้ใช้ว่าผูกอยู่กับ Google Account ใดๆอยู่บ้าง ซึ่งนักพัฒนาสามารถแสดงเบอร์โทรศัพท์เหล่านั้นขึ้นมาได้เลย ซึ่งจะช่วยให้ผู้ใช้ไม่จำเป็นต้องกรอกเบอร์โทรศัพท์เองทุกครั้ง

โดยจะแสดงหน้าดังกล่าวด้วย Intent ที่สร้างขึ้นมาจาก Auth.CredentialsApi ผ่านคำสั่ง getHintPickerIntent(...) แบบนี้

private fun requestPhoneNumberHint(apiClient: GoogleApiClient) {
    val hintRequest = HintRequest.Builder()
        .setPhoneNumberIdentifierSupported(true)
        .build()
    val intent = Auth.CredentialsApi.getHintPickerIntent(apiClient, hintRequest)
    startIntentSenderForResult(
        intent.intentSender,
        CredentialsApi.CREDENTIAL_PICKER_REQUEST_CODE,
        null, 0, 0, 0
    )
}

จากนั้นก็จะมีหน้าต่างจาก Google Play Services แสดงขึ้นมาเพื่อให้ผู้ใช้เลือกเบอร์โทรศัพท์ ซึ่งผู้ใช้สามารถปิดหน้าต่างนี้ได้ ถ้าไม่ต้องการเลือก

ผลลัพธ์จะถูกส่งกลับมาที่ onActivityResult(...) โดยจะต้องเช็คค่าของ resultCode ก่อนว่าผู้ใช้ได้เลือกเบอร์โทรศัพท์หรือไม่

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == CredentialsApi.CREDENTIAL_PICKER_REQUEST_CODE) {
        when(resultCode) {
            Activity.RESULT_OK -> /* Phone number selected */
            Activity.RESULT_CANCELED -> /* Not select any phone number */
            CredentialsApi.ACTIVITY_RESULT_NO_HINTS_AVAILABLE -> /* No phone number available */
        }
    }
}

และถ้าเข้าเงื่อนไข Activity.RESULT_OK ก็แปลว่าผู้ใช้ได้เลือกเบอร์โทรศัพท์ของตัวเองแล้ว ซึ่งเบอร์โทรศัพท์ดังกล่าวจะอยู่ใน data ที่ส่งเข้ามาใน onActivityResul(...) นั่นเอง

ในการดึงเบอร์โทรศัพท์ที่ส่งกลับมาให้จะต้องใช้คำสั่งแบบนี้

val data: Intent? = /* ... */
val credential: Credential? = data?.getParcelableExtra(Credential.EXTRA_KEY)
val phoneNumber: String? = credential?.id

เบอร์โทรศัพท์ที่ได้จะเป็นแบบ E164 Format (ขึ้นต้นด้วยรหัสประเทศ)

+66123456789

จากนั้นก็เอาเบอร์โทรศัพท์ไปใส่ลงในช่องกรอกเบอร์โทรศัพท์ได้เลย

การดัก OTP จาก SMS ที่ส่งเข้ามาในเครื่อง

เนื่องจากต้องการแค่ OTP สำหรับแอปตัวเองเท่านั้น ไม่ต้องการเข้าถึง SMS ทั้งหมดในเครื่อง ทำให้ Google Play Services ได้สร้าง SMS Retriever API ขึ้นมาเพื่อช่วยให้นักพัฒนาดัก OTP จาก SMS เฉพาะของแอปตัวเองได้ โดยไม่ต้องขอ Permission ใดๆเลย

โดย Google Play Services ได้กำหนดรูปแบบของ SMS ไว้แบบนี้

<#> {message} {hash}
  • ต้องขึ้นต้นด้วย <#> เสมอ
  • กำหนดข้อความที่ต้องการไว้ใน {message} ได้เลย
  • กำหนด Hash 11 ตัวแรกของแอปไว้ใน {hash}

ยกตัวอย่างเช่น

<#> Use 123456 as your password for Awesome app FAr9qCn9VsM

ดังนั้นในฝั่ง Server ที่จะต้องส่งข้อความผ่านบริการ Bulk SMS ก็ให้ส่งข้อความในรูปแบบนี้แทน เพื่อให้ Google Play Services สามารถรู้ได้ว่านี่คือ SMS ของแอปตัวไหน เพื่อที่จะได้ส่งข้อความดังกล่าวให้กับแอปนั้นๆได้ถูกต้อง

แล้วจะไปหา Hash ของแอปได้จากที่ไหน?

เพื่อให้สะดวกและง่าย ขอแนะนำให้เพิ่มคำสั่งสำหรับดึง Hash ไว้ในแอปเพื่อใช้ชั่วคราวไปเลย (ลบออกหลังใช้เสร็จด้วยนะ)

import android.content.Context
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.util.Base64
import android.util.Log
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException

object AppSignatureHelper {
    private const val HASH_TYPE = "SHA-256"
    private const val NUM_HASHED_BYTES = 9
    private const val NUM_BASE64_CHAR = 11

    fun printAppHash(context: Context) {
        val appSignatures: List<String> = getAppSignatures(context)
        for (appSignature in appSignatures) {
            Log.d("AppHash", "11-Character Hash String : $appSignature")
        }
    }

    private fun getAppSignatures(context: Context): ArrayList<String> {
        val appCodes: ArrayList<String> = ArrayList()
        try {
            val packageName = context.packageName
            val packageManager = context.packageManager
            val signatures: Array<Signature> = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures
            for (signature in signatures) {
                val hash = hash(packageName, signature.toCharsString())
                if (hash != null) {
                    appCodes.add(String.format("%s", hash))
                }
            }
        } catch (ignored: PackageManager.NameNotFoundException) {}
        return appCodes
    }

    private fun hash(packageName: String, signature: String): String? {
        val appInfo = "$packageName $signature"
        try {
            val messageDigest: MessageDigest = MessageDigest.getInstance(HASH_TYPE)
            messageDigest.update(appInfo.toByteArray(StandardCharsets.UTF_8))
            var hashSignature: ByteArray = messageDigest.digest()
            hashSignature = hashSignature.copyOfRange(0, NUM_HASHED_BYTES)
            var base64Hash: String = Base64.encodeToString(hashSignature, Base64.NO_PADDING or Base64.NO_WRAP)
            base64Hash = base64Hash.substring(0, NUM_BASE64_CHAR)
            return base64Hash
        } catch (ignored: NoSuchAlgorithmException) {}
        return null
    }
}

โดยคำสั่งดังกล่าวจะทำการดึง Hash 11 ตัวแรกของแอปมาแสดงใน Logcat ให้

val context: Context = /* ... */
AppSignatureHelper.printAppHash(this)
หมายเหตุ - Hash จะเปลี่ยนไปตาม Keystore ที่ใช้ ดังนั้นตอน Debug Build กับ Release Build จะได้ Hash คนละตัวกัน

กลับมาดูที่ SMS Retriever API กันต่อ

ในการรับข้อความ SMS ผ่าน SMS Retriver API จะต้องมีโค้ดทั้งหมด 2 อย่างเดียวกัน คือ

  • โค้ดสำหรับเชื่อมต่อกับ SMS Retriever API
  • โค้ดสำหรับ Broadcast Receiver เพื่อรับข้อความ​ SMS จาก Google Play Services

เริ่มจากการเชื่อมต่อกับ SMS Retriever API ก่อน ซึ่งจะต้องใช้คำสั่งแบบนี้

private fun connectSmsRetrieverClient(activity: Activity) {
    SmsRetriever.getClient(activity)
        .startSmsRetriever()
        .addOnSuccessListener {
            // Connected to SMS Retriever API
        }
        .addOnFailureListener { e: Exception ->
            // Can't connect to SMS Retriever API with some reason
        }
}

ถ้าเชื่อมต่อสำเร็จก็จะรับข้อความ SMS จาก Google Play Services ได้ทันที

ต่อมาก็ต้องสร้าง Broadcast Receiver เพื่อรับข้อความ SMS โดยจะสร้างเป็นแบบ Context-registered เพื่อเรียกใช้งานจากใน Activity นั้นๆโดยตรงเลย

private fun registerSmsReceiver(activity: Activity) {
    val intentFilter = IntentFilter().apply {
        addAction(SmsRetriever.SMS_RETRIEVED_ACTION)
    }
    activity.registerReceiver(smsBroadcastReceiver, intentFilter)
}

private fun unregisterSmsReceiver(activity: Activity) {
    activity.unregisterReceiver()
}

private val smsBroadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent?) {
        if (intent?.action == SmsRetriever.SMS_RETRIEVED_ACTION) {
            val status: Status? = intent.extras?.getParcelable(SmsRetriever.EXTRA_STATUS)
            when (status?.statusCode) {
                CommonStatusCodes.SUCCESS -> /* SMS retrieved */
                CommonStatusCodes.TIMEOUT -> /* Timeout */
            }
        }
    }
}

โดยให้ใช้คำสั่ง registerSmsReceiver(...) หลังจากที่เชื่อมต่อกับ SMS Retriever API สำเร็จแล้ว และเมื่อเสร็จแล้วก็อย่าลืมใช้คำสั่ง unregisterSmsReceiver(...) เพื่อหยุดการทำงานของ Broadcast Receiver ด้วย

และเมื่อ Broadcast ได้รับข้อความ SMS เรียบร้อยแล้ว ก็สามารถข้อความ SMS ออกมาจาก Intent ใน onReceive(...) ด้วยคำสั่งแบบนี้ได้เลย

val intent: Intent = /* ... */
val message: String? = intent.extras?.getParcelable(SmsRetriever.EXTRA_SMS_MESSAGE)

จากนั้นก็ให้แกะ OTP ที่อยู่ข้างในข้อความ SMS แล้วเอาไปใส่ในแอปของผู้ที่หลงเข้ามาอ่านได้เลย เท่านี้ก็เสร็จเรียบร้อยแล้ว

สรุป

ด้วยความสามารถของ SMS Retriever API จาก Google Play Services จะช่วยให้นักพัฒนาสามารถลดขั้นตอนในการกรอก OTP ของผู้ใช้ในกรณีที่เบอร์โทรศัพท์ของเครื่องนั้นตรงกับเบอร์ที่จะทำการส่ง OTP โดยไม่จำเป็นต้องขอ Permission ใดๆจากผู้ใช้เลย

อย่างไรก็ตาม ก็ยังคงมีข้อจำกัดบางอย่างอยู่เหมือนกัน เช่น

  • เบอร์โทรศัพท์ที่จะขึ้นให้เลือกใน Google Account นั้นๆ จะถูกจัดการด้วย Google Play เอง นักพัฒนาไม่สามารถทำอะไรได้
  • ถ้ามีระบบส่ง SMS อยู่แล้ว การเปลี่ยนไปใช้รูปแบบที่ Google Play Services กำหนด ก็คงจะไม่ใช่เรื่องง่ายซักเท่าไร

ก็ลองดูนะครับ ว่าวิธีนี้จะเหมาะสมกับแอปของผู้ที่หลงเข้ามาอ่านหรือไม่ เพราะจริงๆแล้วยังมีวิธีอื่นที่เป็นทางเลือกให้ใช้อยู่มากมายเหมือนกัน

แหล่งข้อมูลอ้างอิง