短信动态密码表单验证

通过短信发送动态密码 (OTP) 是电话号码验证、账号恢复、升级身份验证和付款确认的常见流程。在浏览器和短信应用之间切换,以记住或复制粘贴多位验证码,会给用户带来不便,增加访问者流失率,并使用户容易受到仿冒网域上的钓鱼式攻击。

结合使用语义 <form> 标记、<input autocomplete="one-time-code" inputmode="numeric">、绑定到来源的短信格式 (@domain #code) 和 WebOTP API (OTPCredential),可打造一个分层、渐进增强的验证流程:

  1. 自动提取和提交:支持 WebOTP API 的浏览器会根据短信中的 @domain #code 行验证来源,填充 <input>,并一键触发验证。
  2. 与来源绑定的键盘自动填充:支持 autocomplete="one-time-code" 的浏览器和操作系统(例如 iOS、iPadOS 和 macOS 上的 Safari)会解析 @domain #code 消息,并且仅在网域匹配时才在键盘自动填充栏中建议验证码。
  3. 顺畅的手动输入:用户从其他设备输入代码时,会通过 inputmode="numeric" 收到数字虚拟键盘,而不会遇到 <input type="number"> 的易用性问题。

安全注意事项

短信动态密码可验证对电话号码的所有权,并提供便捷的升级验证,但短信传输和电话号码可能会受到 SIM 卡交换、号码回收和拦截的影响。请始终将客户端自动填充和 WebOTP API 与绑定到来源的 @domain #code 短信格式搭配使用,以便浏览器和操作系统在建议或提供验证码之前验证目标网域是否匹配。对于主要的防钓鱼式攻击身份验证,请优先使用通行密钥,而不是基于短信的验证码(请参阅通行密钥方向)。

语义表单标记

将验证控件封装在具有 method="POST" 和显式 <button type="submit"> 的语义 <form> 元素中。请始终使用单个 <input> 元素,而不是将数字拆分到多个框中。

  • 将 type="text" 与 inputmode="numeric" 搭配使用:切勿将 type="number" 用于动态密码。动态密码是一个标识符字符串,可能包含前导零(例如 012345),而不是可计数的数量。使用 type="number" 会添加递增/递减微调按钮,可能会去除前导零,并允许意外的滚轮值更改。此外,请避免使用 type="tel",这是一种针对完整电话号码的旧版解决方法。将 type="text" 与 inputmode="numeric" 配对,以便在移动设备上显示纯数字虚拟键盘。
  • 添加 autocomplete="one-time-code":指示浏览器和操作系统在 <input> 获得焦点时,在自动填充栏中建议收到的短信验证码。
  • 将说明性提示放在 <input> 上方:使用 for 和 id 关联可见的 <label>,并将所有格式说明(通过 aria-describedby 关联)放在 <input> 的上方,这样自动填充弹出窗口和移动虚拟键盘就不会遮挡说明。
  • 请勿将数字拆分到多个 <input> 元素中:多框动态密码模式(六个单独的 maxlength="1" 输入框)会破坏许多浏览器中的原生 autocomplete="one-time-code" 自动填充功能,阻碍复制粘贴,并使屏幕阅读器导航变得复杂。如果需要分段式视觉设计,请使用 letter-spacing 和等宽字体设置单个 <input> 的样式。
<form id="otp-form" action="/verify-otp" method="POST">
  <div class="form-group">
    <label for="otp-input">Verification code</label>
    <!-- Place the hint above the input so autofill popovers and mobile keyboards do not obscure it -->
    <p id="otp-format-hint" class="field-hint">
      Enter the 6-digit code sent to your phone.
    </p>
    <!-- MANDATORY: Use a single input with type="text", inputmode="numeric", and autocomplete="one-time-code" -->
    <input
      type="text"
      id="otp-input"
      name="otp"
      inputmode="numeric"
      autocomplete="one-time-code"
      pattern="\d{6}"
      maxlength="6"
      aria-describedby="otp-format-hint"
      required>
  </div>
  <button type="submit">Verify code</button>
</form>
/* Optional: Style a single input to visually space digits without splitting the DOM element */
#otp-input {
  font-family: ui-monospace, monospace;
  font-size: 1.25rem; /* Example value: must be at least 1rem (16px) to prevent mobile browser auto-zoom */
  letter-spacing: 0.35em;
  min-height: 48px;
  padding: 0.5rem 0.75rem;
  box-sizing: border-box;
}

绑定到来源的短信格式

为了让 WebOTP API 和 Safari 的网域绑定 autocomplete="one-time-code" 自动填充功能验证验证码是否属于您的网站,您的后端短信服务必须将短信的最后一行格式化为:来源网域以 @ 为前缀,动态密码以 # 为前缀:

Your verification code is 123456.

@example.com #123456

