如何在複雜業務場景中優雅實現Android指紋驗證?

FeelsChaotic發表於2018-02-23

前言

目前指紋領域無論從產品角度還是技術角度都已經趨於成熟,但是當各位開發者準備深入探究的時候,卻發現網上很多文章都是皮毛,很難有較深的啟示。本文將著重介紹指紋驗證開發整個過程,包括技術選型、產品的設計方案邏輯、程式碼的架構以及後續測試中遇到的相容性問題等幾個方面。在這裡拋磚引玉,希望能給予大家一些啟發。

技術選型

產品:我們們 Android 端能做指紋驗證嗎?
開發:不能,一堆相容問題。
產品:我們們 Android 端能做指紋驗證嗎?
開發:不能,一堆相容問題。
產品:我們們 Android 端能做指紋驗證嗎?
開發:不能,一堆相容問題。
產品:我們們 Android 端能做指紋驗證嗎?
開發:我……我試試吧……

著手調研,開發前肯定先拿市面上競品的功能來瞧瞧。我們同比了支付寶、微信支付和招商App。

產品:怎麼支付寶和微信就沒相容問題了?

開發:那是因為支付寶和騰迅有自己的協議!(一聽怎麼XXX支援,怎麼XXX沒問題,升起無名火)這個標準直接和裝置廠商合作,而應用方只有微信和支付寶自己。支付寶指紋支付標準是 IFAA ,騰訊的指紋支付標準是 SOTER,也就是說沒有其他應用方會使用這個標準。所以很看應用方和裝置廠商的協商程度。現在 IFAA 沒有開源,只有 SOTER 是開源的了,如果接入,我們能省去相容性測試的工作量,而且有些 6.0 以下的機型 SOTER 也支援。還有!(星星眼)每個指紋將會有唯一 ID,也就是說,我們能把賬號和指紋繫結起來,更加安全。

產品:不行不行!這 SOTER 壓根沒支援華為,華為使用者是我們的主要使用者群,而且以後機型的擴充套件受第三方支援的限制。

開發:之前小米和華為就沒有支援 SOTER 標準,現在小米是支援了,華為不見得會支援,因為 SOTER 和廠商合作,出廠的時候就將私鑰儲存在 TEE 中,華為目前多 TEE 系統開發尚未成熟,只能支援一個 TEE ,顯然華為不願意將唯一的 TEE 交給騰訊掌控。其他手機廠商一般使用高通或第三方的 TEE 系統方案,這些系統目前都支援多 TEE 執行環境,即使將其中一個 TEE 的公共金鑰交給騰訊運營,並不影響手機廠商運營自己的 TEE 平臺。

產品:不接入了,我們用 Google API。

開發:那好,來制定下條件先:

  1. 裝置硬體不支援直接沒得玩
  2. 手機要有除了指紋外的安全認證方式(比如密碼、圖案) ,這是安卓系統的雙重鎖規則。
  3. 使用者手機至少錄入了一個指紋,沒錄入指紋說明平時沒有用過指紋驗證功能,這種使用者我們就不管了。
  4. 使用 Google API,不管什麼情況,只要驗證的指紋是系統指紋列表裡存在的,就驗證通過,Google API 是沒有提供指紋唯一ID的,所以想要根據本機上的指紋索引來區別不同手指無法做到,也就無法實現指紋和賬號繫結。
  5. 僅支援 Android 6.0 以上系統,Google 官方支援指紋識別的標準介面是在 Android6.0 開始的,如果廠商在這之前就已經做了指紋識別,那我們就不管了。(開發者也可以使用廠商提供的第三方指紋識別SDK)

產品:(點頭)可以,開幹吧!用 Google API 相容性問題處理和測試量較大,所以我們支援的機型做成可配置,控制風險。第一期先支援幾個機型。

2018.12.10 更新
SOTER 已支援部分華為機型 SOTER 支援機型 wiki

架構

好了,demo 寫完了,看下了產品文件。啥?場景這麼複雜?!分支繁多,還需要結合到之前存在的手勢驗證功能(使用者有兩種安全方式可選:指紋驗證和手勢驗證)。

業務場景有四個:

  1. 冷啟動app的指紋驗證
  2. 切換賬號登陸後的引導設定
  3. 在設定頁使用者手動開啟指紋登陸
  4. 設定頁手動關閉指紋登陸

