Đăng nhập nhanh bằng chế độ giao diện người dùng tức thì

Việc chuyển hướng người dùng chưa xác thực đến một trang đăng nhập riêng khi họ nhấp vào đường liên kết "Đăng nhập", bắt đầu thanh toán, lưu một mục vào danh sách yêu thích hoặc gặp tường phí của bài viết sẽ khiến người dùng mất ngữ cảnh và tăng tỷ lệ bỏ ngang. Chế độ giao diện người dùng tức thì (uiMode: 'immediate' trong navigator.credentials.get()) cho phép đăng nhập nhanh theo ngữ cảnh bằng cách hiển thị ngay một trình chọn tài khoản trình duyệt hợp nhất khi có khoá truy cập cục bộ (publicKey) hoặc mật khẩu đã lưu (password: true) trên thiết bị.

Không giống như lời nhắc WebAuthn phương thức chuẩn (luôn hiển thị giao diện người dùng của trình duyệt và quay lại mã QR trên nhiều thiết bị hoặc khoá bảo mật bên ngoài), chế độ Giao diện người dùng tức thì sẽ nhanh chóng không thực hiện được và trả về NotAllowedError nếu không có thông tin xác thực đủ điều kiện trên thiết bị. Tính năng này cho phép ứng dụng của bạn xác thực người dùng cũ tại chỗ chỉ bằng một lần nhấn, đồng thời chuyển đổi liền mạch người dùng không có thông tin đăng nhập cục bộ sang trang đăng nhập chuẩn, trang thanh toán không cần đăng nhập hoặc biểu mẫu đăng nhập cùng dòng.

Kích hoạt chế độ Giao diện người dùng tức thì bên trong trình xử lý sự kiện do người dùng bắt đầu (yêu cầu kích hoạt người dùng tạm thời) tại những thời điểm người dùng có ý định rõ ràng ngay trước khi rời khỏi trang hiện tại. Nếu quá trình xác thực tức thì thành công, hãy hoàn tất ngay ý định của người dùng (bằng cách cập nhật trang hiện tại tại chỗ hoặc chuyển thẳng đến đích đến đã xác thực của họ). Nếu không thành công với NotAllowedError, hãy hiển thị quy trình đăng nhập hoặc đăng ký có đường dẫn để hoàn tất ý định của người dùng khi hoàn tất.

Các trường hợp sử dụng tiềm năng:

  • Chặn nút hoặc đường liên kết đăng nhập ở tiêu đề: Chặn lượt nhấp vào đường liên kết "Đăng nhập" chung (event.preventDefault()) để xác thực người dùng cũ ngay lập tức hoặc tiếp tục chuyển đến trang đăng nhập chuyên biệt trên NotAllowedError.
  • Đăng nhập linh động trước khi thanh toán: Xác thực người mua sắm cũ bằng một lần nhấn khi họ nhấp vào "Tiến hành thanh toán" để họ chuyển thẳng đến bước vận chuyển và thanh toán, hoặc chuyển sang màn hình đăng nhập tiêu chuẩn hoặc màn hình thanh toán không cần đăng nhập khi dự phòng.
  • Đăng nhập cùng dòng cho các hành động nhỏ (Yêu thích, Thích, Đánh dấu, Lưu): Xác thực tại chỗ khi người dùng đã đăng xuất nhấp vào nút hành động nhỏ và hoàn tất hành động ngay lập tức (ví dụ: bật/tắt aria-pressed="true") hoặc mở một phương thức đăng nhập dự phòng hoặc trang đăng nhập có tham số URL trả về.
  • Tường phí mềm trong bài viết: Xác thực độc giả tại chỗ khi họ nhấp vào "Tiếp tục đọc bằng tài khoản của bạn" trên biểu ngữ tường phí hoặc mở rộng biểu mẫu đăng nhập hoặc đăng ký cùng dòng trong trường hợp dự phòng.

