如何编写一个 GitHub 二步验证客户端?(附仓库地址及apk下载链接)

一、原理介绍

GitHub 二步验证使用 TOTP(Time-based One-Time Password)算法,基于 RFC 6238 标准。

简单来说:

  1. 开启二步验证时,GitHub 生成一个密钥,通过二维码展示
  2. 用户用客户端扫描二维码,保存密钥
  3. 登录时,客户端用密钥 + 当前时间生成 6 位验证码
  4. GitHub 用同样的算法验证

验证码每 30 秒刷新一次,时间窗口内的验证码是固定的。

二、整体流程

用户开启 GitHub 2FA

GitHub 生成二维码

客户端扫描二维码

解析 otpauth:// URL

提取密钥 Secret

安全存储到本地

用户登录 GitHub

要求输入验证码

打开客户端

从本地读取密钥

计算当前时间窗口

HMAC-SHA1 计算哈希

动态截取生成 6 位码

显示验证码

用户输入验证

三、项目结构

lib/
├── main.dart                 # 应用入口
├── models/
│   └── account.dart          # 账户数据模型
├── services/
│   ├── totp_service.dart     # TOTP 核心算法
│   ├── storage_service.dart  # 安全存储
│   └── account_provider.dart # 状态管理
├── screens/
│   ├── home_page.dart        # 主页面(显示验证码)
│   ├── scan_qr_page.dart     # 扫描二维码页面
│   └── add_account_page.dart # 添加账户页面
└── widgets/
    └── code_card.dart        # 验证码卡片组件

四、依赖配置

dependencies:
  flutter:
    sdk: flutter

  # TOTP 算法实现
  otp: ^3.1.4

  # Base32 编解码
  base32: ^2.2.0

  # 二维码扫描
  mobile_scanner: ^6.0.2

  # 安全存储(iOS Keychain / Android 加密存储)
  flutter_secure_storage: ^9.2.2

  # 状态管理
  provider: ^6.1.2

  # 唯一 ID 生成
  uuid: ^4.5.1

五、核心实现

5.1 数据模型

首先定义账户模型,存储每个 2FA 账户的信息:

class Account {
  final String id;          // 唯一标识
  final String name;        // 账户名称,如 "GitHub"
  final String email;       // 用户标识
  final String secret;      // 密钥(Base32 编码)
  final int digits;         // 验证码位数,默认 6
  final int interval;       // 时间间隔,默认 30 秒
  final String algorithm;   // 算法:SHA1/SHA256/SHA512
  final String? issuer;     // 发行者

  Account({
    String? id,
    required this.name,
    required this.email,
    required this.secret,
    this.digits = 6,
    this.interval = 30,
    this.algorithm = 'SHA1',
    this.issuer,
  });
}

5.2 二维码扫描与解析

GitHub 的二维码内容格式:

otpauth://totp/GitHub:username?secret=JBSWY3DPEHPK3PXP&issuer=GitHub

解析流程:

扫描二维码

获取 URL 字符串

是否 otpauth:// 协议?

返回错误

分离路径和参数

解析账户名

解析密钥 secret

解析其他参数

返回 OtpAuthData

代码实现:

static OtpAuthData? parseOtpAuthUrl(String url) {
  // 检查协议
  if (!url.startsWith('otpauth://totp/')) {
    return null;
  }

  // 分离路径和查询参数
  final uriString = url.replaceFirst('otpauth://totp/', '');
  final parts = uriString.split('?');

  // 解析标签(issuer:email)
  final label = Uri.decodeComponent(parts[0]);
  String name, email, issuer;
  if (label.contains(':')) {
    final labelParts = label.split(':');
    issuer = labelParts[0];
    email = labelParts[1];
    name = issuer;
  } else {
    email = label;
    name = label;
  }

  // 解析查询参数
  final queryParams = Uri.splitQueryString(parts[1]);
  final secret = queryParams['secret'] ?? '';
  final digits = int.parse(queryParams['digits'] ?? '6');
  final interval = int.parse(queryParams['period'] ?? '30');
  final algorithm = queryParams['algorithm'] ?? 'SHA1';

  return OtpAuthData(
    name: name,
    email: email,
    secret: secret.toUpperCase(),
    digits: digits,
    interval: interval,
    algorithm: algorithm,
  );
}

