콜백 매개변수가 일치해도 WordPress에서 DiscourseConnect HMAC 검증이 실패합니다

안녕하세요,

WordPress 사이트와 호스팅된 Discourse 인스턴스 간에 DiscourseConnect를 설정하고 있는데, HMAC 서명 검증 실패가 지속적으로 발생하고 있습니다.

구성

  • WordPress가 인증/제공자(provider) 역할을 수행합니다.
  • Discourse는 DiscourseConnect를 사용합니다.
  • WordPress가 DiscourseConnect 요청을 수신하여 ssosig를 포함하는 콜백을 반환합니다.
  • 콜백은 Discourse에 도달하지만, HMAC 서명이 검증되지 않아 Discourse가 이를 거부합니다.

꽤 광범위한 디버깅을 수행했으며 문제를 상당히 좁혀놓은 상태입니다.

확인된 사항

  • wp_unslash()가 콜백 값을 변경하지 않습니다.
  • EA/WordPress 검증 입력값이 WordPress에서 관찰된 쿼리 문자열로부터 재구성된 매개변수와 일치합니다.
  • 예상되는 콜백 코드가 확실히 실행되고 있습니다.
  • 배포된 소스 파일이 빌드 매니페스트와 일치합니다.
  • 잘못된 파일이나 중복 정의 문제를 확인했습니다.
  • 최신 수정 사항 이후 PHP-FPM이 재시작되었으므로, 이는 오래된 PHP 프로세스/opcache 상태가 아닙니다.
  • 해당 변경 사항 이후 완전히 새로운 DiscourseConnect 요청을 보내도 여전히 HMAC 검증에 실패합니다.

즉, 현재 실패는 새로운 요청에서 재현 가능합니다.

아직 증명하지 못한 사항

  1. Discourse가 런타임에서 사용하는 유효한 시크릿이 WordPress에서 사용하는 시크릿과 바이트 단위까지 완전히 동일한지 여부.
  2. WordPress가 요청을 관찰하는 시점 이전에 어딘가에서 sso 페이로드가 변경되지 않는지 여부.

이 시점에서 더 이상 설정이나 코드를 무작정 변경하고 싶지 않습니다.

질문

현재 Discourse/DiscourseConnect에서, Discourse가 예상 HMAC을 계산할 때 정확히 어떤 페이로드와 시크릿을 사용하는지 확인하는 가장 좋은 방법은 무엇인가요?

실제 시크릿을 공개하지 않으면서 Discourse 측 HMAC 입력값과 WordPress 측 입력값을 비교할 수 있도록 하는 권장되는 디버깅/로깅 방법이 있는지 궁금합니다.

WordPress/PHP 처리, URL 인코딩, Base64 페이로드, 리버스 프록시 또는 호스팅된 Discourse와 관련된 이 상황을 유발할 수 있는 알려진 문제가 있다면 관련 정보를 제공해 주시면 감사하겠습니다.

필요하다면 정제된(sanitized) 요청/콜백 값, 관련 WordPress 코드 및 로그를 제공할 수 있습니다.

감사합니다.

워드프레스 쪽에서는 어떤 것을 사용하고 계신가요? WP-Discourse 플러그인인가요? 아니라면, 코드를 공유해 주실 수 있나요?

Richard님, 안녕하세요. WP-Discourse가 설치되어 있지만, DiscourseConnect 제공자 및 로그인 동기화 기능이 비활성화되어 있습니다.

현재 DiscourseConnect 제공자로 작은 커스텀 WordPress 플러그인을 사용 중입니다. 이 플러그인은 Discourse에서 ssosig 파라미터를 수신한 후, 공유 시크릿(shared secret)을 사용하여 수신된 HMAC을 검증하고, 이후 Discourse로 응답을 구성하여 서명하여 반환합니다.

현재 오류는 Discourse에서 WordPress로 들어오는 요청 단계에서 발생하고 있습니다. 새로 작성한 진단 결과에 따르면, Discourse에서 수신한 sig와 HMAC 재계산 결과가 일치하지 않습니다.

관련된 PHP 콜백/검증 코드를 게시할 수 있습니다. 게시 전에는 구성 값 및 시크릿을 제거하겠습니다.

그러면 반대 방향이군요?

가장 명백한 방법은 WP-Discourse 플러그인을 사용하는 것이지만, 아마도 사용하지 않는 좋은 이유가 있으실 겁니다.
네, 코드를 올려 주세요.

네, 맞습니다. 원본 게시물에서 제가 표현을 반대로 썼습니다.

현재 실패하는 방향은 Discourse → WordPress입니다.

Discourse는 ssosig를 포함하는 DiscourseConnect 요청을 생성합니다. WordPress가 이를 수신한 후, 우리의 커스텀 EA 제공자가 서명을 검증하려고 시도합니다. 입력되는 HMAC 검증이 실패하여 WordPress는 그 지점에서 중단됩니다. 인증된 사용자 페이로드를 Discourse로 반환하는 단계까지 도달하지 못합니다.

WP-Discourse는 설치되어 있지만, 그 DiscourseConnect 제공자와 사용자 로그인 동기화 기능은 비활성화되어 있습니다. EA 계정/프로필 계층을 우리가 직접 제어하기를 원하기 때문에 현재 커스텀 EA 제공자를 사용하고 있습니다.

관련된 콜백/설정/HMAC 검증 코드는 모든 비밀 키와 비공개 설정을 제거한 상태로 아래에 게시하겠습니다.

이것은 EA의 커스텀 WordPress 제공자에서 사용된, 위생적으로 처리된 표준 수신 DiscourseConnect 검증 경로(Discourse → WordPress)입니다.

<?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();

매개변수는 wp_unslash()가 아니라 urldecode()를 사용하여 디코딩해야 합니다.