25 October 2017

ทำ Phone Number Verification ด้วย Auth API ของ Google Play Services

Updated on

        เรื่องมีอยู่ว่าเจ้าของบล็อกได้ไปเจอบทความของ Google Play Services ตัวหนึ่งที่เกี่ยวกับการทำ Authentication ด้วยเบอร์มือถือที่ต้องยืนยันด้วย OTP ซึ่งใน Google Play Services ได้มี API ที่ช่วยจัดการในเรื่องนี้ให้ง่ายขึ้นด้วยนะ (เพิ่งจะรู้เหมือนกัน)

ว่าด้วยเรื่องระบบล็อกอิน

        ในทุกวันนี้แอปฯส่วนใหญ่จะมาพร้อมกับระบบล็อกอินเข้าใช้งานจนเป็นเรื่องพื้นฐานไปแล้ว บ้างก็ใช้วิธีล็อกอินด้วยรหัสผ่าน บ้างก็ใช้ Facebook หรือ Google+ และอีกวิธีหนึ่งที่นิยมกันก็คือการใช้เบอร์โทรศัพท์แล้วยืนยันด้วย OTP (แต่ถ้าอยากจะให้ชีวิตสะดวกสบายมากกว่านี้ ขอแนะนำให้ใช้ Firebase Authentication เลย มีครบครัน)

        ซึ่งการทำระบบล็อกอินด้วย OTP เนี่ย บางทีก็อยากจะทำบางฟีเจอร์ที่ช่วยให้ผู้ใช้สามารถใช้งานได้สะดวกขึ้นเนอะ อย่างเช่น

        • แสดงเบอร์ให้เลือก โดยไม่ต้องนั่งกรอกเอง
        • กรอก OTP จาก SMS ให้อัตโนมัติ ถ้า OTP ถูกส่งเข้ามาในเครื่องนั้นๆ

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

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

        แต่หารู้หรือไม่ว่า...

ชีวิตง่ายขึ้นได้ด้วย Auth API จาก Google Play Services

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


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

        • การดึงเบอร์ที่ผูกอยู่กับ Google Account นั้นๆ
        • การดัก SMS ที่ส่งเข้ามาในเครื่อง

        ก่อนอื่นให้เพิ่ม Auth API ของ Google Play Services ไว้ใน build.gradle แบบนี้ก่อน

compile 'com.google.android.gms:play-services-auth:11.4.2'

        ถ้ามีเวอร์ชันที่ใหม่กว่า ก็ใช้ซะ

        หมายเหตุ - ฟีเจอร์เหล่านี้มีให้ใช้งานใน Google Play Services v10.2.X ขึ้นไปเท่านั้นนะ

การดึงเบอร์ที่ผูกอยู่กับ Google Account นั้นๆ

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

        โดยจะมีคำสั่งง่ายๆแบบนี้

import android.app.PendingIntent;
import android.content.IntentSender;

import com.google.android.gms.auth.api.credentials.Credential;
import com.google.android.gms.auth.api.credentials.CredentialsApi;
import com.google.android.gms.auth.api.credentials.HintRequest;
import com.google.android.gms.common.api.GoogleApiClient;

...

private static final int REQUEST_CODE_PHONE_NUMBER_HINT = 1234;

private void requestPhoneNumberHint(GoogleApiClient apiClient) {
    try {
        HintRequest hintRequest = new HintRequest.Builder()
                .setPhoneNumberIdentifierSupported(true)
                .build();
        PendingIntent intent = Auth.CredentialsApi.getHintPickerIntent(
                apiClient, hintRequest);
        startIntentSenderForResult(intent.getIntentSender(),
                REQUEST_CODE_PHONE_NUMBER_HINT, null, 0, 0, 0);
    } catch (IntentSender.SendIntentException e) {
        e.printStackTrace();
    }
}

        จะเห็นว่าหลักการทำงานของคำสั่งนี้คือการใช้ Intent นั่นเอง นั่นหมายความว่าคำสั่งนี้จะต้องเรียกใช้งานในคลาส Activity

        และเมื่อดูที่คำสั่ง requestPhoneNumberHint(...) ก็จะเห็นว่ามันต้องส่งคลาส GoogleApiClient เข้ามาด้วย ให้สร้างขึ้นมาแบบนี้

