تسجيل الدخول السريع باستخدام وضع واجهة المستخدم الفورية

عندما ينقر مستخدم غير مصادَق عليه على رابط "تسجيل الدخول" أو يبدأ عملية الدفع أو يحفظ عنصرًا في المفضّلة أو يصل إلى حاجز الدفع لمقالة، يتم توجيهه إلى صفحة تسجيل دخول مخصّصة، ما يؤدي إلى فقدان السياق وزيادة معدّل مغادرة المسار للمستخدمين. يتيح وضع واجهة المستخدم الفورية (uiMode: 'immediate' في navigator.credentials.get()) تسجيل الدخول السريع في السياق من خلال عرض محدّد حساب موحّد في المتصفّح فورًا عند توفّر مفاتيح مرور محلية (publicKey) أو كلمات مرور محفوظة (password: true) على الجهاز.

على عكس طلبات WebAuthn العادية التي تظهر في نافذة منبثقة وتعرض دائمًا واجهة مستخدم المتصفّح وتعود إلى رموز الاستجابة السريعة على أجهزة متعددة أو مفاتيح الأمان الخارجية، يتوقف وضع "واجهة المستخدم الفورية" بسرعة مع ظهور الخطأ NotAllowedError إذا لم تكن هناك بيانات اعتماد مؤهَّلة متاحة على الجهاز. يتيح ذلك لتطبيقك مصادقة المستخدمين المكرّري الزيارة في مكانهم بنقرة واحدة، مع نقل المستخدمين الذين ليس لديهم بيانات اعتماد محلية بسلاسة إلى صفحة تسجيل الدخول العادية أو صفحة إتمام الدفع بلا تسجيل دخول أو نموذج تسجيل الدخول المضمّن.

تفعيل وضع واجهة المستخدم الفورية داخل معالج أحداث يبدأه المستخدم (يتطلّب تفعيل المستخدم المؤقت) في لحظات تكون فيها نية المستخدم واضحة قبل الانتقال من الصفحة الحالية إذا نجحت المصادقة الفورية، يجب إكمال الإجراء الذي ينوي المستخدم تنفيذه على الفور (سواء من خلال تعديل الصفحة الحالية في مكانها أو الانتقال مباشرةً إلى الوجهة التي تمت المصادقة عليها). إذا تعذّر ذلك وظهرت رسالة الخطأ NotAllowedError، اعرِض مسار تسجيل الدخول أو التسجيل مع مسار لإكمال الإجراء الذي ينوي المستخدم تنفيذه عند الانتهاء.

حالات الاستخدام المحتملة:

  • اعتراض رابط أو زر تسجيل الدخول في العنوان: يمكنك اعتراض نقرة على رابط "تسجيل الدخول" العام (event.preventDefault()) لمصادقة المستخدمين المكرري الزيارة على الفور، أو مواصلة الانتقال إلى صفحة تسجيل الدخول المخصّصة على NotAllowedError.
  • تسجيل الدخول الديناميكي قبل الدفع: يمكنك مصادقة المتسوّقين المتكررين بنقرة واحدة عندما ينقرون على "المتابعة إلى الدفع" ليتم توجيههم مباشرةً إلى خطوة الشحن والدفع، أو الانتقال إلى شاشة تسجيل الدخول العادية أو شاشة إتمام الدفع بلا تسجيل دخول في حال حدوث خطأ.
  • تسجيل الدخول المضمّن للإجراءات المصغّرة (الإضافة إلى المفضّلة، الإعجاب، الإضافة إلى الإشارات المرجعية، الحفظ): يمكنك المصادقة في المكان نفسه عندما ينقر مستخدم سجّل خروجه على زر إجراء مصغّر وإكمال الإجراء على الفور (على سبيل المثال، تبديل aria-pressed="true")، أو فتح نافذة تسجيل دخول احتياطية أو صفحة تسجيل دخول باستخدام مَعلمة عنوان URL للإرجاع.
  • حظر مقيّد بعدد القراءات ضمن المقال: يمكنك مصادقة القرّاء في المكان نفسه عندما ينقرون على "مواصلة القراءة باستخدام حسابك" في بانر حاجز الدفع، أو توسيع نموذج تسجيل الدخول أو التسجيل المضمّن في حال تعذّر ذلك.

