【Jack实战】如何用 UserAuthenticationKit 给 HarmonyOS 应用加一道本地身份验证

大家好,我是鸿蒙Jack。本期以我的《时光旅记》APP 为例,聊一下我是怎么使用 UserAuthenticationKit 做本地身份验证的。

《时光旅记》里有一个"隐私保护小本"的功能。用户可以把一些照片、随笔、旅行瞬间放进一个受保护的小本里。这个场景有一个很明确的要求:不是所有内容都需要上锁,但只要用户主动开启了隐私保护,进入小本、编辑小本、开启或关闭隐私保护时,都应该先确认一下当前操作的人是不是机主本人。

这个需求不适合自己写一个密码弹窗,也不应该自己做人脸或指纹识别。正确做法是把身份确认交给系统统一用户认证控件,也就是 UserAuthenticationKit。它可以基于设备本地已经录入的锁屏口令、人脸、指纹完成认证,应用只拿认证结果,不接触用户的生物特征数据。

我在《时光旅记》里的实践场景

我没有把整个应用都锁起来,而是只在高敏感入口加认证。

用户进入普通首页、时间轴、旅行计划时,不需要打扰;只有碰到"受保护小本"时才拉起认证。这样做体验会轻很多,也符合 User Authentication Kit 的定位:当用户请求访问个人数据或执行敏感操作时,再调用系统用户身份认证控件。

先看一下《时光旅记》里这套能力对应的真实页面。

小本列表里,普通小本可以直接进入,开启隐私保护的小本会在进入详情前先做本地身份验证。

进入受保护内容前,我不在应用里自定义密码框,而是交给系统统一身份认证控件处理。应用只根据认证结果决定是否继续打开小本。

编辑小本时,如果用户开启或关闭隐私保护,我也会先要求认证。访问内容要验证,改变保护状态同样要验证。

我的业务规则很简单:




成功
取消
失败
用户点击小本
小本是否开启隐私保护
直接进入小本详情
本次会话是否已解锁
拉起 UserAuthenticationKit
认证是否成功
记录小本已解锁
停留当前页面
展示失败提示

这里的"本次会话已解锁"是我自己在业务层维护的状态。User Authentication Kit 只负责认证,至于认证成功以后允许访问哪个小本、什么时候清理解锁状态,应该放回应用自己的业务逻辑里。

UserAuthenticationKit 的基本流程

接入前先把流程想清楚,代码就不会散。

UserAuthenticationKit 的核心调用链是:
系统认证控件 UserAuthenticationKit JackUserAuthenticationKit 业务页面 用户 系统认证控件 UserAuthenticationKit JackUserAuthenticationKit 业务页面 用户 点击受保护小本 requestUserAuth(title) getAvailableStatus() 当前设备可用认证能力 getUserAuthInstance(authParam, widgetParam) on('result', callback) start() 拉起系统认证控件 人脸、指纹或锁屏口令认证 返回 UserAuthResult onResult(result) JackUserAuthResult 成功则解锁小本,失败则提示

实际开发时主要关注四件事。

第一,module.json5 要声明权限 ohos.permission.ACCESS_BIOMETRIC。这个权限是系统授权,声明后由系统处理。

第二,发起认证前用 getAvailableStatus 查设备能力。不同设备可能有人脸、指纹、锁屏口令中的一种或几种,也可能某个可信等级不支持。

第三,通过 getUserAuthInstance(authParam, widgetParam) 获取认证实例,然后订阅 result,最后调用 start()

第四,认证结果要区分"失败"和"取消"。用户取消认证时,我不弹失败 Toast,因为取消是一个正常选择。

权限配置

entry/src/main/module.json5 里加入权限声明。我的项目里是这样写的:

json5 复制代码
{
  "name": "ohos.permission.ACCESS_BIOMETRIC",
  "reason": "$string:permission_biometric_reason",
  "usedScene": {
    "abilities": [
      "EntryAbility"
    ],
    "when": "inuse"
  }
}