import com.google.android.gms.auth.api.Auth;
import com.google.android.gms.common.api.GoogleApiClient;

...

GoogleApiClient apiClient = new GoogleApiClient.Builder(getContext())
        .enableAutoManage(getFragmentActivity(), null)
        .addApi(Auth.CREDENTIALS_API)
        .build();

        คลาส Builder จะต้องส่ง Context เข้าไปด้วย และคำสั่ง enableAutoManage(...) จะต้องส่ง FragmentActivity เข้าไปด้วย ดังนั้นคำสั่งนี้จึงควรเรียกใช้งานใน Activity ที่เป็น FragmentActivity ครับ

        เวลาเรียกใช้งานก็จะใช้คำสั่งแบบนี้

requestPhoneNumberHint(apiClient);

        แล้วจะมีหน้าต่างแสดงขึ้นมาเพื่อให้ผู้ใช้เลือกเบอร์ที่มีอยู่ใน Google Account


        ซึ่งผลลัพธ์จะถูกส่งกลับมาผ่าน onActivityResult(...) ซึ่งจะต้องเช็ค Result Code ว่าได้ผลลัพธ์เป็นอะไร แล้วค่อยเรียกใช้คำสั่งตามที่ต้องการ

import android.app.Activity;
import android.content.Intent;

import com.google.android.gms.auth.api.credentials.Credential;
import com.google.android.gms.auth.api.credentials.CredentialsApi;

...

private static final int REQUEST_CODE_PHONE_NUMBER_HINT = 1234;

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQUEST_CODE_PHONE_NUMBER_HINT) {
        if (resultCode == Activity.RESULT_OK) {
            Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY);
            String phoneNumber = credential.getId();
            // Do something with selected phone number
        } else if (resultCode == CredentialsApi.ACTIVITY_RESULT_OTHER_ACCOUNT) {
            // User will add phone number manually
        } else if (resultCode == CredentialsApi.ACTIVITY_RESULT_NO_HINTS_AVAILABLE) {
            // No hint phone number in user's Google account
        } else if (resultCode == Activity.RESULT_CANCELLED) {
            // Hint phone number dialog has dismissed
        } 
    }
}

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

+66123456789

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

        Google Play Services ได้เปิดให้แอปฯอื่นๆสามารถอ่าน SMS ที่มาจากแอปฯของตัวเองได้ (มีวิธี  Verify อยู่) เพื่อเอาไปทำฟีเจอร์อย่างการรับ OTP อัตโนมัติได้ ผู้ใช้จะได้ไม่ต้องมานั่งกรอกเอง

        ซึ่งใน Auth API จะมีคำสั่งชุดหนึ่งที่เรียกว่า SMS Retriever API ที่จะช่วยให้นักพัฒนาสามารถดัก SMS ของตัวเองได้

แล้วจะรู้ได้ยังไงว่า SMS นั้นเป็นของแอปฯไหน?

        เจ้า Google Play Services ได้กำหนด Format ของ SMS ไว้ดังนี้ครับ

<#> {message}
{11-character hash}

        • ต้องขึ้นต้นด้วย <#> 
        • เว้นวรรคหนึ่งครั้ง
        • ข้อความที่ต้องการ
        • เคาะบรรทัดหนึ่งครั้ง
        • รหัส Hash 11 ตัวแรกสุดของแอปฯนั้นๆ

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

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

        จะต้องส่ง SMS ในรูปแบบนี้ทุกครั้ง เพื่อให้ Google Play Services รู้ว่านี่คือ SMS ของแอปฯอะไร แล้วจะส่งข้อความเข้ามาให้

แล้ว Hash 11 ตัวแรกของแอปฯจะไปหามาจากไหน?

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

import android.content.Context;
import android.content.ContextWrapper;
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;
import java.util.ArrayList;
import java.util.Arrays;

public class AppSignatureHelper extends ContextWrapper {
    public static final String TAG = AppSignatureHelper.class.getSimpleName();

    private static final String HASH_TYPE = "SHA-256";
    public static final int NUM_HASHED_BYTES = 9;
    public static final int NUM_BASE64_CHAR = 11;

    public AppSignatureHelper(Context context) {
        super(context);
    }

