如何在复杂业务场景中优雅实现Android指纹验证?

前言

目前指纹领域无论从产品角度还是技术角度都已经趋于成熟,但是当各位开发者准备深入探究的时候,却发现网上很多文章都是皮毛,很难有较深的启示。本文将着重介绍指纹验证开发整个过程,包括技术选型、产品的设计方案逻辑、代码的架构以及后续测试中遇到的兼容性问题等几个方面。在这里抛砖引玉,希望能给予大家一些启发。

技术选型

产品:咱们 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 和四个场景的关系又是如何?我们看看类图。

可以看到,四个场景,对应四个状态类,控制器和状态类实现了同一个接口,在内部根据当前场景转发给对应的类, 那怎么根据场景转发给对应类?我们建立一个映射表,把场景和类对应起来。每次匹配的话只要 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?

表现机型:MI 5s、vivo X9

在同一机型上调用 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。


更新:

Android P 引入了若干可提升应用和运行应用的设备安全性的功能。
其中一项:


我是 FeelsChaotic,一个写得了代码 p 得了图,剪得了视频画得了画的程序媛,致力于追求代码优雅、架构设计和 T 型成长。

欢迎关注 FeelsChaotic 的简书掘金,如果我的文章对你哪怕有一点点帮助,欢迎 ❤️!你的鼓励是我写作的最大动力!

最最重要的,请给出你的建议或意见,有错误请多多指正!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,043评论 25 707
  • 最近项目需要使用到指纹识别的功能,查阅了相关资料后,整理成此文。 指纹识别是在Android 6.0之后新增的功能...
    湫水长天阅读 3,698评论 2 46
  • Apple Pay自推出以来就备受关注,很多朋友非常看好其前景,不过其安全性也受到更多人的关注,毕竟这是关乎大家钱...
    饼哥阿杜阅读 1,736评论 0 1
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,490评论 18 139
  • 不是远足才能看到的幽静, 不是被瞩目才会绽放的明静。 我想要成为那一湾水, 顺风到江里,顺风到海中…… 有不经风的...
    栖风慢阅读 210评论 0 1