Epic Stack 无密码登录实践:基于 WebAuthn 与 SimpleWebAuthn 的 Passkey 完整接入方案
2026/9/17 18:55:32 网站建设 项目流程

Epic Stack 无密码登录实践:基于 WebAuthn 与 SimpleWebAuthn 的 Passkey 完整接入方案

【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack

本文以 docs/decisions/039-passkeys.md 决策文档为骨架,结合 Epic Stack 仓库中的路由实现、Prisma 数据模型与端到端测试,系统讲解如何在 React Router 全栈应用中落地 WebAuthn 标准的 Passkey 认证:包括注册与登录的完整握手流程、Passkey数据模型设计、多认证策略并存策略、安全管理细节与测试验证方案。

为什么需要 Passkey:传统认证方式的三大痛点

在 Epic Stack 引入 Passkey 之前,项目默认支持两种认证方式:用户名/密码(username/password)与 OAuth 第三方登录。这两种方式覆盖了绝大多数应用场景,但从安全与体验两个维度看都存在结构性短板。

密码认证的固有问题(详见 docs/decisions/039-passkeys.md 的 Context 章节):

  • 用户习惯在多个服务间复用同一密码,一旦某个站点数据泄露,攻击者可撞库
  • 密码可被钓鱼(phishing)或窃取,短信验证码同样可被拦截
  • 密码管理与定期更换对用户是持续负担
  • 密码找回(reset)流程本身是复杂的潜在攻击面

OAuth 第三方登录的固有问题

  • 依赖第三方服务的可用性,服务宕机时登录直接不可用
  • 存在向第三方共享用户数据的隐私顾虑
  • 并非所有用户都拥有或愿意使用社交账号
  • 第三方策略与回调配置带来额外运维成本

WebAuthn(Web Authentication)是 W3C 发布的网页认证标准,用公钥密码学取代共享密钥(密码)。它允许网站注册两类认证器(authenticator):

  1. 平台认证器(platform authenticator):设备内置的认证能力,如 Touch ID、Face ID、Windows Hello,以及 1Password 等密码管理器
  2. 漫游认证器(roaming authenticator):独立于设备的硬件安全密钥(security key),或作为安全密钥使用的手机

Passkey 的认证流程与安全收益

决策文档给出了两条核心流程:

注册(Registration)流程

  1. 服务端生成 challenge,下发注册选项(registration options)
  2. 客户端(浏览器)创建新的密钥对,用私钥对 challenge 签名
  3. 公钥与元数据发送回服务端存储
  4. 私钥永远安全地保存在认证器内,不离开设备

认证(Authentication)流程

  1. 服务端生成新的 challenge
  2. 客户端用认证器中保存的私钥签名
  3. 服务端用之前存储的公钥验签

由此获得四个关键安全特性(决策文档原文):

  • 私钥永远不会离开认证器
  • 每个凭据(credential)与特定网站绑定,天然防钓鱼
  • 生物识别 / PIN 验证在本地完成,生物特征数据不经过网络
  • 服务端不存储任何共享密钥

决策:为什么选 SimpleWebAuthn 与多认证策略并存

技术选型

决策文档明确:Passkey 支持基于@simplewebauthn/server@simplewebauthn/browser实现。在当前仓库的 package.json 中可以看到依赖版本为"@simplewebauthn/browser": "^13.2.2""@simplewebauthn/server": "^13.2.2"。选择该库的理由:

  • 维护活跃、被广泛使用
  • 客户端与服务端均为 TypeScript 类型安全实现
  • 封装了 WebAuthn 规范的大量复杂性
  • 支持所有主流浏览器与平台

多认证策略并存而非激进替换