reason 对应的字符串要写清楚用途。我的场景是访问受保护的小本内容,所以文案会围绕"用于访问隐私保护内容前进行身份验证"去写,避免审核时看不出权限用途。

我为什么先封装一层

一开始这段代码直接写在页面里也能跑,但问题很快出现:页面既要查人脸、指纹、锁屏口令是否可用,又要创建 challenge,又要订阅回调,还要处理错误码。最后业务页面里混进了一大段 Kit 细节。

所以我把它封装成了 JackUserAuthenticationKit。页面只关心一个结果:

ts 复制代码
let result: JackUserAuthResult = await JackUserAuthenticationKit.requestUserAuth({
  title: '进入受保护小本'
});

成功就继续业务;取消就安静返回;失败才展示 result.message

完整封装代码

下面这份就是《时光旅记》当前使用的完整封装代码,文件路径是 entry/src/main/ets/kit/userauth/JackUserAuthenticationKit.ets

ts 复制代码
import { BusinessError } from '@kit.BasicServicesKit';
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { userAuth } from '@kit.UserAuthenticationKit';

export interface JackUserAuthOptions {
  title: string;
  authTypes?: Array<userAuth.UserAuthType>;
  authTrustLevel?: userAuth.AuthTrustLevel;
}

export interface JackUserAuthResult {
  success: boolean;
  canceled: boolean;
  code: number;
  message: string;
}

const JACK_USER_AUTH_IN_PROGRESS_CODE: number = -10001;
const JACK_USER_AUTH_NO_AVAILABLE_TYPE_CODE: number = -10002;

export class JackUserAuthenticationKit {
  private static authInProgress: boolean = false;
  private static readonly defaultAuthTrustLevel: userAuth.AuthTrustLevel = userAuth.AuthTrustLevel.ATL2;

  public static isAuthInProgress(): boolean {
    return JackUserAuthenticationKit.authInProgress;
  }

  public static supportsAuthType(
    authType: userAuth.UserAuthType,
    authTrustLevel: userAuth.AuthTrustLevel = JackUserAuthenticationKit.defaultAuthTrustLevel
  ): boolean {
    try {
      userAuth.getAvailableStatus(authType, authTrustLevel);
      return true;
    } catch (_error) {
      return false;
    }
  }

  public static getAvailableAuthTypes(
    authTrustLevel: userAuth.AuthTrustLevel = JackUserAuthenticationKit.defaultAuthTrustLevel,
    preferredAuthTypes: Array<userAuth.UserAuthType> = [
      userAuth.UserAuthType.FACE,
      userAuth.UserAuthType.FINGERPRINT,
      userAuth.UserAuthType.PIN
    ]
  ): Array<userAuth.UserAuthType> {
    let result: Array<userAuth.UserAuthType> = [];
    for (let index: number = 0; index < preferredAuthTypes.length; index++) {
      let authType: userAuth.UserAuthType = preferredAuthTypes[index];
      if (JackUserAuthenticationKit.supportsAuthType(authType, authTrustLevel)) {
        result.push(authType);
      }
    }
    return result;
  }

  public static resolveFailureMessage(code: number): string {
    if (code === userAuth.UserAuthResultCode.FAIL) {
      return '身份验证未通过,请重试';
    }
    if (code === userAuth.UserAuthResultCode.NOT_ENROLLED) {
      return '设备未录入可用的人脸、指纹或锁屏口令';
    }
    if (code === userAuth.UserAuthResultCode.LOCKED) {
      return '身份验证已被锁定,请稍后再试';
    }
    if (code === userAuth.UserAuthResultCode.BUSY) {
      return '认证服务正忙,请稍后再试';
    }
    if (code === userAuth.UserAuthResultCode.TYPE_NOT_SUPPORT ||
      code === userAuth.UserAuthResultCode.TRUST_LEVEL_NOT_SUPPORT) {
      return '当前设备不支持所需的身份认证能力';
    }
    if (code === userAuth.UserAuthResultCode.TIMEOUT) {
      return '身份验证超时,请重试';
    }
    if (code === 201) {
      return '当前应用未获得生物认证权限';
    }
    if (code === JACK_USER_AUTH_IN_PROGRESS_CODE) {
      return '身份验证正在进行,请稍候';
    }
    if (code === JACK_USER_AUTH_NO_AVAILABLE_TYPE_CODE) {
      return '当前设备没有可用的人脸、指纹或锁屏口令,无法完成验证';
    }
    return '身份验证未通过';
  }