每一次驗證的狀態,都會通過 AuthenticationCallback 回撥,我們可以理解為是指紋驗證的生命週期。

public class MyAuthCallback extends FingerprintManagerCompat.AuthenticationCallback {

        @Override
        public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) {
            super.onAuthenticationSucceeded(result);
        }

        @Override
        public void onAuthenticationError(int errMsgId, CharSequence errString) {
            //驗證過程中遇到不可恢復的錯誤
            super.onAuthenticationError(errMsgId, errString);
        }

        @Override
        public void onAuthenticationFailed() {
            super.onAuthenticationFailed();
        }

        @Override
        public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) {
            //驗證過程中遇到可恢復錯誤
            super.onAuthenticationHelp(helpMsgId, helpString);
        }
    }
複製程式碼

onAuthenticationSucceeded 和 onAuthenticationError 的回撥意味著本次的認證結束,會根據當前所處業務場景給予使用者不同的引導。

而 onAuthenticationFailed 和 onAuthenticationHelp 的情況,四個業務場景都是一樣的,都是在介面上提示使用者,我們可以合併一起處理。

所以我們根本不需要一個業務場景就對應一個 AuthenticationCallback 回撥類,我們可以只用一個 AuthenticationCallback 回撥類來根據當前所處的業務場景分發行為。但是我又不想在 onAuthenticationSucceeded 和 onAuthenticationError 的回撥中有 Switch 邏輯。所以對於四個場景各不相同的 onAuthenticationSucceeded 和 onAuthenticationError 的回撥方法,我們用狀態模式來分離,這樣把與特定狀態相關的行為區域性化,並且將不同場景下的行為分割開來。(需要給使用者什麼提示,什麼操作,包括驗證次數超限的處理,取決於當前所處的場景狀態)

另外一點:需要在執行時刻根據狀態來改變行為,比如說使用者從一個正常態,轉移到驗證過程異常或者驗證過程被劫持的狀態。

驗證過程異常情況,也即是說,受使用者 root 或自定製情況,通過測試的同一個機型有可能驗證過程異常。

驗證過程被劫持,因為 Google API 只返回 true 或 false,我們當然不能無條件相信這個驗證結果,所以需要在應用內產生一對非對稱的金鑰,保證驗證過程不會被篡改。如果拿到驗證結果解密失敗,就進入了被劫持的狀態了。

驗證過程異常和驗證被劫持的狀態基本處理一致,都是屬於使用者無法再繼續驗證的場景,我們可以把這兩個狀態合為一。按照開發的思路,有異常,被劫持,那肯定是失敗了,是吧? 但是按照產品的思路,其他 3 個業務場景按失敗處理,但如果是關閉指紋的場景下(4. 設定頁手動關閉指紋登陸),就算是失敗了,也要讓他去關閉成功,不然可能會出現使用者手機中途 root 或極端情況下,無法關閉指紋,從而引起客訴。

按照分析我們可以發現,被劫持和驗證過程異常的情況的處理,依賴於當時所處的場景,所以呢,我們無法把被劫持和驗證過程異常當做一個獨立的狀態了。只能抽出作為一個公共方法。

綠色底為 Activity 層,白色底為 Util 層

為了不和業務邏輯耦合在一起,工具類包裝了一層,主要封裝了驗證條件的判斷,指紋類的初始化等等,最主要的是封裝了加密類 CryptoObjectCreatorHelper ,我們考慮到安全因素,如果不加密的話,就意味著App 無條件信任認證的結果,這個過程可能被攻擊,資料可以被篡改,這是 App 在這種情況下必須承擔的風險。但是這個加密過程和業務是無關的,我們不想讓 Activity 層感知到,所以金鑰和加密物件的銷燬,會統一由工具類來把控。

為了安全,每次驗證過程的金鑰都不同,驗證過程一結束,也就是回撥 onAuthenticationSucceeded 和 onAuthenticationError 時,都需要銷燬掉金鑰,但是我們不想讓業務層來操作,所以工具類也有自己的一個 AuthenticationCallback ,在 AuthenticationCallback 裡做一些和業務無關的操作,再回撥 Activity 的 AuthenticationCallbackListener 。