决策文档强调,Passkey 是"未来",但密码与 OAuth 必须继续保留,理由有三:

  1. 过渡与采纳(Adoption and Transition):Passkey 在各平台与浏览器仍在逐步铺开,用户需要时间熟悉新交互;企业内部可能对认证方式有既有要求
  2. 兜底选项(Fallback Options):部分用户设备不兼容;企业环境可能禁用生物识别;多备份认证方式提升整体可靠性
  3. 用户选择(User Choice):不同用户对安全/便捷的偏好不同,特定场景需要特定认证类型,多方式共存最大化可访问性

这一结论与仓库现状一致:登录页同时提供密码登录、GitHub OAuth 登录与 Passkey 登录三种入口,认证体系整体说明可参考 docs/authentication.md。

数据模型:Passkey 的 Prisma 设计

决策文档要求用"一个专门的 Prisma 模型"存储 Passkey,跟踪三类信息:认证器元数据(AAGUID、设备类型、传输方式)、安全信息(公钥、计数器)、用户关系与时间戳。仓库中的实现位于 prisma/schema.prisma:

model Passkey { id String @id aaguid String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt publicKey Bytes user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String webauthnUserId String counter BigInt deviceType String // 'singleDevice' or 'multiDevice' backedUp Boolean transports String? // Stored as comma-separated values @@index(userId) }

各字段含义与决策文档一一对应:

字段类型说明
idString @id凭据 ID(credential ID),全局唯一,注册与登录时用它定位记录
aaguidString认证器厂商与型号的唯一标识,可用于帮助用户区分多个密码管理器
publicKeyBytes用于验签的公钥(COSE 格式字节)
counterBigInt认证器签名计数器,用于防重放攻击,登录成功后更新
deviceTypeStringsingleDevice(平台认证器)或multiDevice(跨平台安全密钥)
backedUpBoolean该凭据是否已被备份(如经云同步)
transportsString?传输方式,逗号分隔存储(usb、nfc、ble、internal、hybrid、cable 等)
userId/webauthnUserIdString用户关系;webauthnUserId是注册时下发的 User Handle
createdAt/updatedAtDateTime创建与更新时间戳

注意onDelete: Cascade@@index(userId):用户删除时凭据级联删除,且按用户查询走索引。transports以逗号分隔字符串存储,是因为 SQLite 不原生支持数组,属于该决策文档"Neutral(中性影响)——新增数据存储与迁移"部分的落地体现。

源码实战(一):Passkey 注册全链路

服务端注册路由

注册路由位于 app/routes/_auth/webauthn/registration.ts,拆成loader(生成注册选项)与action(校验并落库)两个端点,均为 JSON API:

loader —— 生成注册选项

const options = await generateRegistrationOptions({ rpName: config.rpName, rpID: config.rpID, userName: user.username, userID: new TextEncoder().encode(userId), userDisplayName: user.name ?? user.email, attestationType: 'none', excludeCredentials: passkeys, authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, })

关键参数解读:

  • rpID即"依赖方 ID",来自 app/routes/_auth/webauthn/utils.server.ts 的getWebAuthnConfig,取当前请求域名的 hostname —— 这正是 Passkey 与域名绑定、防钓鱼的根本机制
  • attestationType: 'none':不要求认证器提供硬件级 attestation 证书,兼顾隐私与兼容性
  • excludeCredentials:传入用户已注册的凭据 ID 列表,避免同一认证器重复注册
  • residentKey: 'preferred'userVerification: 'preferred':倾向可发现凭据与用户验证,但不强制,兼容性更好

生成的options.challenge通过passkeyCookie(名为webauthn-challenge的 httpOnly Cookie)随响应下发,注册校验时再取回比对。Cookie 配置见 utils.server.ts:sameSite: 'lax'httpOnly: truemaxAge两小时、生产环境secure、用SESSION_SECRET签名。

action —— 校验并落库

const verification = await verifyRegistrationResponse({ response: data, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }) // ... 检查 credential 是否已注册 await prisma.passkey.create({ data: { id: credential.id, aaguid, publicKey: Buffer.from(credential.publicKey), userId, webauthnUserId, counter: credential.counter, deviceType: credentialDeviceType, backedUp: credentialBackedUp, transports: credential.transports?.join(','), }, })

服务端校验四个要素:expectedChallenge(来自 Cookie)、expectedOrigin(当前站点 Origin)、expectedRPID(hostname)、requireUserVerification: true(强制要求用户验证)。校验通过后把公钥、计数器、AAGUID、设备类型、备份状态、传输方式写入数据库。若credential.id已存在则拒绝注册,防止凭据重复。注意请求体与 Cookie 都经过 Zod Schema(RegistrationResponseSchemaPasskeyCookieSchema)校验后再进入业务逻辑。

客户端注册 UI

设置页位于 app/routes/settings/profile/passkeys.tsx,流程为:

const resp = await fetch('/webauthn/registration') const { options } = await resp.json() const regResult = await startRegistration({ optionsJSON: options }) const verificationResp = await fetch('/webauthn/registration', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(regResult), })

