ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebAuthn无密码登录实战:原理到Java后端与前端完整落地

WebAuthn无密码登录实战:原理到Java后端与前端完整落地 我最近在排查内部AI工具的账号体系顺手把豆包网页版和电脑客户端的登录交互翻了翻发现还是“密码验证码”那套老组合。这类高频使用的效率工具用户每天要进进出出好几次为什么还在靠密码硬扛顺着这个痛点我把WebAuthn从规范、浏览器API、Java后端到前端集成完整跑了一遍做成一篇可以直接抄作业的实战指南。这篇内容会拆清WebAuthn的底层原理、梳理它与OAuth2的真实分工、给出基于Java Spring Boot的后端实现和Vue/React侧的前端代码并把联调时的跨域、兼容性坑一并列出来。想给项目做无密码登录、企业内部SSO安全加固或者只是好奇WebAuthn怎么落地的都可以照这份思路走。1. 豆包这类AI产品为什么我建议优先考虑WebAuthn1.1 高频Web应用正在被密码拖后腿豆包这类AI助手的用户画像很典型每天打开多次、跨电脑和手机切换、经常在公共网络下登录。密码方案在这种场景下暴露的问题不是一个而是一串。密码疲劳。用户记不住只能把所有网站用同一个密码一次泄露遍地遭殃。钓鱼攻击。伪造一个登录页用户把密码填进去攻击者直接拿到凭证。短信验证码的边界。验证码可以拦截、可以撞库用户还要多等几秒钟。找回密码流程成本高。后台要写大量工单接口还要处理安全问题答案被忘记的case。这不是理论上的风险而是每个做账号体系的工程师都真实处理过的脏活。我在接手之前一直觉得“WebAuthn算锦上添花”直到发现用户密码重置工单占到客服量的三成才意识到无密码认证不是炫技是为了把账号体系的维护成本打下来。1.2 WebAuthn在AI工具场景能解决什么WebAuthnWeb Authentication是W3C和FIDO联盟联合制定的Web认证标准核心思路是不传密码传签名。用户的私钥存储在设备的安全区域服务器只保存公钥。登录时浏览器让用户在设备上完成一个简单动作——指纹、人脸、Windows Hello或者PIN码然后生成一段签名发给服务器服务端用公钥验签即可。它在豆包这类产品上有几个天然优势无密码登录体验。用户第一次绑定设备后后续登录就是一次生物识别确认比输入密码快一个数量级。防钓鱼。WebAuthn的认证结果和当前站点域名强绑定就算用户在钓鱼网站上点了登录签名也对不上天然免疫钓鱼。多设备恢复链路清晰。配合platform authenticator和可移动认证器用户可以在新设备上重新绑定不需要写“安全问题答案”。我特别想说明一点WebAuthn不是要把密码彻底赶走更合适的定位是给高价值操作或高频登录提供更硬的凭证。豆包这类AI工具最适合先做试点因为用户黏性高、登录频率高替换体感明显。2. WebAuthn的注册与登录握手从挑战值到签名验证2.1 先建立直觉私钥留在设备里公钥交给服务器理解WebAuthn可以类比成你给柜子配了一把锁和一把钥匙钥匙私钥永远在自己手里锁公钥可以复制很多份发给服务器和任何人。别人拿锁没意义因为锁不能反向推导出钥匙。只有你的钥匙能打开锁。在这个类比里柜子就是你的在线账号。锁匠浏览器负责把钥匙造出来钥匙坯就是硬件安全模块。服务器只需要保留锁的“编号”和锁定逻辑不存任何钥匙副本。实际WebAuthn的数据结构更精致。注册阶段认证器Authenticator生成一个密钥对私钥存在安全硬件中公钥连同一些元数据交给服务器。登录阶段认证器用私钥对一段服务器下发的挑战值challenge签名服务器用之前保存的公钥验签。整个过程中密码字符串从来没有出现在网络上。2.2 注册阶段的数据流后端生成参数浏览器返回凭证注册流程的第一步是服务器告诉浏览器“我要给这个用户创建一串新的凭证”。这段指令是PublicKeyCredentialCreationOptions核心字段包括rp依赖方信息也就是你的站点身份包含id域名和name。user用户信息包含id用户唯一标识、name、displayName。challenge一段随机挑战值必须是随机的、不可预测的。pubKeyCredParams允许使用的公钥算法ES256算法编号-7和RS256算法编号-257最常见。authenticatorSelection认证器偏好比如只允许平台内置认证器还是允许跨设备USB/蓝牙认证器。attestation是否要求返回设备厂商的证明一般生产环境建议none规避设备隐私问题。浏览器收到这些选项后调用navigator.credentials.create()弹出指纹或PIN码确认随后返回一个PublicKeyCredential对象。服务端提取出attestationObject和clientDataJSON做校验校验通过后把credentialId和publicKey存入用户表。2.3 登录阶段的数据流断言产生与验证登录阶段服务器下发的是PublicKeyCredentialRequestOptions关键字段是challenge和allowCredentials其中allowCredentials限定当前用户只能使用已绑定的某几个凭据。浏览器调用navigator.credentials.get()用户完成生物识别后返回一个PublicKeyCredential里面包含id使用的凭证ID。response.clientDataJSON包含挑战值、来源站点、操作类型。response.authenticatorData包含依赖方ID哈希、标志位、签名计数signCount。response.signature对挑战值和认证器数据拼接结果的签名。服务器把所有这些内容拆开验证确认签名有效、来源站点正确、挑战值对应、凭证属于该用户才算登录成功。这套“你问一句我答一句并签名”的模式就是典型的挑战-应答认证和密码登录在流程结构上的区别是服务器不需要知道任何秘密。3. WebAuthn不是OAuth2的平替先理清认证与授权的边界3.1 二者解决的是两个不同的问题很多开发者把WebAuthn和OAuth2放在同一个篮子里对比其实它们解决的问题根本不在一个维度上。WebAuthn回答的是“你是谁”OAuth2回答的是“你能做什么”。我见过团队试图用OAuth2去替代密码登录结果绕了一大圈最后还是要回到某种用户认证方式上。用一个生活场景来区分WebAuthn类似“身份证验证”。进出大楼前门卫确认你是本人发给你一个准入凭证。OAuth2类似“访客权限等级”。大楼内部不同区域能否进入取决于你的访客证上写了哪些楼层。你会发现这两个事情通常要配合使用。OAuth2的授权服务器在颁发访问令牌之前必须先证明当前用户是本人这个步骤就可以由WebAuthn来承担。把两者当作对手就像把“护照”和“签证权限”对立起来一样没有意义。3.2 关键对比协议目标、使用场景、Token机制下面是实际选型时我常拿来对照的一张表不建议死背更核心的是理解每行背后的目标差异。维度WebAuthnOAuth2核心问题认证确认用户身份授权决定第三方能访问哪些资源输出产物公钥、凭证ID、签名Access Token、Refresh Token凭证形态密钥对无共享秘密Token可能短期有效交互方式浏览器 认证器之间的挑战-应答授权服务器 客户端之间的重定向/令牌交换典型场景无密码登录、二因素认证小程序/App接入第三方登录、开放API授权安全重点防钓鱼、防重放、防克隆防Token泄露、防越权、防回调劫持很多人会理直气壮说“OAuth2也能防密码泄露”但OAuth2默认假设认证环节已经存在它并不规定也不负责用户到底怎么证明自己。把密码换掉这件事OAuth2本身解决不了。3.3 给豆包这类产品做技术选型的建议如果现在要给豆包网页版做登录改造我的建议是分两层用户登录层用WebAuthn替代密码作为主要认证方式。首次绑定设备时留一个备用认证器允许用户在新设备登录后管理自己的凭据。开放平台层继续用OAuth2。第三方开发者要读取用户的数据或者调用AI服务走OAuth2授权流程授权服务器内部再用WebAuthn确认用户身份。这两层叠加后用户和开发者都满意用户的登录体验更顺第三方接入的授权模型保持标准生态。我踩过把两者混在一起的坑最后连Token刷新和凭据管理都搞不清边界所以这句话务必记住认证和授权拆开设计各管一层。4. Java后端集成注册接口从零到一4.1 依赖选型为什么我直接用Yubico WebAuthn Server库Java生态里做WebAuthn服务端主流的方案就是Yubico的webauthn-server-core库官方维护、API完整、社区案例多。一开始我考虑过自己解析CBOR和COSE算法做了半周就放弃了——读规范、调各类认证器兼容性的成本远超预期有成熟库就别重复造轮子。以Maven为例在pom.xml中加入dependency groupIdcom.yubico/groupId artifactIdwebauthn-server-core/artifactId version0.10.0/version /dependency需要注意版本界限0.10.0要求Java 11和Jackson 2.x共同存在项目里如果有老Jackson版本先做一次版本对齐。这个坑我不止一次遇到Service层面还没开始写先被依赖冲突耗掉半天。4.2 服务端核心组件配置有了依赖之后第一步不是写接口而是先初始化RelyingParty这是所有操作的入口。它的作用相当于你的站点在WebAuthn世界里的身份证。下面的配置以端口8080、域名为localhost为例Configuration public class WebAuthnConfig { Bean public RelyingParty relyingParty(UserCredentialRepository credentialRepository) { RelyingPartyIdentity rpIdentity RelyingPartyIdentity.builder() .id(localhost) // 必须是当前域名不带协议和端口 .name(Demo AI Platform) .build(); return RelyingParty.builder() .identity(rpIdentity) .credentialRepository(credentialRepository) .origins(Set.of(http://localhost:8080)) .build(); } }这里最容易出错的是origin配置。origins填的是前端页面的完整源包含协议和设备而rp.id只是裸域名。如果你把origin填成https://localhost:8080但前端页面实际跑在http://localhost:5173浏览器在验证来源时一定会拒绝。4.3 生成注册选项的后端接口注册接口要做的事情很纯粹接收用户名查找或创建用户实体然后调用RelyingParty.startRegistration()构造注册选项。我把选项直接转成前端需要的JSON结构清晰一些比较好维护。RestController RequestMapping(/api/webauthn) public class WebAuthnRegisterController { private final RelyingParty relyingParty; private final UserCredentialRepository credentialRepository; public WebAuthnRegisterController(RelyingParty relyingParty, UserCredentialRepository credentialRepository) { this.relyingParty relyingParty; this.credentialRepository credentialRepository; } PostMapping(/register/options) public ResponseEntityMapString, Object startRegistration(RequestBody RegisterStartRequest req) { // 1. 查询或创建用户 DemoUser user credentialRepository.findUserByUsername(req.getUsername()); if (user null) { user new DemoUser(UUID.randomUUID(), req.getUsername()); } // 2. 生成注册选项user.id 要转成 32 字节的 ByteArray PublicKeyCredentialCreationOptions options relyingParty.startRegistration( StartRegistrationOptions.builder() .user(UserIdentity.builder() .name(user.getUsername()) .displayName(user.getDisplayName()) .id(ByteArray.fromBase64Url(user.getId().toString())) .build()) .timeout(60000) .build() ); // 3. 将选项中的二进制字段转为前端可直接使用的 Base64URL 字符串 MapString, Object result new HashMap(); result.put(challenge, options.getChallenge().getBase64Url()); result.put(rp, Map.of( id, options.getRp().getId(), name, options.getRp().getName())); result.put(user, Map.of( id, options.getUser().getId().getBase64Url(), name, options.getUser().getName(), displayName, options.getUser().getDisplayName())); result.put(pubKeyCredParams, options.getPubKeyCredParams()); result.put(authenticatorSelection, options.getAuthenticatorSelection()); return ResponseEntity.ok(result); } }这里有个操作细节ByteArray的getBase64Url()是Yubico库自带的Base64URL编码方式和前端JavaScript里的base64url几乎一一对应不要自作聪明换用Java标准库的Base64编码器否则前端解出来的字节会错位。4.4 校验注册凭证并存库前端把PublicKeyCredential提交回来之后真正的核心工作在finishRegistration()里。我建议在Service层做包装便于复用事务逻辑。public String completeRegistration(String username, String credentialJson) { PublicKeyCredential pkc PublicKeyCredential.parseRegistrationResponseJson(credentialJson); RegistrationResult registrationResult relyingParty.finishRegistration( FinishRegistrationOptions.builder() .request(storedRequest) .credential(pkc) .build() ); // 落库保存凭证ID、公钥、签名计数 RegisteredCredential credential RegisteredCredential.builder() .credentialId(registrationResult.getKeyId().getId()) .userHandle(user.getUserHandle()) .publicKeyCose(registrationResult.getPublicKeyCose()) .signatureCount(registrationResult.getSignatureCount()) .build(); credentialRepository.saveCredential(user.getId(), credential); return registered; }一定要弄清楚PublicKeyCredential.parseRegistrationResponseJson参数需要的是前端传来的完整PublicKeyCredentialJSON而不是只提attestationObject。很多新手只传单个字段导致库内部反序列化直接报错。回调接口里数据要用application/json传递不能用表单格式。5. 断言验证后端收到签名后必须做的三次校验5.1 重新组装客户端数据校验登录阶段前端会把clientDataJSON、authenticatorData、signature一起提交。服务端要做的不只是“验签”而是先做一层层解包。第一步是解析clientDataJSONCollectedClientData clientData CollectedClientData.fromJson( new String(credential.getResponse().getClientDataJSON().getBytes(), StandardCharsets.UTF_8) );然后必须确认三件事clientData.getType()是webauthn.get不是webauthn.create。clientData.getChallenge()能和后端刚刚生成的challenge对应上。clientData.getOrigin()来自前端页面的源必须等于后端origins配置里的某一个。这一步即使全部通过也不能立刻认为用户登录成功。因为clientDataJSON只是浏览器声明自己做了什么真正证明设备持有私钥的是后续的签名和authenticatorData。5.2 验证authenticatorData与签名authenticatorData里有一串非常关键的信息rpIdHash对rp.id做哈希后的结果用于证明这一操作确实是发给当前站点。flags包含UP用户在场和UV用户验证等标志。signCount认证器上的交互计数用于发现设备被克隆。Yubico库提供finishAuthentication()一步完成签名校验和authenticatorData解析但我建议在调用前显式校验rpIdHash和origin而不是依赖库的默认行为因为库在不同版本中容忍度有差异。AssertionResult result relyingParty.finishAuthentication( FinishAuthenticationOptions.builder() .request(assertionRequest) .credential(assertionCredential) .build() );AssertionResult返回后紧接着检查result.isSuccess()。如果你在业务上要求设备必须做了用户验证还需要检查result.isUserPresent()和result.isUserVerified()这两个flag不是默认保证的。5.3 signCount的防克隆逻辑与存储策略signCount是容易忽略但很重要的字段。它代表认证器累计执行操作的次数存在设备内部。服务器保存上一次的signCount每次登录后对比新计数值大于旧值正常更新保存值。新计数值小于或等于旧值可能发生了克隆或重放建议拒绝此次登录并告警。我在实现时会把signCount单独放进数据库字段而不是合并到JSON里原因是这样查询更快而且可以定期跑脚本统计异常登录。简单的落库逻辑参考public boolean validateSignCount(RegisteredCredential stored, long receivedSignCount) { if (receivedSignCount stored.getSignatureCount()) { credentialRepository.updateSignatureCount(stored.getCredentialId(), receivedSignCount); return true; } return false; }这里有一个实际教训有些虚拟机和软件认证器在特定版本下signCount会跳变导致误判。建议在测试环境记录日志先观察两周再决定是否在生产把该字段作为硬性拒绝条件。6. 前端集成用navigator.credentials对接注册和登录6.1 注册入口的完整前端代码前端的主要工作是“把后端给的条件翻译成浏览器API认识的数据”以及“把浏览器返回的二进制数据转回Base64URL给后端”。以注册为例async function startRegistration(username) { const res await fetch(/api/webauthn/register/options, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username }) }); const options await res.json(); // 后端返回的是 Base64URL 字符串需要转成 ArrayBuffer/Uint8Array const publicKey { challenge: base64urlToBytes(options.challenge), rp: options.rp, user: { id: base64urlToBytes(options.user.id), name: options.user.name, displayName: options.user.displayName }, pubKeyCredParams: options.pubKeyCredParams, timeout: 60000, attestation: none, authenticatorSelection: options.authenticatorSelection }; const credential await navigator.credentials.create({ publicKey }); return { id: credential.id, rawId: bytesToBase64url(credential.rawId), type: credential.type, clientDataJSON: bytesToBase64url(credential.response.clientDataJSON), attestationObject: bytesToBase64url(credential.response.attestationObject) }; }这里的base64urlToBytes和bytesToBase64url是工具函数通常几十行就能实现。千万不能直接用btoa处理ArrayBufferbtoa接收的是二进制字符串不是二进制字节数组。小工具封装一次全项目共用。6.2 登录入口的完整前端代码登录流程和注册非常相似差别在于调用的API变成了navigator.credentials.get()且需要传allowCredentials限定用户可选用的凭证。async function startLogin(username) { const res await fetch(/api/webauthn/login/options, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username }) }); const options await res.json(); const publicKey { challenge: base64urlToBytes(options.challenge), rpId: options.rpId, timeout: 60000, userVerification: required, allowCredentials: options.allowCredentials.map(item ({ id: base64urlToBytes(item.id), type: item.type })) }; const assertion await navigator.credentials.get({ publicKey }); return { id: assertion.id, rawId: bytesToBase64url(assertion.rawId), type: assertion.type, clientDataJSON: bytesToBase64url(assertion.response.clientDataJSON), authenticatorData: bytesToBase64url(assertion.response.authenticatorData), signature: bytesToBase64url(assertion.response.signature), userHandle: assertion.response.userHandle ? bytesToBase64url(assertion.response.userHandle) : null }; }关于userVerification参数我建议用required这样能强制认证器执行指纹或人脸校验。如果你设成discouraged很多平台的认证器会直接跳过用户验证登录链路就退化成“按一下也算登录”安全等级会明显降低。6.3 前端需要处理的错误分支实战中前端会碰到很多非正常路径我个人按触发频率整理了这样一张表错误场景浏览器行为建议处理页面不是HTTPS/localhostnavigator.credentials为undefined提前判断并提示环境不支持用户取消指纹/PIN弹窗抛出NotAllowedError提示用户重新操作不刷新页面当前域名和后端rp.id不匹配抛出SecurityError检查页面域名和后端配置没有可用凭据抛出NotAllowedError且无弹窗引导用户返回注册绑定设备设备不支持WebAuthn抛出NotSupportedError降级到密码登录不阻断用户我见过不少项目中前端只处理成功回调结果线上用户反馈“点登录没反应”一查才发现NotAllowedError被当成普通错误吞掉了。错误分支和成功分支同等重要务必在接口层把错误类型透出给用户。7. 前后端联调中的坑跨域、BaseURL与浏览器兼容性7.1 跨域与rpId的关系WebAuthn有一个让很多前端同学迷惑的点我给后端API是A域名前端页面是B域名到底以谁为准答案是浏览器只认当前页面URL的源后端ryId必须等于页面域名。比如页面是https://app.example.com后端API是https://api.example.com那么rp.id必须配置为app.example.comorigin也要设置成https://app.example.com。后端接口可以跨域但WebAuthn校验的来源始终是页面的源。所以APP部署时不要图省事把登录页放在CDN而API放在另一台服务器先想清楚哪个域名是用户浏览器里的“最终源”。前后端分离场景下CORS配置只解决响应能否被读取的问题WebAuthn的内部校验跟CORS没有关系它是浏览器内部的安全策略。7.2 本地开发调试的技巧本地开发最容易卡在HTTPS证书上。WebAuthn只允许安全上下文调用也就是HTTPS或localhost例外做枚举。如果后端接口跑在localhost前端也跑在localhost的某个端口通常不需要自签证书。但如果前端想要在局域网IP比如手机调试访问浏览器就会拒绝调用WebAuthn。解决思路有两个开发环境配置自签名证书把前端页面也跑成HTTPS。使用ngrok这类内网隧道工具让手机访问一个HTTPS域名同时确保这个域名与rp.id一致。我在本地联调时会直接把rp.id写成localhost前端Vite服务放在http://localhost:5173后端Spring Boot放在http://localhost:8080。这个组合下WebAuthn的源校验天然通过是最省事的开发配置但上线前必须改回正式域名并在测试环境完整过一遍。7.3 踩过的兼容性坑和规避方案最后分享几个我在真实联调里遇到过的兼容性问题都是控制台报错不明显、但行为很怪异的类型。部分浏览器对attestationObject的内容要求严格。COSE公钥格式不规范时finishRegistration会抛UnsupportedAlgorithmException。规避方法是注册时把attestation设为none让认证器返回最简单格式。Windows上的Edge和Chrome在调用navigator.credentials.create时如果系统PIN锁屏策略未配置或Windows Hello未设置会直接跳过弹窗。你需要先在系统中配置好Windows Hello再进行测试。有些iOS版本的Safari对platform authenticator支持不完整页面在iframe环境中还需要PublicKey-Credentials-Get/Create权限策略。前后端脚手架里常见的主框架页面、飞书/企业微信内嵌WebView都可能触发这个限制。后端challenge必须是一次性的并且每次注册/登录都重新生成。如果前端刷新页面后继续使用旧challenge部分浏览器会拒绝操作这也是导致“偶尔能用、偶尔报错”的高频原因。遇到这些情况我的排查路径是先看浏览器控制台能不能调出navigator.credentials再看tpId与页面源是否一致最后抓一下浏览器实际发出的clientDataJSON里的origin和challenge。把这三个信息列出来80%的联调问题都能定位到具体环节。整套流程跑下来我能抓到的最有价值的经验就是WebAuthn并行的坑其实不多但每一个都藏在源、域名、证书和一次随机数这些细节里。先把这些细节盯住再上业务逻辑你也能相对顺畅地完成一套无密码登录系统。
返回列表