Weryfikacja formularza za pomocą jednorazowego hasła SMS

Dostarczanie jednorazowego kodu dostępu przez SMS to powszechny sposób weryfikacji numeru telefonu, odzyskiwania konta, uwierzytelniania wielopoziomowego i potwierdzania płatności. Przełączanie się między przeglądarką a aplikacją do obsługi wiadomości SMS w celu zapamiętania lub skopiowania i wklejenia wielocyfrowego kodu powoduje problemy dla użytkowników, zwiększa współczynnik porzuceń i naraża użytkowników na wyłudzanie informacji w sfałszowanych domenach.

Połączenie semantycznych znaczników <form>, <input autocomplete="one-time-code" inputmode="numeric">, formatowania SMS-ów powiązanego z pochodzeniem (@domain #code) i interfejsu WebOTP API (OTPCredential) tworzy wielowarstwowy, proces weryfikacji z progresywnym ulepszaniem:

  1. Automatyczne wyodrębnianie i przesyłanie: przeglądarki obsługujące interfejs WebOTP API weryfikują pochodzenie na podstawie wiersza @domain #code w SMS-ie, wypełniają pole <input> i uruchamiają weryfikację jednym kliknięciem.
  2. Autouzupełnianie klawiatury powiązane z domeną: przeglądarki i systemy operacyjne obsługujące autocomplete="one-time-code" (np. Safari na iOS, iPadOS i macOS) analizują wiadomość @domain #code i sugerują kod na pasku autouzupełniania klawiatury tylko wtedy, gdy domena jest zgodna.
  3. Bezproblemowe ręczne wpisywanie: użytkownicy wpisujący kod z innego urządzenia otrzymują numeryczną klawiaturę wirtualną w inputmode="numeric", co pozwala uniknąć problemów z użytecznością, które występują w <input type="number">.

Bezpieczeństwo

SMS z hasłem jednorazowym potwierdza posiadanie numeru telefonu i zapewnia wygodną weryfikację, ale SMS-y i numery telefonów mogą być narażone na zamianę karty SIM, ponowne wykorzystanie numeru i przechwycenie. Zawsze łącz autouzupełnianie po stronie klienta i interfejs WebOTP API z formatem SMS-ów @domain #code powiązanych z domeną, aby przeglądarka i system operacyjny mogły sprawdzić, czy domena docelowa jest zgodna, zanim zasugerują lub dostarczą kod. W przypadku podstawowego uwierzytelniania odpornego na phishing preferuj klucze dostępu zamiast kodów SMS (patrz Wprowadzenie do kluczy dostępu).

Semantyczne znaczniki formularzy

Umieść element sterujący weryfikacją w semantycznym elemencie <form> z atrybutem method="POST" i wyraźnym atrybutem <button type="submit">. Zawsze używaj jednego elementu <input>, zamiast dzielić cyfry na kilka pól.

  • Używanie type="text" z inputmode="numeric": nigdy nie używaj type="number" do kodów haseł jednorazowych. Jednorazowy kod dostępu to ciąg znaków identyfikatora, który może zawierać zera na początku (np. 012345), a nie policzalną ilość. Użycie type="number" powoduje dodanie przycisków zwiększania/zmniejszania, może usuwać zera wiodące i umożliwia przypadkowe zmiany wartości kółkiem przewijania. Unikaj też type="tel", który jest starszym obejściem przeznaczonym dla pełnych numerów telefonów. Połącz type="text" z inputmode="numeric", aby na urządzeniach mobilnych wyświetlać klawiaturę wirtualną zawierającą tylko cyfry.
  • Dodaj autocomplete="one-time-code": instruuje przeglądarki i systemy operacyjne, aby sugerowały przychodzące kody weryfikacyjne z SMS-ów na pasku autouzupełniania, gdy element <input> jest aktywny.
  • Umieść wskazówki nad elementem <input>: powiąż widoczny element <label> za pomocą atrybutów for i id oraz umieść instrukcje dotyczące formatu (połączone za pomocą atrybutu aria-describedby) wizualnie nad elementem <input>, aby wyskakujące okienka autouzupełniania i klawiatury wirtualne na urządzeniach mobilnych nigdy nie zasłaniały instrukcji.
  • Nie dziel cyfr między wiele elementów <input>: wzorce haseł jednorazowych z wieloma polami (6 osobnych pól wejściowych maxlength="1") uniemożliwiają natywne autouzupełnianie autocomplete="one-time-code" w wielu przeglądarkach, utrudniają kopiowanie i wklejanie oraz komplikują nawigację za pomocą czytnika ekranu. Jeśli wymagany jest podzielony projekt wizualny, zastosuj do pojedynczego elementu <input> styl letter-spacing i czcionkę o stałej szerokości znaków.
<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;
}