Các mẫu phản tác dụng cần tránh:

  • KHÔNG sử dụng chế độ Giao diện người dùng tức thì trên trang đăng nhập chuyên biệt: Đừng bao giờ đính kèm uiMode: 'immediate' vào nút "Đăng nhập" hoặc "Đăng nhập bằng khoá truy cập" chính trên trang đăng nhập đích cuối cùng. Người dùng truy cập vào trang đăng nhập chuyên biệt, cụ thể là khi họ không có thông tin xác thực cục bộ trên thiết bị hiện tại hoặc khi họ dự định sử dụng khoá truy cập trên nhiều thiết bị (quét mã QR bằng điện thoại) hoặc khoá bảo mật vật lý bên ngoài. Vì uiMode: 'immediate' chặn mã QR trên nhiều thiết bị và các lựa chọn khoá bảo mật, đồng thời ngay lập tức gửi NotAllowedError khi không có thông tin xác thực cục bộ, nên việc sử dụng phương thức này trên một trang đăng nhập chuyên biệt sẽ khiến người dùng rơi vào ngõ cụt, nơi việc nhấp vào nút không có tác dụng gì. Trên trang đăng nhập chuyên biệt, hãy sử dụng Giao diện người dùng có điều kiện (mediation: 'conditional' có autocomplete="username webauthn") trên dữ liệu đầu vào của biểu mẫu kết hợp với nút navigator.credentials.get({ publicKey }) của phương thức chuẩn (xem phần Xác thực bằng khoá truy cập).
  • KHÔNG sử dụng chế độ Giao diện người dùng tức thì để xác thực lại từng bước: Tuyệt đối không sử dụng uiMode: 'immediate' để xác thực lại người dùng đã đăng nhập trước khi thực hiện các thao tác nhạy cảm (chẳng hạn như thay đổi mật khẩu, cập nhật địa chỉ giao hàng, chỉnh sửa phương thức thanh toán đã lưu hoặc thêm khoá truy cập mới). Quá trình xác thực lại phải hạn chế lời nhắc đối với thông tin đăng nhập của người dùng đã đăng nhập thông qua danh sách allowCredentials không trống. Chế độ Giao diện người dùng tức thì sẽ từ chối danh sách này bằng NotAllowedError và việc bỏ qua allowCredentials sẽ khiến người dùng vô tình chọn một tài khoản khác với tài khoản hiện đã đăng nhập. Để xác thực lại theo từng bước, hãy làm theo hướng dẫn trong bài viết Xác thực lại bằng khoá truy cập.

Các quy định ràng buộc về quyền riêng tư và bảo mật

Trình duyệt thực thi các quy tắc nghiêm ngặt về quyền riêng tư và bảo mật khi uiMode: 'immediate' được yêu cầu:

  • Cần có hoạt động kích hoạt tạm thời của người dùng: Bạn PHẢI gọi navigator.credentials.get({ uiMode: 'immediate' }) bên trong trình xử lý sự kiện do người dùng bắt đầu (chẳng hạn như trình nghe click trên phần tử <button> hoặc <a>) để ngăn chặn việc thăm dò thông tin xác thực thầm lặng. Đừng bao giờ gọi API này tự động khi tải trang (DOMContentLoaded). Việc gọi API không sử dụng hoạt động kích hoạt tạm thời.
  • Không truyền danh sách allowCredentials không trống: KHÔNG điền publicKey.allowCredentials khi uiMode: 'immediate' được chỉ định. Trình duyệt sẽ từ chối ngay lập tức mọi yêu cầu về chế độ Giao diện người dùng tức thì có danh sách allowCredentials không trống bằng cách gửi NotAllowedError để các bên bị phụ thuộc không thể thăm dò mã thông tin xác thực cụ thể nhằm suy luận các lượt truy cập trước đó hoặc theo dõi người dùng trên các phiên.
  • Không truyền AbortSignal (signal): KHÔNG đặt thuộc tính signal trên navigator.credentials.get() khi uiMode: 'immediate' được chỉ định, vì trình duyệt không cho phép đóng hộp thoại đăng nhập tức thì theo phương thức lập trình. Nếu trang của bạn cũng chạy yêu cầu Giao diện người dùng có điều kiện (mediation: 'conditional') ở chế độ nền, hãy gọi abort() trên Giao diện người dùng có điều kiện AbortController đó trước khi gọi chế độ Giao diện người dùng tức thì và bỏ qua signal khỏi chính yêu cầu tức thì đó.
  • Phiên duyệt web ẩn danh và riêng tư: Các yêu cầu được thực hiện trong cửa sổ duyệt web ẩn danh hoặc riêng tư luôn bị từ chối bằng NotAllowedError để các phiên riêng tư không thể phân biệt được với các phiên không có thông tin xác thực cục bộ.

Thiết lập phía máy chủ

Điểm cuối tạo lựa chọn

Hiển thị một điểm cuối tạo các tuỳ chọn yêu cầu WebAuthn có thể phát hiện cho khách truy cập chưa được xác thực:

  1. Tạo thử thách liên kết với phiên: Tạo một vùng đệm thử thách ngẫu nhiên bằng mật mã, lưu trữ vùng đệm đó trong phiên máy chủ và mã hoá dưới dạng Base64URL.
  2. Duy trì lựa chọn ưu tiên về việc xác minh người dùng: Đặt userVerification: 'preferred' và lưu trữ expectedUserVerification: 'preferred' trong phiên.
  3. Bỏ qua allowCredentials: Đừng đưa danh sách allowCredentials không trống vào các tuỳ chọn cho chế độ Giao diện người dùng tức thì, vì trình duyệt sẽ từ chối danh sách cho phép không trống có NotAllowedError.
