简介:这份资源是面向Android开发者与Java学习者的仿谷歌身份验证器开源项目源码,基于Java 8实现,可用于理解TOTP动态口令的完整落地流程。项目围绕固定秘钥与实时时间点生成6位验证码、每30秒刷新一次的核心机制展开,覆盖秘钥生成与保存、后台与APP同步校验、二维码绑定及手机丢失后凭秘钥恢复等典型场景,适合需要为自有系统补充二次验证能力或研究认证原理的开发者参考。压缩包共114个文件,约5.72MB,以35个java源文件与31个xml布局配置为主体,辅以26个png界面素材、3个apk成品包及gradle构建脚本、jar依赖等,结构完整、可直接导入Android Studio运行调试。已有442人学习下载,读者可从中获取验证码生成算法、前后端校验流程与工程目录组织的完整实现思路。
1. 从一次登录验证说起:这个仿谷歌身份验证器到底能干什么
前阵子接手一个后台管理系统的二次开发,登录环节要求加动态口令,产品经理甩过来一句“就做成谷歌身份验证器那样”。我第一反应是去翻现成的开源实现,结果找到的这个项目挺对胃口——一个用 Java 写的、仿谷歌身份验证器(Google Authenticator)的 Android 端 Demo,核心是 TOTP 动态口令的生成与校验。它不依赖任何第三方鉴权服务,算法逻辑全在本地,源码结构清晰,适合拿来当理解双因素认证(2FA)的入口,也适合直接嵌进自己的 App 里做二次开发。
这个项目适合两类人:一类是想搞明白“手机上一串 6 位数字到底怎么算出来的”的开发者,另一类是手里有个 Java/Android 项目、需要补上动态口令这一环的工程同学。它把 RFC 6238 那套 TOTP 逻辑用 Java 8 的语法实现了一遍,配合 Android 界面展示,属于那种“代码量不大但知识点密集”的系统开源小项目。下面我按自己拆源码的顺序,从目录结构一路讲到怎么改。
2. 项目结构与技术选型:一个 TOTP 验证器由哪些零件拼成
2.1 目录骨架与模块职责
拿到源码包,先别急着点运行,把目录摊开看一遍能省很多事。这个项目的结构是典型的 Android 工程加一个纯 Java 的算法层,大致分成这么几块:
GoogleAuth_Android/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/googleauth/ │ │ │ ├── MainActivity.java // 界面入口,展示动态码与倒计时 │ │ │ ├── TotpGenerator.java // TOTP 核心算法 │ │ │ ├── Base32Utils.java // Base32 编解码 │ │ │ └── HmacSha1.java // HMAC-SHA1 摘要 │ │ ├── res/layout/activity_main.xml // 布局 │ │ └── AndroidManifest.xml │ └── build.gradle ├── build.gradle └── settings.gradle算法层四个类各管一摊:Base32Utils负责把密钥字符串(谷歌验证器导出的那种大写字母加数字的串)还原成字节数组;HmacSha1做基于密钥和消息的哈希;TotpGenerator把时间戳切片、拼消息、调 HMAC、做动态截断,最后取模得到 6 位码;MainActivity只负责定时刷新和展示。这种分层的好处是算法层完全不碰 Android API,你可以原样搬到服务端做校验,这点在二次开发时很关键。
2.2 技术选型:为什么是 Java 8 加原生 Android
项目标题里特意标了“java8源码”,这不是随便写的。TOTP 实现里大量用到byte[]操作、位运算和时间处理,Java 8 的java.time包(Instant、Clock)比老式Calendar干净得多,算时间步长时一行Instant.now().getEpochSecond() / 30就完事。选原生 Android 而不是跨平台框架,是因为这个 Demo 的重点在算法演示,界面越薄越好,MainActivity里一个Handler加Runnable做每秒刷新就够了,没必要引入重型依赖。
依赖方面,整个项目没有引入 Google 官方的googleauth库,HMAC-SHA1 用的是 JDK 自带的javax.crypto.Mac,Base32 是自己手写的。这么做的好处是零外部依赖、逻辑全透明,坏处是 Base32 实现得自己保证正确性——后面避坑章节会专门说这个。构建工具是 Gradle,build.gradle里compileSdkVersion和minSdkVersion按你本地 SDK 调整即可,算法层对 API 级别没有硬要求。
2.3 跑起来之前:环境与最小验证
新手最容易卡在“代码看着对但跑不起来”。我的建议是先用纯 Java 方式验证算法层,再上 Android。把TotpGenerator、Base32Utils、HmacSha1三个类单独拎到一个普通 Java 工程里,写个main方法跑一遍:
public class TotpTest { public static void main(String[] args) { // 谷歌验证器导出的密钥,通常是 Base32 编码的大写串 String secret = "JBSWY3DPEHPK3PXP"; // 生成当前时间对应的 6 位动态码 String code = TotpGenerator.generate(secret, System.currentTimeMillis() / 1000, 30, 6); System.out.println("当前动态码: " + code); } }这段代码里secret是共享密钥,System.currentTimeMillis() / 1000转成 Unix 秒,30是时间步长(秒),6是输出位数。跑通后拿手机上的谷歌验证器扫同一个密钥的二维码,两边数字应该一致。这一步过了,再回 Android 工程点运行,界面上的倒计时和数字才有意义。如果纯 Java 都跑不对,问题一定在算法层,跟 Android 环境无关,排查范围一下就缩小了。
3. 核心算法拆解:6 位动态码是怎么一步步算出来的
3.1 Base32 解码:密钥字符串到字节数组
谷歌验证器给你的密钥是一串 Base32 字符,比如JBSWY3DPEHPK3PXP,它不是随便的字符串,而是每 5 个 bit 映射一个字符。要拿它做 HMAC,必须先还原成原始字节。项目里Base32Utils.decode的核心逻辑是查表加移位:
private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; public static byte[] decode(String input) { // 去掉填充符和空白,统一大写 input = input.replace("=", "").replace(" ", "").toUpperCase(); int buffer = 0, bitsLeft = 0; ByteArrayOutputStream out = new ByteArrayOutputStream(); for (char c : input.toCharArray()) { int val = ALPHABET.indexOf(c); if (val < 0) throw new IllegalArgumentException("非法 Base32 字符: " + c); buffer = (buffer << 5) | val; // 每次左移 5 位并入新字符 bitsLeft += 5; if (bitsLeft >= 8) { bitsLeft -= 8; out.write((buffer >> bitsLeft) & 0xFF); // 凑满 8 位就吐出一个字节 } } return out.toByteArray(); }buffer是个位累加器,bitsLeft记录当前攒了多少 bit。每读一个字符左移 5 位并入,攒够 8 位就右移取出一个字节。参数上要注意ALPHABET的顺序不能错,indexOf返回的就是该字符对应的 5 位值。这里有个细节:末尾不足 8 位的残余 bit 直接丢弃,这是 Base32 规范允许的,因为密钥长度通常能被 5 整除。如果你自己改这段,务必保留toUpperCase(),否则小写密钥会直接抛异常。
3.2 HMAC-SHA1:密钥与时间消息的哈希
TOTP 的本质是 HMAC-SHA1(密钥, 时间步长计数器),输出 20 字节摘要。项目里HmacSha1.sign直接调 JDK:
public static byte[] sign(byte[] key, byte[] message) throws Exception { Mac mac = Mac.getInstance("HmacSHA1"); // SecretKeySpec 要求密钥非空,空密钥会抛异常 mac.init(new SecretKeySpec(key, "HmacSHA1")); return mac.doFinal(message); }key是上一步 Base32 解码出来的字节,message是 8 字节的大端时间计数器。这里Mac实例不是线程安全的,每次调用新建一个,别图省事做成静态单例——多线程下会算出错码,这是血泪经验。另外HmacSHA1这个算法名大小写敏感,写成Hmac-SHA1会抛NoSuchAlgorithmException。
3.3 动态截断与取模:从 20 字节到 6 位数字
拿到 20 字节摘要后,不能直接取前几位,RFC 4226 规定了“动态截断”:用最后一个字节的低 4 位当偏移量,从该位置取 4 个字节拼成整数,再对 10^6 取模。TotpGenerator里这段是整个算法最绕的地方:
public static String generate(String secret, long unixTime, int step, int digits) { byte[] key = Base32Utils.decode(secret); long counter = unixTime / step; // 时间切片 byte[] msg = new byte[8]; for (int i = 7; i >= 0; i--) { // 大端写入 8 字节计数器 msg[i] = (byte) (counter & 0xFF); counter >>= 8; } byte[] hash = HmacSha1.sign(key, msg); int offset = hash[hash.length - 1] & 0x0F; // 低 4 位作偏移 int binary = ((hash[offset] & 0x7F) << 24) // 最高位清零防负数 | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF); int otp = binary % (int) Math.pow(10, digits); return String.format("%0" + digits + "d", otp); // 补前导零 }counter是时间步长计数器,unixTime / step保证同一 30 秒窗口内值不变。写 8 字节时从高位到低位倒着填,这是大端序,写反了算出来的码和手机对不上。offset取最后一个字节低 4 位,范围 0~15,保证offset + 3不越界。& 0x7F把最高位清零,避免拼出来的int变负数导致取模结果为负。最后String.format补零,因为12345要显示成012345,少了前导零校验必失败。
3.4 界面刷新:倒计时与自动重算
MainActivity的职责很轻,用一个Handler每 500 毫秒刷新一次,显示剩余秒数和当前码:
private final Handler handler = new Handler(Looper.getMainLooper()); private final Runnable tick = new Runnable() { @Override public void run() { long now = System.currentTimeMillis() / 1000; long remain = 30 - (now % 30); // 当前窗口剩余秒数 String code = TotpGenerator.generate(SECRET, now, 30, 6); tvCode.setText(code); tvCountdown.setText(String.valueOf(remain)); handler.postDelayed(this, 500); // 半秒刷新,避免跳秒 } };now % 30得到当前窗口已过秒数,30 -就是剩余。刷新间隔设 500 毫秒而不是 1000,是为了让倒计时看起来不跳秒,代价可以忽略。SECRET一般从SharedPreferences或扫码结果里来,Demo 里写死方便演示,实际项目里要改成动态注入。注意handler.postDelayed要在onDestroy里removeCallbacks,否则页面销毁后还在跑,属于内存泄漏的常见来源。
4. 避坑与排查:这几个坑我替你踩过了
4.1 动态码和手机对不上
现象:自己算的 6 位码和谷歌验证器显示的不一致,但算法看着没问题。原因八成是时间不同步或 Base32 解码错位。先确认设备时间是否自动同步,TOTP 对时间敏感,差 30 秒就换窗口;再检查密钥有没有多余空格、小写字母,decode里虽然做了toUpperCase,但空格没处理干净会抛异常。解决:打印counter值和手机端同一时刻的窗口号对比,再单独测Base32Utils.decode的输出字节长度是否符合预期(密钥长度 × 5 / 8)。
4.2 前导零丢失导致校验失败
现象:大部分时候能验证通过,偶尔失败。原因:otp是int,12345直接String.valueOf得到 5 位,服务端按 6 位比对就挂了。解决:必须用String.format("%06d", otp)补零,别用Integer.toString。这个坑很隐蔽,因为失败率只有十分之一左右,测试时容易漏。
4.3 HMAC 实例复用引发并发错码
现象:单线程测试正常,压测或快速连点时偶发错码。原因:把Mac做成了静态成员复用,Mac内部有状态,多线程并发doFinal会互相污染。解决:每次sign新建Mac实例,或者用ThreadLocal隔离。这个项目里HmacSha1.sign每次新建,是对的,二次开发时别“优化”成单例。
4.4 Android 高版本明文与网络限制
现象:如果后续要接扫码或远程下发密钥,http://请求在 Android 9 以上直接失败。原因:默认禁止明文流量。解决:要么全走 HTTPS,要么在AndroidManifest.xml的application节点加android:usesCleartextTraffic="true"(仅调试用)。另外minSdkVersion调太低时java.time不可用,需要开coreLibraryDesugaring或退回System.currentTimeMillis。
4.5 时间步长参数写死
现象:想改成 60 秒窗口,改了generate的step但界面倒计时还是按 30 算。原因:MainActivity里now % 30和30 -是硬编码的,和算法层的step没联动。解决:把步长抽成常量或配置项,界面和算法共用同一个值,否则两边窗口对不齐,校验必然失败。
5. 二次开发:把它改成能落地的双因素认证模块
5.1 从 Demo 到可用模块的改造清单
这个 Demo 离生产还有距离,但改造路径很清晰。第一步,把算法层三个类抽成独立 module 或 jar,服务端和 Android 端共用同一份代码,保证两端算出的码一致——这是双因素认证的命门,两端算法有任何偏差都会导致用户永远验证不过。第二步,密钥不能写死在代码里,改成注册时随机生成 16 字节再 Base32 编码,通过二维码(otpauth://totp/...格式)下发给用户。第三步,服务端校验要允许前后各一个时间窗口的容错,也就是校验counter-1、counter、counter+1三个值,防止网络延迟导致用户输的码刚好跨窗口。
改造时我一般会加一个校验入口,签名如下:
// 服务端校验:允许前后各一个窗口的时钟漂移 public static boolean verify(String secret, String userCode, long unixTime, int step, int digits, int window) { for (int i = -window; i <= window; i++) { String expected = generate(secret, unixTime + (long) i * step, step, digits); if (expected.equals(userCode)) return true; // 命中即通过 } return false; }window一般取 1,取大了会扩大重放攻击面。userCode要做去空格和长度校验,别直接equals。这个函数放在服务端,客户端只负责生成展示,密钥绝不下发到不可信环境。
5.2 验证方法与一个实用技巧
改完之后怎么确认没改坏?我的做法是写一组固定向量测试:给定密钥JBSWY3DPEHPK3PXP和固定时间戳(比如1700000000),把算出的码和 RFC 6238 附录里的测试向量比对,跑通再上真机。真机验证时,用两台设备同一时刻截图对比,误差不应超过一个窗口。另一个技巧是给generate加一个可注入的Clock参数,测试时传固定时钟,避免测试用例依赖真实时间导致偶发失败——这个改动很小,但对回归测试帮助极大。
5.3 我踩过的那个坑
说个真实的教训。早期我把Base32Utils.decode里的toUpperCase去掉了,觉得“密钥本来就是大写”,结果某次用户从别处复制密钥带了小写,整个验证链路静默失败,日志里只有一句“非法 Base32 字符”,排查了半天才定位到大小写。从那以后我每次改这类编解码工具,都强制先跑一遍大小写混合、带空格、带填充符的边界用例,再谈功能。希望这个项目你拿去改的时候,别在同一个地方翻车,也希望这篇拆解能帮到你。
本文还有配套的精品资源,点击获取