对外发短信强制执行以下格式规则:

  • 首先包含人类可读的文本:始终以包含验证码的清晰、人类可读的句子开始短信,以便在其他设备上手动输入验证码的用户可以轻松阅读。
  • 将 @<domain> #<otp> 放在最后一行:@ 字符必须是最后一行的第一个字符,其后不得有尾随行或签名文本。
  • 仅使用托管域名:请勿在 @domain 令牌中包含网址协议(https:// 或 http://)、端口 (:8080) 或路径 (/verify)。
  • 使用一个空格分隔令牌:在 @<domain> 和 #<otp> 之间只放置一个空格,切勿在域名内或 # 与代码之间包含空格。

将 WebOTP API 集成到验证流程中

WebOTP API (OTPCredential) 需要安全上下文(生产环境中的 https:// 或本地开发期间的 http://localhost)。不要粘贴查询 DOM 并调用 form.submit() 的不连贯 DOMContentLoaded 脚本,而是将 navigator.credentials.get() 直接集成到验证界面的生命周期和提交处理程序中:

  • 将请求生命周期与活跃的动态密码步骤相关联:在验证输入框呈现并处于活跃状态时(例如,在用户请求短信验证码后动态密码步骤加载时,以及用户每次点击“重新发送验证码”按钮时),开始监听动态密码。
  • 在所有退出路径中管理 AbortController:将 AbortSignal (signal: ac.signal) 传递给 navigator.credentials.get()。在以下情况下调用 ac.abort():
    • 用户手动提交表单(或提交通过 autocomplete="one-time-code" 填充的代码)。
    • 请求新的短信验证码(“重新发送验证码”),在创建新控制器之前中止上一个控制器。
    • 验证组件、对话框或路由会卸载。
  • 触发应用的提交处理程序 (form.requestSubmit()):当 navigator.credentials.get() 解析为 otp 时,设置 otpInput.value = otp.code 并调用 form.requestSubmit()(或直接调用验证函数)。请勿调用 form.submit(),因为 form.submit() 会绕过 submit 事件监听器(event.preventDefault()/fetch() 处理程序),并跳过原生约束条件验证。
const otpForm = document.getElementById('otp-form');
const otpInput = document.getElementById('otp-input');

let otpAbortController = null;

function stopSmsOtpListener() {
  if (otpAbortController) {
    otpAbortController.abort();
    otpAbortController = null;
  }
}

async function startSmsOtpListener() {
  // Feature-detect WebOTP support
  if (!otpInput || !('OTPCredential' in window)) {
    return;
  }

  // Cancel any previous pending WebOTP request (e.g., when re-sending an SMS code)
  stopSmsOtpListener();
  const ac = new AbortController();
  otpAbortController = ac;

  try {
    const otp = await navigator.credentials.get({
      otp: { transport: ['sms'] },
      signal: ac.signal,
    });

    if (!otp) return;
    otpInput.value = otp.code;

    // Trigger standard form submission so submit event listeners and validation run
    if (otpForm) {
      otpForm.requestSubmit();
    }
  } catch (err) {
    // Expected when aborted by manual submission, resend, unmount, or user dismissal
    if (err && err.name === 'AbortError') {
      return;
    }
    console.error('WebOTP verification error:', err);
  }
}

otpForm.addEventListener('submit', async (event) => {
  // MANDATORY: Abort any in-flight WebOTP request when the form is submitted
  stopSmsOtpListener();

  // Application-specific submission logic (e.g., AJAX fetch or native POST)
  event.preventDefault();
  const formData = new FormData(otpForm);
  // await fetch(otpForm.action, { method: 'POST', body: formData });
});

// Start listening when the OTP verification step becomes active
startSmsOtpListener();

跨域 iframe 验证

默认情况下,在跨域 <iframe> 中接收短信动态密码会受到限制,主要用于嵌入式付款确认流程(例如 3D 安全验证,其中 shop.example 嵌入了来自 bank.example 的发卡机构质询)。若要在跨域 <iframe> 中支持 WebOTP,需要进行以下三项协调配置:

  1. 在短信的最后一行绑定两个来源:先列出顶级嵌入网域 (@shop.example),后跟 #<otp>,再后跟一个空格和嵌入式 iframe 网域 (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. 发送 Permissions-Policy HTTP 响应标头:在父文档的 HTTP 标头中,将 otp-credentials 委托给跨域 iframe 源:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. 将 allow="otp-credentials" 添加到 <iframe> 元素:在嵌入页面的 <iframe> 标记上明确授予权限政策,并为屏幕阅读器添加描述性 title 属性:

<iframe
  src="https://bank.example/verify"
  allow="otp-credentials"
  title="Payment verification">
</iframe>

在 https://bank.example 提供的 <iframe> 文档中,运行上面显示的相同 <form> 标记和 navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) 验证生命周期。

后备策略

WebOTP API 后备

如果您的目标浏览器不支持 WebOTP API (OTPCredential),请勿加载第三方 JavaScript polyfill,因为读取传入的短信需要操作系统级集成。而是应依赖于 <input> 标记中内置的三层渐进增强:

  • 渐进增强体验:在 if ('OTPCredential' in window) 后面保护 navigator.credentials.get({ otp: { transport: ['sms'] } }) 调用。当 OTPCredential 不可用时:
    1. 支持 autocomplete="one-time-code" 的浏览器(例如 iOS 14+、iPadOS 和 macOS 上的 Safari,以及 iCloud 短信中继)会解析 @domain #code 短信,并在 <input> 获得焦点时在系统键盘自动填充栏中显示验证码。
    2. 所有其他浏览器都会回退到在单个 <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required> 中手动输入,在移动设备上显示数字键盘,并通过 <button type="submit"> 提交。

权限政策后备

如果浏览器不支持通过 Permissions-Policy 和 <iframe allow="otp-credentials"> 委托 otp-credentials,则非同源 <iframe> 内的嵌入式验证 <form> 会平稳降级为 autocomplete="one-time-code" 键盘建议和手动数字输入,而不会抛出未处理的异常。

Inputmode 和 AbortController 支持

现代浏览器均支持 HTML inputmode 属性和 AbortController (AbortSignal),无需使用 polyfill 或后备代码。