好久沒寫文章了,最近也比較偷懶,今天繼續討論我實際開發中遇到的需求,那就是關於APP解鎖,大家都知道。現在越來越多的APP在填入賬號密碼後,第二次登入後,基本不會再次重複輸入賬號密碼了。而是快捷登入,而常用的就是 指紋解鎖 和 手勢解鎖 二種.
好了,我們就開始我們今天的解鎖之旅。
這邊我只是展示我的需求的邏輯,不同專案可能邏輯不同,不影響本文主要內容。
主要步驟就分三步:
- 賬號密碼登入。登入成功後彈出一個彈框讓使用者選擇快捷登入方式。
- 然後跳到相應的快捷登入的設定介面
- 下次登入的時候就進行快捷登入
我們一步步來看。
快捷登入方式選擇
當用賬號密碼登入成功後,我們就在登入介面直接彈出一個彈框,然後讓使用者選擇想要的快捷登入方式,當然如果使用者二種都不想要,那就直接按取消,然後登入到主頁,然後下次再開啟應用就會又要重新輸入賬號密碼。
這裡就會遇到我們的第一個問題:
因為Android手機有很多種類,有些有指紋,有些沒有指紋, 那我們需要在有指紋的時候,跳出這個有二種選擇的彈框,如果沒有指紋解鎖,就直接跳到手勢解鎖的介面。
我的判斷可能比較籠統,當然還有更好的:
- 我直接就判斷SDK是否>= 23,因為指紋解鎖是SDK 23 出來的,但是很多國產手機可能是Android 5的系統,但是也有指紋解鎖。這裡我就直接忽略了。莫怪我心狠。
在網上看到有人用反射,就是在Application中,用反射獲取FingerprintManager這個類的物件,看是否能成功獲取,如果能,就存一個boolean變數為ture,說明這個手機裡面有指紋相關的。如果獲取失敗,就說明沒有指紋。
public class MyApplication extends Application { public static final String HAS_FINGERPRINT_API = "hasFingerPrintApi"; public static final String SETTINGS = "settings"; @Override public void onCreate() { super.onCreate(); SharedPreferences sp = getSharedPreferences(SETTINGS, MODE_PRIVATE); if (sp.contains(HAS_FINGERPRINT_API)) { // 檢查是否存在該值,不必每次都通過反射來檢查 return; } SharedPreferences.Editor editor = sp.edit(); try { Class.forName("android.hardware.fingerprint.FingerprintManager"); // 通過反射判斷是否存在該類 editor.putBoolean(HAS_FINGERPRINT_API, true); } catch (ClassNotFoundException e) { editor.putBoolean(HAS_FINGERPRINT_API, false); e.printStackTrace(); } editor.apply(); } }複製程式碼
我們解決了彈出框彈出的時機後,我們就要來做這個彈出框:
我以前做彈出框都是使用Dialog系列,後來無意間看到谷歌推薦大家使用DialogFragment來做彈框,取代原來的Dialog,所以正好藉著這次機會,自己寫了這個DialogFragment。我下面只給出重要部分。具體的大家去百度下DialogFragment即可。
public class LockChooseFragment extends DialogFragment {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
//設定DialogFragment 的主題及彈框的Style。
setStyle(DialogFragment.STYLE_NO_TITLE, android.R.style.Theme_Material_Light_Dialog);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_lock_choose, container, false);
unbinder = ButterKnife.bind(this, view);
return view;
}
@Override
public void onResume() {
super.onResume();
aty = (LoginActivity) getActivity();
//讓我們的彈框無法點選外面區域消失
getDialog().setCanceledOnTouchOutside(false);
getDialog().setCancelable(false);
}
}複製程式碼
好了。接下去彈框出來了要點選一種解鎖,然後進行下一個介面。我們先從簡單的手勢解鎖來說好了。
手勢解鎖
我用的是Github的開源手勢解鎖:PatternLockView
哈哈,是不是太簡單了。。。莫怪我偷懶啊。因為github中的API寫的很清楚了。我就不重複介紹怎麼使用。我使用了覺得的確還不錯。推薦哈。
指紋解鎖
首先我們知道谷歌提供了fingerprint包。包下面的類具體有下面這些:
- FingerprintManager:主要用來協調管理和訪問指紋識別硬體裝置
- FingerprintManager.AuthenticationCallback這個一個callback介面,當指紋認證後系統會回撥這個介面通知app認證的結果是什麼
- FingerprintManager.AuthenticationResult這是一個表示認證結果的類,會在回撥介面中以引數給出
- FingerprintManager.CryptoObject這是一個加密的物件類,用來保證認證的安全性。
在開始之前,我們需要知道使用指紋識別硬體的基本步驟:
在AndroidManifest.xml中申明如下許可權:
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
獲得FingerprintManager的物件引用
- 在執行是檢查裝置指紋識別的相容性,比如是否有指紋識別裝置等。
下面我們詳細說一下上面的2和3 步驟:
獲得FingerprintManager的物件引用
這是app開發中獲得系統服務物件的常用方式,如下:
// Using the Android Support Library v4
fingerprintManager = FingerprintManagerCompat.from(this);
// Using API level 23:
fingerprintManager = (FingerprintManager)getSystemService(Context.FINGERPRINT_SERVICE);複製程式碼
上面給出兩種方式,第一種是通過V4支援包獲得相容的物件引用,這是google推行的做法;還有就是直接使用api 23 framework中的介面獲得物件引用。
在執行是檢查裝置指紋識別的相容性,比如是否有指紋識別裝置等
檢查執行條件要使得我們的指紋識別app能夠正常執行,有一些條件是必須滿足的。
- API level 23
指紋識別API是在api level 23也就是android 6.0中加入的,因此我們的app必須執行在這個系統版本之上。因此google推薦使用 Android Support Library v4包來獲得FingerprintManagerCompat物件,因為在獲得的時候這個包會檢查當前系統平臺的版本。 - 硬體
指紋識別肯定要求你的裝置上有指紋識別的硬體,因此在執行時需要檢查系統當中是不是有指紋識別的硬體:
使用 fingerprintManager.isHardwareDetected()
來判斷是否有該硬體支援,fingerprintManager.hasEnrolledFingerprints()
判斷是否手機中錄有指紋。
這裡我在使用我的手機做開發時候就遇到了一個大坑,上面提到了。谷歌推薦使用FingerprintManagerCompat,但是我在用FingerprintManagerCompat 來呼叫isHardwareDetected()和hasEnrolledFingerprints()時候,返回的都是false,但是用FingerprintManager來呼叫isHardwareDetected()和hasEnrolledFingerprints()時候,卻是返回true,而實際上我用的是小米5,Android 6 ,API23 的手機,也的確是有指紋功能的,所以我不知道為什麼反而FingerprintManagerCompat這個相容類返回是有問題的,應該跟國內廠商的底層原始碼修改有關。我在Google Issue Tracker中也有很多人遇到了這個問題。但基本都什麼華為,小米,三星等,都不是谷歌親兒子。所以後來我用的是FingerprintManager這個類,這個類的使用要求在API23及以上,因為畢竟谷歌的指紋是API23才出來的,而我上面又正好直接判斷API23才顯示指紋解鎖的選項。不謀而合。。哈哈。可能這裡有點偷懶了。
判斷了是否有硬體支援,和手機是否有指紋之後,要注意,谷歌還需要判斷當前裝置必須是處於安全保護中的,即:你的裝置必須是使用螢幕鎖保護的,這個螢幕鎖可以是password,PIN或者圖案都行。為什麼是這樣呢?因為google原生的邏輯就是:想要使用指紋識別的話,必須首先使能螢幕鎖才行,這個和android 5.0中的smart lock邏輯是一樣的,這是因為google認為目前的指紋識別技術還是有不足之處,安全性還是不能和傳統的方式比較的。
KeyguardManager keyguardManager =(KeyguardManager)getSystemService(Context.KEYGUARD_SERVICE);
if (keyguardManager.isKeyguardSecure()) {
// this device is secure.
}複製程式碼
所以這裡總結判斷是:
- 裝置是否有硬體支援
- 手機是否處於安全保護中(沒開就提示使用者開啟鎖屏功能)
- 手機中是否有指紋記錄(沒有就提示使用者去設定應用中新增一個指紋)
好了,這些前戲都做好了,我們就要開始指紋的驗證了。
驗證指紋
要開始掃描使用者按下的指紋是很簡單的,只要呼叫FingerprintManager的authenticate方法即可,那麼現在我們來看一下這個介面:
上圖是google的api文件中的描述,現在我們挨個解釋一下這些引數都是什麼:
- crypto這是一個加密類的物件,指紋掃描器會使用這個物件來判斷認證結果的合法性。這個物件可以是null,但是這樣的話,就意味這app無條件信任認證的結果,雖然從理論上這個過程可能被攻擊,資料可以被篡改,這是app在這種情況下必須承擔的風險。因此,建議這個引數不要置為null。這個類的例項化有點麻煩,主要使用javax的security介面實現。
- cancel 這個是CancellationSignal類的一個物件,這個物件用來在指紋識別器掃描使用者指紋的是時候取消當前的掃描操作,如果不取消的話,那麼指紋掃描器會移植掃描直到超時(一般為30s,取決於具體的廠商實現),這樣的話就會比較耗電。建議這個引數不要置為null。
- flags 標識位,根據圖的文件描述,這個位暫時應該為0,這個標誌位應該是保留將來使用的。
- callback 這個是FingerprintManager.AuthenticationCallback類的物件,這個是這個介面中除了第一個引數之外最重要的引數了。當系統完成了指紋認證過程(失敗或者成功都會)後,會回撥這個物件中的介面,通知app認證的結果。這個引數不能為NULL。
- handler 這是Handler類的物件,如果這個引數不為null的話,那麼FingerprintManager將會使用這個handler中的looper來處理來自指紋識別硬體的訊息。通常來講,開發這不用提供這個引數,可以直接置為null,因為FingerprintManager會預設使用app的main looper來處理。
根據上面的引數,我們一個個來具體的分析:
建立CryptoObject類物件
上面我們分析FingerprintManager的authenticate方法的時候,看到這個方法的第一個引數就是CryptoObject類的物件,現在我們看一下這個物件怎麼去例項化。
我們知道,指紋識別的結果可靠性是非常重要的,我們肯定不希望認證的過程被一個第三方以某種形式攻擊,因為我們引入指紋認證的目的就是要提高安全性。但是,從理論角度來說,指紋認證的過程是可能被第三方的中介軟體惡意攻擊的,常見的攻擊的手段就是攔截和篡改指紋識別器提供的結果。這裡我們可以提供CryptoObject物件給authenticate方法來避免這種形式的攻擊。
FingerprintManager.CryptoObject是基於Java加密API的一個包裝類,並且被FingerprintManager用來保證認證結果的完整性。通常來講,用來加密指紋掃描結果的機制就是一個Javax.Crypto.Cipher物件。Cipher物件本身會使用由應用呼叫Android keystore的API產生一個key來實現上面說道的保護功能。
為了理解這些類之間是怎麼協同工作的,這裡我給出一個用於例項化CryptoObject物件的包裝類程式碼,我們先看下這個程式碼是怎麼實現的,然後再解釋一下為什麼是這樣。
public class CryptoObjectHelper
{
// This can be key name you want. Should be unique for the app.
static final String KEY_NAME = "com.createchance.android.sample.fingerprint_authentication_key";
// We always use this keystore on Android.
static final String KEYSTORE_NAME = "AndroidKeyStore";
// Should be no need to change these values.
static final String KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES;
static final String BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC;
static final String ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7;
static final String TRANSFORMATION = KEY_ALGORITHM + "/" +
BLOCK_MODE + "/" +
ENCRYPTION_PADDING;
final KeyStore _keystore;
public CryptoObjectHelper() throws Exception
{
_keystore = KeyStore.getInstance(KEYSTORE_NAME);
_keystore.load(null);
}
public FingerprintManagerCompat.CryptoObject buildCryptoObject() throws Exception
{
Cipher cipher = createCipher(true);
return new FingerprintManagerCompat.CryptoObject(cipher);
}
Cipher createCipher(boolean retry) throws Exception
{
Key key = GetKey();
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
try
{
cipher.init(Cipher.ENCRYPT_MODE | Cipher.DECRYPT_MODE, key);
} catch(KeyPermanentlyInvalidatedException e)
{
_keystore.deleteEntry(KEY_NAME);
if(retry)
{
createCipher(false);
} else
{
throw new Exception("Could not create the cipher for fingerprint authentication.", e);
}
}
return cipher;
}
Key GetKey() throws Exception
{
Key secretKey;
if(!_keystore.isKeyEntry(KEY_NAME))
{
CreateKey();
}
secretKey = _keystore.getKey(KEY_NAME, null);
return secretKey;
}
void CreateKey() throws Exception
{
KeyGenerator keyGen = KeyGenerator.getInstance(KEY_ALGORITHM, KEYSTORE_NAME);
KeyGenParameterSpec keyGenSpec =
new KeyGenParameterSpec.Builder(KEY_NAME, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(ENCRYPTION_PADDING)
.setUserAuthenticationRequired(true)
.build();
keyGen.init(keyGenSpec);
keyGen.generateKey();
}
}複製程式碼
上面的類會針對每個CryptoObject物件都會新建一個Cipher物件,並且會使用由應用生成的key。這個key的名字是使用KEY_NAME變數定義的,這個名字應該是保證唯一的,建議使用域名區別。GetKey方法會嘗試使用Android Keystore的API來解析一個key(名字就是上面我們定義的),如果key不存在的話,那就呼叫CreateKey方法新建一個key。
cipher變數的例項化是通過呼叫Cipher.getInstance方法獲得的,這個方法接受一個transformation引數,這個引數制定了資料怎麼加密和解密。然後呼叫Cipher.init方法就會使用應用的key來完成cipher物件的例項化工作。
這裡需要強調一點,在以下情況下,android會認為當前key是無效的:
- 一個新的指紋image已經註冊到系統中
- 當前裝置中的曾經註冊過的指紋現在不存在了,可能是被全部刪除了
- 使用者關閉了螢幕鎖功能
- 使用者改變了螢幕鎖的方式
當上面的情況發生的時候,Cipher.init方法都會丟擲KeyPermanentlyInvalidatedException的異常,上面我的程式碼中捕獲了這個異常,並且刪除了當前無效的key,然後根據引數嘗試再次建立。
上面的程式碼中使用了android的KeyGenerator來建立一個key並且把它儲存在裝置中。KeyGenerator類會建立一個key,但是需要一些原始資料才能建立key,這些原始的資訊是通過KeyGenParameterSpec類的物件來提供的。KeyGenerator類物件的例項化是使用它的工廠方法getInstance進行的,從上面的程式碼中我們可以看到這裡使用的AES(Advanced Encryption Standard )加密演算法的,AES會將資料分成幾個組,然後針對幾個組進行加密。
接下來,KeyGenParameterSpec的例項化是使用它的Builder方法,KeyGenParameterSpec.Builder封裝了以下重要的資訊: - key的名字
- key必須在加密和解密的時候是有效的
- 上面程式碼中BLOCK_MODE被設定為Cipher Block Chaining也就是KeyProperties.BLOCK_MODE_CBC,這意味著每一個被AES切分的資料塊都與之前的資料塊進行了異或運算了,這樣的目的就是為了建立每個資料塊之間的依賴關係。
- CryptoObjectHelper類使用了PKSC7(Public Key Cryptography Standard #7)的方式去產生用於填充AES資料塊的位元組,這樣就是要保證每個資料塊的大小是等同的(因為需要異或計算還有方面演算法進行資料處理,詳細可以檢視AES的演算法原理)。
- setUserAuthenticationRequired(true)呼叫意味著在使用key之前使用者的身份需要被認證。
每次KeyGenParameterSpec建立的時候,他都被用來初始化KeyGenerator,這個物件會產生儲存在裝置上的key。
怎麼使用CryptoObjectHelper呢?
下面我們看一下怎麼使用CryptoObjectHelper這個類,我們直接看程式碼就知道了:
CryptoObjectHelper cryptoObjectHelper = new CryptoObjectHelper();
fingerprintManager.authenticate(cryptoObjectHelper.buildCryptoObject(), 0,cancellationSignal, myAuthCallback, null);複製程式碼
使用是比較簡單的,首先new一個CryptoObjectHelper物件,然後呼叫buildCryptoObject方法就能得到CryptoObject物件了。
取消指紋掃描
上面我們提到了取消指紋掃描的操作,這個操作是很常見的。這個時候可以使用CancellationSignal這個類的cancel方法實現:
這個方法專門用於傳送一個取消的命令給特定的監聽器,讓其取消當前操作。
因此,app可以在需要的時候呼叫cancel方法來取消指紋掃描操作。
處理使用者的指紋認證結果
前面我們分析authenticate介面的時候說道,呼叫這個介面的時候必須提供FingerprintManager.AuthenticationCallback類的物件,這個物件會在指紋認證結束之後系統回撥以通知app認證的結果的。在android 6.0中,指紋的掃描和認證都是在另外一個程式中完成(指紋系統服務)的,因此底層什麼時候能夠完成認證我們app是不能假設的。因此,我們只能採取非同步的操作方式,也就是當系統底層完成的時候主動通知我們,通知的方式就是通過回撥我們自己實現的FingerprintManager.AuthenticationCallback類,這個類中定義了一些回撥方法以供我們進行必要的處理:
這裡寫圖片描述
下面我們簡要介紹一下這些介面的含義:
- OnAuthenticationError(int errorCode, ICharSequence errString) 這個介面會再系統指紋認證出現不可恢復的錯誤的時候才會呼叫,並且引數errorCode就給出了錯誤碼,標識了錯誤的原因。這個時候app能做的只能是提示使用者重新嘗試一遍。
- OnAuthenticationFailed() 這個介面會在系統指紋認證失敗的情況的下才會回撥。注意這裡的認證失敗和上面的認證錯誤是不一樣的,雖然結果都是不能認證。認證失敗是指所有的資訊都採集完整,並且沒有任何異常,但是這個指紋和之前註冊的指紋是不相符的;但是認證錯誤是指在採集或者認證的過程中出現了錯誤,比如指紋感測器工作異常等。也就是說認證失敗是一個可以預期的正常情況,而認證錯誤是不可預期的異常情況。
- OnAuthenticationHelp(int helpMsgId, ICharSequence helpString) 上面的認證失敗是認證過程中的一個異常情況,我們說那種情況是因為出現了不可恢復的錯誤,而我們這裡的OnAuthenticationHelp方法是出現了可以回覆的異常才會呼叫的。什麼是可以恢復的異常呢?一個常見的例子就是:手指移動太快,當我們把手指放到感測器上的時候,如果我們很快地將手指移走的話,那麼指紋感測器可能只採集了部分的資訊,因此認證會失敗。但是這個錯誤是可以恢復的,因此只要提示使用者再次按下指紋,並且不要太快移走就可以解決。
- OnAuthenticationSucceeded(FingerprintManagerCompati.AuthenticationResult result)這個介面會在認證成功之後回撥。我們可以在這個方法中提示使用者認證成功。這裡需要說明一下,如果我們上面在呼叫authenticate的時候,我們的CryptoObject不是null的話,那麼我們在這個方法中可以通過AuthenticationResult來獲得Cypher物件然後呼叫它的doFinal方法。doFinal方法會檢查結果是不是會攔截或者篡改過,如果是的話會丟擲一個異常。當我們發現這些異常的時候都應該將認證當做是失敗來來處理,為了安全建議大家都這麼做。
關於上面的介面還有2點需要補充一下:- 上面我們說道OnAuthenticationError 和 OnAuthenticationHelp方法中會有錯誤或者幫助碼以提示為什麼認證不成功。Android系統定義了幾個錯誤和幫助碼在FingerprintManager類中,如下:
我們的callback類實現的時候最好需要處理這些錯誤和幫助碼。 - 當指紋掃描器正在工作的時候,如果我們取消本次操作的話,系統也會回撥OnAuthenticationError方法的,只是這個時候的錯誤碼是FingerprintManager.FINGERPRINT_ERROR_CANCELED(值為5),因此app需要區別對待。
- 上面我們說道OnAuthenticationError 和 OnAuthenticationHelp方法中會有錯誤或者幫助碼以提示為什麼認證不成功。Android系統定義了幾個錯誤和幫助碼在FingerprintManager類中,如下:
比如這是我寫的自定義的AuthenticationCallback類
class FingerAuthCallback extends FingerprintManagerCompat.AuthenticationCallback {
@Override
public void onAuthenticationError(int errMsgId, CharSequence errString) {
super.onAuthenticationError(errMsgId, errString);
showError(errString);
}
@Override
public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) {
super.onAuthenticationHelp(helpMsgId, helpString);
showError(helpString);
}
@Override
public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
mIcon.setImageResource(R.drawable.ic_fingerprint_success);
mErrorTextView.setTextColor(
ContextCompat.getColor(context, R.color.success_color));
mErrorTextView.setText(
context.getResources().getString(R.string.fingerprint_success));
}
@Override
public void onAuthenticationFailed() {
super.onAuthenticationFailed();
showError(context.getResources().getString(R.string.fingerprint_not_recognized));
}
}複製程式碼
額外補充
指紋解鎖可以用這個Github上的開源的庫:FingerprintAuthHelper
我使用了。起碼我測試沒問題。
谷歌的指紋解鎖的Demo:FingerprintDialog (進入後點選右上角的download按鈕,下載demo)
參考文章:
感謝createchance的 Android 6.0指紋識別App開發demo