项目背景

TOTP (Time-based One-Time Password) 是一种基于时间的一次性密码算法,广泛应用于双因素身份验证场景。Google Authenticator、Microsoft Authenticator等知名应用都采用这个标准协议。本文将详细介绍如何在HarmonyOS应用中使用ArkTS语言实现一个功能完整的TOTP验证码生成组件。

在这里插入图片描述

项目结构设计

项目采用了典型的分层架构,将不同职责的代码分离到不同目录:

├── common/
│   ├── constants/     # 常量定义,如默认周期、位数等
│   └── utils/         # 工具类,包括加密、编码、时间处理
├── model/             # 数据模型,定义账户结构
├── service/           # 核心业务逻辑,TOTP算法实现
└── components/        # UI组件,负责展示和交互

这种组织方式的优势在于:

  • 职责分离: 业务逻辑与UI渲染解耦,便于单独测试
  • 可维护性: 修改某个模块不会影响其他部分
  • 可复用性: 工具类和服务层可以在其他项目中复用

核心实现详解

1. TOTP算法实现

TOTP算法遵循RFC 6238标准,其核心是将当前时间转换为计数器,然后通过HMAC算法生成动态验证码。整个过程可以分解为几个关键步骤。

首先看TOTP服务类的完整实现:

// service/TOTP.ets
export class TOTP {
  private secret: string;          // Base32编码的密钥
  private digits: number;          // 验证码位数
  private period: number;          // 时间周期(秒)
  private algorithm: TOTPAlgorithm; // 哈希算法
  private decodedSecret?: Uint8Array; // 解码后的密钥缓存
  private cachedToken?: CachedToken;  // 验证码缓存

  constructor(config: TOTPConfig) {
    this.secret = config.secret;
    this.digits = config.digits || TOTPConstants.DIGITS;
    this.period = config.period || TOTPConstants.PERIOD;
    this.algorithm = config.algorithm || TOTPConstants.ALGORITHM;
  }

  async generateToken(timestamp: number = TimeUtils.getTime()): Promise<string> {
    // 1. 计算时间计数器
    // 将毫秒时间戳转为秒,再除以周期得到计数器值
    const epoch = Math.floor(timestamp / 1000);
    const counter = Math.floor(epoch / this.period);

    // 2. 缓存检查,避免同一时间片重复计算
    if (this.cachedToken?.timeslot === counter) {
      return this.cachedToken.value;
    }

    // 3. 将计数器转为8字节大端序数组
    // TOTP标准要求使用8字节表示计数器
    const counterBuffer = new ArrayBuffer(8);
    const counterView = new DataView(counterBuffer);
    counterView.setUint32(0, 0, false);  // 高4字节为0
    counterView.setUint32(4, counter, false);  // 低4字节存储计数器

    // 4. 使用HMAC算法计算哈希值
    const hmacArray = await CryptoUtils.hmac(
      this.algorithm,
      this.getDecodedSecret(),
      new Uint8Array(counterBuffer)
    );

    // 5. 动态截断(Dynamic Truncation)
    // 取哈希结果最后一个字节的低4位作为偏移量
    const offset = hmacArray[hmacArray.length - 1] & 0x0f;
    // 从偏移位置取4字节,转为整数后对10^digits取模
    const code =
      ((hmacArray[offset] & 0x7f) << 24 |
        (hmacArray[offset + 1] & 0xff) << 16 |
        (hmacArray[offset + 2] & 0xff) << 8 |
        (hmacArray[offset + 3] & 0xff)) % Math.pow(10, this.digits);

    // 6. 格式化为指定位数的字符串,不足补0
    const token = code.toString().padStart(this.digits, '0');

    // 7. 更新缓存
    this.cachedToken = { value: token, timeslot: counter };
    return token;
  }

  // 计算当前时间片剩余秒数
  getTimeRemaining(timestamp: number = TimeUtils.getTime()): number {
    const epoch = Math.floor(timestamp / 1000);
    return this.period - (epoch % this.period);
  }

  // 计算当前时间片已用进度(0~1)
  getProgress(timestamp: number = TimeUtils.getTime()): number {
    const remainingMs = timestamp % (this.period * 1000);
    return remainingMs / (this.period * 1000);
  }
}

这段代码实现了TOTP的核心逻辑。其中最关键的是generateToken方法,它严格遵循RFC 6238标准。动态截断算法确保了即使攻击者知道哈希结果的一部分,也无法推算出完整的验证码。

缓存机制的设计也很重要:由于验证码在30秒内保持不变,频繁调用generateToken时可以直接返回缓存结果,避免重复的加密计算,提升性能。

2. 使用CryptoArchitectureKit进行加密计算

HarmonyOS提供了@kit.CryptoArchitectureKit框架,封装了各种加密算法。在TOTP中,我们需要使用HMAC(Hash-based Message Authentication Code)算法。

