跳至正文
区块

Passkey & MFAPasskey 与多因素验证

登录第二步:通行密钥、验证器 App、短信验证码和恢复码四种验证方式,可互相切换,并支持记住设备。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/auth-05.json
会连带安装 shadcn 组件 avatar、button、input、input-otp, 本站条目 flow-tone、toggle-switch、grid-layout。已存在的文件会询问是否覆盖;本地改过的组件先加 --dry-run 或 --diff 看清差异再装。
还会创建示例页面 app/login-verify/page.tsx,安装后就是一个可以访问的路由。生产项目里请按需删除,或改成自己的路径。

用法

<Auth05
  account={{ email: "linwei@example.com", phone: "13800138000" }}
  helpHref="/support"
  onPasskey={async () => { await verifyPasskey() }}
  onSendCode={async () => { await sendSms() }}
  onVerify={async ({ method, code }) => { await verifyCode(method, code) }}
  onSuccess={({ method, rememberDevice }) => location.assign("/")}
  onSwitchAccount={() => location.assign("/login")}
/>

这是登录的第二步:账号已经通过第一步认证,这里要求用户再完成一次验证。顶部的账号摘要显示脱敏邮箱(lin***@example.com)和「不是你?切换账号」;下面是标题、当前验证方式的面板,以及「其他验证方式」列表。

桌面宽度下,「其他验证方式」放在主面板右侧的一栏,用虚线与面板分开。这样主面板的位置和高度不随切换变化,其余方式始终一眼可见;窄屏变成单列,列表排在面板下方。列表里不包含当前方式,点一项就切换过去。

验证方式

methods 决定哪些方式可用,顺序就是列表顺序,默认四种全开;defaultMethod 指定默认方式(不在 methods 里时退回第一项)。

方式说明回调
passkey 通行密钥点主按钮,等待系统弹窗,完成后成功;也可能取消或出错onPasskey()
totp 身份验证器 App6 位验证码,输满自动提交,错误态与 手机号验证码登录 一致onVerify({ method: "totp", code })
sms 短信验证码显示脱敏手机号,点「发送验证码」后出现输入框和重发倒计时onSendCode(),再 onVerify({ method: "sms", code })
recovery 恢复码单个输入框,默认自动转大写并在第 4 位后插入短横线,格式 XXXX-XXXX(可用 recoveryCode 改格式或关闭格式化),旁边提示每个恢复码只能用一次onVerify({ method: "recovery", code })

通行密钥面板的状态:

状态表现
初始主按钮「使用通行密钥验证」
等待中按钮禁用并带加载图标,提示「请在浏览器或系统弹窗中完成验证」;这期间不能切换方式
已取消回调抛出 name 为 NotAllowedError 的错误,显示「已取消,可以重试」,按钮恢复可点
出错其他错误,显示 Error.message
不支持检测不到 PublicKeyCredential 时,显示「当前浏览器不支持通行密钥」,主按钮禁用,并给出「改用 xxx」的按钮

验证成功后整块内容换成成功状态,并调用 onSuccess({ method, rememberDevice })。

账号摘要

account 的 email、phone、name、username 至少要有一个。类型用联合类型表达,漏传时编译期就会报错;运行时仍有兜底(摘要为空、头像显示 ?),JS 项目也不会崩溃。

显示规则:

条件摘要显示
有 name 和 username林清禾 @qinghe,username 用小字补充,都不脱敏
只有 name 或 username原样显示,不脱敏
都没有,有 email脱敏邮箱,如 lin***@example.com
都没有,只有 phone脱敏手机号,如 138 **** 8000

头像首字取实际显示的那个名字(有 name 用 name,否则 username、email、phone)。读屏读到的是「当前账号:」加上摘要内容,不写死「邮箱」。phone 另外用于短信面板,所以只用 name 登录的账号如果还要短信验证,也要传 phone。

<Auth05 account={{ name: "林清禾", username: "qinghe" }} />
<Auth05 account={{ username: "qinghe" }} />
<Auth05 account={{ email: "linwei@example.com", phone: "13800138000" }} />

备用码格式

recoveryCode 配置备用码输入框,不传时是 XXXX-XXXX,和之前的行为一致。

字段说明
format掩码字符串:X(大写)代表一个字母或数字,其他字符原样插入,只在输入写到那一位时才插入,所以退格不会卡在分隔符上。默认 "XXXX-XXXX"。传 false 不做格式化:输入框里是什么就是什么,提交时只去掉首尾空白
uppercase输入时就把输入框里的值转成大写(不是只靠 CSS 的 text-transform),提交的值也是大写。有掩码时默认 true,format 为 false 时默认 false(原样输入),显式传 true 才转
maxLength最大字符数,只在 format 为 false 时生效;有掩码时长度由掩码决定。按输入框里的原始字符计数,连字符和空格都算;提交前会去掉首尾空白,长度判断用去空白之后的值
placeholder占位文字,默认由 format 生成(false 时为空)