  public static async requestUserAuth(options: JackUserAuthOptions): Promise<JackUserAuthResult> {
    if (JackUserAuthenticationKit.authInProgress) {
      return JackUserAuthenticationKit.buildFailedResult(JACK_USER_AUTH_IN_PROGRESS_CODE);
    }

    let authTrustLevel: userAuth.AuthTrustLevel = options.authTrustLevel ?
      options.authTrustLevel : JackUserAuthenticationKit.defaultAuthTrustLevel;
    let authTypes: Array<userAuth.UserAuthType> = options.authTypes ?
      options.authTypes : JackUserAuthenticationKit.getAvailableAuthTypes(authTrustLevel);

    if (authTypes.length === 0) {
      return JackUserAuthenticationKit.buildFailedResult(JACK_USER_AUTH_NO_AVAILABLE_TYPE_CODE);
    }

    JackUserAuthenticationKit.authInProgress = true;
    try {
      let authParam: userAuth.AuthParam = {
        challenge: JackUserAuthenticationKit.createChallenge(),
        authType: authTypes,
        authTrustLevel: authTrustLevel
      };
      let widgetParam: userAuth.WidgetParam = {
        title: options.title
      };
      let authInstance: userAuth.UserAuthInstance = userAuth.getUserAuthInstance(authParam, widgetParam);

      return await new Promise<JackUserAuthResult>((resolve) => {
        let finished: boolean = false;
        const callback: userAuth.IAuthCallback = {
          onResult: (result: userAuth.UserAuthResult): void => {
            if (finished) {
              return;
            }
            finished = true;
            authInstance.off('result');
            resolve(JackUserAuthenticationKit.buildResult(result.result));
          }
        };
        authInstance.on('result', callback);
        try {
          authInstance.start();
        } catch (error) {
          if (finished) {
            return;
          }
          finished = true;
          authInstance.off('result');
          let err: BusinessError = error as BusinessError;
          resolve(JackUserAuthenticationKit.buildFailedResult(Number(err.code)));
        }
      });
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      return JackUserAuthenticationKit.buildFailedResult(Number(err.code));
    } finally {
      JackUserAuthenticationKit.authInProgress = false;
    }
  }

  private static createChallenge(): Uint8Array {
    const random = cryptoFramework.createRandom();
    return random.generateRandomSync(32).data;
  }

  private static buildResult(code: number): JackUserAuthResult {
    let success: boolean = code === userAuth.UserAuthResultCode.SUCCESS;
    let canceled: boolean = code === userAuth.UserAuthResultCode.CANCELED ||
      code === userAuth.UserAuthResultCode.CANCELED_FROM_WIDGET;
    return {
      success: success,
      canceled: canceled,
      code: code,
      message: success || canceled ? '' : JackUserAuthenticationKit.resolveFailureMessage(code)
    };
  }

  private static buildFailedResult(code: number): JackUserAuthResult {
    return {
      success: false,
      canceled: false,
      code: code,
      message: JackUserAuthenticationKit.resolveFailureMessage(code)
    };
  }
}

导出入口保持简单,文件路径是 entry/src/main/ets/kit/userauth/index.ets

ts 复制代码
export * from './JackUserAuthenticationKit';

在业务页面里怎么接

业务页面不再直接 import @kit.UserAuthenticationKit,而是只依赖自己的封装。

ts 复制代码
import { JackUserAuthenticationKit, JackUserAuthResult } from '../../kit/userauth';

在《时光旅记》里,我有一组小本解锁状态。认证成功后,把当前小本 ID 放进已解锁列表;离开小本或切换 Tab 时,再移除这个解锁状态。