// common/utils/CryptoUtils.ets
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class CryptoUtils {
  static async hmac(algName: string, key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
    try {
      // 1. 创建对称密钥生成器
      // HMAC使用对称密钥,这里创建专门的生成器
      const symKeyGenerator = cryptoFramework.createSymKeyGenerator('HMAC');

      // 2. 将密钥转换为框架要求的DataBlob格式
      const keyBlob: cryptoFramework.DataBlob = { data: key };

      // 3. 生成HMAC密钥对象
      const symKey = await symKeyGenerator.convertKey(keyBlob);

      // 4. 创建HMAC实例,指定哈希算法(SHA1/SHA256/SHA512)
      const mac = cryptoFramework.createMac(algName);

      // 5. 初始化HMAC实例,绑定密钥
      await mac.init(symKey);

      // 6. 输入要计算的数据
      await mac.update({ data: data });

      // 7. 完成计算并获取结果
      const macResult = await mac.doFinal();

      return macResult.data;
    } catch (error) {
      let error = e as BusinessError;
      throw new Error(`HMAC计算失败: ${error.code} - ${error.message}`);
    }
  }
}

这个工具类封装了CryptoArchitectureKit的使用细节。需要注意的几点:

  • 异步操作: 所有加密操作都是异步的,必须使用async/await或Promise处理
  • DataBlob格式: 框架要求数据以特定格式传入,需要进行转换
  • 错误处理: 加密操作可能失败(如密钥格式错误),需要捕获异常

相比自己实现HMAC算法,使用系统提供的框架有几个优势:经过充分测试、性能优化好、可能利用硬件加速。

3. Base32编码解码

TOTP密钥通常以Base32格式编码(如JBSWY3DPEHPK3PXP),需要解码为原始字节才能用于加密计算。Base32使用32个字符(A-Z和2-7)表示数据,每5位二进制对应1个字符。

// common/utils/Base32Utils.ets
export class Base32Utils {
  // Base32字符表,共32个字符
  private static readonly BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

  static decode(base32: string): Uint8Array {
    // 1. 预处理:转大写并移除填充符
    const cleanStr = base32.toUpperCase().replace(/=+$/, '');
    const alphabet = Base32Utils.BASE32_CHARS;
    const decoded: number[] = [];

    // 2. 位操作解码
    let buffer = 0;  // 临时缓冲区
    let bits = 0;    // 当前缓冲区中的位数

    for (let i = 0; i < cleanStr.length; i++) {
      const char = cleanStr[i];
      const val = alphabet.indexOf(char);
      if (val === -1) continue;  // 跳过非法字符

      // 3. 将5位数据加入缓冲区
      buffer = (buffer << 5) | val;
      bits += 5;

      // 4. 缓冲区满8位时输出一个字节
      if (bits >= 8) {
        bits -= 8;
        decoded.push(buffer >> bits);
        buffer &= (1 << bits) - 1;  // 保留剩余位
      }
    }

    return new Uint8Array(decoded);
  }
}

Base32解码的核心是位操作:

  • 每次读入一个字符(5位数据)
  • 累积到缓冲区
  • 当缓冲区达到8位时,输出一个字节
  • 这样可以将5位对齐的Base32转为8位对齐的字节数组

4. 时间同步处理

TOTP算法依赖准确的时间,客户端和服务器必须使用相同的时间基准。项目使用HarmonyOS的系统时间接口:

// common/utils/TimeUtils.ets
import { systemDateTime } from '@kit.BasicServicesKit';

export class TimeUtils {
  // 获取当前时间戳(毫秒)
  static getTime() {
    return systemDateTime.getTime(false);
  }
}

关于时间同步的几点说明:

  • 不使用NTP: 虽然NTP可以获取精确时间,但会增加网络依赖和安全风险
  • 系统时间: 直接使用设备系统时间,简单可靠
  • 容差窗口: 服务端通常允许前后30-60秒的误差,轻微时钟偏差可以被接受
  • 用户责任: 应该在界面提示用户保持系统时间准确

5. ArkUI组件实现

TOTPItem组件负责展示单个账户的验证码、发行方信息和倒计时进度。这是一个典型的ArkUI自定义组件:

// components/TOTPItem.ets
@Component
export struct TOTPItem {
  // 1. 组件属性
  @Prop account: TOTPAccount;        // 从父组件传入的账户信息
  @State countdown: number = 30;     // 倒计时秒数
  @State progress: number = 1;       // 进度值(0~1)
  @State token: string = "";         // 当前验证码

  private totp?: TOTP;               // TOTP服务实例
  private totalFrames: number = 3600; // 动画总帧数
  private progressAnimator?: AnimatorResult; // 动画控制器

