货拉拉专送司机Android指纹认证登录实践与总结

1. 前言

指纹登录是一种很常见的登录方式,特别在各种金融类APP中,指纹登录的功能已成为标配。为了丰富我们的登录方式,提高我们的用户体验,降低我们的短信成本,我们最近在货拉拉专送司机Android端上线了指纹登录功能。Google从Android 6.0开始,提供了开放的指纹识别相关API,我们在基于指纹验证的基础功能上实现登录的业务场景,希望通过本次的分享能让你的app快速实现指纹登录能力。

2. 指纹识别

从Android 6.0(Android M Api23)开始,Android 系统支持指纹识别功能,指纹识别的API主要是FingerprintManager核心类。FingerprintManager提供了基础的指纹识别功能,实现的方法主要有:判断系统是否支持指纹,系统是否录入过指纹,发起指纹验证,取消验证和验证结果回调。然而需要注意的是,FingerprintManager在Android 9.0(Android P Api28)做了 @Deprecated 标记,将被弃用。Android 9.0以后Google官方不再推荐使用FingerprintManager接口,推荐使用以BiometricPrompt为核心的新API代替。BiometricPrompt支持设备提供的生物识别,包括指纹、虹膜、面部等。但是目前来看,虹膜和面部等生物识别的Api尚未完全开放,大部分设备仅支持指纹识别,不过在指纹识别上进行了统一,比如要求使用统一的指纹识别UI,不允许开发者自定义了。下面介绍FingerprintManager和BiometricPrompt这两种接口的使用方法。

2.1 FingerprintManager

FingerprintManager使用步骤:

① 申请指纹权限

xml 复制代码
<!-- AndroidP(6.0)时 开启触摸传感器与身份认证的权限 -->
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>

② 判断是否支持指纹识别

  • isHardwareDetected() 判断是否有硬件支持
  • isKeyguardSecure() 判断是否设置锁屏,因为一个手机最少要有两种登录方式
  • hasEnrolledFingerprints() 判断系统中是否添加至少一个指纹
kotlin 复制代码
/**
 *  判断是否支持指纹识别
 */
public static boolean supportFingerprint(Context context) {
    if (VERSION.SDK_INT < 23) {
        // 系统版本过低,不支持指纹功能
        return false;
    } else {
        KeyguardManager km = context.getSystemService(KeyguardManager.class);
        FingerprintManagerCompat fm = FingerprintManagerCompat.from(context);
        if (!fm.isHardwareDetected()) {
            // 手机不支持指纹功能
            return false;
        } else if (!km.isKeyguardSecure()) {
            // 未设置锁屏,请先设置锁屏并添加一个指纹
            return false;
        } else if (!fm.hasEnrolledFingerprints()) {
            // 至少需要在系统设置中添加一个指纹
            return false;
        }
    }
    return true;
}

③ 开启指纹验证

java 复制代码
@RequiresApi(api = Build.VERSION_CODES.M)
public void authenticate(FingerprintManager.CryptoObject cryptoObject, 
              CancellationSignal cancellationSignal,
              int flag,
              FingerprintManager.AuthenticationCallback authenticationCallback, 
              Handler handler) {
    if (fingerprintManager != null) {
      fingerprintManager.authenticate(cryptoObject, cancellationSignal, flag, authenticationCallback, handler);
    }
}

authenticate()这是指纹识别中最核心的方法,用于拉起指纹识别扫描器进行指纹识别。解释一下这个方法参数:

  • Int flags:可选标志,暂无用处,传0即可

  • Handler handler:这个参数作用是告诉系统使用这个Handler的Looper处理指纹识别的Message。默认就是交给主线程的Looper处理,传null即可。

  • FingerprintManagerCompat.CryptoObject crypto:这是一个密码对象的包装类,当前支持Signature和Cipher形式的密码对象加密,作用是指纹扫描器会使用这个对象判断指纹认证结果的合法性。可以传null,但是不建议传null,下面会解释。

  • CancellationSignal cancel:这个对象的作用就是用来取消指纹扫描器的扫描操作。比如在用户点击识别框上的"取消"按钮或者切换到密码登录后,就要及时取消扫描器的扫描操作。不及时取消的话,指纹扫描器就会一直扫描,直至超时。这会造成两个问题:

    • 耗电。
    • 在超时时间内,用户将无法再次调起指纹识别。
  • FingerprintManagerCompat.AuthenticationCallback callback:指纹识别结果的回调接口,其中声明了几个方法:

    • onAuthenticationFailed():指纹验证失败回调方法;
    • onAuthenticationSucceeded(AuthenticationResult result):这个接口会在认证成功之后回调;
    • onAuthenticationHelp(int helpMsgId, CharSequence helpString):指纹验证错误时提示帮助,比如说指纹错误,我们将显示在界面上 让用户知道情况;
    • onAuthenticationError(int errMsgId, CharSequence errString):当出现错误的时候回调此函数,比如多次尝试都失败了的时候;