下面是完整的业务接入代码,可以直接理解为"隐私保护小本"的接入模板。

ts 复制代码
@State unlockedNotebookIds: Array<string> = [];

private isNotebookUnlocked(notebookId: string): boolean {
  return this.unlockedNotebookIds.indexOf(notebookId) >= 0;
}

private unlockNotebook(notebookId: string): void {
  if (notebookId.length === 0 || this.isNotebookUnlocked(notebookId)) {
    return;
  }
  this.unlockedNotebookIds = this.unlockedNotebookIds.concat([notebookId]);
}

private removeNotebookUnlock(notebookId: string): void {
  if (!this.isNotebookUnlocked(notebookId)) {
    return;
  }
  this.unlockedNotebookIds = this.unlockedNotebookIds.filter((id: string) => id !== notebookId);
}

private isNotebookAccessible(notebook: NotebookRecord | undefined): boolean {
  if (notebook === undefined) {
    return false;
  }
  return !isNotebookPrivacyProtected(notebook) || this.isNotebookUnlocked(notebook.id);
}

private async requestNotebookBiometricAuth(title: string, unlockNotebookId: string = ''): Promise<boolean> {
  let result: JackUserAuthResult = await JackUserAuthenticationKit.requestUserAuth({ title: title });
  if (result.success) {
    if (unlockNotebookId.length > 0) {
      this.unlockNotebook(unlockNotebookId);
    }
    return true;
  }
  if (!result.canceled && result.message.length > 0) {
    this.showToast(result.message);
  }
  return false;
}

private async ensureNotebookPrivacyAccess(notebook: NotebookRecord, title: string): Promise<boolean> {
  if (!isNotebookPrivacyProtected(notebook) || this.isNotebookUnlocked(notebook.id)) {
    return true;
  }
  return this.requestNotebookBiometricAuth(title, notebook.id);
}

private async openNotebookWithPrivacyGuard(notebookId: string): Promise<void> {
  let notebook: NotebookRecord | undefined = this.getNotebookById(notebookId);
  if (notebook === undefined) {
    return;
  }
  if (!(await this.ensureNotebookPrivacyAccess(notebook, '进入受保护小本'))) {
    return;
  }
  this.openNotebookDirect(notebookId);
}

这段业务代码里最重要的是 ensureNotebookPrivacyAccess。它不关心认证控件怎么拉起,也不关心人脸、指纹、锁屏口令哪个可用,只表达业务意图:如果小本没加锁,直接通过;如果已加锁且本次会话解锁过,也直接通过;否则发起系统身份认证。

开启隐私保护时也要认证

还有一个容易漏的点:不是只有"进入受保护内容"要认证,"开启隐私保护"本身也应该认证。

原因很简单。假设用户手机临时交给别人,对方打开应用后把某个小本设置成隐私保护,反而会影响真正用户后续访问。我的处理方式是在保存小本时判断隐私保护状态是否变化,只要开启或关闭都先认证。

核心代码是这样:

ts 复制代码
let previousPrivacyProtection: boolean = notebook.isPrivacyProtected;
let nextPrivacyProtection: boolean = this.notebookPrivacyProtectionInput;
if (previousPrivacyProtection !== nextPrivacyProtection) {
  let authTitle: string = nextPrivacyProtection ? '开启隐私保护前请先验证' : '关闭隐私保护前请先验证';
  if (!(await this.requestNotebookBiometricAuth(authTitle, notebook.id))) {
    this.isBusy = false;
    return;
  }
}

新建小本时,如果用户一开始就勾选了隐私保护,也同样先认证。

ts 复制代码
if (this.notebookPrivacyProtectionInput) {
  if (!(await this.requestNotebookBiometricAuth('开启隐私保护前请先验证'))) {
    this.isBusy = false;
    return;
  }
}

这类场景不要只拦"读取",也要拦"改变保护状态"。

我这里为什么选择 ATL2

AuthTrustLevel 不是越高越好,要看业务场景。