  // 2. 更新验证码
  async updateToken() {
    if (this.totp) {
      const newToken = await this.totp.generateToken();
      // 只有验证码真正变化时才更新(避免不必要的渲染)
      if (newToken != this.token) {
        this.token = newToken;
      }
    }
  }

  // 3. 更新进度和倒计时
  updateProgress() {
    if (this.totp) {
      this.countdown = this.totp.getTimeRemaining();
      this.progress = this.totp.getProgress();

      // 周期结束时生成新验证码
      if (this.countdown >= this.account.period) {
        this.updateToken();
      }
    }
  }

  // 4. UI结构
  build() {
    Row() {
      // 左侧:发行方和账户名
      Column({ space: 2 }) {
        Text(this.account.issuer)
          .fontSize(18)
        Text(this.account.label)
          .fontColor($r('sys.color.font_secondary'))
          .fontSize(12)
      }
      .alignItems(HorizontalAlign.Start)

      Blank()  // 弹性空白,将左右内容推开

      // 右侧:验证码和倒计时
      Text(this.token)
        .margin({ right: 12 })
        .fontSize(28)
        .fontWeight(FontWeight.Bold)

      // 环形进度条,中心显示倒计时秒数
      Progress({
        value: this.progress * this.totalFrames,
        total: this.totalFrames,
        type: ProgressType.Ring
      })
        .width(32)
        .color(Color.Red)
        .style({ strokeWidth: 2 })
        .overlay(this.countdown.toString(), {
          align: Alignment.Center
        })
    }
    .width('100%')
    .height(72)
    .padding(12)
    .borderRadius(16)
    .backgroundColor($r('sys.color.comp_background_primary'))
  }
}

在这里插入图片描述

这个组件的UI结构比较清晰:

  • Row布局: 左右排列内容
  • 左侧Column: 垂直堆叠发行方和账户名
  • Blank: 自动填充中间空白
  • 右侧: 大号验证码文本 + 环形倒计时进度条

状态管理使用@State装饰器,当这些状态变化时,UI会自动重新渲染。@Prop装饰器表示该属性从父组件传入,是只读的。

6. 组件生命周期管理

ArkUI组件有完整的生命周期钩子,类似于React或Vue。正确使用生命周期对于资源管理至关重要:

// components/TOTPItem.ets (生命周期部分)
@Component
export struct TOTPItem {
  // ... 省略其他代码

  // 组件即将显示时调用
  aboutToAppear(): void {
    // 1. 创建TOTP服务实例
    this.totp = new TOTP({
      secret: this.account.secret,
      digits: this.account.digits,
      period: this.account.period
    });

    // 2. 初始化状态
    this.updateProgress();  // 计算初始进度
    this.updateToken();     // 生成初始验证码

    // 3. 启动定时器
    this.startTimer();
  }

  // 组件即将销毁时调用
  aboutToDisappear(): void {
    // 清理定时器,防止内存泄漏
    this.stopTimer();
  }

  // 启动动画定时器
  private startTimer() {
    this.totalFrames = this.account.period * TOTPConstants.MAX_FRAME_RATE;

    // 使用Animator API创建动画
    this.progressAnimator = this.getUIContext().createAnimator({
      duration: this.account.period * 1000,  // 动画时长等于TOTP周期
      easing: 'linear',                       // 线性动画
      delay: 0,
      fill: 'forwards',
      direction: 'normal',
      iterations: -1,                         // 无限循环
      begin: this.totalFrames,                // 从满进度开始
      end: 0                                  // 到0结束
    });

    // 每帧回调,更新进度
    this.progressAnimator.onFrame = () => {
      this.updateProgress();
    };

    this.progressAnimator.play();
  }

  // 停止定时器
  private stopTimer() {
    this.progressAnimator?.finish();
    this.progressAnimator = undefined;
  }
}

生命周期管理的关键点:

aboutToAppear时机:

  • 在组件首次渲染之前调用
  • 适合进行数据初始化、网络请求、定时器启动等操作
  • 此时可以安全地访问组件的props

aboutToDisappear时机:

  • 在组件从组件树移除时调用
  • 必须在这里清理资源:停止定时器、取消网络请求、移除事件监听等
  • 不清理会导致内存泄漏,定时器继续运行浪费资源

为什么不用setInterval:

  • createAnimator与系统刷新率同步,性能更好
  • 自动处理应用切到后台的情况
  • 更适合动画场景,不会出现卡顿

7. 数据模型设计

使用TypeScript类定义账户数据结构,提供类型安全和默认值处理:

// model/TOTPAccount.ets
import { TOTPConstants } from "../common/constants/TOTPConstants";
import { TOTPAlgorithm } from "../service/TOTP";