    /**
     * Get all the app signatures for the current package
     * @return
     */
    public ArrayList<String> getAppSignatures() {
        ArrayList<String> appCodes = new ArrayList<>();

        try {
            // Get all package signatures for the current package
            String packageName = getPackageName();
            PackageManager packageManager = getPackageManager();
            Signature[] signatures = packageManager.getPackageInfo(packageName,
                    PackageManager.GET_SIGNATURES).signatures;

            // For each signature create a compatible hash
            for (Signature signature : signatures) {
                String hash = hash(packageName, signature.toCharsString());
                if (hash != null) {
                    appCodes.add(String.format("%s", hash));
                }
            }
        } catch (PackageManager.NameNotFoundException e) {
            Log.e(TAG, "Unable to find package to obtain hash.", e);
        }
        return appCodes;
    }

    private static String hash(String packageName, String signature) {
        String appInfo = packageName + " " + signature;
        try {
            MessageDigest messageDigest = MessageDigest.getInstance(HASH_TYPE);
            messageDigest.update(appInfo.getBytes(StandardCharsets.UTF_8));
            byte[] hashSignature = messageDigest.digest();

            // truncated into NUM_HASHED_BYTES
            hashSignature = Arrays.copyOfRange(hashSignature, 0, NUM_HASHED_BYTES);
            // encode into Base64
            String base64Hash = Base64.encodeToString(hashSignature, Base64.NO_PADDING | Base64.NO_WRAP);
            base64Hash = base64Hash.substring(0, NUM_BASE64_CHAR);

            Log.d(TAG, String.format("pkg: %s -- hash: %s", packageName, base64Hash));
            return base64Hash;
        } catch (NoSuchAlgorithmException e) {
            Log.e(TAG, "hash:NoSuchAlgorithm", e);
        }
        return null;
    }
}

        ตอนเปิดแอปฯก็ให้เรียกใช้งานคลาส AppSignatureHelper เพื่อแสดง Hash ใน LogCat ซะ

private void printAppHash() {
    List<String> appSignatureList = new AppSignatureHelper(this).getAppSignatures();
    for (String appSignature : appSignatureList) {
        Log.d("App Hash", "11-Character Hash String : " + appSignature);
    }
}

        หมายเหตุ : Hash จะเปลี่ยนไปตามแอปฯและ Keystore ที่ใช้ ดังนั้นระหว่าง Debug กับ Release ก็จะมี Hash คนละชุดกันนะ และ Hash นั้นสำคัญมาก อย่าเผลอให้ใครเอาไปล่ะ ดังนั้นคำสั่งข้างบนนี้เมื่อได้ Hash แล้วก็ให้ลบทิ้งเพื่อความปลอดภัยซะ

มาดูโค้ดที่จำเป็นกันต่อ

        ซึ่งการรับข้อความ SMS จะต้องมีการเขียนโค้ดเพิ่มเข้าไป 2 อย่างด้วยกัน คือ

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

        เมื่อแอปฯของผู้ที่หลงเข้ามาอ่านอยู่ในหน้าที่ต้องการให้อ่าน SMS จะต้องเชื่อมต่อกับ SMS Retriever Client ด้วยคำสั่งดังนี้

import android.app.Activity;
import android.support.annotation.NonNull;

import com.google.android.gms.auth.api.phone.SmsRetriever;
import com.google.android.gms.auth.api.phone.SmsRetrieverClient;
import com.google.android.gms.tasks.OnFailureListener;
import com.google.android.gms.tasks.OnSuccessListener;

...

private void connectSmsRetrieverClient(Activity activity) {
    SmsRetrieverClient client = SmsRetriever.getClient(activity);
    client.startSmsRetriever()
            .addOnSuccessListener(new OnSuccessListener<Void>() {
                @Override
                public void onSuccess(Void aVoid) {
                    // Do something when connect to SMS Retriever API successfully
                }
            })
            .addOnFailureListener(new OnFailureListener() {
                @Override
                public void onFailure(@NonNull Exception e) {
                    // Do something when failed to connect to SMS Retriever API
                }
            });
}

        ถ้าเชื่อมต่อสำเร็จ (onSuccess) ก็จะสามารถรับ SMS จาก Google Play Services ได้

        จากนั้นให้สร้าง Broadcast Receiver ขึ้นมาเพื่อรับ SMS จาก Google Play Services

