大家好,我是鸿蒙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 同时等待认证结果。
取消认证不应该提示"失败"。CANCELED 和 CANCELED_FROM_WIDGET 我都归为 canceled: true,业务层看到取消就安静返回。
错误提示要转成人能看懂的话。比如 NOT_ENROLLED 不要直接把错误码丢给用户,而是提示"设备未录入可用的人脸、指纹或锁屏口令"。
最后
UserAuthenticationKit 本身不复杂,真正容易写乱的是业务边界。我的建议是先想清楚三个问题:哪些操作需要认证,认证成功后解锁什么范围,什么时候清理解锁状态。
在《时光旅记》里,我把系统认证能力收进 JackUserAuthenticationKit,把"受保护小本"的状态留在业务页面。这样拆完以后,后面如果旅行计划、私密时间轴、账号敏感操作也要加本地认证,只需要复用同一个封装,不需要再复制一套 getAvailableStatus、getUserAuthInstance、on('result') 和错误码处理。
这就是我在《时光旅记》里接入 User Authentication Kit 的完整实践。