结合自己做过的生物认证方案,抽离出完整的认证逻辑。
注意:目前仅适配ios,安卓不适配。原因是安卓往往需要用第三方辅助认证而不是直接调起手机自身的生物认证
功能包含:
-
- 应用切入后台N分钟后切回 → 触发认证
-
- 应用前台挂机(无操作)N分钟 → 触发认证
-
- 应用退出/进程被杀后重启 → 触发认证
-
- 页面主动调用认证(如敏感操作前)→ 直接触发
-
- 认证成功后重置所有状态 → 取消认证要求
/**
* iOS 生物认证工具类(适配指纹/Touch ID、人脸/Face ID)
* 基于 uni-app 官方文档:<https://uniapp.dcloud.net.cn/api/system/authentication.html#>
* 核心场景覆盖:
* 1. 应用切入后台N分钟后切回 → 触发认证
* 2. 应用前台挂机(无操作)N分钟 → 触发认证
* 3. 应用退出/进程被杀后重启 → 触发认证
* 4. 页面主动调用认证(如敏感操作前)→ 直接触发
* 5. 认证成功后重置所有状态 → 取消认证要求
*/
const config = {
// 本地存储key
backgroundTimeKey: 'app_background_start_time',
foregroundIdleKey: 'app_foreground_idle_start_time',
timeLong: 10 * 60 * 1000, // 超时时间(10分钟)
// 生物认证核心配置(根据官方文档修正)
soter: {
authContent: {
fingerPrint: '请用指纹解锁', // 官方参数是 fingerPrint,不是 fingerprint
facial: '请用FaceID解锁' // 官方参数是 facial
},
maxRetry: 3, // 失败重试次数
// 官方错误码映射(根据文档修正为900开头)
errorCode: {
0: '识别成功',
90001: '本设备不支持生物认证',
90002: '用户未授权使用该生物认证接口',
90003: '请求使用的生物认证方式不支持',
90004: '未传入challenge或challenge长度过长',
90005: 'auth_content长度超过限制',
90007: '内部错误',
90008: '用户取消授权',
90009: '识别失败',
90010: '重试次数过多被冻结',
90011: '用户未录入所选识别方式'
}
}
}
// 全局定时器缓存
let timers = {
backgroundTimer: null,
foregroundIdleTimer: null
}
/**
* 检测设备支持的生物认证类型
* @returns {Promise<string|null>} 支持的类型:facial/fingerPrint | null
*/
export const checkSoterSupport = () => {
return new Promise((resolve) => {
// 仅iOS端执行生物认证逻辑
if (uni.getSystemInfoSync().platform !== 'ios') {
console.warn('当前仅适配iOS系统,非iOS设备不支持生物认证')
resolve(null)
return
}
uni.checkIsSupportSoterAuthentication({
success: (res) => {
const { supportMode } = res
console.log('设备支持的生物认证方式:', supportMode)
// 根据官方文档,支持模式是数组,包含 fingerPrint 或 facial
if (supportMode && supportMode.includes('facial')) {
resolve('facial')
} else if (supportMode && supportMode.includes('fingerPrint')) {
resolve('fingerPrint')
} else {
resolve(null)
}
},
fail: (err) => {
console.error('检测生物认证支持失败:', err)
resolve(null)
}
})
})
}
/**
* 检查是否已录入生物信息
* @param {string} authMode - 认证方式:fingerPrint 或 facial
* @returns {Promise<boolean>} 是否已录入
*/
export const checkIsEnrolled = (authMode) => {
return new Promise((resolve) => {
uni.checkIsSoterEnrolledInDevice({
checkAuthMode: authMode,
success: (res) => {
resolve(res.isEnrolled || false)
},
fail: (err) => {
console.error('检查生物信息录入状态失败:', err)
resolve(false)
}
})
})
}
/**
* 核心:执行iOS生物认证
* @returns {Promise<{success: boolean, data: any, error: string|null}>} 认证结果
*/
export const startSoterAuthen = async () => {
let retryCount = 0
const supportType = await checkSoterSupport()
if (!supportType) {
return {
success: false,
data: null,
error: config.soter.errorCode[90001] // 设备不支持
}
}
// 检查是否已录入生物信息
const isEnrolled = await checkIsEnrolled(supportType)
if (!isEnrolled) {
return {
success: false,
data: null,
error: config.soter.errorCode[90011] // 用户未录入
}
}
return new Promise((resolve) => {
const doAuthen = () => {
// 每次认证生成唯一挑战串(根据文档最长512字符)
const timestamp = Date.now()
const randomStr = Math.random().toString(36).slice(2, 10)
const challenge = `uni_soter_${timestamp}_${randomStr}`
console.log(`开始生物认证,模式:${supportType},挑战串:${challenge}`)
uni.startSoterAuthentication({
requestAuthModes: [supportType],
challenge: challenge,
authContent: config.soter.authContent[supportType],
success: (res) => {
console.log('生物认证成功:', res)
// 根据官方文档,success回调中也有errCode字段
if (res.errCode === 0) {
resolve({
success: true,
data: res,
error: null
})
} else {
// 虽然走了success回调,但errCode不是0,表示有错误
const errorMsg = config.soter.errorCode[res.errCode] || '认证失败'
resolve({
success: false,
data: res,
error: errorMsg
})
}
},
fail: (err) => {
retryCount++
console.error(`生物认证失败(第${retryCount}次):`, err)
// 根据官方文档,错误码在err.errCode中
const errCode = err.errCode || 90009
let errorMsg = config.soter.errorCode[errCode] || config.soter.errorCode[90009]
// 用户主动取消时直接返回,不重试
if (errCode === 90008) {
resolve({ success: false, data: err, error: errorMsg })
return
}
// 设备锁定或不可用
if (errCode === 90010) {
resolve({ success: false, data: err, error: errorMsg })
return
}
// 未超重试次数则重新认证
if (retryCount < config.soter.maxRetry) {
console.log(`剩余重试次数:${config.soter.maxRetry - retryCount}`)
setTimeout(() => {
doAuthen()
}, 1000) // 添加延迟避免过快重试
return
}
// 超重试次数返回失败
resolve({ success: false, data: err, error: errorMsg })
}
})
}
doAuthen()
})
}
/**
* 应用切入后台时的计时逻辑
*/
export const handleAppBackground = () => {
const enterBackgroundTime = Date.now()
uni.setStorageSync(config.backgroundTimeKey, enterBackgroundTime)
// 清除旧定时器
if (timers.backgroundTimer) clearTimeout(timers.backgroundTimer)
// 启动后台超时定时器
timers.backgroundTimer = setTimeout(() => {
console.log('应用后台停留超时,标记需要认证')
// 设置需要认证的标志
uni.setStorageSync('need_authen_flag', true)
}, config.timeLong)
// 后台时终止前台挂机计时
clearForegroundIdleTimer()
}
/**
* 应用切回前台时的判断逻辑
* @param {boolean} useAuthen - 是否开启生物认证功能
* @returns {boolean} 是否需要认证
*/
export const handleAppForeground = (useAuthen) => {
if (!useAuthen) {
resetAllAuthState()
return false
}
// 停止后台定时器
if (timers.backgroundTimer) {
clearTimeout(timers.backgroundTimer)
timers.backgroundTimer = null
}
const enterBackgroundTime = uni.getStorageSync(config.backgroundTimeKey)
let needAuthen = false
if (enterBackgroundTime) {
const timeDiff = Date.now() - enterBackgroundTime
needAuthen = timeDiff > config.timeLong
if (needAuthen) {
console.log(`前后台切换超时,需要认证,时间差:${(timeDiff / 1000).toFixed(0)}秒`)
uni.setStorageSync('need_authen_flag', true)
} else {
console.log(`前后台切换未超时,时间差:${(timeDiff / 1000).toFixed(0)}秒`)
}
// 处理完清除缓存
uni.removeStorageSync(config.backgroundTimeKey)
}
// 检查前台挂机是否超时
const idleStartTime = uni.getStorageSync(config.foregroundIdleKey)
if (idleStartTime) {
const idleTimeDiff = Date.now() - idleStartTime
if (idleTimeDiff > config.timeLong) {
console.log(`前台挂机超时,需要认证,挂机时间:${(idleTimeDiff / 1000).toFixed(0)}秒`)
needAuthen = true
uni.setStorageSync('need_authen_flag', true)
}
// 清除挂机开始时间,重新计时
uni.removeStorageSync(config.foregroundIdleKey)
}
// 前台切回后启动挂机计时
startForegroundIdleTimer(useAuthen)
return needAuthen
}
/**
* 应用启动/进程被杀重启时的初始化逻辑
* @param {boolean} useAuthen - 是否开启生物认证功能
* @returns {boolean} 是否需要认证
*/
export const initAppAuthState = (useAuthen) => {
if (!useAuthen) {
resetAllAuthState()
return false
}
let needAuthen = false
// 先检查是否有挂起的认证需求
const existingAuthFlag = uni.getStorageSync('need_authen_flag')
if (existingAuthFlag) {
needAuthen = true
console.log('检测到挂起的认证需求')
} else {
// 检查后台时间
const enterBackgroundTime = uni.getStorageSync(config.backgroundTimeKey)
if (enterBackgroundTime) {
needAuthen = (Date.now() - enterBackgroundTime) > config.timeLong
console.log(`进程异常退出,启动判断:${needAuthen ? '超时需认证' : '未超时'}`)
} else {
// 全新启动/进程彻底被杀,强制认证
needAuthen = true
console.log('APP全新启动/进程被杀,强制需要认证')
}
}
if (needAuthen) {
uni.setStorageSync('need_authen_flag', true)
}
// 清除缓存并启动前台挂机计时
uni.removeStorageSync(config.backgroundTimeKey)
uni.removeStorageSync(config.foregroundIdleKey)
startForegroundIdleTimer(useAuthen)
return needAuthen
}
/**
* 检查是否需要认证
* @returns {boolean} 是否需要认证
*/
export const checkIfNeedAuthen = () => {
return !!uni.getStorageSync('need_authen_flag')
}
/**
* 启动前台挂机计时(无操作N分钟后触发认证)
* @param {boolean} useAuthen - 是否开启认证功能
*/
export const startForegroundIdleTimer = (useAuthen) => {
if (!useAuthen) return
// 清除旧定时器
clearForegroundIdleTimer()
const idleStartTime = Date.now()
uni.setStorageSync(config.foregroundIdleKey, idleStartTime)
// 启动新定时器
timers.foregroundIdleTimer = setTimeout(() => {
console.log('前台挂机超时,标记需要认证')
uni.setStorageSync('need_authen_flag', true)
}, config.timeLong)
console.log('前台挂机计时器已启动')
}
/**
* 重置前台挂机计时(用户有操作时调用)
* @param {boolean} useAuthen - 是否开启认证功能
*/
export const resetForegroundIdleTimer = (useAuthen) => {
if (!useAuthen) return
clearForegroundIdleTimer()
startForegroundIdleTimer(useAuthen)
}
/**
* 清除前台挂机定时器
*/
export const clearForegroundIdleTimer = () => {
if (timers.foregroundIdleTimer) {
clearTimeout(timers.foregroundIdleTimer)
timers.foregroundIdleTimer = null
}
uni.removeStorageSync(config.foregroundIdleKey)
}
/**
* 重置所有认证状态(认证成功后调用)
*/
export const resetAllAuthState = () => {
// 清除所有定时器
if (timers.backgroundTimer) clearTimeout(timers.backgroundTimer)
if (timers.foregroundIdleTimer) clearTimeout(timers.foregroundIdleTimer)
timers.backgroundTimer = null
timers.foregroundIdleTimer = null
// 清除本地缓存
uni.removeStorageSync(config.backgroundTimeKey)
uni.removeStorageSync(config.foregroundIdleKey)
uni.removeStorageSync('need_authen_flag')
console.log('认证状态已重置,取消所有认证要求')
}
/**
* 获取当前认证配置
* @returns {Object} 配置信息
*/
export const getConfig = () => {
return { ...config }
}
/**
* 设置超时时间
* @param {number} minutes - 超时时间(分钟)
*/
export const setTimeoutMinutes = (minutes) => {
if (minutes > 0) {
config.timeLong = minutes * 60 * 1000
uni.setStorageSync('auth_timeout_minutes', minutes)
console.log(`认证超时时间设置为:${minutes}分钟`)
}
}
/**
* 工具类初始化
*/
export const init = () => {
// 从存储中读取超时设置
const savedMinutes = uni.getStorageSync('auth_timeout_minutes')
if (savedMinutes) {
config.timeLong = savedMinutes * 60 * 1000
}
console.log('生物认证工具类初始化完成')
}
// 导出核心方法和配置
export default {
config,
init,
checkSoterSupport,
checkIsEnrolled,
startSoterAuthen,
handleAppBackground,
handleAppForeground,
initAppAuthState,
checkIfNeedAuthen,
startForegroundIdleTimer,
resetForegroundIdleTimer,
clearForegroundIdleTimer,
resetAllAuthState,
getConfig,
setTimeoutMinutes
}
/*********************************************************************
* 以下是【完整使用模板】,可直接复制到对应文件中使用
*********************************************************************/
/**************************
* 模板1:Store文件(如 @/store/app.js)
* 功能:状态管理 + 调用工具库方法
* 注意:根据官方文档,iOS只有fingerPrint和facial两种模式,无payment模式
**************************/
/*
import { defineStore } from 'pinia'
import authLib from '@/utils/function/SoterAuthen'
export const appState = defineStore('app', {
state: () => ({
needAuthen: false, // 是否需要认证
useAuthen: uni.getStorageSync('useAuthen') || false, // 是否开启认证功能
timeLong: authLib.config.timeLong,
soterSupport: null // 设备支持的认证类型
}),
actions: {
// 初始化认证状态
async initSoterSupport() {
this.soterSupport = await authLib.checkSoterSupport()
return this.soterSupport
},
// 设置是否开启生物认证
setUseAuthen(val) {
uni.setStorageSync('useAuthen', val)
this.useAuthen = val
if (!val) {
this.resetAuthenState()
} else {
// 开启认证时重新检测设备支持
this.initSoterSupport()
}
},
// 应用切入后台(调用工具库方法)
HiddenStartTime() {
if (this.useAuthen) {
authLib.handleAppBackground()
}
},
// 应用切回前台(调用工具库方法)
onAppShow() {
if (this.useAuthen) {
this.needAuthen = authLib.handleAppForeground(this.useAuthen)
console.log('切回前台后是否需要认证:', this.needAuthen)
}
},
// 应用启动初始化(调用工具库方法)
initAppState() {
if (this.useAuthen) {
this.needAuthen = authLib.initAppAuthState(this.useAuthen)
console.log('应用启动后是否需要认证:', this.needAuthen)
}
},
// 重置认证状态(认证成功后调用)
resetAuthenState() {
authLib.resetAllAuthState()
this.needAuthen = false
},
// 手动触发认证(供页面调用)
async triggerAuthen() {
if (!this.useAuthen) {
return { success: true, data: null, error: null }
}
const result = await authLib.startSoterAuthen()
if (result.success) {
this.resetAuthenState()
}
return result // 返回认证结果(success/error)
},
// 检查当前是否需要认证
checkNeedAuthen() {
this.needAuthen = authLib.checkIfNeedAuthen()
return this.needAuthen
},
// 启动前台挂机计时(供App.vue调用)
startForegroundIdle() {
if (this.useAuthen) {
authLib.startForegroundIdleTimer(this.useAuthen)
}
},
// 重置前台挂机计时(用户操作时调用)
resetForegroundIdle() {
if (this.useAuthen) {
authLib.resetForegroundIdleTimer(this.useAuthen)
}
}
},
getters: {
isNeedAuthen: (state) => state.needAuthen,
isUseAuthen: (state) => state.useAuthen,
getSoterType: (state) => {
if (!state.soterSupport) return 'none'
const typeMap = {
'facial': 'Face ID',
'fingerPrint': 'Touch ID'
}
return typeMap[state.soterSupport] || '生物认证'
}
}
})
*/
/**************************
* 模板2:App.vue(应用生命周期绑定)
* 功能:关联应用前后台/启动事件,触发认证逻辑
**************************/
/*
import { appState } from '@/store/app'
import authLib from '@/utils/function/SoterAuthen'
export default {
// 应用启动时初始化
onLaunch() {
// 初始化工具类
authLib.init()
const appStore = appState()
// 初始化设备支持检测
appStore.initSoterSupport()
// 初始化认证状态
appStore.initAppState()
},
// 应用切回前台时判断认证状态 + 启动前台挂机计时
onShow() {
const appStore = appState()
appStore.onAppShow()
appStore.startForegroundIdle()
},
// 应用切入后台时启动计时
onHide() {
const appStore = appState()
appStore.HiddenStartTime()
}
}
*/
/**************************
* 模板3:页面敏感操作触发认证(如转账/改密码页)
* 场景:执行敏感操作前主动触发生物认证
**************************/
/*
<template>
<view class="container" @touchstart="handleUserAction">
<button @click="doSensitiveOperation">执行敏感操作(需认证)</button>
</view>
</template>
<script setup>
import { appState } from '@/store/app'
const appStore = appState()
// 用户操作时重置挂机计时
const handleUserAction = () => {
appStore.resetForegroundIdle()
}
// 执行敏感操作前触发认证
const doSensitiveOperation = async () => {
// 检查是否需要认证
if (appStore.checkNeedAuthen()) {
const result = await appStore.triggerAuthen()
if (result.success) {
// 认证成功,执行敏感业务逻辑
console.log('认证通过,执行敏感操作')
uni.showToast({ title: '认证成功', icon: 'success' })
// 示例:调用接口等
// await callSensitiveApi()
} else {
// 认证失败,展示错误提示
uni.showToast({
title: result.error || '认证失败',
icon: 'none',
duration: 2000
})
return // 认证失败,不执行后续操作
}
}
// 无需认证或认证成功,继续执行
console.log('执行敏感操作...')
}
</script>
*/
/**************************
* 模板4:认证页(pages/auth/index.vue)
* 功能:展示认证提示 + 触发生物认证 + 重置状态
**************************/
/*
<template>
<view class="auth-page">
<!-- 认证图标 -->
<view class="auth-icon">
<text class="icon" v-if="supportType === 'facial'">👤</text>
<text class="icon" v-else-if="supportType === 'fingerPrint'">🔒</text>
<text class="icon" v-else>⚠️</text>
</view>
<!-- 提示信息 -->
<view class="auth-tip">{{ tipText }}</view>
<!-- 认证按钮 -->
<button
class="auth-btn"
:loading="loading"
:disabled="loading || !supportType"
@click="startAuthen"
>
{{ loading ? '认证中...' : '立即认证' }}
</button>
<!-- 错误提示 -->
<view class="error-tip" v-if="errorMsg">
{{ errorMsg }}
</view>
<!-- 备选方案 -->
<view class="alternative" v-if="showAlternative">
<view class="divider">
<text class="divider-text">或</text>
</view>
<button class="password-btn" @click="usePassword">使用密码验证</button>
</view>
</view>
</template>
<script setup>
import { ref, onLoad } from 'vue'
import authLib from '@/utils/function/SoterAuthen'
import { appState } from '@/store/app'
const appStore = appState()
const supportType = ref('') // 支持的认证类型
const tipText = ref('') // 认证提示文案
const errorMsg = ref('') // 错误提示
const loading = ref(false) // 加载状态
const showAlternative = ref(false) // 是否显示备选方案
// 页面加载时检测设备支持的认证类型
onLoad(async () => {
loading.value = true
const type = await authLib.checkSoterSupport()
supportType.value = type
if (type) {
// 匹配工具库的提示文案
tipText.value = authLib.config.soter.authContent[type] || '请完成生物认证'
// 检查是否已录入生物信息
const isEnrolled = await authLib.checkIsEnrolled(type)
if (!isEnrolled) {
errorMsg.value = '您尚未录入生物信息,请先在系统设置中录入'
showAlternative.value = true
}
} else {
tipText.value = '当前设备不支持生物认证'
errorMsg.value = '请使用其他验证方式'
showAlternative.value = true
}
loading.value = false
})
// 点击按钮触发生物认证
const startAuthen = async () => {
if (!supportType.value) return
errorMsg.value = ''
loading.value = true
// 调用工具库核心认证方法
const result = await authLib.startSoterAuthen()
if (result.success) {
// 认证成功,重置状态并返回
appStore.resetAuthenState()
uni.showToast({ title: '认证成功', icon: 'success' })
// 延迟返回,让用户看到成功提示
setTimeout(() => {
uni.navigateBack()
}, 1000)
} else {
// 认证失败,展示错误信息
errorMsg.value = result.error
// 特定错误码显示备选方案
if ([90008, 90010, 90011].includes(result.data?.errCode)) {
showAlternative.value = true
}
}
loading.value = false
}
// 使用密码验证
const usePassword = () => {
uni.showModal({
title: '密码验证',
content: '此功能需要跳转到密码验证页面',
success: (res) => {
if (res.confirm) {
// 跳转到密码验证页
uni.navigateTo({
url: '/pages/auth/password'
})
}
}
})
}
</script>
<style scoped>
.auth-page {
padding: 40rpx;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.auth-icon {
margin-bottom: 60rpx;
}
.auth-icon .icon {
font-size: 120rpx;
color: #fff;
}
.auth-tip {
font-size: 36rpx;
color: #fff;
margin-bottom: 80rpx;
text-align: center;
line-height: 1.5;
}
.auth-btn {
width: 80%;
height: 88rpx;
line-height: 88rpx;
background: rgba(255, 255, 255, 0.9);
color: #764ba2;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
margin-bottom: 40rpx;
}
.auth-btn:disabled {
opacity: 0.6;
}
.error-tip {
margin-top: 30rpx;
font-size: 28rpx;
color: #ffd700;
text-align: center;
line-height: 1.5;
padding: 0 40rpx;
}
.alternative {
margin-top: 60rpx;
width: 100%;
}
.divider {
display: flex;
align-items: center;
margin-bottom: 40rpx;
}
.divider:before,
.divider:after {
content: '';
flex: 1;
height: 1rpx;
background: rgba(255, 255, 255, 0.3);
}
.divider-text {
padding: 0 30rpx;
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
}
.password-btn {
width: 80%;
height: 80rpx;
line-height: 80rpx;
background: transparent;
color: #fff;
border: 2rpx solid rgba(255, 255, 255, 0.5);
border-radius: 40rpx;
font-size: 30rpx;
margin: 0 auto;
}
</style>
*/