الأنماط المضادة التي يجب تجنّبها:

  • لا تستخدِم وضع واجهة المستخدم الفورية في صفحة تسجيل دخول مخصّصة: لا تربط أبدًا uiMode: 'immediate' بالزر الأساسي "تسجيل الدخول" أو "تسجيل الدخول باستخدام مفتاح مرور" في صفحة تسجيل الدخول للوجهة النهائية. يصل المستخدمون إلى صفحة تسجيل الدخول المخصّصة تحديدًا عندما لا تكون لديهم بيانات اعتماد محلية على الجهاز الحالي، أو عندما ينوون استخدام مفتاح مرور على أجهزة متعددة (مسح رمز استجابة سريعة ضوئيًا باستخدام هاتف) أو مفتاح أمان خارجي. بما أنّ uiMode: 'immediate' يمنع ظهور خيارات رمز الاستجابة السريعة ومفتاح الأمان على جميع الأجهزة ويعرض على الفور NotAllowedError في حال عدم توفّر بيانات اعتماد محلية، فإنّ استخدامه في صفحة تسجيل دخول مخصّصة يضع المستخدم في مأزق حيث لا يؤدي النقر على الزر إلى أي نتيجة. في صفحة تسجيل الدخول المخصّصة، استخدِم واجهة المستخدم المشروطة (mediation: 'conditional' مع autocomplete="username webauthn") في حقول إدخال النماذج المقترنة بزر navigator.credentials.get({ publicKey }) عادي في نافذة مشروطة (راجِع المصادقة باستخدام مفتاح المرور).
  • لا تستخدِم وضع واجهة المستخدم الفورية لإعادة المصادقة الإضافية: لا تستخدِم أبدًا uiMode: 'immediate' لإعادة مصادقة مستخدم سجّل الدخول قبل العمليات الحسّاسة (مثل تغيير كلمة مرور أو تعديل عنوان شحن أو تعديل طرق الدفع المحفوظة أو إضافة مفتاح مرور جديد). يجب أن تقتصر إعادة المصادقة على بيانات اعتماد المستخدم الذي سجّل الدخول من خلال قائمة allowCredentials غير فارغة، والتي يرفضها وضع واجهة المستخدم الفورية مع NotAllowedError، وسيؤدي حذف allowCredentials إلى السماح للمستخدم باختيار حساب مختلف عن الحساب الذي سجّل الدخول إليه حاليًا عن غير قصد. لإعادة المصادقة الإضافية، اتّبِع الخطوات الواردة في مقالة إعادة المصادقة باستخدام مفتاح المرور.

قيود الخصوصية والأمان

تفرض المتصفّحات قواعد صارمة للخصوصية والأمان عند طلب uiMode: 'immediate':

  • يجب تفعيل المستخدم المؤقت: يجب استدعاء navigator.credentials.get({ uiMode: 'immediate' }) داخل معالج أحداث يبدأه المستخدم (مثل مستمع click على عنصر <button> أو <a>) لمنع فحص بيانات الاعتماد الصامت. لا تستدعيها تلقائيًا عند تحميل الصفحة (DOMContentLoaded). لا يؤدي طلب واجهة برمجة التطبيقات إلى استهلاك التفعيل المؤقت.
  • عدم تمرير قائمة allowCredentials غير فارغة: يجب عدم تعبئة publicKey.allowCredentials عند تحديد uiMode: 'immediate'. ترفض المتصفّحات على الفور أي طلب لوضع واجهة المستخدم الفورية يتضمّن قائمة allowCredentials غير فارغة من خلال عرض الخطأ NotAllowedError، وبالتالي لا يمكن للأطراف المعتمِدة البحث عن معرّفات بيانات اعتماد معيّنة لاستنتاج الزيارات السابقة أو تتبُّع المستخدمين على مستوى الجلسات.
  • عدم تمرير السمة AbortSignal (signal): يجب عدم ضبط السمة signal في navigator.credentials.get() عند تحديد uiMode: 'immediate'، لأنّ المتصفّحات لا تسمح بإغلاق مربّع حوار تسجيل الدخول الفوري آليًا. إذا كانت صفحتك تُشغّل أيضًا طلب واجهة مستخدم شرطية (mediation: 'conditional') في الخلفية، يمكنك استدعاء abort() في واجهة المستخدم الشرطية هذه AbortController قبل استدعاء وضع واجهة المستخدم الفورية، وحذف signal من الطلب الفوري نفسه.
  • جلسات التصفّح المتخفي والتصفّح بخصوصية تامة: يتم دائمًا رفض الطلبات المقدَّمة في نوافذ التصفّح المتخفي أو التصفّح بخصوصية تامة باستخدام NotAllowedError لكي تظل الجلسات الخاصة غير قابلة للتمييز عن الجلسات التي لا تتضمّن بيانات اعتماد محلية.