import android.content.BroadcastReceiver;
import android.content.IntentFilter;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;


import com.google.android.gms.auth.api.phone.SmsRetriever;
import com.google.android.gms.common.api.CommonStatusCodes;
import com.google.android.gms.common.api.Status;

...

private void registerSmsReceiver() {
    IntentFilter intentFilter = new IntentFilter();
    intentFilter.addAction(SmsRetriever.SMS_RETRIEVED_ACTION);
    registerReceiver(smsBroadcastReceiver, intentFilter);
}

private void unregisterSmsReceiver() {
    unregisterReceiver(smsBroadcastReceiver);
}

private BroadcastReceiver smsBroadcastReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        if (SmsRetriever.SMS_RETRIEVED_ACTION.equals(intent.getAction())) {
            Bundle extras = intent.getExtras();
            if (extras != null) {
                Status status = extras.getParcelable(SmsRetriever.EXTRA_STATUS);
                if (status != null) {
                    switch (status.getStatusCode()) {
                        case CommonStatusCodes.SUCCESS:
                            String message = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE);
                            // Do something when success
                            break;
                        case CommonStatusCodes.TIMEOUT:
                            // Do something when timeout
                            break;
                    }
                }
            }
        }
    }
};

        อยากจะให้ดัก SMS เมื่อไรก็เรียกคำสั่ง registerSmsReceiver() และเมื่อใช้งานเสร็จแล้วก็เรียกคำสั่ง unregisterSmsReceiver() ด้วย แล้วผลลัพธืที่ได้จะส่งเข้ามาใน onReceiver(...) ที่เหลือก็ขึ้นอยู่กับผู้ที่หลงเข้ามาอ่านแล้วล่ะว่าจะเอา SMS ไปทำอะไร หรือดึง OTP ออกมาจาก SMS ด้วยวิธีไหน เพราะมันเป็นแค่ String ธรรมดาๆแล้ว

        เรียบร้อย~

สรุป

        ใน Auth API นั้นมีคำสั่งต่างๆที่จะช่วยให้การทำ Phone Number Verification นั้นสะดวกขึ้น ลดขั้นตอนที่ผู้ใช้จะต้องกดให้น้อยลง แต่ก็จะมีข้อจำกัดบางอย่างดังนี้

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

        อย่างที่สองคือรูปแบบของ SMS ที่มีการกำหนดรูปแบบ ดังนั้นถ้าผู้ที่หลงเข้ามาอ่านคนใดที่ต้องทำระบบที่มี SMS แบบนี้อยู่แล้ว การบอกให้แก้ไขรูปแบบ SMS ก็อาจจะเป็นเรื่องยาก ดังนั้นวิธีนี้จึงไม่ตอบโจทย์เท่าไรนัก

        อย่างที่สามคือ Hash 11 ตัวแรกสุดไม่ได้เป็นความลับ ดังนั้นนักพัฒนาคนอื่นๆจึงสามารถเอารหัสดังกล่าวไปใช้เพื่อ Inject ข้อความที่ต้องการเข้ามาในแอปฯได้ โดยหวังว่าจะเกิดช่องโหว่อะไรก็ตามผ่านช่องทางนี้ ดังนั้นเขียนโค้ดเช็คข้อความ SMS ให้รัดกุมล่ะ

        และถ้าใช้ Firebase Authentication ก็สามารถใช้วิธีนี้ได้เช่นกัน เพราะว่า SMS ที่ใช้ในการยืนยันตัวตนสามารถกำหนดรูปแบบของข้อความได้ผ่าน Firebase Console


        สุดท้ายแล้ววิธีนี้ก็เป็นตัวเลือกหนึ่งเท่านั้น ถ้าผู้ที่หลงเข้ามาอ่านมีระบบจัดการตรงนี้อยู่แล้วหรือไม่ต้องการทำ ก็อาจจะไม่จำเป็นซักเท่าไร แต่ถ้าจำเป็นต้องทำอะไรแนวๆนี้ ก็ลองดูว่าวิธีนี้จะเหมาะกับผู้ที่หลงเข้ามาอ่านหรือป่าวนะครับ

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

        • Effective phone number verification [Android Developers Blog]
        • Automatic SMS Verification with the SMS Retriever API [Google Developers]
        • Android credentials sample codes [GitHub]