Estoy configurando DiscourseConnect entre un sitio de WordPress y una instancia de Discourse alojada y me he encontrado con un fallo persistente en la validación de la firma HMAC.
WordPress recibe la solicitud de DiscourseConnect y devuelve el callback que contiene sso y sig.
El callback llega a Discourse, pero Discourse lo rechaza porque la firma HMAC no se valida.
Hemos realizado una depuración bastante exhaustiva y hemos acotado el problema considerablemente.
Lo que hemos confirmado
wp_unslash() no altera los valores del callback.
Las entradas de validación de EA/WordPress coinciden con los parámetros reconstruidos a partir de la cadena de consulta observada en WordPress.
El código del callback esperado definitivamente se está ejecutando.
Los archivos de fuente desplegados coinciden con nuestro manifiesto de compilación.
Hemos revisado problemas de archivos incorrectos y definiciones duplicadas.
PHP-FPM se ha reiniciado tras las últimas correcciones, por lo que no se trata de un proceso PHP antiguo ni de un estado de opcache obsoleto.
Una solicitud de DiscourseConnect completamente nueva después de esos cambios sigue fallando en la validación HMAC.
En otras palabras, el fallo actual es reproducible en una solicitud nueva.
Lo que aún no hemos podido demostrar
Que el secreto efectivo utilizado por Discourse en tiempo de ejecución sea idéntico, byte a byte, al secreto utilizado por WordPress.
Que el payload sso no esté siendo modificado en algún punto antes de que WordPress observe la solicitud.
En esta etapa no queremos seguir cambiando configuraciones o código a ciegas.
Pregunta
Para la versión actual de Discourse/DiscourseConnect, ¿cuál es la mejor manera de determinar exactamente qué payload y secreto está utilizando Discourse cuando calcula el HMAC esperado?
¿Existe algún método de depuración/registro recomendado que nos permita comparar la entrada HMAC del lado de Discourse con la entrada del lado de WordPress sin exponer públicamente el secreto real?
Si hay algún problema conocido que involucre el manejo de WordPress/PHP, la codificación de URL, los payloads Base64, los proxies inversos o Discourse alojado que pueda producir esta situación, también agradecería cualquier orientación.
Puedo proporcionar valores de solicitud/callback saneados, el código relevante de WordPress y registros si es necesario.
Hola, Richard. WP-Discourse está instalado, pero su proveedor DiscourseConnect y las funciones de sincronización de inicio de sesión están desactivados.
Estamos utilizando un pequeño plugin personalizado de WordPress como proveedor de DiscourseConnect. Recibe los parámetros sso y sig de Discourse, verifica el HMAC entrante utilizando el secreto compartido y luego construye/firma la respuesta para devolverla a Discourse.
El fallo se está produciendo actualmente en la solicitud entrante de Discourse a WordPress: nuestro nuevo diagnóstico muestra que la recálculo del HMAC no coincide con el sig recibido de Discourse.
Estoy dispuesto a publicar el código PHP relevante de la callback/validación. Eliminaré los valores de configuración/secretos antes de publicarlo.
Sí, tienes razón. Mi redacción en la publicación original estaba al revés.
El fallo actual es Discourse → WordPress.
Discourse genera la solicitud DiscourseConnect que contiene sso y sig. WordPress la recibe y nuestro proveedor EA personalizado intenta verificar la firma. Esa verificación HMAC entrante falla, por lo que WordPress se detiene ahí. No llega tan lejos como para devolver el payload del usuario autenticado a Discourse.
WP-Discourse está instalado, pero su proveedor DiscourseConnect y sus funciones de sincronización de inicio de sesión de usuario están desactivados. Actualmente estamos usando el proveedor EA personalizado porque queremos que la capa de cuenta/perfil de EA siga bajo nuestro control.
Publicaré a continuación el código relevante del callback/configuración/validación HMAC, con todos los secretos y la configuración privada eliminados.
Este es el camino de validación normal y saneado de DiscourseConnect entrante (Discourse → WordPress) en el proveedor personalizado de WordPress de EA.
<?php
// Las constantes de configuración se definen en otro lugar; sus valores privados se omiten.
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 ),
'Inicio de sesión de la comunidad 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() {
// Configuración de encabezados de respuesta omitida.
if ( ! self::enabled() ) {
$this->fail( 'El inicio de sesión de la comunidad está desactivado.', 503 );
}
if ( ! self::ready() ) {
$this->fail( 'El inicio de sesión de la comunidad no está configurado.', 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(
'Solicitud de inicio de sesión de la comunidad no válida. Comience de nuevo desde la comunidad.'
);
}
// Inicio de sesión de WordPress posterior y código de respuesta autenticado omitidos.
}
}
// Instanciado por el arranque del plugin:
new EA_Discourse_Connect();
Gracias, Richard. He revisado ese punto en relación con el manejo de solicitudes de PHP/WordPress y el flujo actual de firma de Discourse.
En nuestro callback leemos desde $_GET, por lo que PHP ya ha decodificado las URL de los parámetros de consulta antes de que EA los reciba. wp_unslash() solo revierte el procesado de barras invertidas de las solicitudes de WordPress; no realiza una decodificación de URL.
Aplicar urldecode() de nuevo en ese punto resultaría en una doble decodificación del valor y podría convertir un + en Base64 en un espacio, lo cual rompería el HMAC por sí mismo.
También he confirmado que nuestro nuevo diagnóstico sigue fallando el HMAC después de que los secretos de WordPress y Discourse se hayan corregido para que coincidan.
Por lo tanto, no creo que reemplazar wp_unslash() con urldecode() sea la solución correcta para esta ruta de callback en particular. Sigo rastreando dónde podría estar divergiendo la carga firmada antes de que llegue a PHP.
Solo quería cerrar el hilo sobre el problema de SSO entre WordPress y Discourse. Lo hemos conseguido.
Al final, la solución fue volver al proveedor WP-Discourse DiscourseConnect estándar, en lugar de seguir con nuestra implementación personalizada de DiscourseConnect/HMAC.
Creamos una nueva clave de API de Discourse, rotamos el secreto compartido de DiscourseConnect, habilitamos el proveedor WP-Discourse a nivel de red y cambiamos la URL de Discourse Connect desde nuestro antiguo callback personalizado a la URL del sitio de WordPress especificada por WP-Discourse.
Luego, lo probamos con un usuario de WordPress completamente nuevo y una sesión limpia de Discourse:
Inicio de sesión en WordPress → Entrar a la Comunidad → inicio de sesión automático en Discourse como el mismo usuario.
Así que el WP-Discourse estándar ahora está manejando el SSO correctamente.
Gracias de nuevo por orientarnos en la dirección correcta mientras lo resolvíamos.