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

안녕하세요,

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

구성

  • WordPress가 인증/제공자(provider) 역할을 수행합니다.
  • Discourse는 DiscourseConnect를 사용합니다.
  • WordPress가 DiscourseConnect 요청을 수신하여 sso와 sig를 포함하는 콜백을 반환합니다.
  • 콜백은 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에서 sso와 sig 파라미터를 수신한 후, 공유 시크릿(shared secret)을 사용하여 수신된 HMAC을 검증하고, 이후 Discourse로 응답을 구성하여 서명하여 반환합니다.

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

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

그러면 반대 방향이군요?

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

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

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

Discourse는 sso와 sig를 포함하는 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()를 사용하여 디코딩해야 합니다.

Richard, 감사합니다. 해당 지점을 PHP/WordPress의 요청 처리 방식과 현재 Discourse 서명 플로우와 대조해 확인했습니다.

우리의 콜백에서는 $_GET에서 값을 읽기 때문에, EA가 파라미터를 받기 전에 PHP가 이미 쿼리 파라미터를 URL 디코딩해 둔 상태입니다. wp_unslash()는 WordPress의 요청 슬래싱을 되돌리는 것일 뿐, URL 디코딩을 수행하지는 않습니다.

해당 시점에 urldecode()를 다시 적용하면 값이 이중 디코딩되어 Base64의 +가 공백으로 변환될 수 있으며, 이는 HMAC 자체를 깨뜨릴 수 있습니다.

또한 WordPress와 Discourse의 시크릿이 일치하도록 수정된 후에도 여전히 새로운 진단에서 HMAC이 실패하는 것을 확인했습니다.

따라서 이 특정 콜백 경로에 대해 wp_unslash()를 urldecode()로 교체하는 것이 올바른 해결책이라고 생각하지 않습니다. 서명된 페이로드가 PHP에 도달하기 전에 어디서 분기(diverge)되고 있는지 추적하는 작업을 계속 진행하겠습니다.

Richard님,

WordPress/Discourse SSO 관련 문제를 마무리하기 위해 연락드립니다. 이제 정상적으로 작동합니다.

결국, 우리가 직접 개발한 DiscourseConnect/HMAC 구현을 계속 사용하는 대신 표준 WP-Discourse DiscourseConnect Provider로 돌아가는 것이 해결책이었습니다.

새로운 Discourse API 키를 생성하고, DiscourseConnect 공유 시크릿을 로테이션하며, 네트워크 레벨에서 WP-Discourse Provider를 활성화했습니다. 또한 Discourse Connect URL을 기존 커스텀 콜백에서 WP-Discourse가 지정한 WordPress 사이트 URL로 변경했습니다.

이후 완전히 새로운 WordPress 사용자와 깨끗한 Discourse 세션으로 테스트를 진행했습니다.

WordPress 로그인 → 커뮤니티 입장 → 동일한 사용자로서 Discourse에 자동 로그인.

이로써 표준 WP-Discourse가 SSO를 올바르게 처리하고 있습니다.

작업 과정에서 올바른 방향으로 이끌어주셔서 다시 한번 감사드립니다.

Gee