Format SMS-a powiązanego z miejscem pochodzenia

Aby zarówno interfejs WebOTP API, jak i funkcja autouzupełniania powiązana z domeną autocomplete="one-time-code" w Safari mogły zweryfikować, czy kod należy do Twojej witryny, usługa SMS w backendzie musi sformatować ostatni wiersz SMS-a, dodając domenę źródłową poprzedzoną znakiem @ i hasło jednorazowe poprzedzone znakiem #:

Your verification code is 123456.

@example.com #123456

Zastosuj te reguły formatowania do SMS-a wychodzącego:

  • Najpierw umieść tekst zrozumiały dla człowieka: zawsze zaczynaj SMS-a od zrozumiałego dla człowieka zdania zawierającego kod, aby użytkownicy, którzy wpisują go ręcznie na innym urządzeniu, mogli go łatwo odczytać.
  • Umieść @<domain> #<otp> w ostatnim wierszu: znak @ musi być pierwszym znakiem ostatniego wiersza, po którym nie może być żadnych dodatkowych wierszy ani tekstu podpisu.
  • Używaj tylko domeny hostowanej: nie uwzględniaj schematu URI adresu URL (https:// lub http://), portu (:8080) ani ścieżki (/verify) w tokenie @domain.
  • Oddzielaj tokeny pojedynczą spacją: umieść dokładnie jedną spację między @<domain> i #<otp>. Nigdy nie umieszczaj spacji w domenie ani między # a kodem.

Integracja interfejsu WebOTP API z procesem weryfikacji

Interfejs WebOTP API (OTPCredential) wymaga bezpiecznego kontekstu (https:// w środowisku produkcyjnym lub http://localhost podczas programowania lokalnego). Zamiast wklejać niepowiązany skrypt DOMContentLoaded, który wysyła zapytania do DOM i wywołuje form.submit(), zintegruj navigator.credentials.get() bezpośrednio z cyklem życia i modułem obsługi przesyłania interfejsu weryfikacji:

  • Powiąż cykl życia żądania z aktywnym krokiem hasła jednorazowego: zacznij nasłuchiwać hasła jednorazowego, gdy dane wejściowe weryfikacji zostaną wyrenderowane i będą aktywne (np. gdy krok hasła jednorazowego zostanie zamontowany po tym, jak użytkownik poprosi o kod SMS, i ponownie, gdy użytkownik kliknie przycisk „Wyślij kod ponownie”).
  • Zarządzanie AbortController we wszystkich ścieżkach wyjścia: przekaż AbortSignal (signal: ac.signal) do navigator.credentials.get(). Zadzwoń do firmy ac.abort(), gdy:
    • Użytkownik ręcznie przesyła formularz (lub przesyła kod wypełniony przez autocomplete="one-time-code").
    • Zażądano nowego kodu SMS („Wyślij kod ponownie”), co spowodowało przerwanie poprzedniego kontrolera przed utworzeniem nowego.
    • Komponent, okno lub ścieżka weryfikacji zostaną odmontowane.
  • Uruchom moduł obsługi przesyłania aplikacji (form.requestSubmit()): gdy navigator.credentials.get() zostanie rozwiązany z otp, ustaw otpInput.value = otp.code i wywołaj form.requestSubmit() (lub wywołaj bezpośrednio funkcję weryfikacji). NIE wywołuj metody form.submit(), ponieważ form.submit() omija detektory zdarzeń submit (moduły obsługi event.preventDefault() / fetch()) i pomija natywną weryfikację ograniczeń.
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();

Weryfikacja elementów iframe współdzielenia

Odbieranie SMS-ów z hasłem jednorazowym w ramach współdzielenia <iframe> jest domyślnie ograniczone i jest używane głównie w przypadku wbudowanych procesów potwierdzania płatności (takich jak weryfikacja 3D Secure, w której shop.example umieszcza wyzwanie wydawcy z bank.example). Obsługa WebOTP w ramach współdzielenia <iframe> wymaga 3 skoordynowanych konfiguracji:

  1. Powiąż oba źródła w ostatnim wierszu SMS-a: najpierw podaj domenę najwyższego poziomu, w której umieszczony jest element iframe (@shop.example), następnie #<otp>, a potem spację i domenę umieszczonego elementu iframe (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Wyślij nagłówek odpowiedzi HTTP Permissions-Policy: Przekaż otp-credentials do źródła elementu iframe współdzielenia w nagłówkach HTTP dokumentu nadrzędnego:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Dodaj allow="otp-credentials" do elementu <iframe>: wyraźnie przyznaj uprawnienia w tagu <iframe> na stronie umieszczania i uwzględnij opisowy atrybut title dla czytników ekranu:

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

W dokumencie <iframe> udostępnianym przez https://bank.example uruchom te same znaczniki <form> i cykl życia weryfikacji navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }), które zostały pokazane powyżej.

Strategie zastępcze

Zastępczy interfejs WebOTP API

Jeśli przeglądarki docelowe nie obsługują interfejsu WebOTP API (OTPCredential), nie wczytuj kodu polyfill JavaScript innej firmy, ponieważ odczytywanie przychodzących SMS-ów wymaga integracji na poziomie systemu operacyjnego. Zamiast tego użyj 3-poziomowego progresywnego ulepszania wbudowanego w znacznik <input>:

  • Progresywne ulepszanie: zabezpiecz wywołanie navigator.credentials.get({ otp: { transport: ['sms'] } }) za pomocą if ('OTPCredential' in window). Gdy OTPCredential jest niedostępny:
    1. Przeglądarki obsługujące autocomplete="one-time-code" (np. Safari na iOS 14 lub nowszym, iPadOS i macOS z przekazywaniem SMS-ów w iCloud) analizują SMS-y @domain #code i wyświetlają kod na pasku autouzupełniania klawiatury systemowej, gdy <input> jest zaznaczony.
    2. W pozostałych przeglądarkach użytkownik musi ręcznie wpisać kod w jednym polu <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>, przy czym na urządzeniach mobilnych wyświetla się klawiatura numeryczna, a formularz przesyła się za pomocą przycisku <button type="submit">.

Zasady dotyczące uprawnień – kreacja zastępcza

Jeśli przeglądarka nie obsługuje przekazywania otp-credentials za pomocą Permissions-Policy i <iframe allow="otp-credentials">, wbudowana weryfikacja <form> w ramach współdzielenia <iframe> jest płynnie zastępowana sugestiami klawiatury autocomplete="one-time-code" i ręcznym wprowadzaniem cyfr bez zgłaszania nieobsługiwanych wyjątków.

Obsługa inputmode i AbortController

Zarówno atrybut HTML inputmode, jak i AbortController (AbortSignal) są obsługiwane w nowoczesnych przeglądarkach i nie wymagają kodu polyfill ani kodu zastępczego.