export class TOTPAccount {
  id: string;              // 唯一标识符
  label: string;           // 账号名称(如用户名、邮箱)
  issuer: string;          // 服务提供商(如Google、GitHub)
  secret: string;          // Base32编码的密钥
  algorithm: TOTPAlgorithm; // 哈希算法(SHA1/SHA256/SHA512)
  digits: number;          // 验证码位数(通常为6,有些服务用8)
  period: number;          // 时间周期(通常为30秒)

  constructor(
    id: string,
    label: string,
    issuer: string,
    secret: string,
    algorithm: TOTPAlgorithm = TOTPConstants.ALGORITHM,
    digits: number = TOTPConstants.DIGITS,
    period: number = TOTPConstants.PERIOD
  ) {
    this.id = id;
    this.label = label;
    this.issuer = issuer;
    this.secret = secret;
    this.algorithm = algorithm;
    this.digits = digits;
    this.period = period;
  }
}

这个模型类的设计考虑:

  • 必填字段: id、label、issuer、secret必须提供
  • 可选参数: algorithm、digits、period有合理的默认值
  • 扩展性: 后续可以添加图标、颜色、排序等字段

实际使用时,这些数据会从二维码扫描或手动输入中获取,符合otpauth://协议格式。

8. 常量定义

将魔法数字集中管理,便于维护和调整:

// common/constants/TOTPConstants.ets
import { TOTPAlgorithm } from "../../service/TOTP";

export class TOTPConstants {
  static readonly PERIOD: number = 30;         // 标准周期30秒
  static readonly DIGITS: number = 6;          // 标准6位验证码
  static readonly ALGORITHM: TOTPAlgorithm = TOTPAlgorithm.SHA1;  // 标准算法SHA1
  static readonly UPDATE_INTERVAL: number = 500;  // UI更新间隔(毫秒)
  static readonly MAX_FRAME_RATE: number = 120;   // 动画最高帧率
}

使用常量的好处:

  • 统一配置: 修改默认值只需改一处
  • 可读性: 代码中直接使用常量名,语义清晰
  • 类型安全: TypeScript会检查常量使用是否正确

使用示例

在页面中使用TOTPItem组件很简单:

// pages/Index.ets
@Entry
@Component
struct Index {
  @State accounts: TOTPAccount[] = [
    new TOTPAccount(
      '1',
      'user@example.com',
      'Google',
      'JBSWY3DPEHPK3PXP'
    )
  ];

  build() {
    Column() {
      ForEach(this.accounts, (account: TOTPAccount) => {
        TOTPItem({ account: account })
      })
    }
  }
}

技术总结

1. 架构设计

  • 分层清晰: UI、业务逻辑、工具类各司其职
  • 单一职责: 每个类只负责一件事,代码简洁易懂
  • 依赖管理: 组件通过接口依赖服务,而非直接耦合实现

2. ArkUI特性应用

  • 声明式UI: 使用@Component@State实现响应式界面
  • 生命周期: 正确使用aboutToAppearaboutToDisappear管理资源
  • 动画系统: 使用createAnimator实现流畅的倒计时动画

3. 系统能力集成

  • 加密框架: 使用CryptoArchitectureKit而非自己实现算法,安全可靠
  • 时间服务: 使用BasicServicesKit获取系统时间

4. 性能优化

  • 缓存机制: 同一时间片内复用验证码,避免重复计算
  • 懒加载: Base32解码结果缓存,只在首次使用时计算
  • 动画优化: 使用Animator替代setInterval,与系统刷新同步

后续扩展方向

这个基础实现可以继续完善:

  1. 数据持久化: 使用首选项或数据库保存账户列表
  2. 二维码扫描: 集成相机Kit实现扫码添加账户
  3. 密钥安全: 使用HUKS(密钥管理服务)加密存储密钥
  4. 批量导入: 支持从其他验证器导出的文件导入
  5. 备份恢复: 实现账户的云备份和恢复功能
  6. 生物识别: 添加指纹或面容识别保护

总结

本文从实际代码出发,详细介绍了在HarmonyOS中实现TOTP验证器卡片组件的完整过程。重点关注了:

  • TOTP算法的准确实现
  • CryptoArchitectureKit的正确使用
  • ArkUI组件的生命周期管理
  • 合理的项目结构组织
  • 性能和安全性的平衡

代码按功能模块分离,每个文件职责明确,便于理解、测试和维护。这种架构设计适合中小型HarmonyOS应用开发。

HarmonyOS赋能资源丰富度建设活动进行中,加入班级成为学员,完成课程并通过认证考核,将获得HarmonyOS 应用开发者认证电子证书,助力职业发展。另外发布学习资源还有机会拿到奖品激励,赶快一起加入鸿蒙生态的建设中吧~
https://developer.huawei.com/consumer/cn/training/classDetail/465c461c9200457b9ece8d31e5a0d94b?type=1?ha_source=hmosclass-zonghe&ha_sourceId=89000236

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