الإعداد من جهة الخادم

نقطة نهاية إنشاء الخيارات

يجب عرض نقطة نهاية تنشئ خيارات طلب WebAuthn قابلة للاكتشاف للزوّار غير المصادَق عليهم:

  1. إنشاء تحدٍ مرتبط بالجلسة: أنشِئ مخزنًا مؤقتًا عشوائيًا للتحدي المشفّر، وخزِّنه في جلسة الخادم، ورمِّزه باستخدام Base64URL.
  2. الاحتفاظ بخيار التحقّق من المستخدم: اضبط userVerification: 'preferred' وخزِّن expectedUserVerification: 'preferred' في الجلسة.
  3. حذف allowCredentials: لا تُدرِج قائمة allowCredentials غير فارغة في خيارات وضع واجهة المستخدم الفورية، لأنّ المتصفّحات ترفض القوائم المسموح بها غير الفارغة التي تحتوي على 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);
});

نقاط نهاية مزدوجة للتحقّق من بيانات الاعتماد

بما أنّ navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) يمكن أن يحلّ المشكلة باستخدام مفتاح مرور (PublicKeyCredential) أو كلمة مرور محفوظة (PasswordCredential)، يجب توفير معالِجات التحقّق لكلا نوعَي بيانات الاعتماد:

  1. التحقّق من تأكيد WebAuthn ‏ (/api/login/verify): يتم التحقّق من التأكيد مقابل طلب الجلسة والمفتاح العام المخزَّن. إذا لم يتم العثور على معرّف بيانات الاعتماد في قاعدة البيانات، يجب عرض حالة HTTP‏ 404 ليتمكّن العميل من إرسال إشعار إلى مدير كلمات المرور من خلال PublicKeyCredential.signalUnknownCredential().
  2. التحقّق من كلمة المرور (/api/login/password): يتم التحقّق من username (cred.id) وpassword (cred.password) من خلال قاعدة بيانات المستخدمين وإنشاء جلسة مصادقة.

التنفيذ من جهة العميل

طلب بيانات اعتماد موحّد

  1. طلب كل من مفاتيح المرور وكلمات المرور: يمكنك فك ترميز خيارات الخادم باستخدام PublicKeyCredential.parseRequestOptionsFromJSON() واستدعاء navigator.credentials.get() داخل معالج نقرات يبدأه المستخدم باستخدام password: true وpublicKey وuiMode: 'immediate' (بدون تمرير signal).
  2. التحقّق من الفرع من قِبل cred.type:
    • إذا كانت القيمة cred.type === 'public-key'، يمكنك تسلسل التأكيد باستخدام cred.toJSON() وإرساله إلى نقطة نهاية التحقّق من WebAuthn. عندما يستجيب الخادم برمز الحالة HTTP 404، يمكنك استدعاء PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id }).
    • إذا كانت القيمة cred.type === 'password'، أرسِل طلب POST { username: cred.id, password: cred.password } إلى نقطة نهاية التحقّق من كلمة المرور.
  3. التعامل مع NotAllowedError في حال عدم توفّر بيانات اعتماد محلية: يمكنك رصد NotAllowedError (يتم عرضه فورًا في حال عدم توفّر مفاتيح مرور محلية أو كلمات مرور محفوظة على الجهاز، أو عندما يغلق المستخدم أداة الاختيار) والانتقال إلى مسار تسجيل الدخول العادي (مثل فتح مربّع حوار تسجيل الدخول المضمّن أو الانتقال إلى /signin باستخدام عنوان URL للعودة).
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;
  }
});

