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):
- 平台认证器(platform authenticator):设备内置的认证能力,如 Touch ID、Face ID、Windows Hello,以及 1Password 等密码管理器
- 漫游认证器(roaming authenticator):独立于设备的硬件安全密钥(security key),或作为安全密钥使用的手机
Passkey 的认证流程与安全收益
决策文档给出了两条核心流程:
注册(Registration)流程:
- 服务端生成 challenge,下发注册选项(registration options)
- 客户端(浏览器)创建新的密钥对,用私钥对 challenge 签名
- 公钥与元数据发送回服务端存储
- 私钥永远安全地保存在认证器内,不离开设备
认证(Authentication)流程:
- 服务端生成新的 challenge
- 客户端用认证器中保存的私钥签名
- 服务端用之前存储的公钥验签
由此获得四个关键安全特性(决策文档原文):
- 私钥永远不会离开认证器
- 每个凭据(credential)与特定网站绑定,天然防钓鱼
- 生物识别 / PIN 验证在本地完成,生物特征数据不经过网络
- 服务端不存储任何共享密钥
决策:为什么选 SimpleWebAuthn 与多认证策略并存
技术选型
决策文档明确:Passkey 支持基于@simplewebauthn/server与@simplewebauthn/browser实现。在当前仓库的 package.json 中可以看到依赖版本为"@simplewebauthn/browser": "^13.2.2"与"@simplewebauthn/server": "^13.2.2"。选择该库的理由:
- 维护活跃、被广泛使用
- 客户端与服务端均为 TypeScript 类型安全实现
- 封装了 WebAuthn 规范的大量复杂性
- 支持所有主流浏览器与平台
多认证策略并存而非激进替换
决策文档强调,Passkey 是"未来",但密码与 OAuth 必须继续保留,理由有三:
- 过渡与采纳(Adoption and Transition):Passkey 在各平台与浏览器仍在逐步铺开,用户需要时间熟悉新交互;企业内部可能对认证方式有既有要求
- 兜底选项(Fallback Options):部分用户设备不兼容;企业环境可能禁用生物识别;多备份认证方式提升整体可靠性
- 用户选择(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) }各字段含义与决策文档一一对应:
| 字段 | 类型 | 说明 |
|---|---|---|
id | String @id | 凭据 ID(credential ID),全局唯一,注册与登录时用它定位记录 |
aaguid | String | 认证器厂商与型号的唯一标识,可用于帮助用户区分多个密码管理器 |
publicKey | Bytes | 用于验签的公钥(COSE 格式字节) |
counter | BigInt | 认证器签名计数器,用于防重放攻击,登录成功后更新 |
deviceType | String | singleDevice(平台认证器)或multiDevice(跨平台安全密钥) |
backedUp | Boolean | 该凭据是否已被备份(如经云同步) |
transports | String? | 传输方式,逗号分隔存储(usb、nfc、ble、internal、hybrid、cable 等) |
userId/webauthnUserId | String | 用户关系;webauthnUserId是注册时下发的 User Handle |
createdAt/updatedAt | DateTime | 创建与更新时间戳 |
注意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: true、maxAge两小时、生产环境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(RegistrationResponseSchema、PasskeyCookieSchema)校验后再进入业务逻辑。
客户端注册 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)
删除操作走action:intent === '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), }, })验签通过后的三个关键步骤:
- 防重放:
prisma.passkey.update把counter更新为verification.authenticationInfo.newCounter(计数器单调递增校验由 SimpleWebAuthn 内部完成,决策文档将其列为counter字段的核心用途) - 创建会话:
prisma.session.create写入新 session,过期时间来自getSessionExpirationDate()(app/utils/auth.server.ts) - 接管登录:调用
handleNewSession(app/routes/_auth/login.server.ts)完成会话 Cookie 设置与重定向,同时清除 challenge Cookie
客户端登录入口
登录页 app/routes/_auth/login.tsx 提供独立的 "Login with a passkey" 按钮,状态机文案依次为:Generating Authentication Options→Requesting your authorization→Verifying your passkey→You'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 协议安全之外,还叠加了两层应用层防护:
- Zod Schema 强校验(utils.server.ts):
RegistrationResponseSchema与AuthenticationResponseSchema分别以satisfies z.ZodType<RegistrationResponseJSON>/satisfies z.ZodType<AuthenticationResponseJSON>约束,精确校验字段类型、type: z.literal('public-key')、transports 枚举(ble|cable|hybrid|internal|nfc|smart-card|usb)等 - Challenge 状态隔离:注册 challenge 与
userId绑定存储于 Cookie(PasskeyCookieSchema),登录 challenge 独立存储;两者在 action 中取回比对,未找到即拒绝("No challenge found") - Cookie 安全属性:httpOnly 防止 XSS 读取、
sameSite: lax缓解 CSRF、生产环境强制secure、SESSION_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 规范复杂,需处理多样设备能力差异(通过
authenticatorSelection的preferred策略兼容) - 必须长期维护密码认证作为兜底(仓库中登录页三入口并存即为此)
- 新技术的用户教育成本:需要清晰的文档与 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),仅供参考