onVerify 收到的 code:

  • 有掩码:输入框里的值,如 ABCD-1234(含掩码里的分隔符)。
  • format: false:去掉首尾空白、按 uppercase 处理过的原值。不会自动去掉连字符:用户输入 ABCD-1234 就收到 ABCD-1234,输入 ABCD1234 就收到 ABCD1234,后端需要自己兼容带或不带连字符两种写法。

超长不会被悄悄截断

format: false 时,组件不给输入框设原生 maxLength,也不在代码里截断。粘贴超过 maxLength 的内容会原样保留,输入框下方出现错误(labels.recoveryTooLong,默认「备用码最多 9 个字符,当前 12 个,请检查是否多粘贴了内容」,支持 {max}、{length}),输入框标记 aria-invalid 并通过 aria-describedby 关联这条 role="alert" 的提示,读屏会播报。超长期间提交按钮禁用,回车也提交不了,焦点留在输入框;删回长度以内,错误立即消失。用禁用按钮而不是“点了再报错”,是因为错误文字已经说明了原因,和“未填完不能提交”的现有表现一致。

有掩码时多出的字符本来就无处可放,所以按掩码只取需要的位数,多余的部分不进入;这时会在输入框下方给一条不带警告语气的提示(labels.recoveryTrimmed,默认「已按格式保留前 8 位」,{count} 是掩码能放的位数),放在 role="status" 的播报区里,读屏会播报,下一次输入时重新计算并清除。

// 掩码:5 位一组,输入 abcde12345 显示为 ABCDE-12345
<Auth05 account={account} recoveryCode={{ format: "XXXXX-XXXXX" }} />

// 不格式化:后端的备用码格式不固定,输入什么就提交什么
<Auth05
  account={account}
  recoveryCode={{ format: false, maxLength: 32, placeholder: "粘贴备用码" }}
/>

后端生成的 8 位码

备用码由后端生成,8 位 A–Z/0–9,形如 XXXX-XXXX,用户也可能输入不带连字符的写法。不想让组件替你补或去连字符,就关掉格式化,只做大写和长度限制:

<Auth05Panel
  account={{ username }}
  methods={["totp", "recovery"]}
  showRememberDevice={false}
  recoveryCode={{
    format: false,
    uppercase: true, // 输入框里的值就是大写
    maxLength: 9, // "XXXX-XXXX" 含连字符共 9 个字符
    placeholder: "XXXX-XXXX",
  }}
  onVerify={async ({ method, code }) => {
    // code 是 "ABCD-1234" 或 "ABCD1234",组件不会替你去掉连字符
    const normalized = method === "recovery" ? code.replaceAll("-", "") : code
    await verifyCode(method, normalized)
  }}
/>

输入 abcd-1234 时输入框里立刻是 ABCD-1234,输入 abcd1234 是 ABCD1234,粘贴 12 个字符会看到超长错误而不是被截成 9 个。

有掩码时,输入未填满不能提交,错误文案是 labels.recoveryError(其中的 {format} 会替换成掩码);format 为 false 时只要求非空,空提交显示 labels.recoveryEmpty。「每个恢复码只能用一次」的提示不变。

隐藏「记住此设备」

后端不支持记住设备时传 showRememberDevice={false}:开关不渲染,onSuccess 里的 rememberDevice 固定为 false,即使你传了受控的 rememberDevice。默认 true,行为和之前一致。

嵌进已有的登录布局

Auth05 是整页:线条网格外壳、整页的上下留白、两栏排版。如果你已经有登录页的外壳,比如 分屏登录页 那种左边品牌面板、右边表单的分屏布局,第二步要在同一个布局里换掉右侧的表单,用 Auth05Panel。

Auth05Panel 只有验证内容:标题和说明、账号摘要、当前方式的面板、其他验证方式、记住设备和帮助链接。没有页面外壳,宽度跟随父容器,也不设最小高度;「其他验证方式」一律在当前方式下方单列,因为嵌入的容器通常很窄。props 和 Auth05 完全相同,也支持 brand(不传就不显示品牌)。

import { Auth05Panel } from "@/components/auth-05/auth-05-panel"

export function VerifyStep({ username }: { username: string }) {
  return (
    <div className="grid min-h-svh md:grid-cols-2">
      {/* 左侧:和第一步同一块品牌面板 */}
      <BrandPanel className="hidden md:flex" />
      {/* 右侧:把第一步的登录表单换成验证面板 */}
      <div className="flex items-center justify-center p-6 sm:p-10">
        <div className="w-full max-w-sm">
          <Auth05Panel
            account={{ username }}
            methods={["totp", "recovery"]}
            showRememberDevice={false}
            recoveryCode={{ format: false }}
            onVerify={async ({ method, code }) => { await verifyCode(method, code) }}
            onSuccess={() => location.assign("/")}
            onSwitchAccount={() => location.assign("/login")}
          />
        </div>
      </div>
    </div>
  )
}