استراتيجيات احتياطية

العودة الفورية إلى وضع واجهة المستخدم

  • تجربة التحسين التدريجي: يمكنك التعامل مع uiMode: 'immediate' كتحسين تدريجي. لا تستنتج التوافق من اسم المتصفّح أو إصداره، لأنّ إشارة التوافق الوحيدة الموثوقة هي فحص وقت التشغيل capabilities.immediateGet من خلال PublicKeyCredential.getClientCapabilities(). إلزامي: يجب دائمًا التحقّق من capabilities.immediateGet قبل استدعاء navigator.credentials.get({ uiMode: 'immediate' })، لأنّ المتصفّحات التي لا تتعرّف على uiMode: 'immediate' تتجاهل مفتاح القاموس غير المعروف وتفتح مربّع حوار يحظر مفتاح المرور ورمز الاستجابة السريعة. عندما تكون قيمة capabilities.immediateGet خاطئة أو عندما يرفض navigator.credentials.get() استخدام NotAllowedError، يمكنك الرجوع إلى تجربة تسجيل الدخول العادية في تطبيقك (مثل فتح نافذة تسجيل دخول مضمّنة أو الانتقال إلى صفحة تسجيل الدخول المخصّصة باستخدام مَعلمة عنوان URL للإرجاع)، لكي يرى المستخدمون في المتصفّحات غير المتوافقة مسار تسجيل الدخول الحالي بدون تغيير:
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' })
}

الاحتياط لرصد إمكانات العميل

يجب دائمًا تثبيت 'webauthn-polyfills' واستيرادها في وحدة العميل لكي تكون PublicKeyCredential.getClientCapabilities() متاحة في أي وقت تتوفّر فيه PublicKeyCredential:

import 'webauthn-polyfills';

الاحتفاظ بنسخة احتياطية من بيانات اعتماد كلمات المرور

يؤدي تمرير password: true مع publicKey في navigator.credentials.get() إلى السماح للمتصفّحات المتوافقة بتضمين كلمات المرور المحفوظة من مدير كلمات المرور في المتصفّح في "محدّد الحساب" الموحّد الفوري.

  • تجربة الاحتفاظ بنسخة احتياطية: تتجاهل المتصفحات التي لا تتيح استخدام PasswordCredential الخيار password: true أو تعود إلى نموذج تسجيل الدخول العادي من خلال التحقّق من ميزة capabilities.immediateGet، حيث يوفّر <input autocomplete="username webauthn"> و<input type="password" autocomplete="current-password"> ميزة الملء التلقائي لكلمات المرور الأصلية.

سهولة استخدام JSON كبديل في حال تعذُّر التسلسل

يجب دائمًا تثبيت 'webauthn-polyfills' واستيرادها في حزمة العميل لكي تعمل PublicKeyCredential.parseRequestOptionsFromJSON() وPublicKeyCredential.prototype.toJSON() بشكل متّسق على جميع المتصفّحات التي تتيح استخدام PublicKeyCredential:

import 'webauthn-polyfills';

الاحتياطي لمزامنة واجهة برمجة التطبيقات Signal

واجهة برمجة التطبيقات WebAuthn Signal API (webauthn-signals) هي تحسين تدريجي يزيل مفاتيح المرور القديمة من مدير كلمات المرور الخاص بالمستخدم عندما يستجيب الخادم برمز HTTP 404.

  • التجربة البديلة: حظر المكالمات من خلال if (PublicKeyCredential.signalUnknownCredential) في حال عدم توفّرها، يمكنك تخطّي طلب الإشارة بسلاسة وعرض رسالة واضحة توجّه المستخدم إلى تسجيل الدخول باستخدام طريقة بديلة.