WebAuthn无密码登录:安全原理、技术实现与生产部署指南
1. 项目概述从“密码疲劳”到“无密码”的必然之路不知道你有没有数过自己每天要输入多少次密码邮箱、社交软件、银行App、公司内网、各种订阅服务……我自己的密码管理器里躺着超过200条记录这还不算那些临时注册、用完即弃的账号。这种“密码疲劳”不仅仅是麻烦它背后隐藏着一个巨大的安全悖论为了记住我们倾向于使用简单、重复的密码而为了安全我们又被告诫必须使用复杂、唯一的密码。这个矛盾催生了密码管理器的繁荣但依然没有从根本上解决问题——密码本身就是一个可以被窃取、被撞库、被钓鱼的“秘密”。“无密码登录”正是在这种背景下从安全专家的愿景逐渐走向主流视野。它并非简单地去掉密码而是用一种更安全、更便捷的认证因子来替代它。最近几年你肯定在越来越多的网站上见过“使用指纹登录”或“使用手机安全密钥登录”的选项这背后核心的技术标准之一就是WebAuthn。这个项目标题“无密码登录的安全问题及采用WebAuthn技术与实现分析”精准地切中了当前身份认证领域最热门的转型痛点。它探讨的不仅仅是“怎么做”更是“为什么做”以及“做了之后会面临什么”。作为一名经历过多次安全加固和用户认证系统改造的从业者我深切体会到从传统的“用户名密码”切换到无密码体系绝非一次简单的技术升级而是一次涉及用户体验、安全架构和运维理念的全面革新。本文将从一个实践者的角度深入拆解无密码登录特别是基于WebAuthn所承诺的安全优势与潜在风险并详细分析其技术实现路径。我会分享在实际部署中遇到的坑、参数配置的考量以及如何平衡安全性与易用性。无论你是正在考虑引入无密码登录的产品经理、负责落地实施的后端或前端工程师还是对下一代身份认证技术感兴趣的安全研究员希望这篇超过5000字的深度分析能为你提供切实的参考。2. 无密码登录的安全图景理想、现实与潜在陷阱无密码登录听起来很美仿佛一键就能进入一个没有密码泄露和忘记密码烦恼的乌托邦。但在我们拥抱它之前必须清醒地认识到任何安全方案都是一把双刃剑。移除密码意味着移除了一个我们熟悉但脆弱的安全层同时引入了新的依赖和攻击面。2.1 无密码登录的核心安全优势解析首先我们必须明确无密码登录特指基于公钥密码学的方案如WebAuthn带来的根本性安全提升。这不仅仅是“更方便了”而是安全模型的范式转移。1. 彻底免疫钓鱼攻击这是WebAuthn等FIDO2标准最大的杀手锏。在传统密码体系中用户可能在evil-phishing.com上输入了他在real-bank.com的密码攻击者就得手了。而WebAuthn的认证过程是“与源绑定的”。当你尝试在evil-phishing.com上登录时浏览器传递给安全密钥如YubiKey或平台认证器如Touch ID的“挑战”中包含了请求来源的域名evil-phishing.com。认证器会检查这个域名是否与当初注册该凭证时绑定的域名real-bank.com一致。如果不一致认证会直接失败用户甚至不会收到任何提示。攻击者无法通过伪造界面来窃取凭证。2. 消除服务器端的密码泄露风险在传统架构中服务器必须存储密码的哈希值用于验证。一旦数据库被拖库攻击者就可以进行离线破解尤其是弱密码。而在WebAuthn模型中服务器只存储用户的公钥。私钥永远只存在于用户的认证器安全密钥或设备中且永远不会离开。服务器在认证时只是用存储的公钥来验证一个由用户私钥签名的“挑战”。这意味着即使服务器被完全攻陷攻击者拿到的也只是一堆无法用于登录的公钥从根本上杜绝了撞库攻击。3. 强制的双因子认证2FA属性一个常见的误解是“无密码等于单因子”。实际上一个符合FIDO2的认证过程天然具备“你所拥有的”认证器和“你所是的”生物特征或“你所知的”PIN多个因子。当你使用手机指纹登录时你同时证明了“你拥有这部手机”和“你的指纹匹配”。这比“密码短信验证码”这种第二因子可被SIM卡劫持的方案要安全得多。2.2 无密码登录面临的新挑战与安全问题然而安全性的提升并非没有代价。移除了密码这个中心化的“秘密”我们迎来了新的复杂性和风险点。1. 认证器丢失、损坏与恢复的灾难性后果这是用户和系统设计者最担心的问题。你的YubiKey丢了或者手机进水无法指纹识别了怎么办在密码时代你可以通过备用邮箱、安全问题不安全或联系客服来重置。在无密码世界里你的私钥就锁在那个物理设备里。如果用户只注册了一个认证器且没有设置恢复机制那么设备丢失就等于永久失去账户访问权。这迫使我们必须设计一套健壮、安全且用户友好的账户恢复流程而这本身就是一个高难度的安全挑战。2. 平台依赖与锁定风险如果你使用苹果设备的Touch ID作为主要认证器那么你换到一台安卓手机或Windows电脑上登录同一服务时可能会遇到麻烦。虽然FIDO2标准支持跨平台认证器如安全密钥但许多用户为了方便首选的是平台内置的认证器。这可能导致用户被无形中“绑定”在某个生态系统中。服务提供商也需要考虑如何引导用户注册多个、不同类型的认证器以提供冗余和灵活性。3. 生物识别数据的隐私与存储疑虑当用户使用指纹或面部识别时他们的生物特征模板存储在哪里这是一个极其敏感的问题。好的消息是在FIDO2/WebAuthn架构下生物特征信息永远不会离开用户的本地设备。它仅在设备本地用于解锁本地存储的私钥私钥本身也不会离开。服务器端对此一无所知。但我们需要在用户引导文案中清晰地传达这一点以打消用户的隐私顾虑。4. 中间人攻击MitM的残余风险虽然WebAuthn能完美防御普通的网络钓鱼但在用户设备本身被恶意软件完全控制如键盘记录器、木马的极端情况下风险依然存在。恶意软件可以劫持整个浏览器会话在用户不知情的情况下发起并完成WebAuthn注册或登录流程。不过这种攻击门槛极高且已超出了大多数网络服务的防御边界。实操心得安全与便利的永恒博弈在实际规划无密码登录方案时我的体会是绝对不能追求“一刀切”地完全废除密码。更务实的路径是将无密码作为首选、最强力的认证方式但同时保留一个经过强化的、作为最后兜底的备用认证流程。例如允许用户通过无密码登录但也支持通过已验证的备用邮箱接收一次性恢复码或者要求用户提前设置并保管好几个“恢复密钥”。关键是要让用户理解更高的安全性要求他们承担更多的保管责任。3. WebAuthn技术核心三驾马车与认证流程拆解要理解WebAuthn的实现必须首先厘清三个核心概念WebAuthn API、CTAP和FIDO2。它们共同构成了无密码登录的基石。WebAuthn这是一个W3C的官方Web标准定义了一组JavaScript API。它的作用是在浏览器或类似的用户代理中为网站提供调用本地认证器如指纹传感器、安全密钥进行注册和登录的能力。你可以把它看作浏览器提供给前端开发者的“桥梁”。CTAP客户端到认证器协议。它定义了外部认证器如通过USB、NFC或蓝牙连接的YubiKey与客户端浏览器或操作系统之间通信的协议。CTAP有两个版本CTAP1用于旧的U2F标准CTAP2则与WebAuthn配合支持更复杂的无密码操作。FIDO2这是FIDO联盟推出的整体项目名称它其实就是WebAuthn CTAP2的组合。当人们说“支持FIDO2”时意味着该系统完整支持基于本地认证器的无密码登录标准。3.1 核心参与方与数据流一次完整的WebAuthn交互涉及四个角色用户操作的发起者。浏览器执行WebAuthn API调用作为客户端与认证器沟通的中介。认证器真正执行密码学操作的硬件或软件模块。分为两类平台认证器内置于设备中如Windows Hello、macOS Touch ID、Android指纹、iOS Face ID。跨平台认证器外部设备如YubiKey、SoloKey等USB/NFC安全密钥。依赖方即我们的网站或应用后端服务器。它发起认证请求并验证结果。3.2 注册流程深度解析注册就是在服务器端为用户创建一个新的公钥凭证。前端关键步骤服务器生成一个随机的“挑战”和一个用户信息对象发送给前端。前端调用navigator.credentials.create()API。这个调用需要传入一个结构复杂的PublicKeyCredentialCreationOptions对象。这个对象是安全性的核心务必理解其关键字段const publicKey { challenge: Uint8Array.from(serverChallenge, c c.charCodeAt(0)), // 服务器提供的随机数防重放 rp: { // Relying Party 依赖方信息 name: 我的超级应用, id: myapp.com, // 重要必须是当前页面的域名或子域名这是防钓鱼的关键 }, user: { id: Uint8Array.from(userId, c c.charCodeAt(0)), // 用户内部ID非明文用户名 name: userexample.com, displayName: 张三, }, pubKeyCredParams: [ // 声明支持的加密算法 { type: public-key, alg: -7 }, // ES256 (ECDSA w/ SHA-256)最常用 { type: public-key, alg: -257 }, // RS256 (RSA PKCS#1 v1.5 w/ SHA-256) ], authenticatorSelection: { // 认证器选择偏好 authenticatorAttachment: platform, // 或 cross-platform userVerification: preferred, // 是否要求用户验证指纹/PIN residentKey: preferred, // 是否使用可发现凭证无用户名登录的关键 requireResidentKey: false, }, timeout: 60000, attestation: none // 认证器证明方式生产环境常用none或indirect };注意rp.id必须与当前网站的域名严格一致。如果你在login.myapp.com操作就不能设置为myapp.com除非你明确配置了有效的域名关系。这是防钓鱼的基石。浏览器弹出认证器选择器如果有多认证器用户选择后认证器会要求进行用户验证指纹/PIN/按钮按压。认证器生成一对新的非对称密钥公钥私钥。私钥安全存储于认证器内部永不导出。公钥与rp.id、challenge等数据一起被包装成一个AttestationObject包含证明信息返回给浏览器。浏览器将生成的PublicKeyCredential对象包含 credential ID 和 attestationObject发送给前端再由前端传给服务器。后端验证要点验证挑战确保收到的响应是针对你刚才发出的那个挑战防止重放攻击。验证RP ID哈希确保认证器认证的网站ID与你的一致。验证签名使用认证器证明中的公钥或从证明声明中提取的证书链来验证整个AttestationObject的签名确保响应来自一个真实的认证器。存储凭证将用户的credential ID、对应的公钥以及认证器信息如AAGUID认证器型号标识安全地存储到该用户的账户下。3.3 登录流程深度解析登录就是使用已注册的私钥来签名一个挑战证明用户拥有该凭证。前端关键步骤服务器生成一个随机的“挑战”连同该用户已注册的credential ID列表如果使用可发现凭证则可能为空发送给前端。前端调用navigator.credentials.get()API传入PublicKeyCredentialRequestOptions对象。const publicKey { challenge: Uint8Array.from(serverChallenge, c c.charCodeAt(0)), rpId: myapp.com, // 必须与注册时一致 allowCredentials: [{ // 可选。如果提供会限制只使用列表中的凭证 type: public-key, id: Uint8Array.from(credentialId, c c.charCodeAt(0)), // 之前存储的credential ID transports: [usb, nfc, ble] // 期望的传输方式 }], userVerification: preferred, timeout: 60000 };浏览器提示用户选择认证器并进行验证。如果是“可发现凭证”用户甚至无需输入用户名认证器会自动列出该rpId下存储的所有凭证供用户选择。认证器使用对应的私钥对challenge进行签名生成一个AuthenticatorAssertionResponse返回。前端将该响应包含签名、认证器数据等发送给服务器。后端验证要点查找凭证根据前端传来的credential ID在数据库中查找对应的凭证记录取出存储的公钥。验证挑战同上防重放。验证签名使用存储的公钥去验证签名是否是对本次挑战和认证器数据的有效签名。验证计数器可选但重要认证器返回的数据中包含一个签名计数器。服务器应存储上次成功的计数器值。每次认证时新的计数器值必须大于上次的值。这可以有效防御认证器被克隆后发起的重放攻击。全部验证通过后即认为用户身份合法创建会话。4. 实战部署从零构建一个WebAuthn登录系统理论讲得再多不如动手搭一遍。下面我将以一个Node.js后端和简单前端为例拆解关键实现步骤和代码片段。我们假设你已经有一个基本的用户系统用户表现在要为其增加WebAuthn无密码登录能力。4.1 后端准备依赖库与数据结构设计对于Node.js社区有几个成熟的库可以大幅降低复杂度比如simplewebauthn/server。它封装了复杂的CBOR编解码和验证逻辑。npm install simplewebauthn/server首先扩展你的用户数据模型需要新增一个表来存储WebAuthn凭证CREATE TABLE user_webauthn_credentials ( id VARCHAR(255) PRIMARY KEY, -- 凭证的唯一ID (Credential ID) user_id BIGINT NOT NULL, -- 关联的用户ID public_key TEXT NOT NULL, -- 公钥的PEM格式或二进制存储 counter BIGINT NOT NULL DEFAULT 0, -- 签名计数器防克隆 transports VARCHAR(100), -- 认证器支持的传输方式如 usb,nfc,ble aaguid CHAR(36), -- 认证器型号标识 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );4.2 核心接口实现注册与登录注册 - 生成选项接口 (/webauthn/register/generate)const { generateRegistrationOptions } require(simplewebauthn/server); app.post(/webauthn/register/generate, async (req, res) { const { username } req.body; // 1. 根据username查找或创建用户获取userId const user await findOrCreateUser(username); // 2. 查询该用户已有的凭证ID防止重复注册同一认证器可选 const existingCreds await getUserCredentials(user.id); const excludeCredentials existingCreds.map(cred ({ id: cred.id, type: public-key, transports: cred.transports ? cred.transports.split(,) : [], })); // 3. 生成注册选项 const options generateRegistrationOptions({ rpName: 我的超级应用, rpID: process.env.RP_ID || localhost, // 生产环境必须是你的域名 userID: user.id, // 内部用户ID建议用Buffer userName: user.username, attestationType: none, // 生产环境可考虑 direct 或 indirect excludeCredentials, // 排除已注册的凭证 authenticatorSelection: { residentKey: preferred, requireResidentKey: false, userVerification: preferred, }, }); // 4. 将生成的挑战(challenge)与用户会话临时关联用于后续验证 req.session.regChallenge options.challenge; req.session.regUserId user.id; res.json(options); });注册 - 验证响应接口 (/webauthn/register/verify)const { verifyRegistrationResponse } require(simplewebauthn/server); app.post(/webauthn/register/verify, async (req, res) { const { body } req; // body 是前端传回的整个 PublicKeyCredential 对象 const expectedChallenge req.session.regChallenge; const userId req.session.regUserId; if (!expectedChallenge || !userId) { return res.status(400).json({ error: 会话已过期 }); } let verification; try { verification await verifyRegistrationResponse({ response: body, expectedChallenge, expectedOrigin: https://${process.env.RP_ID || localhost}, // 严格验证来源 expectedRPID: process.env.RP_ID || localhost, }); } catch (error) { console.error(验证失败:, error); return res.status(400).json({ error: 凭证验证失败 }); } const { verified, registrationInfo } verification; if (verified registrationInfo) { // 存储凭证到数据库 const newCredential { id: registrationInfo.credentialID, publicKey: registrationInfo.credentialPublicKey, counter: registrationInfo.counter, transports: body.response.transports?.join(,), aaguid: registrationInfo.aaguid, user_id: userId, }; await saveUserCredential(newCredential); // 清理会话 delete req.session.regChallenge; delete req.session.regUserId; return res.json({ verified: true }); } else { return res.status(400).json({ verified: false }); } });登录 - 生成断言选项接口 (/webauthn/login/generate)const { generateAuthenticationOptions } require(simplewebauthn/server); app.post(/webauthn/login/generate, async (req, res) { const { username } req.body; // 如果是可发现凭证登录这里可能为空 let user null; let allowCredentials []; if (username) { // 传统流程用户先输入用户名 user await findUserByUsername(username); if (user) { const creds await getUserCredentials(user.id); allowCredentials creds.map(cred ({ id: cred.id, type: public-key, transports: cred.transports ? cred.transports.split(,) : [], })); } } else { // 可发现凭证流程不传用户名allowCredentials为空 // 认证器会自己查找该rpId下的所有凭证 } const options generateAuthenticationOptions({ rpID: process.env.RP_ID || localhost, allowCredentials, // 如果为空则进行可发现凭证流程 userVerification: preferred, }); // 存储挑战到会话关联可能的用户如果已知 req.session.authChallenge options.challenge; if (user) { req.session.authUserId user.id; } res.json(options); });登录 - 验证断言接口 (/webauthn/login/verify)const { verifyAuthenticationResponse } require(simplewebauthn/server); app.post(/webauthn/login/verify, async (req, res) { const { body } req; const expectedChallenge req.session.authChallenge; if (!expectedChallenge) { return res.status(400).json({ error: 会话已过期 }); } // 1. 根据凭证ID查找数据库记录 const credentialId body.id; const credential await findCredentialById(credentialId); if (!credential) { return res.status(400).json({ error: 未知的凭证 }); } // 2. 验证断言 let verification; try { verification await verifyAuthenticationResponse({ response: body, expectedChallenge, expectedOrigin: https://${process.env.RP_ID || localhost}, expectedRPID: process.env.RP_ID || localhost, credential: { id: credential.id, publicKey: credential.public_key, // 从数据库取出 counter: credential.counter, transports: credential.transports ? credential.transports.split(,) : [], }, }); } catch (error) { console.error(登录验证失败:, error); return res.status(400).json({ error: 认证失败 }); } const { verified, authenticationInfo } verification; if (verified) { // 3. 更新计数器防止重放 if (authenticationInfo.newCounter credential.counter) { console.error(计数器异常可能存在凭证克隆攻击); return res.status(400).json({ error: 安全校验失败 }); } await updateCredentialCounter(credential.id, authenticationInfo.newCounter); // 4. 查找凭证对应的用户完成登录 const user await findUserByCredentialId(credential.id); // ... 创建用户会话 (session/JWT) // 清理会话 delete req.session.authChallenge; delete req.session.authUserId; return res.json({ verified: true, user }); } else { return res.status(400).json({ verified: false }); } });4.3 前端关键实现与用户体验优化前端的主要工作是调用WebAuthn API并处理好与后端的通信。这里有几个关键的实践点1. Base64Url编解码WebAuthn API使用ArrayBuffer而网络传输需要字符串。credential.id和challenge等都需要在Base64Url和ArrayBuffer之间转换。// 工具函数 function bufferToBase64Url(buffer) { const bytes new Uint8Array(buffer); let str ; for (const byte of bytes) { str String.fromCharCode(byte); } return btoa(str).replace(/\/g, -).replace(/\//g, _).replace(//g, ); } function base64UrlToBuffer(base64Url) { const base64 base64Url.replace(/-/g, ).replace(/_/g, /); const pad base64.length % 4; const padded base64 (pad ? .repeat(4 - pad) : ); const binary atob(padded); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } return bytes.buffer; }2. 注册流程封装async function startRegistration(username) { // 1. 从服务器获取注册选项 const optionsResp await fetch(/webauthn/register/generate, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username }), }); const options await optionsResp.json(); // 2. 转换挑战为ArrayBuffer options.challenge base64UrlToBuffer(options.challenge); options.user.id base64UrlToBuffer(options.user.id); if (options.excludeCredentials) { options.excludeCredentials options.excludeCredentials.map(cred ({ ...cred, id: base64UrlToBuffer(cred.id), })); } // 3. 调用WebAuthn API let credential; try { credential await navigator.credentials.create({ publicKey: options }); } catch (err) { console.error(注册失败:, err); alert(注册失败: err.message); return; } // 4. 将凭证数据转换为可传输格式 const attestationResponse { id: credential.id, rawId: bufferToBase64Url(credential.rawId), type: credential.type, response: { attestationObject: bufferToBase64Url(credential.response.attestationObject), clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON), }, transports: credential.response.getTransports?.() || [], }; // 5. 发送到服务器验证 const verificationResp await fetch(/webauthn/register/verify, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(attestationResponse), }); const result await verificationResp.json(); if (result.verified) { alert(安全密钥注册成功); } else { alert(验证失败请重试。); } }3. 可发现凭证的优雅降级不是所有浏览器和认证器都支持可发现凭证。前端需要做能力检测并据此调整UI。例如如果PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()返回false则隐藏“免用户名登录”按钮引导用户先输入用户名。5. 生产环境部署的进阶考量与避坑指南将WebAuthn从Demo搬到生产环境会面临一系列新的挑战。以下是我在实际部署中积累的一些关键经验。5.1 安全性强化配置1. RP ID的严格配置rpId必须是你网站的域名如myapp.com且不能包含端口、协议。对于本地开发可以使用localhost。在Nginx/Apache反向代理配置中务必确保请求头如Origin,Host正确传递否则来源验证会失败。2. 挑战的管理与过期服务器生成的挑战必须是密码学安全的随机数且长度足够建议至少16字节。挑战必须在服务器端有状态地存储并与当前会话严格绑定验证后立即失效。过期时间建议设为2-5分钟。3. 认证器证明的验证策略attestation参数在生产环境中可以设置为none以简化流程因为大多数场景下我们只关心“用户拥有一个有效的认证器”而不关心“是哪个品牌的认证器”。如果你有强审计需求如金融场景可以设置为direct来获取详细的认证器证明但这需要后端集成一个证明验证服务来检查证书链复杂度激增。4. 计数器的强制检查务必实现并启用签名计数器验证。这是防御认证器被物理克隆后发起重放攻击的最后一道防线。数据库中的counter字段应初始化为0每次成功认证后更新为新的值。如果收到的计数器值不大于存储的值必须拒绝此次认证并发出安全警报。5.2 用户体验与兼容性处理1. 多认证器引导强烈建议引导用户注册至少两个认证器例如一个平台认证器手机指纹和一个跨平台认证器安全密钥。并在UI上清晰地管理已注册的凭证列表允许用户重命名或删除。2. 优雅的降级与回退方案永远不要只提供WebAuthn一种登录方式尤其是在过渡期。应将WebAuthn作为首选但同时保留传统的“密码二次验证”作为备用方案。提供一个清晰的账户恢复流程例如 - 使用已绑定的备用邮箱接收一次性恢复码。 - 要求用户在设置无密码登录时下载并安全保管一组“恢复密钥”类似于加密货币的助记词。 - 提供人工客服验证流程作为最后手段且需严格的身份核实。3. 浏览器与平台兼容性虽然主流现代浏览器都已支持WebAuthn但支持程度不一。iOS上的Safari对跨平台认证器USB密钥的支持曾有限制。前端需要做特性检测并对不支持的浏览器给出友好提示。javascript if (!window.PublicKeyCredential) { // 提示用户浏览器不支持引导使用备用登录方式 } // 检测是否支持可发现凭证 PublicKeyCredential.isConditionalMediationAvailable?.().then(available { if (available) { // 可以启用自动填充UI如 passkeys } });4. 移动端适配在移动设备上调用WebAuthn API可能会触发原生的生物识别验证模态框。要确保你的Web应用在移动浏览器中体验良好避免在iframe中调用因为许多浏览器禁止在跨域iframe中使用WebAuthn。5.3 运维与监控1. 详细的审计日志记录每一次注册和登录尝试包括时间、用户、使用的认证器AAGUID、RP ID、是否成功、失败原因等。这些日志对于安全事件调查和用户问题排查至关重要。2. 凭证的生命周期管理提供让用户查看和管理已注册凭证的界面。考虑凭证的自动过期策略例如一年未使用的凭证需要重新验证但这需要谨慎设计避免影响用户体验。3. 错误处理与用户提示WebAuthn的错误信息可能对用户不友好如NotAllowedError。前端需要捕获这些错误并转换为用户能理解的语言例如“操作已取消”、“指纹验证失败请重试”或“您的安全密钥未响应请检查连接”。一个典型的错误处理示例try { const credential await navigator.credentials.get({ publicKey: options }); } catch (err) { let message 登录过程中出现错误; if (err.name NotAllowedError) { message 登录请求被取消或认证失败如指纹不匹配。; } else if (err.name SecurityError) { message 安全错误可能由于域名不匹配或非安全上下文HTTPS。; } else if (err.name UnknownError) { message 认证器发生未知错误请尝试重新插入安全密钥或重启设备。; } showUserFriendlyError(message); }部署WebAuthn是一个系统工程它要求开发、安全、运维和产品团队紧密协作。从安全角度看它极大地提升了账户的安全性从用户体验看它提供了无与伦比的便捷性。然而其成功的真正关键在于如何妥善处理那个最古老的安全问题当用户丢失了他们的“钥匙”时我们如何安全地帮他们“换锁”。这或许是无密码时代留给我们的终极课题。

相关新闻