// Server-side options generation example (/api/login/options)
router.post('/api/login/options', (req, res) => {
  const options = {
    challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
    rpId: 'example.com',
    userVerification: 'preferred',
  };

  req.session.challenge = options.challenge;
  req.session.expectedUserVerification = 'preferred';
  return res.json(options);
});

Điểm cuối xác minh thông tin xác thực kép

Vì navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) có thể phân giải bằng khoá truy cập (PublicKeyCredential) hoặc mật khẩu đã lưu (PasswordCredential), hãy cung cấp trình xử lý xác minh cho cả hai loại thông tin xác thực:

  1. Xác minh câu nhận định WebAuthn (/api/login/verify): Xác minh câu nhận định dựa trên thử thách của phiên và khoá công khai được lưu trữ. Nếu không tìm thấy mã thông tin xác thực trong cơ sở dữ liệu, hãy trả về trạng thái HTTP 404 để ứng dụng có thể thông báo cho trình quản lý mật khẩu thông qua PublicKeyCredential.signalUnknownCredential().
  2. Xác minh mật khẩu (/api/login/password): Xác minh username (cred.id) và password (cred.password) dựa trên cơ sở dữ liệu người dùng và thiết lập một phiên đã xác thực.

Triển khai phía máy khách

Yêu cầu thông tin xác thực hợp nhất

  1. Yêu cầu cả khoá truy cập và mật khẩu: Giải mã các tuỳ chọn máy chủ bằng PublicKeyCredential.parseRequestOptionsFromJSON() và gọi navigator.credentials.get() bên trong trình xử lý lượt nhấp do người dùng bắt đầu bằng password: true, publicKey và uiMode: 'immediate' (mà không truyền signal).
  2. Xác minh chi nhánh muộn nhất vào ngày cred.type:
    • Nếu cred.type === 'public-key', hãy chuyển đổi câu nhận định sang dạng tuần tự bằng cred.toJSON() và POST câu nhận định đó đến điểm cuối xác minh WebAuthn. Khi máy chủ phản hồi bằng HTTP 404, hãy gọi PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id }).
    • Nếu cred.type === 'password', hãy POST { username: cred.id, password: cred.password } đến điểm cuối xác minh mật khẩu của bạn.
  3. Xử lý NotAllowedError khi không có thông tin xác thực cục bộ: Phát hiện NotAllowedError (được gửi ngay lập tức khi không có khoá truy cập cục bộ hoặc mật khẩu đã lưu trên thiết bị, hoặc khi người dùng đóng bộ chọn) và chuyển sang quy trình đăng nhập chuẩn (chẳng hạn như mở hộp thoại đăng nhập cùng dòng hoặc chuyển đến /signin bằng URL trả về).
let isAuthenticating = false;

async function authenticateWithImmediateUi() {
  const optionsResponse = await fetch('/api/login/options', { method: 'POST' });
  if (!optionsResponse.ok) {
    throw new Error('Failed to fetch authentication options');
  }
  const optionsJSON = await optionsResponse.json();
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);

  // MANDATORY: Request both password and publicKey with uiMode: 'immediate' and NO signal parameter
  const cred = await navigator.credentials.get({
    password: true,
    publicKey,
    uiMode: 'immediate',
  });

  if (!cred) {
    throw new DOMException('No credential returned', 'NotAllowedError');
  }

  if (cred.type === 'public-key') {
    const encoded = cred.toJSON();
    const verifyResponse = await fetch('/api/login/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(encoded),
    });

    if (verifyResponse.status === 404) {
      await PublicKeyCredential.signalUnknownCredential({
        rpId: publicKey.rpId || window.location.hostname,
        credentialId: encoded.id, // Base64URL-encoded credential ID string
      });
      throw new Error('Passkey credential is no longer recognized by the server');
    }

    if (!verifyResponse.ok) {
      throw new Error('Passkey verification failed');
    }
    return await verifyResponse.json();
  }

  if (cred.type === 'password') {
    const verifyResponse = await fetch('/api/login/password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: cred.id,
        password: cred.password,
      }),
    });

    if (!verifyResponse.ok) {
      throw new Error('Password verification failed');
    }
    return await verifyResponse.json();
  }

  throw new Error(`Unsupported credential type: ${cred.type}`);
}

// Example: Intercepting an inline micro-action (Favorite button) or Header Sign-in click
const favoriteButton = document.getElementById('favorite-btn');