工具類的 CallBack 是 FingerprintManagerCompat.AuthenticationCallback 實現類,業務層的 AuthenticationCallbackListener 是自定義介面,因為不想把和業務無關的往上傳遞,比如說,驗證成功的 AuthenticationResult ,驗證錯誤的 typeId,這些業務並不關心。Activity 的 AuthenticationCallbackListener 會把請求統一轉發給控制器 FingerPrintTypeController,在轉發給控制器的前後,我們可以做一些通用的業務操作,比如說停止介面的掃描動畫,發一些非同步的請求等等,這個就是代理模式的應用了。

那控制器 FingerPrintTypeController 和四個場景的關係又是如何?我們看看類圖。

如何在複雜業務場景中優雅實現Android指紋驗證?

可以看到,四個場景,對應四個狀態類,控制器和狀態類實現了同一個介面,在內部根據當前場景轉發給對應的類, 那怎麼根據場景轉發給對應類?我們建立一個對映表,把場景和類對應起來。每次匹配的話只要 O(1) 複雜度。

 private interface FingerPrintType {
        void onAuthenticationSucceeded();

        void onAuthenticationError(String content);
    }

 private class LoginAuthType implements FingerPrintType {
        @Override
        public void onAuthenticationSucceeded() { }

        @Override
        public void onAuthenticationError(String content) { }
    }

    private class ClearType implements FingerPrintType {
        @Override
        public void onAuthenticationSucceeded() { }

        @Override
        public void onAuthenticationError(String content) { }
    }

    private class LoginSettingType implements FingerPrintType {
       @Override
        public void onAuthenticationSucceeded() { }

        @Override
        public void onAuthenticationError(String content) { }
    }

    private class SettingType implements FingerPrintType {
        @Override
        public void onAuthenticationSucceeded() { }

        @Override
        public void onAuthenticationError(String content) { }
    }

    private class FingerPrintTypeController implements FingerPrintType {
        private Map<String, FingerPrintType> typeMappingMap = new HashMap<>();

        public FingerPrintTypeController() {
            typeMappingMap.put(GESTURE_FINGER_SETTING, new SettingType());
            typeMappingMap.put(GESTURE_FINGER_LOGIN_SETTING, new LoginSettingType());
            typeMappingMap.put(GESTURE_FINGER_CLEAR, new ClearType());
            typeMappingMap.put(GESTURE_FINGER_LOGIN, new LoginAuthType());
        }

        @Override
        public void onAuthenticationSucceeded() {
            typeMappingMap.get(mType).onAuthenticationSucceeded();
        }

        @Override
        public void onAuthenticationError(String content) {
            typeMappingMap.get(mType).onAuthenticationError(content);
        }
    }
複製程式碼

這個時候產品又說了,同樣是異常情況,但是被劫持和異常過程異常的提示文案要不一樣,ok,那我們將提示語和操作分離開來,提示和業務場景的對應關係也預先快取在 Map 裡,直接 get 獲取具體提示,作為引數傳入就可以了。

      //普通異常情況提示
        exceptionTipsMappingMap = new HashMap<>();
        exceptionTipsMappingMap.put(GESTURE_FINGER_SETTING, getString(R.string.fingerprint_no_support_fingerprint_gesture));
        exceptionTipsMappingMap.put(GESTURE_FINGER_LOGIN_SETTING, getString(R.string.fingerprint_no_support_fingerprint_gesture));
        exceptionTipsMappingMap.put(GESTURE_FINGER_CLEAR, null);
        exceptionTipsMappingMap.put(GESTURE_FINGER_LOGIN, getString(R.string.fingerprint_no_support_fingerprint_account));
複製程式碼

相容問題

1. 明明符合條件,isHardwareDetected() 返回 false?

在同一機型上呼叫 FingerprintManagerCompat 的 isHardwareDetected() 和 hasEnrolledFingerprints() 時候,返回的都是 false,但是呼叫 FingerprintManager 的 isHardwareDetected() 和 hasEnrolledFingerprints() 時,卻是返回 true。

解決:是否符合指紋條件可以多加一層判斷。

2. Letv X500 Android 6.0,API23 不按正常的套路回撥

onAuthenticationError 和 onAuthenticationFailed,理論上應該是識別失敗的情況,但是該機型點選取消指紋識別也會先回撥一次Error,如果遇到這種情況,只能根據具體專案環境中去進行規避適配了。

