فشل التحقق HMAC في DiscourseConnect مع WordPress رغم تطابق معلمات الاستدعاء

مرحباً،

أقوم بإعداد DiscourseConnect بين موقع WordPress ونسخة Discourse مستضافة، وقد وصلت إلى فشل دائم في التحقق من توقيع HMAC.

الإعداد

  • موقع WordPress هو جهة المصادقة/المُوفّر.
  • يستخدم Discourse خاصية DiscourseConnect.
  • يستقبل WordPress طلب DiscourseConnect ويعيد استدعاءً (callback) يحتوي على sso و sig.
  • يصل الاستدعاء إلى Discourse، لكن Discourse يرفضه لأن توقيع HMAC لا يتحقق.

لقد أجرينا تصحيحاً للأخطاء (Debugging) بشكل واسع إلى حد ما، وضيّقنا نطاق المشكلة بشكل كبير.

ما قمنا بتأكيده

  • دالة wp_unslash() لا تُغيّر قيم الاستدعاء.
  • مدخلات التحقق الخاصة بـ EA/WordPress تتطابق مع المعلمات المُعاد بناؤها من سلسلة الاستعلام (query string) التي رصدها WordPress.
  • كود الاستدعاء المتوقع يُنفَّذ بالتأكيد.
  • ملفات المصدر المُنفَّذة تطابق قائمة البناء (build manifest) لدينا.
  • لقد تحققنا من مشاكل الملفات الخاطئة أو التعريفات المكررة.
  • تم إعادة تشغيل PHP-FPM بعد آخر التصحيحات، لذا فإن هذا ليس بسبب عملية PHP قديمة أو حالة opcache قديمة.
  • طلب DiscourseConnect جديد تماماً بعد تلك التغييرات لا يزال يفشل في التحقق من HMAC.

بعبارة أخرى، الفشل الحالي قابل للتكرار على طلب جديد.

ما لم نتمكن من إثباته بعد

  1. أن السر (secret) الفعّال الذي يستخدمه Discourse وقت التشغيل مطابق حرفياً (byte-for-byte) للسر الذي يستخدمه WordPress.
  2. أن حمولة sso لا يتم تغييرها في مكان ما قبل النقطة التي يلاحظ فيها WordPress الطلب.

في هذه المرحلة، لا نريد الاستمرار في تغيير الإعدادات أو الكود بشكل عشوائي.

السؤال

بالنسبة لنسخ Discourse/DiscourseConnect الحالية، ما هو أفضل طريقة لتحديد بالضبط أي حمولة وسر يستخدمهما Discourse عند حساب HMAC المتوقع؟

هل توجد طريقة توصية للتصحيح/التسجيل (debugging/logging) تتيح لنا مقارنة مدخلات HMAC في جانب Discourse بمدخلات جانب WordPress دون الكشف عن السر الفعلي علناً؟

إذا كانت هناك أي مشاكل معروفة تتضمن معالجة WordPress/PHP، أو ترميز URL، أو حمولات Base64، أو الوكلاء العكسيين (reverse proxies)، أو Discourse المستضاف الذي قد يؤدي إلى هذه الحالة، فسأقدّر أي إرشادات.

يمكنني تقديم قيم طلب/استدعاء معقمة (sanitized)، وأكواد WordPress ذات الصلة، والسجلات إذا لزم الأمر.

شكراً.

إذن، ما الذي تستخدمه على جانب ووردبريس؟ هل تستخدم إضافة WP-Discourse؟ وإذا لم تكن كذلك، فهل يمكنك مشاركة كودك؟

مرحبًا ريتشارد. تم تثبيت WP-Discourse، لكن مُقدِّم DiscourseConnect ووظائف مزامنة تسجيل الدخول معطّلة.

نحن نستخدم إضافة ووردبريس مخصصة صغيرة كـ مُقدِّم DiscourseConnect. تستقبل هذه الإضافة معاملات sso وsig من Discourse، وتتحقق من HMAC الواردة باستخدام السر المشترك، ثم تُنشئ/تُوقّع الاستجابة لإرسالها مرة أخرى إلى Discourse.

الخطأ يحدث حاليًا على الطلب الوارد من Discourse إلى ووردبريس — وتشير تشخيصاتنا الجديدة إلى أن إعادة حساب HMAC لا تطابق sig المستلمة من Discourse.

يسعدني مشاركة كود PHP الخاص بالاستدعاء/التحقق ذي الصلة. سأزيل قيم الإعدادات/الأسرار قبل نشره.

إذن الأمر بالعكس، أليس كذلك؟

الطريق الواضح هو استخدام إضافة WP-Discourse، لكن على الأرجح لديك أسباب جيدة لعدم القيام بذلك.
نعم، من فضلك شارك الكود.

