简介:这份资源是仿谷歌身份验证器(Google Authenticator)的 Android 开源实现,基于 Java 8 编写,面向需要为账户增加二次验证能力的 Android 开发者与安全学习者。它围绕「固定秘钥 + 实时时间点生成 6 位动态验证码、每 30 秒刷新」的核心原理,完整呈现网页下发秘钥或二维码、后台与 APP 同步计算验证码、两端比对通过才放行的验证流程,可用于理解 TOTP 机制及密码泄漏场景下的账户防护思路。压缩包共 114 个文件,约 5.72MB,以 35 个 java 源码与 31 个 xml 布局配置为主体,辅以 26 个 png 界面素材、3 个 apk 安装包及 gradle 构建脚本、jar 依赖等,工程结构完整、可直接导入研究。目前已有 442 人学习下载,适合作为二次验证功能开发与安全机制剖析的参考样例。
1. 仿谷歌身份验证器:一份 Java8 源码能跑出什么
手上这份 Java8 源码包,标题写得很直白——仿谷歌身份验证器,Android 端。它不是教你调个第三方 SDK 就完事的那种 Demo,而是把 TOTP 那套东西从密钥生成、Base32 编解码、HMAC-SHA1 摘要到 6 位动态码截断,整条链路摊开在源码里。对做系统开源项目的人来说,这类资源的价值不在“能跑”,而在“能改”:你想把验证器嵌进自己的登录流程、想搞清楚为什么服务端和客户端对不上码、想换 SHA256 或调步长,源码在手才有得谈。适合已经会写 Android 界面、但对 OTP 内部机制只停留在“调库”层面的开发者,也适合做安全模块评审、需要一份可对照实现的从业者。下面按“这东西怎么落地 → 参数怎么设 → 哪里会翻车”的顺序拆。
2. TOTP 链路拆解:从密钥到 6 位码的每一步
2.1 为什么是 TOTP 而不是 HOTP
谷歌身份验证器用的是 TOTP(基于时间的一次性密码),本质是 HOTP 加了一个时间因子。HOTP 的计数器是每次验证后自增的整数,客户端和服务端必须严格同步计数,一旦某次请求丢了、重试了,计数器错位,后面全对不上。TOTP 把计数器换成floor(当前Unix时间 / 步长),步长默认 30 秒,双方只要时钟大致对齐就能各自算出同一个计数器,不需要任何状态同步。这就是为什么你换手机、重装 App,只要重新扫一次密钥就能继续用——状态全在时间和密钥里,不在设备上。
源码里对应的是两个核心方法:一个负责把时间戳折算成计数器,一个负责用计数器做 HMAC。理解这一点,后面所有“码不对”的排查都有了根:要么密钥不一致,要么时间偏了,要么步长/算法对不上,没有第四种。
2.2 密钥生成与 Base32 编码
服务端下发给客户端的密钥通常是 16 到 32 字节的随机数,但二维码里不能直接塞二进制,得转成 Base32 字符串(谷歌验证器只认 Base32,不认 Base64,这是第一个容易想当然的地方)。源码里一般会有一个Base32工具类,做编码和解码两个方向。
// 生成 20 字节随机密钥并转 Base32,20 字节对应 SHA1 的块长,是常见默认值 public static String generateSecret() { byte[] buffer = new byte[20]; new SecureRandom().nextBytes(buffer); // 必须用 SecureRandom,不能用 Random return Base32.encode(buffer); // 输出类似 JBSWY3DPEHPK3PXP 的大写串 }逻辑说明:SecureRandom保证密钥不可预测,用java.util.Random是血泪级的错误,种子可推、序列可复现,等于把二次验证废掉。20 字节是 SHA1 的推荐密钥长度,短于 16 字节会削弱强度,长于 64 字节在 HMAC 里会先被哈希,收益递减。Base32 编码后不带填充符=,因为很多验证器扫二维码时对填充符处理不一致,源码里如果保留了=,部分机型会解析失败——这是实测过的坑,后面避坑章还会提。
2.3 HMAC-SHA1 与动态截断
拿到密钥和时间计数器后,标准流程是:计数器转 8 字节大端序 → 用密钥做 HMAC-SHA1 → 取摘要最后 4 位算偏移 → 从偏移处取 4 字节 → 与0x7FFFFFFF做与运算去掉符号位 → 对 10^6 取模得到 6 位码。
public static String generateCode(byte[] key, long timeStep) { byte[] data = new byte[8]; for (int i = 7; i >= 0; i--) { // 计数器转 8 字节大端序 data[i] = (byte) (timeStep & 0xff); timeStep >>= 8; } byte[] hash = hmacSha1(key, data); // 20 字节摘要 int offset = hash[hash.length - 1] & 0x0f; // 低 4 位作偏移,范围 0-15 int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); int otp = binary % 1000000; // 6 位 return String.format("%06d", otp); // 补前导零,别漏 }参数说明:timeStep就是System.currentTimeMillis() / 1000 / 30,注意 Java 里整数除法是截断不是四舍五入,这正好符合 RFC 6238 的定义。offset取摘要末字节低 4 位是规范写死的,不是随便挑的。& 0x7f是为了屏蔽符号位,Java 的byte是有符号的,不屏蔽会得到负数,取模后码就乱了——这是新手最容易翻车的一行。String.format("%06d")补零同样关键,12345和012345在验证器里是两个不同的码。
2.4 二维码 URI 的拼装规则
客户端扫码靠的是otpauth://URI,格式是otpauth://totp/发行方:账号?secret=xxx&issuer=发行方&algorithm=SHA1&digits=6&period=30。源码里如果只拼了secret而漏了algorithm、digits、period,多数验证器会按默认值处理,看起来能用,但一旦你服务端改了参数,客户端就静默按默认算,码永远对不上。建议拼全,别依赖默认。
String uri = String.format( "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30", urlEncode(issuer), urlEncode(account), secret, urlEncode(issuer));issuer和account都要 URL 编码,中文发行方名不编码会在部分扫码器里截断。issuer出现两次是规范要求,一次在路径里做显示,一次在查询参数里做归类,别嫌冗余。
3. 把源码跑起来:Android 工程配置与验证闭环
3.1 工程导入与最低版本约束
这份源码是 Java8 写的,导入 Android Studio 后先看build.gradle里的compileSdkVersion和minSdkVersion。TOTP 本身不依赖高版本 API,SecureRandom、Mac、Base64在 API 十几就有了,但源码里如果用了java.time(部分实现会用Instant算时间),那minSdkVersion得抬到 26,或者开 desugaring。常见做法是直接用System.currentTimeMillis()绕开,兼容性最好。
android { compileSdkVersion 33 defaultConfig { minSdkVersion 21 // 覆盖绝大多数在用设备 targetSdkVersion 33 } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } }逻辑说明:sourceCompatibility设成 1.8 是必须的,源码里用了 lambda 或接口默认方法的话,不设会编译报错。minSdkVersion 21是权衡,再低会丢掉一部分设备但收益不大。如果源码里确实用了java.time,在defaultConfig里加coreLibraryDesugaringEnabled true并引入 desugar 依赖,比硬抬 minSdk 更稳妥。
3.2 用服务端脚本做交叉验证
光在 App 里看到码跳数字不算验证,得和服务端算出来的码对上才算。最省事的办法是写一段独立的 Java 或 Python 脚本,用同一个密钥、同一个时间点算码,和 App 显示比对。Python 侧用pyotp或手写都行,手写更能暴露参数差异。
import hmac, hashlib, struct, time, base64 def totp(secret_b32, t=None, step=30, digits=6): key = base64.b32decode(secret_b32 + "=" * ((8 - len(secret_b32) % 8) % 8)) counter = int((t or time.time()) // step) msg = struct.pack(">Q", counter) # 大端 8 字节,和 Java 侧一致 h = hmac.new(key, msg, hashlib.sha1).digest() o = h[-1] & 0x0f code = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % (10 ** digits) return str(code).zfill(digits)这段脚本的每个参数都要和 Java 侧逐一对齐:step=30、digits=6、sha1、大端打包。跑通后把两边输出并排看,同一秒内必须完全一致。不一致就按“密钥 → 时间 → 步长 → 算法 → 位数”的顺序二分排查,别一上来就怀疑源码有 bug,九成是参数没对齐。这个闭环建立起来,后面改任何参数都有对照基准。
3.3 时间偏移的容错窗口怎么设
真实设备时钟不会永远准,服务端验证时通常允许前后各一个步长的容差,也就是校验counter-1、counter、counter+1三个值,任一匹配即通过。源码里如果只校验当前 counter,用户手机慢了几秒就会频繁验证失败,体验很差。
public static boolean verify(byte[] key, String inputCode, int window) { long counter = System.currentTimeMillis() / 1000 / 30; for (int i = -window; i <= window; i++) { if (generateCode(key, counter + i).equals(inputCode)) return true; } return false; }window设 1 是常见做法,覆盖 ±30 秒;设 2 容错更宽但被暴力猜中的窗口也变大,6 位码空间是 100 万,窗口 5 意味着单次尝试命中率变成 11/100 万,仍然可接受但不建议再放大。注意容差只该在服务端做,客户端显示不需要容差,否则用户看到的码会“提前跳”。
4. 避坑与排查:码对不上时先看这五条
4.1 现象:App 和服务端码始终差一位或完全不符
原因:Base32 解码时填充符处理不一致,或密钥字符串里混入了空格、小写字母。谷歌验证器对密钥大小写不敏感,但部分自实现解码器只认大写。解决:下发前统一toUpperCase()并去掉所有空白,解码时按 8 的倍数补=,编码时去掉=。用 3.2 的脚本做基准,先确认密钥解码出的字节数组两边完全一致,再往下查。
4.2 现象:刚装上能用,过几天开始随机失败
原因:设备时间漂移,或服务端和客户端用了不同的步长。Android 设备关掉自动对时后,一天漂几十秒很常见。解决:服务端开 ±1 步长容差;客户端在生成码的界面提示用户开启自动时间同步;排查时打印System.currentTimeMillis()/1000和服务端时间戳对比,差值超过 30 秒基本就是它。
4.3 现象:换 SHA256 后所有码失效
原因:只改了 HMAC 算法,没改密钥长度和 URI 里的algorithm参数。SHA256 的 HMAC 输出是 32 字节,截断逻辑里hash[hash.length-1]仍然成立,但密钥建议至少 32 字节,且 URI 必须写algorithm=SHA256,否则客户端按 SHA1 算。解决:算法、密钥长度、URI 参数三处同步改,改完用脚本重新建基准。
4.4 现象:6 位码出现 5 位,前面少个零
原因:用了Integer.toString(otp)而不是格式化补零。解决:统一String.format("%06d", otp),服务端比较时也按字符串比,别转成 int 比,012345转 int 变成12345,和用户输入的012345永远不等。
4.5 现象:二维码扫出来提示“无效的密钥”
原因:URI 里的secret带了非法字符,或issuer/account没做 URL 编码,中文或空格把查询串截断。解决:拼 URI 前对每个动态段做URLEncoder.encode(x, "UTF-8"),secret只保留 Base32 字符集A-Z2-7,生成后自己先扫一遍再下发。
5. 进阶:把验证器做成可配置的验证服务
源码跑通只是起点,真正落地时你会遇到“多个业务线共用一套验证、算法要能按租户切换”的需求。我的做法是把 TOTP 核心抽成一个无 Android 依赖的纯 Java 模块,参数全部外置。
| 参数 | 默认值 | 可调范围 | 影响 |
|---|---|---|---|
| algorithm | SHA1 | SHA1/SHA256/SHA512 | 摘要长度与密钥建议长度 |
| digits | 6 | 6/8 | 码空间,8 位更抗暴力但输入更烦 |
| period | 30 | 15/30/60 | 容差窗口与用户体验的平衡 |
| window | 1 | 0-2 | 服务端校验容差步数 |
| secretBytes | 20 | 16-64 | 密钥强度 |
抽模块时把generateCode、verify、buildUri三个入口暴露出去,Android 层只负责扫码和展示,服务端复用同一份逻辑,避免两边各写一套导致参数漂移。验证方法上,我习惯在 CI 里放一组固定密钥加固定时间戳的用例,断言输出等于已知值,任何改动先过这组用例——这比人肉扫码靠谱得多。
还有一个容易被忽略的点:密钥存储。客户端不该把密钥明文写进 SharedPreferences,至少用 Android Keystore 包一层;服务端存库时加密,别和用户表放同一个库还明文。二次验证的意义在于“即使密码泄露也进不去”,密钥明文落盘等于把这道门又拆了。
从那以后我每次接 OTP 相关的活,都先把“固定密钥 + 固定时间戳”的基准用例跑一遍再动业务代码,翻车次数肉眼可见地降下来。希望帮到你。
本文还有配套的精品资源,点击获取