startRegistration来自@simplewebauthn/browser,负责唤起浏览器的原生认证交互(生物识别 / PIN / 安全密钥)。注册成功后调用revalidator.revalidate()刷新列表。

管理列表按注册时间倒序展示,每条凭据显示:

  • 类型标签:deviceType === 'platform'显示 "Device",否则显示 "Security Key"
  • 相对注册时间(formatDistanceToNow

删除操作走actionintent === 'delete'时执行prisma.passkey.delete({ where: { id: passkeyId, userId } })——where同时限定userId,从服务端保证"只能删除自己的凭据"。

源码实战(二):Passkey 登录全链路

服务端登录路由

登录路由位于 app/routes/_auth/webauthn/authentication.ts:

loader —— 生成认证选项

const options = await generateAuthenticationOptions({ rpID: config.rpID, userVerification: 'preferred', }) // challenge 写入 webauthn-challenge Cookie

登录阶段不需要userName,因为 Passkey 登录是"免输入用户名"的(discoverable credentials 场景),挑战码随响应写入 Cookie。

action —— 验签并建立会话

const passkey = await prisma.passkey.findUnique({ where: { id: authResponse.id }, include: { user: true }, }) const verification = await verifyAuthenticationResponse({ response: authResponse, expectedChallenge: cookie.challenge, expectedOrigin: config.origin, expectedRPID: config.rpID, credential: { id: authResponse.id, publicKey: passkey.publicKey, counter: Number(passkey.counter), }, })

验签通过后的三个关键步骤:

  1. 防重放prisma.passkey.updatecounter更新为verification.authenticationInfo.newCounter(计数器单调递增校验由 SimpleWebAuthn 内部完成,决策文档将其列为counter字段的核心用途)
  2. 创建会话prisma.session.create写入新 session,过期时间来自getSessionExpirationDate()(app/utils/auth.server.ts)
  3. 接管登录:调用handleNewSession(app/routes/_auth/login.server.ts)完成会话 Cookie 设置与重定向,同时清除 challenge Cookie

客户端登录入口

登录页 app/routes/_auth/login.tsx 提供独立的 "Login with a passkey" 按钮,状态机文案依次为:Generating Authentication OptionsRequesting your authorizationVerifying your passkeyYou're logged in! Navigating...

const optionsResponse = await fetch('/webauthn/authentication') const { options } = await optionsResponse.json() const authResponse = await startAuthentication({ optionsJSON: options }) const verificationResponse = await fetch('/webauthn/authentication', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authResponse, remember, redirectTo }), }) const { location } = await verificationResponse.json() await navigate(location ?? '/')

整个登录过程与密码表单完全解耦:用户无需输入用户名,也无需输入任何密码,体验与决策文档描述一致——"用户尝试用 Passkey 登录时,服务端生成挑战,浏览器唤起已注册的 Passkey 认证,成功后无需输入密码即完成登录"。

安全设计与输入校验