因此一个指纹识别事件顺序是这样:开始识别 --> (onAuthenticationHelp/onAuthenticationFailed)[0个或多个] --> onAuthenticationSucceeded/onAuthenticationError。

取消识别

csharp 复制代码
/**
 * 停止识别
 */
public void cancelListening() {
    if (cancellationSignal != null) {
        cancellationSignal.cancel();
    }
}

2.2 BiometricPrompt

BiometricPrompt和FingerprintManager差异如下:

① 引入依赖

arduino 复制代码
implementation "androidx.biometric:biometric:1.1.0"

② 申请指纹权限

xml 复制代码
<!--AndroidP(9.0)时 生物识别权限-->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

③ 开启指纹验证

less 复制代码
BiometricPrompt.PromptInfo promptInfo=new BiometricPrompt.PromptInfo.Builder()
    .setTitle("指纹验证")
    .setDescription("用户指纹验证")
    .setNegativeButtonText("取消")
    .build();
getprompt().authenticate(promptInfo);

private BiometricPrompt getprompt() {
    Executor executor = ContextCompat.getMainExecutor(this);
    BiometricPrompt.AuthenticationCallback callback=new BiometricPrompt.AuthenticationCallback() {
        
        // 指纹验证成功
        @Override
        public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
        
        }
        
        // 指纹验证失败
        @Override
        public void onAuthenticationFailed() {
        
        }
        
        // 指纹验证错误
        @Override
        public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
        
        }
    }
    BiometricPrompt biometricPrompt = new BiometricPrompt(this,executor,callback);
    return biometricPrompt;
}

④ 效果图

BiometricPrompt指纹识别有系统的识别弹窗,不能自定义UI,以下是在honor x8的显示效果:

以上介绍了指纹识别的基础用法,但是仔细看下识别的结果回调,仅仅返回识别成功/失败。这就存在两个问题:

  • 无法跟账号关联起来,满足不了指纹登录功能的需求;
  • 如果手机被Root,或者通过hook重写authenticate方法,在authenticate方法中直接调用AuthenticationCallback.onAuthenticationSucceeded方法,这样就可以随意修改识别结果,这显然是不安全的。

因此在使用authenticate()方法时,若对第一个CryptoObject参数使用方式不正确,则存在指纹验证被绕过的风险,所以我们需要将指纹识别与KeyStore结合起来使用。

2.3 AndroidKeystore

AndroidKeystore系统是一个密钥库管理系统,可以在一个安全的容器中(如:借助于系统芯片中提供的可信执行环境 TEE)存储加密密钥,在我们的加密密钥进入 Keystore 之后,可以在不用导出密钥的前提下完成加密操作。

另外,我们可以结合身份验证系统(如指纹验证)来实现只有在用户完成身份验证之后才能使用密钥,即可以让应用指定密钥的授权使用方式,一旦生成或导入密钥,授权方式将无法更改,并且之后每次需要使用密钥时,都会由 Android Keystore 库强制执行授权。

下面看下如何结合 Keystore 和 Fingerprint 来实现数据的加密和解密:

首先我们需要通过 Keystore 库来生成(用于后续对数据进行加密的)密钥,这里采用 AES 加密算法,并且设置这个密钥为强制授权访问方式。

① 新建一个KeyStore密钥库,用于存放密钥;

ini 复制代码
KeyStore keystore = KeyStore.getInstance(KEYSTORE_NAME);
keystore.load(null);

② 获取KeyGenerator密钥生成工具,生成密钥;

在生成的过程中设置了要求用户身份验证的选项,后续步骤中的加密和解密步骤我们可以通过指纹识别来完成用户身份验证并通过相应的密钥进行加解密。

ini 复制代码
/**
 * 获取KeyGenerator密钥生成工具,生成密钥
 */
@RequiresApi(api = Build.VERSION_CODES.M)
public void createKey() throws Exception {
    KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_NAME);
    KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(DEFAULT_KEY_NAME, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
            .setUserAuthenticationRequired(true)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7);
    keyGenerator.init(builder.build());
    keyGenerator.generateKey();
}

③ 通过密钥初始化Cipher对象,生成加密对象CryptoObject;

ini 复制代码
/**
 * 通过密钥初始化Cipher对象,生成加密对象CryptoObject
 */
public Cipher createCipher() throws UnrecoverableKeyException, NoSuchAlgorithmException, KeyStoreException, NoSuchPaddingException, InvalidKeyException {
    SecretKey key = (SecretKey) keyStore.getKey(DEFAULT_KEY_NAME, null);
    Cipher cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/" + KeyProperties.BLOCK_MODE_CBC + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7);
    cipher.init(Cipher.ENCRYPT_MODE, key);
    return cipher;
}