两者共用同一套状态与零件,只是排版不同,所以回调、受控属性、labels 的用法完全一致。

只用面板

只用 Auth05Panel、已经有自己的登录页外壳时,装 auth-05-panel 就够了,不必装整页版:

pnpm dlx shadcn@latest add https://yiui.dev/r/auth-05-panel.json
会连带安装 shadcn 组件 avatar、button、input、input-otp, 本站条目 flow-tone、toggle-switch。已存在的文件会询问是否覆盖;本地改过的组件先加 --dry-run 或 --diff 看清差异再装。

最小依赖清单:

类别内容
文件(共 11 个)auth-05-panel.tsx、parts.tsx、panels.tsx、use-auth-05.ts、form-parts.tsx、use-auth-form.ts、mask.ts、config.ts、types.ts(都在 components/auth-05/ 下),hooks/use-controllable-state.ts,以及方条开关用到的 lib/flow-tone.ts
shadcn 组件avatar、button、input、input-otp
本站组件方条开关(toggle-switch,「记住此设备」用它)
npm 包motion、lucide-react、@base-ui/react、class-variance-authority、input-otp(后三个随 shadcn 组件和方条开关带入)
样式变量--edge(线条颜色,亮暗各一份)和 border-edge 对应的 color-edge 映射,安装时自动写进 globals.css

和完整安装的区别:

  • 不会创建示例页:整页版会在 app/login-verify/page.tsx 放一个示例页,auth-05-panel 不创建任何 app/ 下的文件。
  • 不含整页外壳:没有 auth-05.tsx,也不依赖线条网格(grid-layout)。
  • 完整版 auth-05 本身就依赖 auth-05-panel,所以先装了面板再装整页版,只会补上整页外壳、示例页和线条网格。

关于「记住此设备」:使用方即使传 showRememberDevice={false} 把开关关掉,toggle-switch 和 flow-tone 仍会被装进来,因为 parts.tsx 里引用了它。没有换成官方 switch,因为方条开关整行可点、手柄和高度与站内其他方条控件同一套,换掉会让整页版和面板的外观与其他控件不一致;这部分只多出 2 个文件,没有额外的 npm 包。

线条颜色 --edge 由 auth-05-panel 自己声明,不靠 grid-layout 带入,所以只装面板也有正确的边框颜色,并跟随亮暗模式。

回调约定

  • onPasskey、onSendCode、onVerify 都是返回 Promise<void> 的函数;成功就正常返回,失败就抛出 Error,其 message 会显示给用户。
  • onPasskey 抛出 name === "NotAllowedError" 的错误会被当作「已取消」,而不是错误。navigator.credentials.get 在用户关闭弹窗、超时或拒绝时都抛这个错误,浏览器不区分这几种情况。
  • 回调进行中,同一操作不会被重复触发(自动提交和回车共用同一把锁)。
  • onVerify 的 method 只会是 totp、sms、recovery。

接入通行密钥

组件不直接调用 navigator.credentials,WebAuthn 由你在 onPasskey 里实现。下面只是示意:challenge 必须由服务端生成并校验,不要在客户端生成。

<Auth05
  account={account}
  onPasskey={async () => {
    // 1. 向服务端要 challenge 和允许的凭据列表(服务端要把 challenge 记下来)
    const options = await fetch("/api/passkey/options", { method: "POST" })
      .then((res) => res.json())

    // 2. 唤起系统弹窗;用户取消时这里抛 NotAllowedError,组件会显示「已取消」
    const credential = (await navigator.credentials.get({
      publicKey: {
        challenge: base64urlToBuffer(options.challenge),
        rpId: options.rpId,
        allowCredentials: options.allowCredentials.map((item: { id: string }) => ({
          type: "public-key",
          id: base64urlToBuffer(item.id),
        })),
        userVerification: "preferred",
        timeout: 60_000,
      },
    })) as PublicKeyCredential

    // 3. 把签名交给服务端校验,不通过就抛 Error
    const res = await fetch("/api/passkey/verify", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(serializeCredential(credential)),
    })
    if (!res.ok) throw new Error("通行密钥验证失败,请重试")
  }}
/>

base64urlToBuffer 和 serializeCredential 是你自己的工具函数,也可以用现成的 WebAuthn 库代替。

示例

