DiscourseConnect HMAC-Validierung schlägt mit WordPress fehl, obwohl Callback-Parameter übereinstimmen

Hallo,

ich richte DiscourseConnect zwischen einer WordPress-Website und einer gehosteten Discourse-Instanz ein und stoße auf einen anhaltenden Fehler bei der HMAC-Signaturvalidierung.

Konfiguration

  • WordPress ist die Authentifizierungs-/Provider-Seite.
  • Discourse nutzt DiscourseConnect.
  • WordPress empfängt die DiscourseConnect-Anfrage und gibt den Callback zurück, der sso und sig enthält.
  • Der Callback erreicht Discourse, wird aber abgelehnt, da die HMAC-Signatur nicht validiert werden kann.

Wir haben umfangreich debuggt und das Problem erheblich eingegrenzt.

Was wir bestätigt haben

  • wp_unslash() verändert die Callback-Werte nicht.
  • Die Validierungseingaben von EA/WordPress stimmen mit den Parametern überein, die aus der von WordPress beobachteten Query String rekonstruiert wurden.
  • Der erwartete Callback-Code wird definitiv ausgeführt.
  • Die deployed Source-Dateien stimmen mit unserem Build-Manifest überein.
  • Wir haben auf falsche Dateien und doppelte Definitionen geprüft.
  • PHP-FPM wurde nach den letzten Korrekturen neu gestartet, es handelt sich also nicht um einen veralteten PHP-Prozess oder einen opcache-Zustand.
  • Eine völlig neue DiscourseConnect-Anfrage nach diesen Änderungen scheitert weiterhin an der HMAC-Validierung.

Mit anderen Worten: Der aktuelle Fehler ist bei einer neuen Anfrage reproduzierbar.

Was wir noch nicht beweisen konnten

  1. Dass das effektive Secret, das Discourse zur Laufzeit verwendet, bytegenau identisch mit dem von WordPress verwendeten Secret ist.
  2. Dass der sso-Payload nicht irgendwo verändert wird, bevor WordPress die Anfrage beobachtet.

An dieser Stelle wollen wir nicht blindlings weiter an Einstellungen oder Code herumfummeln.

Frage

Wie ist für das aktuelle Discourse/DiscourseConnect der beste Weg, exakt festzustellen, welchen Payload und welches Secret Discourse verwendet, wenn es die erwartete HMAC berechnet?

Gibt es eine empfohlene Debugging-/Logging-Methode, mit der wir den HMAC-Eingabewert auf der Discourse-Seite mit dem auf der WordPress-Seite vergleichen können, ohne das eigentliche Secret öffentlich preiszugeben?

Falls es bekannte Probleme gibt, die mit der Verarbeitung durch WordPress/PHP, URL-Encoding, Base64-Payloads, Reverse Proxies oder gehostetem Discourse zusammenhängen und diese Situation verursachen könnten, wäre ich auch für Hinweise dankbar.

Ich kann bereinigte Anfrage-/Callback-Werte, relevanten WordPress-Code und Logs bereitstellen, falls benötigt.

Vielen Dank.

Was nutzt ihr denn auf der WordPress-Seite? Das WP-Discourse-Plugin? Falls nicht, könnt ihr euren Code posten?

Hallo Richard. WP-Discourse ist installiert, aber der DiscourseConnect-Anbieter und die Login-Sync-Funktionen sind deaktiviert.

Wir verwenden ein kleines, selbstgeschriebenes WordPress-Plugin als DiscourseConnect-Anbieter. Es empfängt die Parameter sso und sig von Discourse, prüft den eingehenden HMAC mit dem gemeinsamen Geheimnis und erstellt danach die signierte Antwort an Discourse.

Der Fehler tritt derzeit bei der eingehenden Anfrage von Discourse an WordPress auf – unsere neue Diagnose zeigt, dass die Neuberechnung des HMAC nicht mit der von Discourse empfangenen sig übereinstimmt.

Ich kann gerne den relevanten PHP-Callback-/Validierungscode posten. Ich entferne vorher die Konfigurationswerte/Geheimnisse.

Das ist also die umgekehrte Richtung, richtig?

Der naheliegende Weg wäre, das WP-Discourse-Plugin zu verwenden, aber du hast wahrscheinlich gute Gründe, dies nicht zu tun.
Ja, bitte poste den Code.

Ja — du hast recht. Meine Formulierung im ursprünglichen Beitrag war vertauscht.

Der aktuelle Fehler tritt auf in der Richtung Discourse → WordPress.

Discourse erzeugt die DiscourseConnect-Anfrage, die sso und sig enthält. WordPress empfängt sie, und unser benutzerdefinierter EA-Provider versucht, die Signatur zu verifizieren. Diese eingehende HMAC-Verifikation schlägt fehl, daher bricht WordPress dort ab. Es kommt nicht so weit, dass es den authentifizierten Nutzern-Payload an Discourse zurückgibt.

WP-Discourse ist installiert, aber sein DiscourseConnect-Provider und die Funktionen zur Synchronisierung der Nutzereinloggung sind deaktiviert. Wir verwenden derzeit den benutzerdefinierten EA-Provider, weil wir die EA-Konto-/Profil-Ebene unter unserer Kontrolle halten möchten.

Ich werde unten den relevanten Callback-/Konfigurations-/HMAC-Validierungscode posten, wobei alle Geheimnisse und privaten Konfigurationen entfernt wurden.

Dies ist der bereinigte reguläre eingehende DiscourseConnect-Validierungspfad (Discourse → WordPress) in EAs eigenem WordPress-Provider.

<?php
// Konfigurationskonstanten sind anderswo definiert; ihre privaten Werte werden hier weggelassen.

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 Community-Anmeldung',
			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() {
		// Einrichtung der Antwort-Header weggelassen.

		if ( ! self::enabled() ) {
			$this->fail( 'Die Community-Anmeldung ist deaktiviert.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'Die Community-Anmeldung ist nicht konfiguriert.', 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(
				'Ungültige Community-Anmeldungsanfrage. Bitte beginnen Sie erneut von der Community aus.'
			);
		}

		// Nachfolgende WordPress-Anmeldung und authentifizierter Antwortcode weggelassen.
	}
}

// Vom Plugin-Bootstrap instanziiert:
new EA_Discourse_Connect();

Du solltest die Parameter mit urldecode() entschlüsseln, nicht mit wp_unslash()