5.3 TOTP 验证码生成

这是整个项目的核心,TOTP 算法流程:

获取当前时间戳

除以 30 得到时间窗口 T

T 转换为 8 字节大端

Base32 解码密钥

HMAC-SHA1 计算

得到 20 字节哈希

取最后 1 字节的低 4 位作为 offset

从 offset 开始取 4 字节

去掉最高位符号位

对 10^6 取模

得到 6 位验证码

代码实现:

static String generateCode({
  required String secret,
  int digits = 6,
  int interval = 30,
  String algorithm = 'SHA1',
}) {
  // 选择算法
  Algorithm algo;
  switch (algorithm.toUpperCase()) {
    case 'SHA256':
      algo = Algorithm.SHA256;
      break;
    case 'SHA512':
      algo = Algorithm.SHA512;
      break;
    default:
      algo = Algorithm.SHA1;
  }

  // 生成验证码
  final code = OTP.generateTOTPCode(
    secret,
    DateTime.now().millisecondsSinceEpoch,
    length: digits,
    interval: interval,
    algorithm: algo,
    isGoogle: true,  // ⚠️ 必须设置!
  );

  return code.toString().padLeft(digits, '0');
}

5.4 一个巨坑!

开发过程中遇到一个致命问题:验证码始终不对

排查后发现 otp 包的实现有问题:

// otp 包源码
var secretList = Uint8List.fromList(utf8.encode(secret));  // 默认行为
if (isGoogle) {
  secretList = base32.decode(secret.toUpperCase());  // 正确行为
}

问题:默认把密钥当 UTF-8 字符串编码,而不是 Base32 解码!

解决:必须设置 isGoogle: true,才会正确解码 Base32 密钥。

GitHub/Google Authenticator 的密钥都是 Base32 编码的,不设置这个参数,验证码永远错误。

5.5 安全存储

密钥必须安全存储,不能明文保存:

class StorageService {
  final FlutterSecureStorage _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
  );

  // 保存账户列表
  Future<void> saveAccounts(List<Account> accounts) async {
    final jsonList = accounts.map((a) => a.toJson()).toList();
    await _storage.write(
      key: 'accounts',
      value: jsonEncode(jsonList),
    );
  }

  // 加载账户列表
  Future<List<Account>> loadAccounts() async {
    final jsonString = await _storage.read(key: 'accounts');
    if (jsonString == null) return [];

    final jsonList = jsonDecode(jsonString) as List;
    return jsonList.map((json) => Account.fromJson(json)).toList();
  }
}

存储方式:

平台 存储方式
iOS Keychain
Android EncryptedSharedPreferences
macOS Keychain
Windows Credential Manager

5.6 状态管理与 UI 刷新

验证码每 30 秒刷新一次,需要定时更新 UI:

class HomePage extends StatefulWidget {
  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  Timer? _refreshTimer;

  
  void initState() {
    super.initState();
    // 每秒刷新一次
    _refreshTimer = Timer.periodic(Duration(seconds: 1), (_) {
      setState(() {});
    });
  }

  
  void dispose() {
    _refreshTimer?.cancel();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    // 显示验证码和倒计时
  }
}

六、完整项目

仓库地址:https://github.com/FuShang114/Free2FA

下载地址:https://github.com/FuShang114/Free2FA/releases/tag/v1.0.0

七、构建方法

# 克隆仓库
git clone https://github.com/FuShang114/Free2FA.git
cd Free2FA

# 安装依赖
flutter pub get

# 构建 APK
flutter build apk --release

APK 输出路径:build/app/outputs/flutter-apk/app-release.apk

八、总结

开发一个 TOTP 客户端的关键点:

  1. 理解 TOTP 算法 - 基于 HMAC-SHA1,时间窗口 30 秒
  2. 正确解析二维码 - otpauth:// 协议格式
  3. Base32 解码密钥 - otp 包必须设置 isGoogle: true
  4. 安全存储 - 使用平台级加密存储
  5. 定时刷新 - 验证码每 30 秒更新

希望这篇文章对你有帮助,欢迎 Star!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