Un puente de registro seguro entre WordPress y una plataforma C#
Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura
Cuando WordPress maneja el registro pero otro sistema es dueño de la cuenta, la contraseña del usuario nuevo tiene que viajar de uno a otro. Ese salto es el momento más riesgoso de todo el flujo, y un POST simple con la contraseña en el cuerpo no alcanza.
GLEbill, una plataforma de facturación electrónica legal de Global Legal Ebilling, funciona así. La plataforma está escrita en C#, y WordPress es su front end de registro: los clientes se registran y administran su perfil en WordPress, WordPress envía cada usuario nuevo y sus credenciales, cifradas, a los endpoints de la API de la plataforma mediante una integración REST personalizada, la plataforma crea la cuenta y el mismo inicio de sesión funciona en ambos sistemas. El código de abajo es una versión de tutorial de ese tipo de puente, no el código del proyecto. Al final te cuento cuándo no deberías sincronizar contraseñas.
El flujo, paso a paso
- El visitante envía tu formulario de registro por HTTPS.
- WordPress valida los datos y crea el usuario. Solo guarda su propio hash de la contraseña.
- Mientras la contraseña en texto plano sigue en memoria, WordPress arma un payload JSON y lo cifra con AES-256-GCM.
- WordPress firma la petición con un HMAC sobre un timestamp, un ID de petición y el cuerpo cifrado, y la envía a la plataforma por TLS.
- La plataforma verifica la firma y el timestamp, descifra el payload, crea la cuenta y hashea la contraseña con su propio hasher.
- WordPress registra si la sincronización funcionó. La contraseña en texto plano nunca se escribe en la base de datos, en un archivo de log ni en un correo.
Captura la contraseña en el momento correcto
El hook user_register se ejecuta después de que WordPress inserta un usuario y recibe el arreglo $userdata sin procesar. Pero con el registro por defecto de WordPress, la contraseña de ese arreglo es una aleatoria que generó WordPress; el usuario elige la real después, con el enlace para establecer su contraseña. Un handler de registro propio es más simple, porque sabes exactamente cuándo existe el texto plano:
add_action( 'admin_post_nopriv_rw_signup', 'rw_handle_signup' );
function rw_handle_signup(): void {
check_admin_referer( 'rw_signup', 'rw_signup_nonce' );
$back = home_url( '/sign-up/' );
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$password = (string) wp_unslash( $_POST['password'] ?? '' ); // Nunca sanitices ni registres una contraseña.
if ( ! is_email( $email ) || strlen( $password ) < 12 ) {
wp_safe_redirect( add_query_arg( 'signup', 'invalid', $back ) );
exit;
}
$user_id = wp_insert_user(
array(
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'first_name' => sanitize_text_field( wp_unslash( $_POST['first_name'] ?? '' ) ),
'last_name' => sanitize_text_field( wp_unslash( $_POST['last_name'] ?? '' ) ),
'role' => 'subscriber',
)
);
if ( is_wp_error( $user_id ) ) {
wp_safe_redirect( add_query_arg( 'signup', 'error', $back ) );
exit;
}
$synced = rw_platform_create_account( $user_id, $password );
update_user_meta( $user_id, 'rw_platform_sync', $synced ? 'ok' : 'failed' );
wp_safe_redirect( home_url( $synced ? '/welcome/' : '/welcome/?setup=pending' ) );
exit;
}
La contraseña pasa solo por wp_unslash(). Pasarla por sanitize_text_field() cambiaría sin avisar las contraseñas que tengan caracteres como < o espacios de más. Los cambios de contraseña necesitan el mismo trato más adelante: manéjalos en tu propio formulario de perfil y usa el hook password_reset para los restablecimientos que pasan por el núcleo de WordPress.
Cifra y firma el payload
TLS ya cifra la conexión, y lo sigues necesitando. Cifrar también el cuerpo ayuda porque TLS termina en el primer proxy, balanceador de carga o API gateway, y cualquiera de ellos podría registrar los cuerpos de las peticiones. Un payload cifrado sigue siendo ilegible en esos logs. La firma HMAC demuestra que la petición viene de tu sitio WordPress y que es reciente.
AES-256-GCM encaja bien aquí porque .NET lo soporta de forma nativa con AesGcm. sodium_crypto_secretbox() de libsodium es igual de sólido, pero del lado de C# necesitarías un binding de libsodium. Genera cada clave una sola vez con php -r "echo base64_encode(random_bytes(32));" y mantén separadas la clave de cifrado y la de firma:
// wp-config.php: RW_PLATFORM_ENC_KEY y RW_PLATFORM_HMAC_KEY (base64, 32 bytes cada una), RW_PLATFORM_API_URL.
function rw_platform_build_request( array $payload ): array {
$key = base64_decode( RW_PLATFORM_ENC_KEY, true );
if ( false === $key || 32 !== strlen( $key ) ) {
throw new RuntimeException( 'RW_PLATFORM_ENC_KEY must be 32 bytes, base64-encoded.' );
}
$iv = random_bytes( 12 ); // Un nonce nuevo de 96 bits para cada mensaje.
$tag = '';
$ciphertext = openssl_encrypt( wp_json_encode( $payload ), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
if ( false === $ciphertext ) {
throw new RuntimeException( 'Encryption failed.' );
}
$body = wp_json_encode(
array(
'v' => 1, // Versión de la clave, para poder rotarla después.
'iv' => base64_encode( $iv ),
'tag' => base64_encode( $tag ),
'data' => base64_encode( $ciphertext ),
)
);
$timestamp = (string) time();
$request_id = wp_generate_uuid4();
$signature = hash_hmac( 'sha256', $timestamp . '.' . $request_id . '.' . $body, base64_decode( RW_PLATFORM_HMAC_KEY, true ) );
return array(
'timeout' => 5,
'headers' => array(
'Content-Type' => 'application/json',
'X-Timestamp' => $timestamp,
'X-Request-Id' => $request_id,
'X-Signature' => $signature,
),
'body' => $body,
);
}
GCM ya detecta cualquier alteración del texto cifrado. El HMAC aparte también cubre el timestamp y el ID de petición, que es lo que le permite a la plataforma rechazar peticiones repetidas, y además le permite descartar una petición inválida antes de tocar la clave de descifrado. Consulta en el manual de PHP todos los parámetros de openssl_encrypt().
Envíalo con reintentos y avisa a alguien cuando falle
Cada intento arma una petición nueva, con un IV, un timestamp y un ID de petición nuevos, así un reintento nunca se confunde con una petición repetida:
function rw_platform_create_account( int $user_id, string $password ): bool {
$user = get_userdata( $user_id );
$payload = array(
'wpUserId' => $user_id,
'email' => $user->user_email,
'firstName' => $user->first_name,
'lastName' => $user->last_name,
'password' => $password,
);
$error = '';
for ( $attempt = 1; $attempt <= 3; $attempt++ ) {
try {
$args = rw_platform_build_request( $payload );
} catch ( RuntimeException $e ) {
$error = $e->getMessage();
break;
}
$response = wp_remote_post( RW_PLATFORM_API_URL, $args );
$code = (int) wp_remote_retrieve_response_code( $response );
if ( ( $code >= 200 && $code < 300 ) || 409 === $code ) {
return true; // 409: un intento anterior ya creó esta cuenta.
}
$error = is_wp_error( $response ) ? $response->get_error_code() : 'HTTP ' . $code;
if ( $code >= 400 && $code < 500 && 429 !== $code ) {
break; // Un error del cliente no se arregla reintentando.
}
if ( $attempt < 3 ) {
usleep( 300000 * $attempt );
}
}
error_log( sprintf( 'Platform sync failed for user %d: %s', $user_id, $error ) );
wp_mail(
get_option( 'admin_email' ),
'Platform account sync failed',
sprintf( "User %d could not be created on the platform (%s).\nNo password was stored. Ask the user to reset their password once the API is back.", $user_id, $error )
);
return false;
}
En el peor caso son tres timeouts de cinco segundos más pausas cortas, así que el visitante espera menos de 20 segundos antes de ver la página de configuración pendiente. Resiste la tentación de poner la contraseña en una cola y reintentar después desde WP-Cron: eso significa guardar una contraseña recuperable. Cuando la sincronización falle, marca la cuenta y deja que el usuario defina una contraseña nueva cuando la plataforma esté disponible. El log y el correo llevan el ID del usuario y el código de error, nunca el payload.
Para que 409 signifique «ya existe», el endpoint de la plataforma tiene que ser idempotente respecto a wpUserId. Si no, un reintento después de una respuesta perdida podría crear una cuenta duplicada.
Verifica y descifra en ASP.NET Core
Del lado de la plataforma, revisa la ventana del timestamp, el ID de petición y la firma antes de descifrar nada. Este endpoint de minimal API funciona con .NET 8 o posterior:
// Program.cs, después de builder.Services.AddMemoryCache() y var app = builder.Build();
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
app.MapPost("/v1/accounts", async (HttpRequest request, IConfiguration config,
IMemoryCache seenRequests, IAccountService accounts) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var timestamp = request.Headers["X-Timestamp"].ToString();
var requestId = request.Headers["X-Request-Id"].ToString();
var signature = request.Headers["X-Signature"].ToString();
if (!long.TryParse(timestamp, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300
|| requestId.Length == 0 || seenRequests.TryGetValue(requestId, out _))
return Results.Unauthorized();
var hmacKey = Convert.FromBase64String(config["Signup:HmacKey"]!);
var expected = Convert.ToHexString(HMACSHA256.HashData(hmacKey,
Encoding.UTF8.GetBytes($"{timestamp}.{requestId}.{body}"))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature)))
return Results.Unauthorized();
seenRequests.Set(requestId, true, TimeSpan.FromMinutes(10));
var envelope = JsonSerializer.Deserialize<Envelope>(body, jsonOptions);
if (envelope is not { V: 1 }) return Results.BadRequest();
var cipher = Convert.FromBase64String(envelope.Data);
var plain = new byte[cipher.Length];
try
{
using var aes = new AesGcm(Convert.FromBase64String(config["Signup:EncKey"]!), 16);
aes.Decrypt(Convert.FromBase64String(envelope.Iv), cipher, Convert.FromBase64String(envelope.Tag), plain);
var signup = JsonSerializer.Deserialize<SignupRequest>(plain, jsonOptions)!;
// CreateAsync hashea la contraseña con el hasher de la plataforma; false significa que el usuario ya existe.
return await accounts.CreateAsync(signup) ? Results.StatusCode(201) : Results.Conflict();
}
catch (CryptographicException)
{
return Results.BadRequest();
}
finally
{
CryptographicOperations.ZeroMemory(plain);
}
});
record Envelope(int V, string Iv, string Tag, string Data);
record SignupRequest(int WpUserId, string Email, string FirstName, string LastName, string Password);
El archivo necesita directivas using para System.Security.Cryptography, System.Text, System.Text.Json y Microsoft.Extensions.Caching.Memory, y app.Run() tiene que ir antes de las dos declaraciones record. Guarda ambas claves en la configuración desde un almacén de secretos, no en appsettings.json. IMemoryCache solo protege un servidor; con varias instancias, guarda los ID de petición ya vistos en un almacén compartido como Redis, con una operación atómica de «guardar si no existe».
Por qué sincronizar contraseñas tiene un costo
Este puente es sólido, pero igual copia una credencial entre dos sistemas, y eso tiene costos:
- Dos lugares guardan un hash de la contraseña, así que hay dos bases de datos que proteger y dos políticas de hashing que mantener al día.
- El texto plano pasa por WordPress, donde cualquier plugin enganchado a la petición podría verlo, incluidos los plugins de formularios que guardan los envíos.
- Cada cambio y restablecimiento de contraseña tiene que llegar a ambos sistemas. Si una sincronización falla, el usuario puede entrar en un lado y no en el otro.
Hay dos alternativas que evitan esos costos:
- Inicio de sesión único con OpenID Connect. La plataforma, o un proveedor de identidad dedicado, es dueña del login. WordPress se vuelve un cliente que redirige hacia ella y recibe un ID token firmado. Las contraseñas viven en un solo lugar y la autenticación multifactor se configura una vez. En .NET, librerías como OpenIddict o Duende IdentityServer pueden convertir la plataforma en un proveedor OIDC.
- Aprovisionamiento con tokens. WordPress le pide a la plataforma que cree la cuenta sin contraseña, con la misma petición firmada, y la plataforma le envía al usuario un enlace de un solo uso para definir su contraseña allí. WordPress nunca toca la credencial.
Sincronizar sigue siendo razonable cuando la plataforma todavía no puede actuar como proveedor de identidad, los usuarios necesitan entrar directamente en ambos sistemas y el presupuesto o el plazo descartan SSO por ahora. En ese caso, toma los controles de este artículo como el mínimo.
Qué vigilar
- Excluye la página de registro de la caché de página, o el nonce del formulario terminará venciendo para todos.
- Apaga todo lo que registre cuerpos de peticiones: plugins de formularios que guardan entradas, plugins de depuración HTTP y logs de proxy del lado de la plataforma.
- Rota las claves subiendo
ven el JSON que envuelve el payload y aceptando ambas versiones durante un periodo corto. - Nunca reutilices un IV con la misma clave. Los IV aleatorios de 12 bytes son seguros con volúmenes de registro normales.
- Mantén sincronizados los relojes de los servidores con NTP. La ventana de cinco minutos depende de eso.
- Limita la frecuencia de envíos del formulario de registro y agrega protección contra bots, porque cada registro dispara una llamada a la API de la plataforma.
Este es el tipo de trabajo que hago para clientes: Plugins e integraciones