نعم — أنت محق. صياغتي في المنشور الأصلي كانت معكوسة.

خطأ الفشل الحالي هو Discourse → WordPress.

يقوم Discourse بتوليد طلب DiscourseConnect الذي يحتوي على sso وsig. يستقبل WordPress هذا الطلب، ثم يحاول مزود EA المخصص لدينا التحقق من التوقيع. يفشل التحقق من HMAC الوارد، لذلك يتوقف WordPress عند هذه النقطة. ولا يصل إلى مرحلة إرجاع حمولة المستخدم الموثق إلى Discourse.

تم تثبيت WP-Discourse، لكن مزود DiscourseConnect ووظائف مزامنة تسجيل دخول المستخدمين معطلة. نحن نستخدم حاليًا مزود EA المخصص لأننا نريد إبقاء طبقة حساب/ملف EA تحت سيطرتنا.

سأقوم بنشر الكود الخاص بالcallback/التكوين/التحقق من HMAC ذي الصلة أدناه، مع إزالة جميع الأسرار والإعدادات الخاصة.

هذا هو مسار التحقق العادي المُطهَّر (sanitised) لطلبات DiscourseConnect الواردة (Discourse ← WordPress) في مُزوّد WordPress المخصص لـ EA.

<?php
// ثوابت التهيئة معرفة في مكان آخر؛ قيمها الخاصة محذوفة.

final class EA_Discourse_Connect {
	public function __construct() {
		add_action( 'admin_post_ea_discourse_connect', array( $this, 'connect' ) );
		add_action( 'admin_post_nopriv_ea_discourse_connect', array( $this, 'connect' ) );
	}

	public static function enabled() {
		return defined( 'EA_DISCOURSE_CONNECT_ENABLED' ) && true === EA_DISCOURSE_CONNECT_ENABLED;
	}

	public static function config() {
		$url = defined( 'EA_DISCOURSE_URL' ) ? EA_DISCOURSE_URL : '';
		$secret = defined( 'EA_DISCOURSE_CONNECT_SECRET' ) ? EA_DISCOURSE_CONNECT_SECRET : '';

		if ( ! is_string( $url ) || ! preg_match( '~\Ahttps://[a-z0-9.-]+(?::[0-9]+)?(?:/[a-z0-9_-]+)*/?\z~i', $url ) ) {
			$url = '';
		}

		return array(
			'url'    => rtrim( $url, '/' ),
			'secret' => is_string( $secret ) ? $secret : '',
		);
	}

	public static function ready() {
		$c = self::config();

		return self::enabled()
			&& $c['url']
			&& strlen( $c['secret'] ) >= 32
			&& 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
	}

	private function fail( $message, $code = 400 ) {
		wp_die(
			esc_html( $message ),
			'تسجيل الدخول إلى مجتمع EA',
			array( 'response' => $code )
		);
	}

	public static function validate( $payload, $signature, $config ) {
		if (
			! is_string( $payload )
			|| ! is_string( $signature )
			|| strlen( $payload ) > 8192
			|| ! preg_match( '/\A[a-f0-9]{64}\z/', $signature )
			|| ! hash_equals(
				hash_hmac( 'sha256', $payload, $config['secret'] ),
				$signature
			)
		) {
			return false;
		}

		$decoded = base64_decode( $payload, true );

		if ( false === $decoded ) {
			return false;
		}

		parse_str( $decoded, $params );

		if (
			empty( $params['nonce'] )
			|| ! is_string( $params['nonce'] )
			|| strlen( $params['nonce'] ) > 256
			|| ! isset( $params['return_sso_url'] )
			|| $config['url'] . '/session/sso_login' !== $params['return_sso_url']
		) {
			return false;
		}

		return $params['nonce'];
	}

	public function connect() {
		// إعداد ترويسات الاستجابة محذوف.

		if ( ! self::enabled() ) {
			$this->fail( 'تسجيل الدخول إلى المجتمع معطّل.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'تسجيل الدخول إلى المجتمع غير مُهيّأ.', 503 );
		}

		$c = self::config();

		$payload = isset( $_GET['sso'] )
			? wp_unslash( $_GET['sso'] )
			: null;

		$sig = isset( $_GET['sig'] )
			? wp_unslash( $_GET['sig'] )
			: null;

		$nonce = self::validate( $payload, $sig, $c );

		if ( false === $nonce ) {
			$this->fail(
				'طلب تسجيل دخول إلى المجتمع غير صالح. ابدأ مرة أخرى من المجتمع.'
			);
		}

		// تسجيل الدخول اللاحق إلى WordPress ورمز الاستجابة الموثّق محذوفان.
	}
}

// يتم إنشاء الكائن بواسطة نقطة بداية الإضافة:
new EA_Discourse_Connect();