如何使用 Flutter 实现一个 OTP 验证器

我刚入职的时候,公司使用 RSA 公司的 token,所谓的 token 就是一个像优盘一样的硬件,每隔 30 秒会产生一个 6 位数字,这个数字作为一次性密码,也即标题里提到的 OTP,one-time-password 的缩写。

后来手机普及后,出现了很多软件实现的 OTP,比如 QQ 安全中心,微软的 Authenticator,以及 Google Authenticator。这些验证器和一次性密码,成为两步验证一种比较流行的方式。我个人也很喜欢,至少我觉得比短信验证码体验要好一些。

什么是 OTP?

OTP (One-Time Password),翻译成中文为一次性密码,是一种加强网络安全的方法。在使用传统的用户名和密码进行登录时,由于密码可能被泄露或者猜测,因此使用 OTP 能够在一定程度上防止网络攻击和不法分子的破坏。

OTP 常见的实现方式有 HOTP 和 TOTP。而 HOTP (HMAC-based One-Time Password),利用哈希算法来生成密码。HMAC(Hash-based Message Authentication Code,中文名:基于哈希的消息认证码),这个算法主要是用于验证消息的合法性,与常见的哈希算法的唯一区别是,在计算哈希摘要时,还需要额外提供一串密钥,俗称加盐(salt 或 nonce)。这串密钥只有客户端和服务端双方知道,被计算摘要的消息要求双方都能知道并保持相同,一般是一个自增计数器,比如:0, 1, 2, 3, 4。被计算出的一次性口令每使用一次,这个计数器就加一,由于密钥只有双方才知道,故双方都可以计算出一样的一次性口令,而第三方不知道这串密钥的,无法计算出一样的口令。RFC 4226 描述了其技术规范。

验证与被验证两方,怎么能保证用于验证的消息始终保持同步呢?在现实世界中,确实是一个麻烦的事情,所以,就有 TOTP。TOTP (Time-based One-Time Password),时间同步动态口令,是一种基于时间的 OTP,也就是说 TOTP 通过计算当前时间和一个密钥来生成密码。这样,验证与被验证两方,共同使用的用于验证的消息,就是时间。其实就是将消息建立在全球时间服务的基础上,实现共识。不但各方得到的时间是一致的,而且时间在单向向前改变数字,成为了 OTP 的很好基础。

如果两边时间不一致,有误差怎么办呢?这确实是一个问题,所有的服务器或者电子设备都有保持正确时间的基础设施。能保证在一个足够小的延迟内时间完全一致。我们在生成验证码的时候,只要忽略那些最高精度的时间,就可以保证多方一致了。不过还是要求两方要接入足够可靠的时间服务器。RFC 6238 描述了 TOTP 的技术规范。

如何运用 OTP?

综上,OTP 就是一次性密码而已,可以提高一个体系内部的密码验证过程的安全性。因为降低了密码的时效性,使得猜测和破解变得更加困难了。

那么我们如何将 OTP 运用在日常生活中呢?如果大家接触安全要求高的领域就很容易发现,其实使用已经非常广泛了。为大众所熟知的,其实就是手机短信验证码。只不过,那个验证码虽然是一次性的,但是未必是 TOTP,因为手机传送短信的信道,时效性比较差,所以,经常一个验证码的有效期长达几分钟或者数小时。不过 OTP 的思想是一样的。

在我们公司的服务器运维中,要求工程师登录服务器的时候提供一次性密码,在堡垒机进行验证。在公司内部的 Web 系统里,涉及到敏感操作,也一般需要提供 OTP,这样降低操作被冒用的概率。(不能完全避免)

我们只要在服务器端,接入相应的 OTP 验证的类库,既可以完成此项认证,并不难。一般来说,更常用的是 TOTP,因为使用方便,开发容易。

为什么要实现一个 OTP 生成器?

其实,TOTP 的原理是有规范的,实现也不难。市面上已经很多很多 App 了,也都可以通用在各个要求的场景。那么为什么每家都要自己实现呢?而不是去用通用的产品。这个问题的答案,就不得而知了。不过我可以基于自己的经验来猜测一下。

大厂是必然要实现自己的验证码生成器的,不然容易被对手所乘。比如,你能想象,如果微软的用户都用 Google 验证器,如果有一天,Google 在发布验证器 App 的时候,突然故意不支持微软的密码生成,会对微软的用户产生巨大的影响。虽然事实上,不可以这样做,但是防人之心不可无。

登录对于每家公司来说,都是公司提供的服务与用户连接的重要入口。打开频率十分高,如果用户在登录自己的服务时候,一直弹出一个竞品的广告,天长日久的,也可能被刺激更换了自己的服务。那么商业上的损失是巨大的。