Passkey 模块在 WebAuthn 协议安全之外,还叠加了两层应用层防护:

  1. Zod Schema 强校验(utils.server.ts):RegistrationResponseSchemaAuthenticationResponseSchema分别以satisfies z.ZodType<RegistrationResponseJSON>/satisfies z.ZodType<AuthenticationResponseJSON>约束,精确校验字段类型、type: z.literal('public-key')、transports 枚举(ble|cable|hybrid|internal|nfc|smart-card|usb)等
  2. Challenge 状态隔离:注册 challenge 与userId绑定存储于 Cookie(PasskeyCookieSchema),登录 challenge 独立存储;两者在 action 中取回比对,未找到即拒绝("No challenge found")
  3. Cookie 安全属性:httpOnly 防止 XSS 读取、sameSite: lax缓解 CSRF、生产环境强制secureSESSION_SECRET签名防篡改

测试验证:CDP 虚拟认证器

WebAuthn 依赖真实硬件交互,无法在 CI 环境手动操作,Epic Stack 的解法是通过 Chrome DevTools Protocol(CDP)注入虚拟认证器,见 tests/e2e/passkey.test.ts:

const client = await page.context().newCDPSession(page) await client.send('WebAuthn.enable', { enableUI: true }) await client.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'usb', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }, })

虚拟认证器配置与决策文档的"Mock authenticator support for development"诉求一一对应:ctap2协议、usb传输、支持常驻密钥与用户验证。测试断言的关键点:

  • 初始状态凭据数量为 0
  • 点击 "Register new passkey" 后监听WebAuthn.credentialAdded事件,等待注册完成
  • 注册后WebAuthn.getCredentials返回凭据数量为 1
  • 页面出现 passkeys 列表与 "Registered ... ago" 文案
  • 之后执行登出并以 Passkey 重新登录(WebAuthn.credentialAsserted断言事件)

这套方案使完整注册→登录链路在 Playwright 中可自动化回归,覆盖了决策文档 Consequences 中提到的"New test infrastructure for WebAuthn / Mock authenticator support / Additional e2e test scenarios"。

落地代价与注意事项

决策文档在 Consequences 部分坦诚列出了引入 Passkey 的代价,结合源码可总结为三类:

正面收益(Positive)

  • 防钓鱼认证显著提升抗攻击能力,公钥凭证与域名绑定
  • 硬件级安全(生物识别 + 安全芯片)强于纯密码
  • 用户可选密码、OAuth、Passkey 三种方式,原生生物识别流程快速熟悉;密码管理器集成带来跨设备无缝访问
  • 顺应 Web 标准与安全最佳实践演进,为 Passkey 生态铺开做好准备

负面代价(Negative)

  • WebAuthn 规范复杂,需处理多样设备能力差异(通过authenticatorSelectionpreferred策略兼容)
  • 必须长期维护密码认证作为兜底(仓库中登录页三入口并存即为此)
  • 新技术的用户教育成本:需要清晰的文档与 UI 引导(设置页的 Device / Security Key 类型标签即为此设计)

中性影响(Neutral)

  • 新增Passkey数据模型与每用户额外存储;既有用户需主动前往设置页完成注册(迁移路径)
  • 新增 WebAuthn 测试设施:CDP 虚拟认证器、mock 支持与 e2e 场景

小结

Epic Stack 的 Passkey 落地是一个完整的端到端工程实践:决策层面论证了 WebAuthn 标准与多认证策略并存的必要性;数据层面设计了跟踪认证器元数据、安全信息与用户关系的Passkey模型;实现层面用@simplewebauthn/server@simplewebauthn/browser支撑注册/登录两条 JSON 路由与设置页管理 UI;测试层面用 CDP 虚拟认证器自动化了整个生命周期。如果你正在为自己的全栈应用接入无密码登录,可以直接复用本仓库的 app/routes/_auth/webauthn 目录与 Passkey 模型,同时保留密码与 OAuth 作为过渡期兜底。

【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询