在我的场景里,隐私保护小本属于应用内个人数据访问,不是支付,也不是高风险资金操作。我选择 ATL2,主要是为了兼顾可用性和安全性。设备如果只有普通人脸、指纹或锁屏口令,用户依然有机会完成认证;如果是支付类场景,我会单独评估是否需要更高等级,比如 ATL4

ts 复制代码
private static readonly defaultAuthTrustLevel: userAuth.AuthTrustLevel = userAuth.AuthTrustLevel.ATL2;

另外,我默认按人脸、指纹、锁屏口令的顺序查询设备能力:

ts 复制代码
preferredAuthTypes: Array<userAuth.UserAuthType> = [
  userAuth.UserAuthType.FACE,
  userAuth.UserAuthType.FINGERPRINT,
  userAuth.UserAuthType.PIN
]

这里不是说一定优先使用人脸,而是把当前设备在指定可信等级下可用的认证类型都交给系统控件。最终展示和切换由系统统一认证控件处理,体验会更一致。

几个实战细节

WidgetParam.title 不要写成"请认证"这种泛泛的文案。系统认证控件会展示这个标题,用户需要知道自己为什么被要求认证。我在不同场景下传的是"进入受保护小本""编辑受保护小本""开启隐私保护前请先验证"。

challenge 我用了 CryptoArchitectureKit 生成 32 字节随机值。官方参数里 challenge 用于随机挑战值,可以用于防重放攻击,最大长度为 32 字节。这里我封装在组件内部生成,业务层不用每次重复写。

authInProgress 是为了防止重复拉起认证。移动端用户可能连点入口,如果不挡一下,业务上很容易出现多个 Promise 同时等待认证结果。

取消认证不应该提示"失败"。CANCELEDCANCELED_FROM_WIDGET 我都归为 canceled: true,业务层看到取消就安静返回。

错误提示要转成人能看懂的话。比如 NOT_ENROLLED 不要直接把错误码丢给用户,而是提示"设备未录入可用的人脸、指纹或锁屏口令"。

最后

UserAuthenticationKit 本身不复杂,真正容易写乱的是业务边界。我的建议是先想清楚三个问题:哪些操作需要认证,认证成功后解锁什么范围,什么时候清理解锁状态。

在《时光旅记》里,我把系统认证能力收进 JackUserAuthenticationKit,把"受保护小本"的状态留在业务页面。这样拆完以后,后面如果旅行计划、私密时间轴、账号敏感操作也要加本地认证,只需要复用同一个封装,不需要再复制一套 getAvailableStatusgetUserAuthInstanceon('result') 和错误码处理。

这就是我在《时光旅记》里接入 User Authentication Kit 的完整实践。

相关推荐
想你依然心痛2 小时前
HarmonyOS 6(API 23)实战:基于 Face AR 专注度检测与 Body AR 手势互动的“智能互动课堂“教师授课系统
华为·ar·harmonyos·悬浮导航·沉浸光感·face ar·body ar
UnicornDev2 小时前
【HarmonyOS 6】设置页面 UI 设计
ui·华为·harmonyos·arkts·鸿蒙
脑极体2 小时前
华为智擎+华为超充:华为如何打通电动出行的“任督二脉”?
华为
Yeats_Liao2 小时前
华为开源自研AI框架昇思MindSpore应用案例:基于ResNet50的中药炮制饮片质量判断
人工智能·华为
Hello__777716 小时前
开源鸿蒙 Flutter 实战|消息通知功能完整实现
flutter·开源·harmonyos
高心星17 小时前
鸿蒙6.0应用开发——页面专场实践案例
华为·页面跳转·鸿蒙6.0·harmonyos6.0·页面专场·专场动画
敲代码的鱼哇18 小时前
发送短信/拨打电话/获取联系人能力 UTS 插件(cz-sms)
android·前端·ios·uni-app·安卓·harmonyos·鸿蒙
Hello__777718 小时前
开源鸿蒙 Flutter 实战|仓库评论与点赞功能完整实现
flutter·开源·harmonyos
代码飞天19 小时前
harmonyOS开发之页面跳转
华为·harmonyos