favoriteButton.addEventListener('click', async (event) => {
  event.preventDefault();
  if (isAuthenticating) return;

  // If already signed in, toggle the micro-action state immediately
  if (document.body.dataset.signedIn === 'true') {
    const nextPressed = favoriteButton.getAttribute('aria-pressed') !== 'true';
    favoriteButton.setAttribute('aria-pressed', String(nextPressed));
    return;
  }

  isAuthenticating = true;
  try {
    const user = await authenticateWithImmediateUi();
    // Complete the user's intended action immediately
    document.body.dataset.signedIn = 'true';
    favoriteButton.setAttribute('aria-pressed', 'true');
    updateHeaderUserState(user);
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // No local credentials available or user dismissed dialog:
      // transition smoothly to fallback sign-in UI or redirect with return URL
      showFallbackSignInUi({ returnAction: 'favorite' });
      return;
    }
    console.error('Authentication error:', err);
  } finally {
    isAuthenticating = false;
  }
});

Chiến lược dự phòng

Dự phòng chế độ giao diện người dùng ngay lập tức

  • Trải nghiệm cải tiến tăng dần: Hãy coi uiMode: 'immediate' là một tính năng cải tiến tăng dần. Đừng suy luận khả năng hỗ trợ từ tên hoặc phiên bản của trình duyệt; tín hiệu hỗ trợ đáng tin cậy duy nhất là quy trình kiểm tra thời gian chạy capabilities.immediateGet thông qua PublicKeyCredential.getClientCapabilities(). BẮT BUỘC: Luôn kiểm tra capabilities.immediateGet trước khi gọi navigator.credentials.get({ uiMode: 'immediate' }) – các trình duyệt không nhận dạng được uiMode: 'immediate' sẽ bỏ qua khoá từ điển không xác định và mở hộp thoại mã QR và khoá truy cập phương thức chặn. Khi capabilities.immediateGet là giá trị giả hoặc khi navigator.credentials.get() từ chối bằng NotAllowedError, hãy quay lại trải nghiệm đăng nhập chuẩn của ứng dụng (chẳng hạn như mở một phương thức đăng nhập cùng dòng hoặc chuyển đến trang đăng nhập chuyên dụng có tham số URL trả về) để người dùng trên các trình duyệt không được hỗ trợ thấy quy trình đăng nhập hiện tại của bạn không thay đổi:
async function isImmediateUiSupported() {
  if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) {
    return false;
  }
  try {
    const capabilities = await PublicKeyCredential.getClientCapabilities();
    return Boolean(capabilities?.immediateGet);
  } catch {
    return false;
  }
}

async function authenticateWithImmediateUi() {
  // MANDATORY: Check immediateGet support so unsupported browsers do not open a modal QR dialog
  const supported = await isImmediateUiSupported();
  if (!supported) {
    // Throw NotAllowedError so the caller's single catch path treats "unsupported" like "no local credentials"
    throw new DOMException('Immediate UI mode is not supported', 'NotAllowedError');
  }
  // Proceed with fetching options and calling navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })
}

Dự phòng phát hiện chức năng của ứng dụng

Luôn cài đặt 'webauthn-polyfills' và nhập vào mô-đun ứng dụng để PublicKeyCredential.getClientCapabilities() luôn có sẵn bất cứ khi nào PublicKeyCredential được hỗ trợ:

import 'webauthn-polyfills';

Dự phòng thông tin xác thực bằng mật khẩu

Việc truyền password: true cùng với publicKey trong navigator.credentials.get() cho phép các trình duyệt hỗ trợ thêm mật khẩu đã lưu từ Trình quản lý mật khẩu của trình duyệt vào Trình chọn tài khoản tức thì hợp nhất.

  • Trải nghiệm dự phòng: Các trình duyệt không hỗ trợ PasswordCredential sẽ bỏ qua tuỳ chọn password: true hoặc quay lại biểu mẫu đăng nhập chuẩn thông qua quy trình kiểm tra tính năng capabilities.immediateGet, trong đó <input autocomplete="username webauthn"> và <input type="password" autocomplete="current-password"> cung cấp tính năng tự động điền mật khẩu gốc.

Dễ dàng dự phòng chuyển đổi tuần tự JSON

Luôn cài đặt 'webauthn-polyfills' và nhập vào gói ứng dụng để PublicKeyCredential.parseRequestOptionsFromJSON() và PublicKeyCredential.prototype.toJSON() hoạt động nhất quán trên các trình duyệt hỗ trợ PublicKeyCredential:

import 'webauthn-polyfills';

Dự phòng đồng bộ hoá Signal API

WebAuthn Signal API (webauthn-signals) là một cải tiến tăng dần giúp xoá các khoá truy cập lỗi thời khỏi Trình quản lý mật khẩu của người dùng khi máy chủ phản hồi bằng HTTP 404.

  • Trải nghiệm dự phòng: Chặn cuộc gọi bằng if (PublicKeyCredential.signalUnknownCredential). Khi không được hỗ trợ, hãy bỏ qua lệnh gọi tín hiệu một cách linh hoạt và hiển thị thông báo rõ ràng hướng dẫn người dùng đăng nhập bằng một phương thức khác.