或者,用户用了一个黑客实现的验证器。在给用户提供验证码的时候,顺便拷贝一份发送到黑客自己的服务器。那么 OTP 就被黑客取得了。一旦从另外渠道取得了用户的固定密码,那么安全就当然无存了。

所以,无聊是基于哪个原因,各个大厂,甚至所有需要使用到 OTP 的厂,都有动机去实现一个自己的 OTP 验证码生成客户端。

如何使用 Flutter 实现一个 OTP 生成器?

我负责开发公司的 OA 系统 App,公司内部也有一些场景需要用到 OTP,现在我们使用聊天软件来发送 OTP,其实有点类似短信验证码。而聊天软件的可靠性一般,短信和邮箱延迟又比较大,而短信还需要保持手机号的准确,相比之下,在用户手机上使用 TOTP 的话,会更经济,使用过程中也不需要网络。作为验证的一种补充,所以,我也要在 OA App 里实现一个 OTP 生成器。

使用 Flutter 来实现这个功能是非常简单的。下面规范文档介绍了一些实现的细节。

1
Extensions to Salted Challenge Response (SCRAM) for 2 factor authentication

首先,我们来设计一下整个功能的基本使用流程:

  1. 首先,用户需要去绑定一个 OTP 验证码,现在一般通过扫码的方式进行绑定;

  2. 然后,用户把绑定后产生的一组密码输入到绑定的服务上,进行第一次验证,验证成功即完成绑定。

  3. 每次打开验证器,会展示一个列表,是用户此前用 1 和 2 绑定的 OTP 列表。每个条目产生一组 6 位数字。

  4. 如果用户不想使用了,可以删除一项绑定关系。

  5. 此外,每次出现数字后,还要展示一个倒计时,告诉用户这组 OTP 的有效期还剩多少时间。

我们可以使用 otp 3.1.4 这个包来实现验证码生成,下面是生成符合规范的 TOTP 的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
/// TOTP 的 code
String get code {
var now = DateTime.now();
// 经过反复尝试,终于试出来正确的参数组合了
// 以下参数组合,在 Twitter 的两步验证中,可以正常工作
return OTP.generateTOTPCodeString(
secret!,
now.millisecondsSinceEpoch,
algorithm: Algorithm.SHA1,
isGoogle: true,
);
}

从上述代码片段可以看出,调用是非常简单的,首先获取到时间,然后调用类库方法即可。类库是开源的,点进去就可以看到具体实现,我就不赘述了。

其实真正耗费我一些时间去摸索的是,到底怎么填写这个参数。用户通过扫码二维码,会得到服务器分配给用户的一个秘钥,其格式是这样的:

1
otpauth://totp/{label}?secret={secret}&issuer={issuer}

这是一个 URI 的规范。说明是一个 totp,label 里包含了用户的信息和服务的信息,例如:Google:alice@gmail.com 类似这样的。secret 这个字段就比较耐人寻味,这个字段的得到的内容是参差的,有的小写字母,有的是大写字母,长短也不一样,一时我也不知道有什么区别。

另外规范里要求 secret 是一个 base32 编码的字符串。一时也搞不清,是否需要自己 decode,至少,类库的文档是比较模糊的。方法调用填上什么参数,都是可以产生出 6 位数字的,就是未必是服务器认可的,不一定正确。要想正常使用,必须使用和服务器一样的生成方法,才能验证通过。

好在消耗了一些时间,终于还是让我凑出了正确的参数填写方法。

这里还有一个交互上的小细节,就是根据原理,TOTP 的验证码默认的有效时间是 30 秒,那么现在的怎么告知用户这个呢,首先是要根据原理,计算出现在剩余的时间,然后用一个小动画,告诉用户这个剩余的时间。

1
2
3
4
/// 剩余有效时间
int get remainingSeconds {
return OTP.remainingSeconds();
}

好在,类库也提供了计算剩余时间的算法。这样直接调用就可以得到数字了。然后,在界面上,我放置了一个,CircularProgressIndicator 圆环进度指示器,用来指示剩余时间,很方便,也比较美观。

总结

TOTP 是一种简易方便的一次性密码生成方法和验证方法,其实现有标准规范,以及很多开源的类库实现。简单动手就可以提升自己系统体系的安全性。自研客户端也很容易实现。

[1] 在这里可以尝试验证实现的 OTP token 是否正确。https://moyuscript.github.io/2fa-test/
[2] 此文介绍了用 node 实现的方法 https://zhuanlan.zhihu.com/p/484991482
[3] 本文介绍了 HOTP 和 TOTP 的原理,有图示 https://bbs.huaweicloud.com/blogs/307638