一、背景
由于登录密码对用户来说并不方便,且有泄露风险。我们可以在现有登录方案:短信验证码/本机号码一键登录/三方人脸识别的基础上,设计一个使用设备生物识别的登录方案,这能进一步提升用户登录便捷性、降低登录成本----短信、本机号码、三方人脸都是要钱的。
二、方案
下面是以前预研的一个基本思路:
核心原则:生物特征只做本地验证,不出设备。 指纹/人脸在 Secure Enclave 里只是"打开保险箱的钥匙",保险箱里存的是你登录所需的凭证。
js
┌─────────────────────────────────────────────────────────┐
│ 生物识别的本质 │
│ │
│ 不是: 指纹 → 服务器验证 → 登录 │
│ 而是: 指纹 → 解锁本地存储的 Token → 用 Token 登录 │
│ │
│ 服务器永远接触不到你的指纹/人脸数据 │
│ 只认 Token,不管 Token 是验证码换的还是生物识别解封的 │
└─────────────────────────────────────────────────────────┘
1. 开启生物识别
已登录后在设置中开启生物识别(指纹/人脸)。
js
┌────────── 开启生物识别(设置页操作)───────────────────────┐
│ │
│ 前置: 用户已经通过验证码登录,持有有效的 session_token │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 用户点击 "开启面容/指纹登录" │ │
│ │ │ │
│ │ 客户端做以下检查: │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 1. 检查设备是否支持生物识别 │ │ │
│ │ │ LAContext.canEvaluatePolicy(.deviceOwner│ │
│ │ │ AuthenticationWithBiometrics) │ │ │
│ │ │ │ │ │
│ │ │ 2. 检查是否已录入指纹/人脸 │ │ │
│ │ │ biometryType: │ │ │
│ │ │ .touchID / .faceID / .none │ │ │
│ │ │ │ │ │
│ │ │ 3. 如果不支持或未录入 → 提示用户去设置 │ │ │
│ │ │ 无法开启此功能 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 检查通过后: │ │
│ │ │ │
│ │ 1. 服务器生成一个 "生物识别凭证" (biometric_token) │ │
│ │ ───────────────► │ │
│ │ POST /api/auth/biometric/enable │ │
│ │ Header: Authorization Bearer session_token │ │
│ │ Body: { │ │
│ │ device_id: "a1b2c3...", │ │
│ │ biometry_type: "faceID" │ │
│ │ } │ │
│ │ │ │
│ │ 服务器返回: │ │
│ │ ◄─────────────── │ │
│ │ { │ │
│ │ biometric_token: "base64url(服务器生成的随机令牌)",│ │
│ │ refresh_token: "base64url(续期用的)", │ │
│ │ expires_at: 当前+7天 │ │
│ │ } │ │
│ │ │ │
│ │ 2. 客户端将凭证存入 Keychain │ │
│ │ 并设置访问控制: 需要生物识别才能读取 │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Keychain 存储详情: │ │
│ │ │ │
│ │ 存取控制标志: │ │
│ │ · .biometryCurrentSet (当前录入的生物特征) │ │
│ │ · .privateKeyUsage │ │
│ │ · .devicePasscode (备选方案,设备密码兜底) │ │
│ │ │ │
│ │ 存储内容: │ │
│ │ { │ │
│ │ biometric_token, │ │
│ │ refresh_token, │ │
│ │ expires_at, │ │
│ │ device_id, │ │
│ │ user_id │ │
│ │ } │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
// 一个参考值,可以追加设备名、bundleid等生成
device_id = SHA256(IDFV + 随机种子 + 安装时间戳)
具体代码:
js
import Security
import LocalAuthentication
func storeBiometricToken(token: Data, key: String) throws {
// 访问控制:必须生物认证才能读取
let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.biometryCurrentSet, .privateKeyUsage],
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: token,
kSecAttrAccessControl as String: accessControl,
// 固定在当前设备,备份还原过来也不能用
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// 先删旧值
SecItemDelete(query as CFDictionary)
// 写入新值
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: "KeychainError", code: Int(status))
}
}
2.生物识别登录流程:
js
┌────────── 用户下次打开 App 走生物识别登录 ──────────────────┐
│ │
│ 用户打开 App │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ 1. 检查本地是否有 biometric_token │ │
│ │ · 没有 → 显示验证码登录页(已有) │ │
│ │ · 有 → 触发生物识别 │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ 2. 弹出系统面容/指纹扫描 │ │
│ │ LAContext.evaluatePolicy( │ │
│ │ .deviceOwnerAuthentication │ │
│ │ WithBiometrics, │ │
│ │ reason: "使用面容登录" │ │
│ │ ) │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ├── 认证成功 ─────────────────────────────────────────│
│ │ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ 3. 用生物识别解封 Keychain │ │
│ │ │ 取到 biometric_token + refresh_token│ │
│ │ │ (这个过程对用户透明,系统自动完成) │ │
│ │ └──────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ 4. 向服务器发起生物识别登录 │ │
│ │ │ POST /api/auth/biometric/login │ │
│ │ │ Body: { │ │
│ │ │ user_id, │ │
│ │ │ device_id, │ │
│ │ │ biometric_token │ │
│ │ │ } │ │
│ │ └──────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ 服务器验证: │ │
│ │ │ 1. biometric_token 是否有效 │ │
│ │ │ 2. device_id 是否匹配 │ │
│ │ │ 3. 是否在有效期内 │ │
│ │ │ 4. 验证通过 → 返回新 session_token │ │
│ │ │ (同时可能刷新 biometric_token) │ │
│ │ └──────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ 登录成功 ✅ → 进入 App 主页 │
│ │ │
│ │ (可选)后台刷新 biometric_token │
│ │ POST /api/auth/biometric/refresh │
│ │ → 更新 Keychain 中的 biometric_token │
│ │ │
│ ├── 认证失败 ─────────────────────────────────────────│
│ │ │ │
│ │ ▼ │
│ │ 显示验证码登录页(用户手动输入验证码登录) │
│ │ │
│ └── 用户选择 "使用其他方式登录" ────────────────────────│
│ │ │
│ ▼ │
│ 回退到验证码登录页 │
│ │
└────────────────────────────────────────────────────────────┘
Swift中实现大体代码:
Swift
// 大体代码:
func attemptBiometricLogin() {
let context = LAContext()
let reason = "确认本人操作,使用面容/指纹登录"
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in
if success {
// 1. 从 Keychain 取出生物识别 Token
let tokenData = try? KeychainManager.loadBiometricToken()
// 2. 用 Token 向服务器请求正式的 session 凭证
APIClient.loginWithBiometricToken(tokenData) { result in
// 处理登录成功或失败(如 Token 过期)
}
} else {
// 用户取消、生物特征不匹配或直接点选"使用其他方式登录"都会进入这里
// 策略:直接退回验证码登录页,不区分具体错误原因
DispatchQueue.main.async {
showSMSLoginScreen()
}
}
}
}
3.核心边界情况处理
情况1:设备未设置人脸/指纹
js
┌──────────────────────────────────────────────────┐
│ 用户点击 "开启面容登录" │
│ │ │
│ ▼ │
│ 检查 LAContext.biometryType: │
│ │ │
│ ├── .none → 设备没有生物识别硬件(如 iPad 5代)│
│ │ 提示: "您的设备不支持面容/指纹" │
│ │ 按钮: "了解详情" (跳转设置) │
│ │ │
│ └── .touchID / .faceID 但未录入 │
│ canEvaluatePolicy 返回错误 │
│ 错误码 kLAErrorBiometryNotEnrolled │
│ 提示: "请先在系统设置中录入面容/指纹" │
│ 按钮: "前往设置" → 跳转 Settings │
│ │
│ UI 处理: │
│ · "开启面容登录"按钮置灰,下方显示原因 │
│ · 或点击后弹 Alert 引导去系统设置 │
└──────────────────────────────────────────────────┘
情况2:用户更改了人脸/指纹(核心安全机制)
js
┌──────────────────────────────────────────────────┐
│ 用户去系统设置添加了新面容 / 增删了指纹 │
│ │ │
│ ▼ │
│ Keychain 中的 biometric_token 的访问控制 │
│ 标记了 .biometryCurrentSet │
│ │ │
│ ▼ │
│ 生物特征集合发生变化 → 该访问控制立即失效 │
│ │ │
│ ▼ │
│ 下次尝试生物识别登录时: │
│ ┌───────────────────────────────────────────┐ │
│ │ LAContext.evaluatePolicy → 认证通过 ✅ │ │
│ │ 因为新面容/指纹是合法的 │ │
│ │ │ │
│ │ 但是!Keychain 读取失败 ❌ │ │
│ │ 错误码: errSecAuthFailed │ │
│ │ 原因: .biometryCurrentSet 要求 │ │
│ │ 生物特征集合 == 存储时的集合 │ │
│ │ 现在变了 → 拒绝访问 │ │
│ └───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ App 检测到 Keychain 读取失败 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ 安全的处理策略: │ │
│ │ │ │
│ │ 1. 清除本地生物识别凭证 │ │
│ │ (删掉失效的 Keychain 条目) │ │
│ │ │ │
│ │ 2. 通知服务器撤销旧 biometric_token │ │
│ │ POST /api/auth/biometric/revoke │ │
│ │ Body: { device_id, reason: │ │
│ │ "biometry_changed" } │ │
│ │ (防止旧凭证被恶意利用) │ │
│ │ │ │
│ │ 3. 回退到验证码登录 │ │
│ │ 并提示用户生物特征已变更 │ │
│ │ "检测到您的面容/指纹设置有变化, │ │
│ │ 为了安全请重新登录后再次开启" │ │
│ └──────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
情况3:换设备
js
┌──────────────────────────────────────────────────┐
│ 用户买了新手机,从 iCloud 恢复或迁移数据 │
│ │ │
│ ▼ │
│ Keychain 数据恢复情况: │
│ ┌───────────────────────────────────────────┐ │
│ │ 取决于 kSecAttrAccessible 的设置: │ │
│ │ │ │
│ │ kSecAttrAccessibleWhenUnlocked │ │
│ │ → 恢复备份会带过来?不一定! │ │
│ │ 取决于备份加密和设备 ID 变化 │ │
│ │ │ │
│ │ kSecAttrAccessibleWhenUnlocked │ │
│ │ ThisDeviceOnly │ │
│ │ → 明确告诉系统 "仅限这台设备" │ │
│ │ → 换设备后此条目不会被恢复 ✅ 安全的 │ │
│ └───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 结论: 方案中使用了 ThisDeviceOnly │
│ 换设备后: │
│ · Keychain 里没有 biometric_token │
│ · 旧设备的 biometric_token 已通过 device_id │
│ 绑定,新设备 device_id 不同 │
│ · 新设备必须走验证码登录(首次安装注册流程) │
│ │
│ 用户登录后可以去设置中重新开启生物识别 │
└──────────────────────────────────────────────────┘
情况4:biometric_token 过期
js
┌──────────────────────────────────────────────────┐
│ biometric_token 有效期设计建议: 7天 │
│ │ │
│ ▼ │
│ 连续 7 天未打开 App: │
│ → biometric_token 过期 │
│ → 服务器拒绝: 401 + reason: "biometric_expired" │
│ → App 回退到验证码登录 │
│ → 登录成功后自动续期 biometric_token │
│ │
│ 每次成功使用生物识别登录后: │
│ → 服务器返回新的 biometric_token │
│ → App 更新 Keychain,有效期延长 7 天 │
│ → 使用 refresh_token 刷新,无需验证码 │
└──────────────────────────────────────────────────┘
4.关键数据结构
客户端存储
js
┌──────────────────────────────────────────────────────┐
│ Keychain (带生物识别保护) │
│ ┌────────────────────────────────────────────────┐ │
│ │ biometric_credential: { │ │
│ │ biometric_token: "zt7G...", │ │
│ │ refresh_token: "r8Xk...", │ │
│ │ device_id: "a1b2c3...", │ │
│ │ user_id: "zhangsan", │ │
│ │ enabled_at: 1700000000 │ │
│ │ } │ │
│ │ │ │
│ │ 访问控制: .biometryCurrentSet + ThisDeviceOnly │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ UserDefaults (轻量偏好) │
│ ┌────────────────────────────────────────────────┐ │
│ │ biometric_enabled: true/false │ │
│ │ last_biometric_login_at: 时间戳 │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
服务端存储
js
┌──────────────────────────────────────────────────────┐
│ 用户-设备表 (在现有基础上增加列) │
│ user_devices: { │
│ "user_id": { │
│ "device_id_1": { │
│ device_name, │
│ biometric_enabled: true, │
│ biometric_token_hash: SHA256(zt7G...), │
│ biometric_enabled_at: 时间戳, │
│ biometric_expires_at: 时间戳, │
│ ... │
│ } │
│ } │
│ } │
│ │
│ 说明: │
│ · biometric_token 用 SHA256 哈希存,防泄露 │
│ · 一个 device 最多一个有效的 biometric_token │
│ · 生物特征变更时, 客户端主动调 revoke 接口 │
│ · 到期自动清理 │
└──────────────────────────────────────────────────────┘