为 Flutter Android 登录配置 Passkey:域名关联与回退验证
在 Flutter Android 应用里配置 Passkey,不能只把原生选择器包装成一个按钮。它是一条由服务端认证、Android 可信关联和 Flutter 页面状态共同组成的登录链路。用户点按登录后,Flutter 先向服务器请求一次性的 WebAuthn options;Android Credential Manager 再展示系统凭据界面;选择器返回的响应交回服务器;服务器验证 origin、rpId、challenge、credential 与用户绑定后才签发业务 session。生物识别通过只是设备操作成功,不等于账号已经登录。
本文适合已有 HTTPS 登录服务、稳定 Android applicationId、可取得发布签名指纹的项目。Android 9 API 28 及以上设备可优先尝试 Passkey;用户没有凭据、主动取消、设备不支持或网络不可用时,必须保留密码或验证码路线。依据 Android Passkey 工作方式,私钥由凭据提供方管理,服务端只存可验证的公钥。因此私钥、完整 assertion、challenge、credential ID、邮箱和 access token 都不应记录进普通日志、埋点或崩溃上报。
服务端先定义可验证的协议
将协议拆成注册和登录两组。已登录用户调用注册 options,服务器生成注册 requestJson 和随机 challenge;客户端将系统创建响应送到注册 verify,服务器校验 attestation、origin 和 challenge 后保存 public key。未登录用户调用 assertion options,服务器生成登录 requestJson;客户端回传 assertion responseJson;服务器在 assertion verify 中验证签名和绑定关系并建立 session。客户端只转运原始 JSON,不自行拼装字段,不用本地状态替代服务器校验。
challenge 必须有随机性、短过期、主体绑定和单次消费。options 返回可携带脱敏 challenge_id,服务器保存真正 challenge、过期时间、请求主体和状态。verify 成功时应在同一数据库事务中完成验证、标记 challenge 已消费和创建 session。这样同一个 assertion 再次提交会返回重放拒绝,而不是不确定的登录失败。challenge 永远不是长期 token,也不能因用户重新打开页面而复用。
为所有阶段设计稳定原因码。网络请求失败、selector 被取消、无可用凭据、挑战过期、域名关联错误、origin 不一致和验签失败不能全部显示为登录失败。页面只展示简短安全的提示;日志记录环境、阶段、HTTP 状态和脱敏原因码。排查时才能知道问题发生在 options、系统界面还是 verify,又不会泄露认证材料。
域名关联必须使用真实签名包
Passkey 需要 Android App 与 relying party 域名的安全关联。将 assetlinks.json 放到实际认证域名的 .well-known 路径,例如 login.example.com。HTTP 重定向、登录页面的子路径、CDN 规则或另一个营销域名都不能代替这个固定位置。文件需要声明关系 delegate_permission/common.get_login_creds、实际 package name 和当前签名的 SHA-256 指纹。
debug、内部测试、Play 生产和企业分发包可能使用不同签名。不能把本机 debug keystore 的指纹带到生产配置。对待测的真实安装包执行:
cd android
./gradlew signingReport
curl -i https://login.example.com/.well-known/assetlinks.json
adb shell pm get-app-links com.example.client
验收记录应含三类证据:signingReport 中对应渠道的 SHA-256;curl 的 200、application/json 响应头与无登录跳转;设备上 package 的关联状态已验证。关系字段和先决条件可对照 Android Credential Manager prerequisites。预发环境应部署独立 assetlinks、使用明确的包名或签名策略,并只请求预发 challenge。
在干净设备以及装过旧版本的设备上分别验证。旧签名、旧 package 或关联缓存经常造成首次复现不一致。发布测试单只需记录 package、versionCode、签名渠道、Android API 与 app-links 命令输出。若只有生产包失败,先检查 Play 签名是否已写入 assetlinks;若所有渠道失败,才继续检查认证域名、服务器 options、网络代理和设备时间。
Android 层只负责系统凭据 UI
在 android/app/build.gradle 或版本目录锁定 androidx.credentials 与 Play services 适配库。官方 Passkey 指南要求使用当前可用版本,并说明 1.2 之前在 Android 14 有兼容风险。升级时固定依赖版本、先运行构建、再安装真机验证,不能在发布日让解析器自动选择新版本。
dependencies {
implementation 'androidx.credentials:credentials:CURRENT_VERSION'
implementation 'androidx.credentials:credentials-play-services-auth:CURRENT_VERSION'
}
CURRENT_VERSION 表示已在版本目录锁定并完成测试的当前官方版本。MainActivity 接收服务器 requestJson,构造 GetPublicKeyCredentialOption 和 GetCredentialRequest,调用 CredentialManager,取得 PublicKeyCredential 后仅返回 authenticationResponseJson。原生层不保存 session、密码、token 或 Activity 静态引用。
val option = GetPublicKeyCredentialOption(requestJson)
val request = GetCredentialRequest(listOf(option))
val credential = credentialManager.getCredential(activity, request).credential
val passkey = credential as PublicKeyCredential
result.success(mapOf(RESPONSE_JSON to passkey.authenticationResponseJson))
GetCredentialCancellationException 映射为 cancelled,代表用户关闭 selector,不应触发循环重试。GetCredentialException 映射为 credential_error,页面应显示回退入口。不要把 native stacktrace、provider 返回文本或 credential JSON 发给页面。注册流程方向相同:服务器生成 CreatePublicKeyCredentialRequest 数据,Android 调 createCredential,客户端把系统响应交回注册 verify。
Flutter 只管理顺序与回退
Dart 层按 options、native selector、verify 三步执行。平台通道将 Dart Map 序列化为 Kotlin 对应结构,具体机制见 Flutter platform channels。只传 requestJson 和 responseJson 的小 DTO,比把完整账户状态机放进 MainActivity 更利于单元测试和原生迁移。
class PasskeyApi {
static const channel = MethodChannel('com.example.client/passkeys');
Future<AuthSession?> signIn(AuthRepository repo) async {
final options = await repo.fetchPasskeyAssertionOptions();
try {
final reply = await channel.invokeMapMethod<String, dynamic>(
'getPasskey', {'requestJson': options.requestJson},
);
final responseJson = reply?['responseJson'] as String?;
if (responseJson == null || responseJson.isEmpty) {
throw const AuthProtocolException('missing passkey response');
}
return repo.verifyPasskeyAssertion(responseJson);
} on PlatformException catch (error) {
if (error.code == 'cancelled') return null;
if (error.code == 'credential_error') return null;
rethrow;
}
}
}
页面状态至少包括 loadingOptions、presentingSelector、verifyingServer、signedIn 和 fallbackAvailable。用户取消、没有凭据或关联未验证时,密码或验证码仍必须可用。离线时不要拿过期 options 打开空 selector。回退入口应清晰,但不把 Passkey 说成已经损坏。系统 selector 必须由用户明确点击触发,不应在页面恢复、应用前后台切换或失败回调中自动反复弹出。
真机验收、日志与发布边界
先在调试环境完成注册、退出和断言,再安装同 applicationId 的内部测试或生产签名 AAB。日志只记录脱敏状态:
auth.passkey options=200 challenge_id=7f… environment=staging
auth.passkey selector_result=cancelled device_api=35
auth.passkey verify=401 reason=challenge_consumed
auth.passkey verify=200 session_issued=true
验收至少覆盖五组。注册后退出并用同一凭据登录成功;重复提交相同 assertion 被拒绝;取消 selector 后页面恢复可操作且没有半成品 session;删除凭据或无凭据设备能走密码或验证码回退;debug 与发布签名分别完成 Digital Asset Links 验证。再主动使 challenge 过期或替换旧值,确认 verify 拒绝并输出可聚合原因码。
每周可以计算 options_success 与 options_total、selector_cancelled 与 selector_shown、verify_success 与 verify_total 的比例,按环境、Android API 和原因码分组。不要记录 credential ID、完整 WebAuthn JSON、邮箱或 token。WebView Cookie 与回跳是另一条链路,应沿用已有的 Flutter Android WebView 登录排查文章。服务端若只验签却不同时核验 origin、rpId、challenge、credential 与用户绑定,就不应签发业务 session。后续可将 Passkey 注册放到已登录安全设置页,复用相同的挑战审计、回退和真机验收闭环。
上线前再做一次接口和状态复盘
把 Passkey 放到生产登录页之前,先确认登录服务仍然把认证与业务会话分开处理。注册 verify 只在校验完成后保存公钥和 credential 元数据;assertion verify 只在校验完成、challenge 未过期且未消费时创建 session。注销时清除本地业务 session,同时根据产品策略调用凭据状态清理接口;这不会删除用户在凭据提供方保存的 Passkey,也不应承诺删除。账户换绑、设备丢失、密码重置和人工风控拦截要沿用现有账户安全流程,不能依赖一个原生 selector 解决所有账号恢复问题。
接口契约应限制输入和输出大小。options 响应只包含当前操作需要的 WebAuthn 数据和脱敏关联标识;verify 请求设置合理的 body 上限与请求超时;服务器拒绝格式错误、未知 credential、重复 challenge 与跨环境 challenge。响应给客户端的错误码保持稳定,例如 options_unavailable、challenge_expired、challenge_consumed、origin_mismatch 与 verification_failed。页面不显示具体安全判断,但开发日志可按原因码聚合。这样客服或发布人员遇到失败时能先判断是用户取消、环境错配还是服务端安全拒绝。
对 Flutter 层写两类测试。Repository 单元测试用假的 HTTP client 覆盖 options 成功、verify 成功、超时、挑战过期和重复提交;页面或 Widget 测试用假的 MethodChannel 覆盖 cancelled、credential_error、空 responseJson 与正常 responseJson。测试断言取消后 loading 状态被关闭、回退按钮可点、不会调用 verify;断言正常响应只调用一次 verify,返回 session 后才进入主页。Android 原生 selector 不适合在每条单元测试中伪造为真实生物识别,真机集成测试负责验证它与实际 provider 的交互。
发布验收应覆盖至少两台设备和两个签名渠道。一台设备已有 Passkey,一台没有凭据;一套安装 debug 或内部测试签名,一套安装与上线一致的签名。每组都检查 assetlinks 下载、selector 是否出现、取消是否可回退、服务端 verify 是否签发 session。若项目同时有 staging 与 production,确保域名、rpId、包名、签名和 challenge 数据库严格隔离。预发成功不代表生产关联也正确,尤其在 Play App Signing 已启用时。
出现线上问题时按固定顺序排查:先看 options 的 HTTP 状态和 challenge_id 是否已生成;再看设备 API、安装包签名、app-links 验证和 selector 结果;最后看 verify 的脱敏原因码。不要先让用户清除数据或反复卸载,因为那会丢掉关键复现条件。也不要为绕开关联错误临时退回不安全的自定义登录页。先用相同版本、相同签名和相同认证域名复现,修复后再以新 challenge 和新安装包回归。
Passkey 的价值在于把密码记忆和钓鱼风险从常规登录路径中移开,而不是消除所有账户安全责任。保持密码或验证码回退、限制敏感日志、做挑战重放测试、验证真实签名包,才能让 Flutter 页面、Android 系统 UI 与服务端认证共同形成可维护的闭环。后续增加注册入口、账号设置或跨设备恢复时,也应继续沿用同一套域名关联、服务器验证、失败边界和测试记录。