④ 调用authenticate() 方法启动指纹传感器并开始监听

ini 复制代码
CryptoObjectHelper cryptoObjectHelper = new CryptoObjectHelper();
if (cancellationSignal == null) {
    cancellationSignal = new CancellationSignal();
}
fingerprintManager.authenticate(
    cryptoObjectHelper.buildCryptoObject(), 
    0,
   cancellationSignal, 
    myAuthCallback, 
    null);

⑤ 在回调的类中监听指纹识别的结果

在指纹验证通过后回调 onAuthenticationSucceeded 方法,并且在输入参数 AuthenticationResult result 中携带加密算法对象,通过此加密算法对象完成加密工作;

ini 复制代码
@Override
public void onSucceeded(FingerprintManager.AuthenticationResult result) {
    try {
        Cipher cipher = result.getCryptoObject().getCipher();
        byte[] bytes = cipher.doFinal(pwd.getBytes());
        aCache.put("pwdEncode", Base64.encodeToString(bytes,Base64.URL_SAFE));
        byte[] iv = cipher.getIV();
        aCache.put("iv", Base64.encodeToString(iv,Base64.URL_SAFE));
    }catch (Exception e){
        e.printStackTrace();
    }
}

接下来我们通过KeyStore和Fingerprint结合实现数据加密和解密的方式,来实现指纹登录功能。

3. 指纹登录

登录功能是我们应用中的逻辑,我们如何把指纹识别穿插在其中呢?实际上,我们可以把指纹登录简单的理解成不需要用密码登录,那么为了能过不用密码登录,我们需要把用户登录需要的信息保存到本地,那么就需要考虑到本地数据的安全问题,在这里,指纹识别就为我们本地数据提供了加密/解密的功能。

3.1 开启指纹登录

在使用指纹登录功能前,我们需要先开启指纹登录,这个步骤是为了获取登录所需要的数据,并进行加密存储,具体过程如下图所示:

  • 开启指纹登录:开启指纹登录的时机,一般都是先用其他方式登录了App,然后再开启指纹登录。
  • 比对系统存在的指纹:指纹数据始终保存在手机本地,不会上传到远程。Android提供的指纹认证API只返回成功或者失败的认证结果。
  • 指纹验证成功:私钥保存在Keystore,只有指纹认证成功后才能使用,公钥上传到服务端,供指纹登录的解密、验签使用。

3.2 使用指纹登录

通过上面开启指纹登录之后,在使用指纹登录时,获取存储加密的数据,上传给服务端进行解密验签,然后进行登录。

  • 登录授权码:指纹登录的请求可能被人截获,虽然无法解密,但是可以重复请求服务器获取token,在指纹登录流程中加入授权码并且使用一次就失效,可以防止攻击者绕过指纹认证。
  • 签名数据:主要是用客户端私钥签名用户信息和授权码,在开启指纹登录时指定必须采用指纹授权的方式才能使用私钥,这样保证签名是有认证过的用户发起的;服务端如果验证签名通过,则代表数据在传输过程中没有被篡改,登录是在可信的客户端发起的。

4. 兼容性

我们通过指纹识别实现了指纹登录的功能,但还需要关注和处理其中的兼容性和安全性问题。下面首先说兼容性:

4.1 版本兼容

指纹识别的API是Google在Android 6.0之后开放出来的。在Android 6.0以下的系统上,某些手机厂商自行支持了指纹识别,如果我们的App要兼容这些设备,就需要集成厂商的指纹识别SDK,这是最大的兼容性问题。不过,现在Android 6.0以下的设备已经很少了,其中支持指纹识别的设备就更少了,不对其进行兼容,也是可以的。

其次,从Android 6.0新增了FingerprintManager用于指纹识别,后续又新增了FingerprintManagerCompat提供了一些兼容操作,再到Android 9.0新增了BiometricManager API用于生物识别,并将FingerprintManager添加了@Deprecated标记,所以在实现时我们也需要注意这些版本兼容问题。

比如在调用FingerprintManagerCompat的isHardwareDetected()和hasEnrolledFingerprints()方法时返回的结果,可能与调用FingerprintManager的isHardwareDetected()和hasEnrolledFingerprints()方法的结果不一致,建议判断符合指纹条件时两种都判断一次。

4.2 UI兼容

FingerprintManager是开发者自定义UI,因此不存在UI兼容性问题;BiometricPrompt使用的是系统弹窗,开发者不可以自定义弹窗样式,仅可以设置弹窗上的相关文案及按钮点击事件。因此不同厂商对系统Dialog样式的不同实现,会导致指纹验证流程中出现不同的交互。

5. 安全性