3. 魅族上遇到的坑

onAuthenticationHelp 回撥不按套路出牌,正常官網文件解釋,這個方法的回撥時機是在指紋認證期間發生可恢復性的錯誤時回撥。結果在魅族上,啟動指紋識別認證的時候就會回撥這個方法,裡面傳遞回來的資訊提示是“等待按下手指”,也就是說,它的 onAuthenticationHelp 回撥跟官網時機不一樣,而且方法的作用也變了,它在正常的情況回撥了 onAuthenticationHelp。

解決:不影響驗證流程,無需解決

4. 小米 鎖屏和切後臺生命週期不一致

產品需求:使用者鎖屏或切到後臺時(onStop)自動停止指紋驗證,回到介面時(onResume)自動調起驗證。

所以我在指紋回撥方法中加入了標誌位 isInAuth。onStop時儲存 isInAuth,onResume時 isInAuth == true 則自動調起驗證。

        @Override
        public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) {
            isInAuth = false;
        }

        @Override
        public void onAuthenticationError(int errMsgId, CharSequence errString) {
            isInAuth = false;
        }

        @Override
        public void onAuthenticationFailed() {
            isInAuth = true;
        }

        @Override
        public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) {
            isInAuth = true;
        }
複製程式碼

然而小米6、米mix2 鎖屏時的生命週期是 onAuthenticationError -> onStop;切到後臺是 onStop -> onAuthenticationError。導致不同流程下拿到 isInAuth 標誌位不一致,無法自動調起驗證。

解決:介面指紋按鈕可以手動調起驗證,無需相容處理。

小米5生命週期同上,但是無論是自動還是手動調起驗證,馬上就回撥了 onAuthenticationError,也就是說 MI5 從後臺切回來後,指紋驗證流程中斷。

解決:用一個棧來儲存呼叫方法順序,如果驗證方法調起,馬上就回撥 onAuthenticationError 方法,則判定是屬於相容問題,按驗證失敗來解決。

5. 金鑰解密失敗

三星SM-A9100 、Nexus 6P金鑰解密失敗
解決:暫無法解決

其他相容解決方案:

  • 三星passSdk(不過從2018下半年開始,Pass SDK 將不再提供 DEVICE_FINGERPRINT_UNIQUE_ID 。也就是不再為每個已註冊的指紋提供索引了。因此將無法通過 SDK 區分使用哪個指紋來驗證使用者。)
  • 魅族 flyme開發平臺提供了指紋驗證官方api

非相容問題

1. 新註冊指紋金鑰解密失敗

系統中註冊了一個新的指紋的情況下,即使指紋在系統指紋列表裡,驗證也不通過。
解決:刪除了當前無效的key,然後根據引數再次生成金鑰。

 @Override
        public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) {
            ...
            /**
             * doFinal方法會檢查結果是不是會攔截或者篡改過,
             * 如果是的話會丟擲一個異常,異常的時候都將認證當做是失敗來處理
             */
            try {
                result.getCryptoObject().getCipher().doFinal();
                mCustomCallback.onAuthenticationSucceeded(true);
            } catch (IllegalBlockSizeException e) {
                //如果是新錄入的指紋,會丟擲該異常,需要重新生成金鑰對重新驗證,這裡加個次數限制,避免進入驗證異常->重新驗證->又驗證異常的死迴圈
                if (happenCount == 0) {
                    beginAuthenticate();
                    happenCount++;
                    return;
                }
                mCustomCallback.onAuthenticationSucceeded(false);
            } catch (Exception e) {
                mCustomCallback.onAuthenticationSucceeded(false);
            }
           ...
        }
複製程式碼
2. 裝置已有指紋,生成金鑰卻異常提示沒有指紋

非復現,和裝置無關,懷疑是谷歌 API 的坑。

java.lang.IllegalStateException: At least one fingerprint must be enrolled to create keys requiring user authentication for every use
複製程式碼

解決:暫時只想到針對這個特定異常,直接使用無金鑰驗證,有一定的安全風險,有更好方案歡迎補充。

本文完整 Demo 地址
Demo 僅供參考架構和相容處理,如果後續接入魅族和三星 SDK,可以考慮用策略模式替換Goolge API。

相關文章