مفاتيح المرور في الإنتاج: ما تعلمناه من تطبيق WebAuthn وواجهة الإكمال التلقائي وقواعد RP ID
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗مفتاح المرور هو بيانات اعتماد WebAuthn بمفتاح عام، يحتفظ به مُصادِق الجهاز ويرتبط بشكل دائم بمعرّف طرف معتمد واحد (RP ID)، وهو يحل محل كلمة المرور والعامل الثاني في مطالبة واحدة. ثلاث تفاصيل تسبّبت في معظم الأخطاء التي واجهها فريقنا: يجب أن يكون RP ID هو النطاق القابل للتسجيل للأصل أو نطاقًا أعلى منه، ويجب أن يكون user.id معرّفًا مبهمًا لا بريدًا إلكترونيًا، ولا تظهر أيقونة مفتاح المرور في الإكمال التلقائي إلا عند استدعاء navigator.credentials.get() مع الوساطة الشرطية.
أهم النقاط
معرّف RP ID دائم وأحادي الاتجاه. مفتاح مرور مسجّل بـ rp.id يساوي example.com يعمل من app.example.com، لكن مفتاح مرور مسجّل بـ rp.id يساوي app.example.com لا يعمل أبدًا من example.com. اختر النطاق الجذري قبل تسجيل أول مستخدم.
الحقل user.id هو معرّف حساب مبهم وليس هوية. مواصفة WebAuthn تحدّه بـ 64 بايت وتنص على ألا يحتوي على معلومات تعريف شخصية، لذا فإن وضع بريد إلكتروني فيه يثبّته داخل بيانات الاعتماد ولا يمكن تغييره لاحقًا.
تسجيل الدخول دون اسم مستخدم يتطلب بيانات اعتماد قابلة للاكتشاف، تُطلب عبر authenticatorSelection.residentKey بقيمة required. بدونها يجب إرسال قائمة allowCredentials، ما يعني أنك تحتاج اسم المستخدم أولًا.
واجهة الإكمال الشرطي استدعاء منفصل. ظهور مفتاح المرور داخل قائمة الإكمال التلقائي يتطلب autocomplete="username webauthn" على حقل الإدخال إضافة إلى mediation بقيمة conditional؛ أما استدعاء get() العادي فيفتح نافذة حاجبة بدلًا من ذلك.
لا تحذف كلمة المرور من اليوم الأول. مفاتيح المرور تنقل مشكلة استعادة الحساب ولا تلغيها. المستخدم الذي يفقد كل أجهزته المتزامنة ما زال بحاجة إلى مسار استرداد موثّق.
ما هو مفتاح المرور وما الذي يحل محله فعليًا؟
مفتاح المرور هو بيانات اعتماد WebAuthn لا يغادر مفتاحها الخاص المُصادِق أبدًا — سلسلة مفاتيح النظام مثل iCloud Keychain أو Google Password Manager أو Windows Hello، أو مفتاح أمان عتادي — بينما يُخزَّن المفتاح العام المقابل على الخادم. المصادقة هي توقيع: الخادم يصدر تحديًا عشوائيًا، والمُصادِق يوقّعه بعد تحقق محلي من المستخدم، ثم يتحقق الخادم من التوقيع باستخدام المفتاح العام المخزّن.
لأن أي سر مشترك لا يعبر الشبكة، لا يوجد ما يلتقطه موقع تصيّد. والخاصية الأقوى يفرضها المتصفح لا المستخدم: المتصفح لا يعرض إلا بيانات اعتماد يطابق RP ID الخاص بها النطاق القابل للتسجيل للأصل الحالي، لذا لا يستطيع نطاق مشابه أن يجعل المُصادِق يوقّع أصلًا. هذه هي الثغرة التي لم يغلقها TOTP، لأن الرمز لمرة واحدة يمكن تمريره للمهاجم لحظيًا بينما توقيع مفتاح المرور لا يمكن.
مفتاح المرور يحل محل كلمة المرور والعامل الثاني معًا في مطالبة واحدة. لكنه لا يحل محل إدارة الجلسات ولا التفويض ولا تحديد المعدل ولا استعادة الحساب. نحن نتعامل معه كترقية لبيانات الاعتماد، لا كنظام مصادقة كامل.
كيف نسجّل مفتاح مرور من معالج مسار في Next.js App Router؟
التسجيل يتم عبر معالجَي مسار: واحد يولّد الخيارات ويخزّن التحدي على الخادم، وآخر يتحقق من الشهادة ويحفظ بيانات الاعتماد. نستخدم @simplewebauthn/server الإصدار 13 بدلًا من كتابة تحليل CBOR يدويًا.
استدعاء الخيارات يضبط rpID على النطاق الجذري من متغير بيئة، ويمرّر userID كـ Uint8Array مبهم لا يتجاوز 64 بايت، ويضبط attestationType على none، ويسرد بيانات الاعتماد الحالية في excludeCredentials، ويطلب authenticatorSelection.residentKey بقيمة required. ثم يُحفظ التحدي المولَّد على الخادم بمدة صلاحية قصيرة واستخدام لمرة واحدة.
حقل excludeCredentials هو الأكثر إغفالًا، وإغفاله يسمح للمُصادِق نفسه بالتسجيل مرتين، فيرى المستخدم إدخالين متطابقين في قائمته دون وسيلة للتمييز بينهما. وقيمة none لـ attestationType هي الافتراض الصحيح لمنتج استهلاكي، لأن طلب الشهادة يعيد بيان مصدر للمُصادِق لا توجد لدى معظم الفرق سياسة للتعامل معه، ويضيف مطالبة خصوصية على بعض المنصات.
نستخدم userVerification بقيمة preferred لا required. القيمة required ترفض المُصادِقات التي لا تستطيع إجراء تحقق بيومتري أو رمز PIN محلي، ما يستبعد بهدوء بعض مفاتيح الأمان وإعدادات أندرويد الأقدم. أما preferred فتظل تُبلغ عن حدوث التحقق عبر علم userVerified، فيمكن اشتراطه على العمليات الحساسة بدل اشتراطه على التسجيل.
في المتصفح، دالة startRegistration من @simplewebauthn/browser الإصدار 13 تأخذ كائن خيارات بدل وسيط موضعي، وهذا هو التغيير الكاسر عن الإصدار 10. ونستخدم معالجات المسار بدل Server Actions هنا، لأن navigator.credentials لا يعمل إلا في المتصفح ضمن إيماءة مستخدم وفي سياق آمن، ولأن زوجين بسيطين من الطلب والاستجابة أسهل بكثير في التسجيل وإعادة التشغيل داخل الاختبارات.
لماذا يعمل مفتاح المرور على نطاق فرعي دون آخر؟
لأن المتصفح يطابق RP ID مع النطاق القابل للتسجيل للأصل، والمطابقة أحادية الاتجاه: يجوز أن يكون RP ID هو نطاق الأصل نفسه أو أي نطاق أعلى منه، ولا يجوز أن يكون نطاقًا أدنى. أصل مثل https://app.example.com يمكنه المطالبة بـ rp.id يساوي app.example.com أو example.com، بينما أصل https://example.com لا يمكنه المطالبة بـ app.example.com.
هذا هو الخطأ المكلف، لأن RP ID يُكتب داخل بيانات الاعتماد عند التسجيل ولا يمكن ترحيله. إذا سجّل المستخدمون على app.example.com ثم أُطلق منتج ثانٍ على dash.example.com، تصبح كل مفاتيح المرور القائمة حبيسة النطاق الفرعي الأول. سجّل عند النطاق الجذري منذ البداية ما لم يكن هناك متطلب عزل مقصود.
للمواقع المختلفة فعلًا — نطاق لكل دولة أو علامة تجارية منفصلة — الآلية هي Related Origin Requests. يقدّم الطرف المعتمد ملف JSON على المسار /.well-known/webauthn من مضيف RP ID، بترويسة Content-Type تساوي application/json ومصفوفة origins تسرد النطاقات الشقيقة. أُطلقت هذه الآلية في Chrome 128 وSafari 18، وتضع المتصفحات حدًا لعدد النطاقات المميزة التي تعالجها — كروم يتوقف عند خمسة — فهي تغطي حفنة من النطاقات لا بديلًا شاملًا.
ملاحظة تخص التطوير المحلي: localhost سياق آمن ويعمل عبر HTTP عادي، لكن أي اسم مضيف تطويري آخر يجب تقديمه عبر HTTPS، ويجب أن يساوي RP ID ذلك المضيف. مفتاح مرور مسجّل على مضيف تطوير محلي لن يصادق أبدًا على الإنتاج، لذا يجب فصل بيانات اعتماد التطوير في جدول أو نطاق تسمية مستقل.
كيف نجعل مطالبة الإكمال التلقائي لمفتاح المرور تظهر؟
الوساطة الشرطية هي ما يضع مفتاح المرور داخل قائمة الإكمال التلقائي بدل نافذة منبثقة. تتطلب ثلاثة أمور معًا: بيانات اعتماد قابلة للاكتشاف، وحقل إدخال يحمل autocomplete="username webauthn"، واستدعاء navigator.credentials.get() مع mediation بقيمة conditional.
هناك فخّان في هذا المسار. الأول أن استدعاء get() الشرطي يعيد وعدًا يبقى معلقًا إلى أجل غير مسمى، ولا يُحل إلا عندما يختار المستخدم مفتاح مرور من القائمة. ابدأه مرة واحدة عند تركيب نموذج تسجيل الدخول وألغه عند التفكيك عبر AbortController؛ فإطلاق استدعاء get() ثانٍ أثناء وجود آخر معلق يرمي خطأ بدل أن يستبدله.
الثاني أن الوساطة الشرطية لا تعرض إلا بيانات الاعتماد القابلة للاكتشاف وتتطلب allowCredentials فارغة. إذا ملأ مسار خيارات المصادقة قائمة allowCredentials من اسم مستخدم معروف، فلن يظهر خيار الإكمال التلقائي إطلاقًا ودون رسالة خطأ. تحقق دائمًا من الدعم عبر PublicKeyCredential.isConditionalMediationAvailable() مع بديل يعتمد على زر يستدعي get() بشكل عادي.
ماذا يجب أن يتحقق منه الخادم في كل تأكيد؟
يجب أن يتحقق الخادم من التحدي، والأصل، وبصمة RP ID، وعلم حضور المستخدم، والتوقيع. العميل الذي يبلّغ بالنجاح لا يثبت شيئًا، لأن التأكيد بأكمله يصل كـ JSON يتحكم به المهاجم.
مع @simplewebauthn/server الإصدار 13، تأخذ دالة verifyAuthenticationResponse الاستجابة والتحدي المتوقع والأصل المتوقع ومعرّف RP المتوقع وكائن بيانات الاعتماد الذي يحمل المعرّف والمفتاح العام والعدّاد ووسائل النقل المخزّنة. ويجب حذف التحدي المخزّن سواء نجح التحقق أو فشل — فالتحدي الذي يبقى بعد محاولة فاشلة هو نافذة إعادة تشغيل، وهو أكثر عيب نجده عند مراجعة تطبيقات WebAuthn المكتوبة يدويًا.
عدّاد التوقيع يستحق تحذيرًا. وُجد لاكتشاف الاستنساخ، لكن معظم المُصادِقات المتزامنة تبلّغ دائمًا بالقيمة صفر، لذا فإن قاعدة ساذجة برفض أي تأكيد لم يزد عدّاده تحجب كل مستخدمي مفاتيح المرور على Apple وGoogle. افرض التزايد الرتيب فقط عندما يكون العدّاد المخزّن والعدّاد الجديد كلاهما غير صفري.
احفظ الحقلين credentialBackedUp وcredentialDeviceType من نتيجة التسجيل. هذان العلمان يوضّحان ما إذا كانت بيانات الاعتماد متزامنة عبر أجهزة المستخدم أم مقيّدة بجهاز واحد، وهي الإشارة اللازمة قبل مطالبة المستخدم بتسجيل مفتاح مرور ثانٍ.
مفاتيح المرور مقابل كلمة المرور مع TOTP مقابل روابط الدخول السحرية
مقاومة التصيّد: مفتاح المرور محمي بمطابقة RP ID التي يفرضها المتصفح؛ كلمة المرور مع TOTP بلا حماية لأن الرموز يمكن تمريرها لحظيًا؛ روابط الدخول السحرية بلا حماية لأن الروابط قابلة لإعادة التوجيه.
الأسرار على الخادم: نشر مفاتيح المرور يخزّن مفتاحًا عامًا فقط؛ كلمة المرور مع TOTP تخزّن بصمة وبذرة TOTP؛ الروابط السحرية لا تخزّن سرًا طويل الأمد لكنها تترك رمزًا فعالًا داخل صندوق البريد.
جهد تسجيل الدخول: مفتاح المرور إيماءة واحدة؛ كلمة المرور مع TOTP حقلان وتبديل تطبيق؛ الرابط السحري تبديل إلى البريد ثم عودة.
نمط الفشل الرئيسي: مفاتيح المرور تفشل عند الاستعادة إذا فُقدت كل الأجهزة المتزامنة؛ كلمة المرور مع TOTP تفشل أمام التصيّد اللحظي وفقدان البذرة؛ الروابط السحرية تفشل مع مشاكل تسليم البريد واختراق الصندوق.
افتراضنا هو مفتاح المرور أولًا مع الاحتفاظ بكلمة مرور للاسترداد، والإبقاء على TOTP فقط للحسابات التي كانت تستخدمه. أما الروابط السحرية فتبقى للانضمام الأول لا لتسجيل الدخول المتكرر، لأنها تجعل الدخول اليومي رهينة زمن وصول البريد.
ماذا ينكسر بعد أن يحذف المستخدم مفتاح مرور؟
لا شيء ينكسر على الخادم، وكل شيء ينكسر في قائمة نظام التشغيل: يظل مدير مفاتيح المرور يعرض بيانات اعتماد حُذفت من الخادم، فيختارها المستخدم ويتلقى خطأ لا يستطيع تفسيره. واجهة WebAuthn Signal API وُجدت لسد هذه الفجوة.
توجد ثلاث دوال تقابل ثلاثة أحداث. الدالة signalAllAcceptedCredentials() تُستدعى بعد حذف بيانات اعتماد أو مباشرة بعد تسجيل دخول ناجح، وتنظّف الإدخالات القديمة بتمرير rpId ومعرّف المستخدم بترميز base64url وقائمة معرّفات بيانات الاعتماد التي ما زالت صالحة. والدالة signalUnknownCredential() تُستدعى عند وصول تأكيد لمعرّف بيانات اعتماد لا سجل له على الخادم، فتزيل ذلك الإدخال اليتيم. والدالة signalCurrentUserDetails() تُستدعى بعد تغيير الاسم أو البريد كي تتوقف القائمة عن عرض بيانات قديمة. أُطلقت هذه الواجهة في Chromium 132، لذا يجب استخدام السلسلة الاختيارية مع كل استدعاء والتعامل معها كتحسين تدريجي.
قاعدة واحدة على الخادم تبقى صالحة بغض النظر عن دعم المتصفح: لا تسمح للمستخدم بحذف آخر بيانات اعتماد لديه ما لم يوجد مسار دخول آخر. نحن نمنع الطلب إذا كان سيترك الحساب بلا مفاتيح مرور وبلا كلمة مرور أو رمز استرداد، ونعيد خطأً محددًا تستطيع الواجهة شرحه بدل خطأ 400 عام.
الأسئلة الشائعة
س: هل يمكننا ضبط RP ID على نطاق فرعي ثم تغييره لاحقًا؟
ج: لا. يُكتب RP ID داخل بيانات الاعتماد عند التسجيل ولا يمكن ترحيله. مفتاح مرور مسجّل بـ rp.id يساوي app.example.com لن يعمل أبدًا على example.com، لذا اختر النطاق الجذري قبل تسجيل أول مستخدم.
س: لماذا لا يظهر مفتاح المرور في قائمة الإكمال التلقائي بالمتصفح؟
ج: ثلاثة أسباب مرتبة حسب الاحتمال: حقل الإدخال ينقصه autocomplete="username webauthn"، أو استدعاء get() ينقصه mediation بقيمة conditional، أو خيارات المصادقة تتضمن allowCredentials غير فارغة. الوساطة الشرطية لا تعرض إلا بيانات الاعتماد القابلة للاكتشاف.
س: ماذا نضع في user.id؟
ج: معرّفًا عشوائيًا مبهمًا لا يتجاوز 64 بايت، مثل UUID أو 16 بايت عشوائية تُخزَّن مع سجل الحساب. تنص مواصفة WebAuthn على ألا يحتوي user.id على معلومات تعريف شخصية، لذا لا تستخدم بريدًا إلكترونيًا ولا معرّف قاعدة بيانات تسلسليًا.
س: هل ما زلنا نحتاج كلمات المرور بعد إطلاق مفاتيح المرور؟
ج: احتفظ بمسار استرداد واحد إلى أن تُحل استعادة مفاتيح المرور فعليًا لمستخدميك. مفتاح المرور المتزامن مع سحابة مزوّد واحد يُفقد بفقدان الوصول إلى ذلك الحساب، والمفتاح المقيّد بجهاز يُفقد بفقدان الجهاز.
س: هل نرفض تسجيل الدخول إذا لم يزد عدّاد التوقيع؟
ج: فقط عندما يكون العدّاد المخزّن والعدّاد الجديد كلاهما غير صفري. معظم المُصادِقات المتزامنة تبلّغ دائمًا بصفر، لذا فإن التحقق الصارم من التزايد يحجب غالبية مستخدمي مفاتيح المرور الحقيقيين.
قراءات إضافية
هندسة الويب المتكاملة
لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر --heapsnapshot-signal، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.
هندسة الويب المتكاملة
لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate
كانت نقطة نهاية JSON عامة تصل إلى الخادم الأصلي في كل طلب بينما بدت إعدادات CDN صحيحة. هذه ملاحظاتنا الميدانية حول قراءة ترويسات x-vercel-cache وAge بدلًا من لوحات التحكم، والفرق بين max-age وs-maxage وstale-while-revalidate، ومتى تتفوق CDN-Cache-Control على ترويسة Cache-Control وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.