然后说下安全性,由于已添加的指纹是存储在手机上的,指纹识别API验证后仅仅返回true或false,如果用户的手机root了,指纹识别是有可能被劫持而返回错误的识别结果。解决这个问题的方法是在调用authicate方法前,通过AndroidKeyStore生成秘钥,并用这把秘钥生成crypto对象传入authicate方法,在AuthenticationCallback.onAuthenticationSucceeded方法中取出crypto对象,如果没有通过指纹认证,crypto对象是无法正常使用的,这在上文中已有详细说明。

使用Google API,只要验证的指纹是手机系统指纹列表里存在的,就能验证通过,Google API是没有提供指纹唯一ID的,所以想要根据本机上的指纹ID来区别不同手指无法做到,也就无法实现指纹和账号绑定。这样会出现下面这种现象,你开通指纹登录后,又在系统中录入了新的指纹,下次可以用新指纹登录你的账号。

在一些低版本中指纹认证成功后,根据FingerprintManager.AuthenticationResult对象可以通过反射方法获取到指纹id,但是通过阅读Android源码时,会发现大部分版本中获取指纹信息的方法被@UnsupportedAppUsage注解,表示该方法不支持App去调用,也无法通过反射的方式来获取。

在上文开启指纹登录阶段,App会生成一对非对称密钥,用于后续服务端认证客户端身份,这个私钥被TEE安全密钥加密存储在手机中,目前没有破解之法,但是在密钥的生成过程中可能被替换,那存储和使用再怎么安全也无济于事了。其次,服务端无法确认上传的公钥是否是客户端生成的,也就是无法验证公钥的来源,无法验证客户端身份。

针对上述的一些安全性问题,在国内腾讯和阿里都选择和手机厂商合作,分别提供了SOTER和IFAA两套方案,两者都要求手机厂商在产线上生成一对非对称密钥,这个密钥称为根密钥,私钥写入手机TEE,即使拿到了手机当前也没有破解之法,私钥也就不会泄漏;公钥上传到认证服务器,认证的时候用于验证手机APP上传的数据签名。针对Android密钥生成可能被拦截的问题,让手机厂商装个补丁,替换掉不安全的中间环节,同时让手机厂商修改能在指纹识别成功后能拿到指纹ID。

下面介绍下SOTER的密钥信任原理,除了厂商内置的根密钥,SOTER又搞了应用密钥和业务密钥,我们来看下应用密钥和业务密钥的产生过程。

应用密钥产生过程:应用密钥在应用首次安装的时候生成,应用密钥的私钥保存在手机端,并使用厂商内置的私钥签名,然后发送到腾讯的认证服务器签名,此时应用密钥的公钥会保存到应用的后台。

业务密钥的产生过程:在使用登录的指纹认证注册时,应用会再生成一个业务密钥,业务密钥的私钥还是保存在手机端,业务密钥使用手机端的应用密钥进行签名,验证只需要在应用的后台验证就可以,此时和认证服务器就没有关系了。

我们通过信任厂商内置根密钥来信任应用密钥,最终确认登录时使用的业务密钥是安全的,这样也可以让服务端确认上传的公钥是否可靠,通过与厂商合作,SOTER解决上述的安全问题。IFAA的应用过程和SOTER差不多,另外国外还有一个FIDO联盟,也是解决这些问题。总的来说,在对安全性要求相对较低的场景(如解锁),使用Android原生指纹接口配合AndroidKeystore实现;在对安全性要求较高的场景(如支付),选择采用第三方解决方案,兼容性问题少,安全性高。

6.上线效果

专送司机端Android指纹登录上线后在Honor 8X设备上运行的部分截图如下:

7. 总结

货拉拉专送司机端Android指纹登录上线以来,根据我们的数据统计,有大量司机开通和使用了指纹登录,这种方便快捷的登录方式更受司机青睐,同时也节约了我们在短信验证码登录方面20%的成本。我们希望通过分享开发指纹登录过程中遇到的问题和解决方案,可以让更多的开发者从中受益,少走弯路,同时也欢迎读者和我们讨论沟通。

相关推荐
J不A秃V头A14 分钟前
Vue3:编写一个插件(进阶)
前端·vue.js
司篂篂37 分钟前
axios二次封装
前端·javascript·vue.js
姚*鸿的博客1 小时前
pinia在vue3中的使用
前端·javascript·vue.js
消失的旧时光-19431 小时前
kotlin的密封类
android·开发语言·kotlin
宇文仲竹1 小时前
edge 插件 iframe 读取
前端·edge
Kika写代码1 小时前
【基于轻量型架构的WEB开发】【章节作业】
前端·oracle·架构
服装学院的IT男3 小时前
【Android 13源码分析】WindowContainer窗口层级-4-Layer树
android
天下无贼!3 小时前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr3 小时前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林3 小时前
npm发布插件超级简单版
前端·npm·node.js