演示规则(只在页面外壳里显示,不属于组件):

  • 通行密钥:1.5 秒后按外壳里选的结果返回,可选「成功 / 取消 / 不支持 / 失败」。
  • 身份验证器 App:123456 成功,其他验证码提示「验证码错误」。
  • 短信验证码:发送有 1 秒延迟,验证码 123456。
  • 恢复码:ABCD-1234 成功,其他提示「恢复码无效或已使用」。
  • 外壳顶部的「演示布局」可切到「嵌入」:左边是品牌面板占位,右边是 Auth05Panel,只开身份验证器和恢复码,账号只有用户名,没有记住设备,备用码不格式化(abc_12345 成功)。
  • 「嵌入·定长码」用后端生成 8 位码的配置:recoveryCode={{ format: false, uppercase: true, maxLength: 9, placeholder: "XXXX-XXXX" }}。演示后端自己去掉连字符再比较,所以 ABCD-1234 和 ABCD1234 都成功;粘贴超过 9 个字符会出现超长错误。

API

Auth05Panel 的 props 与 Auth05 相同。

  • 受控与非受控:method / defaultMethod / onMethodChange 控制当前验证方式;rememberDevice / defaultRememberDevice / onRememberDeviceChange 控制「记住此设备 30 天」,默认关闭。
  • passkeySupported 用来覆盖浏览器能力检测,演示和测试时很有用;不传时在挂载后检测 window.PublicKeyCredential。
  • account 的 email、phone、name、username 至少一个,显示规则见上面的「账号摘要」;phone 是 11 位手机号,组件显示为 138 **** 8000;avatar 不传时头像显示摘要名字的首字。
  • showRememberDevice 默认 true;recoveryCode 见「备用码格式」,超长与丢弃提示的文案是 labels.recoveryTooLong、labels.recoveryTrimmed。
  • headingLevel 是主标题「验证你的身份」的级别:Auth05 默认 1,Auth05Panel 默认 2。嵌进已有页面时,宿主页通常已经有 h1,按页面层级设成 2 或 3。
  • codeLength 默认 6 位,resendSeconds 默认 60 秒。
  • helpHref 提供「都无法使用?联系管理员」的链接,不传就不显示这个入口。
  • labels 覆盖 Auth05Labels,每种方式的名称、说明、标题分别是 <method>Name、<method>Hint、<method>Title。brand 接收 name 和可选 logo。
  • 导出组件:Auth05(整页)、Auth05Panel(只有验证内容)。导出类型:Auth05Props、Auth05Method、Auth05Account、Auth05RecoveryCode、Auth05Labels。

文件结构:auth-05.tsx 为整页入口,auth-05-panel.tsx 是嵌入用的 Auth05Panel,use-auth-05.ts 放两者共用的状态,parts.tsx 是共用的页面零件(标题、账号摘要、其他方式列表等),panels.tsx 是三种验证面板,form-parts.tsx 封装验证码格子,mask.ts 负责脱敏、账号摘要与恢复码格式,config.ts 管理文案,use-auth-form.ts 管理异步状态,types.ts 定义 API,page.tsx 为安装到项目里的页面入口。auth-05 条目只含 auth-05.tsx 和 page.tsx,其余文件属于 auth-05-panel 条目(auth-05 依赖它)。

根节点 data-slot="auth-05" 暴露 data-method 和 data-state(Auth05Panel 的根节点是 auth-05-content,属性相同);账号摘要为 auth-05-account,主面板为 auth-05-panel,其他方式列表为 auth-05-alternatives。

可访问性

  • 切换验证方式或验证成功后,焦点会落在新内容的标题上,Tab 可继续进入控件。
  • 「其他验证方式」是一个导航区域里的按钮列表,用 Tab 和回车或空格操作;有请求进行中时列表禁用。
  • 通行密钥的状态(等待中、已取消、不支持)放在常驻的 aria-live="polite" 区域里;短信已发送、验证成功同样会播报;错误用 role="alert"。
  • 验证码输入支持整串粘贴、退格和方向键,提供数字键盘和短信自动填充,读屏名称是「验证码,共 6 位」;错误通过 aria-describedby 关联,所有格子带错误态。
  • 恢复码输入框有可见标签,关闭自动填充,并提示每个码只能用一次;超长错误用 role="alert" 并通过 aria-describedby 关联,有掩码时丢掉多余字符的提示放在 role="status" 里。
  • 倒计时不逐秒播报;过渡动画遵守减少动态效果设置。

注意事项

只提供界面,不负责真实认证:WebAuthn 调用、验证码和恢复码校验、短信发送与限流、会话与设备信任都由服务端和你的回调完成。「记住此设备」只通过 onSuccess 的 rememberDevice 告知你,是否真的信任设备由你决定。通行密钥的支持情况在水合前是未知的,此时主按钮保持可点;确认不支持后才会禁用并给出建议。

源码