Cómo crear un sistema de registro de cuenta seguro para MU Online
Construye un sistema de registro de cuenta en PHP para tu servidor de MU Online blindado contra SQL injection, contraseñas débiles y bots, usando prepared statements, hashing correcto y validación rigurosa.
El formulario de registro es la puerta de entrada de tu servidor de MU Online y, al mismo tiempo, uno de los blancos preferidos de quien quiere atacarlo. Un registro mal hecho abre camino al SQL injection (que puede destruir o filtrar toda la base de datos), permite contraseñas ridículamente débiles
El formulario de registro es la puerta de entrada de tu servidor de MU Online y, al mismo tiempo, uno de los blancos preferidos de quien quiere atacarlo. Un registro mal hecho abre camino al SQL injection (que puede destruir o filtrar toda la base de datos), permite contraseñas ridículamente débiles, deja que bots creen miles de cuentas fantasma y, en el peor de los casos, expone las credenciales de los jugadores. Este tutorial, de nivel avanzado, muestra cómo construir un sistema de registro en PHP que resiste esos ataques usando los pilares correctos: prepared statements, hashing de contraseña, validación rigurosa en el servidor y defensa contra la automatización. El foco está en el concepto y la seguridad real, no en un copiar-y-pegar ciego.
Antes que nada, entiende el modelo de amenaza. El registro recibe datos que vienen de fuera, es decir, de gente que puede ser malintencionada. La regla número uno de la seguridad es: toda entrada es hostil hasta que se demuestre lo contrario. Cada campo del formulario debe tratarse como un potencial ataque. A partir de esa mentalidad, cada decisión técnica de este tutorial cobra sentido.
Requisitos previos
- Servidor de MU y base de datos funcionando. Si aún estás empezando de cero, comienza por la guía de cómo crear un servidor de MU Online.
- PHP 7.4+ u 8.x con PDO y el driver de tu SGBD (normalmente SQL Server vía
sqlsrv/PDO). - HTTPS configurado en el dominio (Let's Encrypt lo resuelve gratis).
- Conocimiento del esquema de cuenta de tu emulador: nombre de la tabla y de las columnas de usuario/contraseña/correo.
- Un usuario de base de datos de bajo privilegio para el sitio.
| Amenaza | Vector | Defensa en este tutorial |
|---|---|---|
| SQL injection | Campos del formulario concatenados en la consulta | Prepared statements siempre |
| Robo de contraseña | Contraseña en texto plano en la base o en el tráfico | Hashing + HTTPS |
| Bots/spam | Registro automatizado en masa | CAPTCHA, rate limit, honeypot, correo |
| Fuerza bruta | Adivinación de contraseña | Rate limiting y contraseñas fuertes obligatorias |
| Enumeración de cuentas | Mensajes que revelan si un usuario existe | Respuestas genéricas |
> Aviso: los nombres de tabla y columna (MEMB_INFO, memb___id, memb__pwd, mail_addr) son ejemplos de la línea Season 6. El formato real, incluido el algoritmo de contraseña esperado, varía según el emulador. Confírmalo antes de implementar.
El dilema del hash de contraseña en MU
Aquí está la tensión central de cualquier registro de MU. El ideal absoluto de seguridad es guardar las contraseñas con un algoritmo fuerte y lento como bcrypt/Argon2 (password_hash() en PHP). El problema: el emulador del juego necesita validar esa misma contraseña en el login, y muchos emuladores antiguos solo entienden MD5 o incluso texto plano. No controlas el algoritmo de forma aislada, tiene que coincidir con lo que el servidor de juego espera.
Por lo tanto, dos situaciones:
- Emulador moderno que soporta hash fuerte — usa
password_hash()y vive feliz. - Emulador heredado atado a MD5 — quedas limitado al formato que el juego acepta. En ese caso, mitiga el riesgo con HTTPS obligatorio, rate limiting, monitoreo y jamás reutilices esa base de datos para otra finalidad.
Confirma siempre el formato esperado, porque varía según la versión. Abajo muestro los dos caminos.
Paso 1: Conectar a la base de datos con PDO
Nunca uses la API antigua sin prepared statements. Configura PDO forzando excepciones y desactivando la emulación de prepared:
<?php
// db.php
function getConnection(): PDO {
$dsn = 'sqlsrv:Server=203.0.113.10,1433;Database=MuOnline';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // prepared reales, no emulados
];
return new PDO($dsn, 'web_mu', getenv('DB_PASS'), $options);
}
Fíjate en que la contraseña de la base de datos viene de una variable de entorno (getenv), nunca hardcodeada en el archivo versionado.
Paso 2: Validar toda entrada en el servidor
La validación en JavaScript es conveniencia, no seguridad, porque es trivial de burlar. Toda regla debe reforzarse en PHP:
<?php
function validarRegistro(array $in): array {
$erros = [];
// Usuario: 4-10 caracteres alfanuméricos (límite común de MU)
if (!preg_match('/^[a-zA-Z0-9]{4,10}$/', $in['usuario'] ?? '')) {
$erros[] = 'El usuario debe tener de 4 a 10 caracteres alfanuméricos.';
}
// Correo válido
if (!filter_var($in['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$erros[] = 'Correo inválido.';
}
// Contraseña fuerte: mínimo 8, con letra y número
$senha = $in['senha'] ?? '';
if (strlen($senha) < 8 || !preg_match('/[A-Za-z]/', $senha) || !preg_match('/[0-9]/', $senha)) {
$erros[] = 'La contraseña debe tener al menos 8 caracteres, con letras y números.';
}
// Confirmación de contraseña
if ($senha !== ($in['senha2'] ?? '')) {
$erros[] = 'Las contraseñas no coinciden.';
}
return $erros;
}
El límite de tamaño del usuario existe también porque el campo en la base de datos de MU suele ser corto (varchar(10)), y superarlo trunca o rompe el login en el juego.
Paso 3: Verificar unicidad con prepared statement
Antes de insertar, comprueba si el usuario ya existe, siempre con placeholder, nunca concatenando:
<?php
function usuarioExiste(PDO $pdo, string $usuario): bool {
// NUNCA: "SELECT ... WHERE memb___id = '$usuario'" <- ¡inyección!
$stmt = $pdo->prepare('SELECT 1 FROM MEMB_INFO WHERE memb___id = ? ');
$stmt->execute([$usuario]); // el dato se trata como dato, no como SQL
return (bool) $stmt->fetchColumn();
}
La diferencia entre la línea comentada y la real es la diferencia entre una base de datos segura y una base destruida por un '; DROP TABLE MEMB_INFO;--.
Paso 4: Insertar la cuenta con el hash apropiado
Aquí aplicamos la decisión sobre el hash. Camino ideal, para emulador moderno:
<?php
$hash = password_hash($senha, PASSWORD_DEFAULT); // bcrypt/Argon2, con salt automático
$stmt = $pdo->prepare(
'INSERT INTO MEMB_INFO (memb___id, memb__pwd, mail_addr, bloc_code, ctl1_code)
VALUES (?, ?, ?, 0, 0)'
);
$stmt->execute([$usuario, $hash, $email]);
Camino heredado, para emulador atado a MD5 (menos seguro, úsalo solo si es obligatorio):
<?php
// Solo porque el emulador lo exige. Mitiga con HTTPS + rate limit + monitoreo.
$hashLegado = strtoupper(md5($senha)); // el formato varía según el emulador
$stmt = $pdo->prepare(
'INSERT INTO MEMB_INFO (memb___id, memb__pwd, mail_addr) VALUES (?, ?, ?)'
);
$stmt->execute([$usuario, $hashLegado, $email]);
En ambos casos, la contraseña nunca va a la base de datos en texto plano y la consulta usa placeholders.
Paso 5: Defensa contra bots y automatización
Un formulario abierto atrae el registro en masa. Combina capas, porque ninguna basta por sí sola:
- CAPTCHA (reCAPTCHA/hCaptcha/turnstile) valida que hay un humano.
- Honeypot: un campo invisible que los humanos no rellenan, pero los bots sí. Si viene relleno, recházalo.
- Rate limiting por IP: como máximo N registros por IP por hora.
- Verificación por correo: la cuenta solo se activa tras hacer clic en el enlace enviado.
El honeypot es barato y eficaz:
<?php
// Campo oculto por CSS en el formulario: <input name="website" style="display:none">
if (!empty($_POST['website'])) {
// El humano no ve ese campo; si vino relleno, es un bot.
http_response_code(400);
exit('Solicitud inválida.');
}
Rate limiting simple registrando los intentos:
<?php
function dentroDoLimite(PDO $pdo, string $ip, int $max = 5): bool {
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM registro_log
WHERE ip = ? AND criado_em > DATEADD(hour, -1, GETDATE())'
);
$stmt->execute([$ip]);
return (int) $stmt->fetchColumn() < $max;
}
Paso 6: Proteger contra CSRF
Para impedir que otro sitio fuerce un envío de tu formulario, genera un token por sesión y valídalo en el POST:
<?php
session_start();
// Al mostrar el formulario:
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// En el HTML: <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
// Al procesar el POST:
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Token inválido.');
}
Paso 7: Respuestas genéricas y HTTPS
Evita mensajes que revelen si un usuario o correo ya existe, porque eso permite la enumeración de cuentas. Prefiere "Si los datos son correctos, recibirás un correo". Y fuerza siempre HTTPS, redirigiendo cualquier acceso HTTP, para que la contraseña y los datos nunca viajen en texto plano.
<?php
// Forzar HTTPS
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301);
exit;
}
Paso 8: Registrar todo para auditoría
Registra los intentos de registro (IP, hora, resultado) sin guardar la contraseña. Estos logs permiten detectar ataques, bloquear IPs abusivas e investigar incidentes. Nunca registres la contraseña en el log, ni siquiera con hash.
Errores comunes y soluciones
| Error | Consecuencia | Solución |
|---|---|---|
| Concatenar la entrada en la consulta | SQL injection, base de datos comprometida | Usar prepared statements en todas las consultas |
| Guardar la contraseña en texto plano | Fuga total en caso de intrusión | Hash con password_hash (o el formato del emulador) |
| Validar solo en JavaScript | Bypass trivial, datos sucios en la base | Reforzar toda la validación en el servidor |
| Sin HTTPS | Contraseña capturada en tránsito | Certificado Let's Encrypt y redirección forzada |
| Formulario sin anti-bot | Miles de cuentas fantasma | CAPTCHA + honeypot + rate limit + correo |
| Mensajes de error específicos | Enumeración de cuentas | Respuestas genéricas |
| Contraseña de la base en el código versionado | Credencial expuesta en el repositorio | Usar variable de entorno |
| Sin token CSRF | Registro forzado por terceros | Token por sesión validado con hash_equals |
Lista de verificación de lanzamiento
- Conexión PDO con
ERRMODE_EXCEPTIONyEMULATE_PREPARES=false - Todas las consultas usan prepared statements, sin excepción
- La contraseña nunca se guarda en texto plano
- Formato de hash confirmado con el emulador (varía según la versión)
- Validación completa reforzada en el servidor
- Límite de tamaño del usuario compatible con la columna de la base de datos
- HTTPS obligatorio con redirección de HTTP
- CAPTCHA activo en el formulario
- Honeypot invisible implementado
- Rate limiting por IP funcionando
- Verificación por correo antes de activar la cuenta
- Token CSRF generado y validado
- Mensajes genéricos para evitar la enumeración
- Credenciales de la base de datos en variables de entorno
- Logs de intento sin guardar la contraseña
- Usuario de base de datos de bajo privilegio
Un sistema de registro seguro no es un único truco, sino la suma de varias capas: prepared statements que cierran la puerta a la inyección, hashing que protege las contraseñas incluso en caso de fuga, validación de servidor que rechaza basura y defensas anti-bot que mantienen la base limpia. Ninguna capa es opcional. El jugador te confía su contraseña, muchas veces la misma que usa en otros lugares, y esa confianza es el activo más valioso de tu servidor. Construye el registro pensando como pensaría un atacante, y tendrás una base sólida sobre la cual todo el resto del proyecto puede crecer con tranquilidad.
Preguntas frecuentes
¿Puedo usar MD5 en las contraseñas porque el emulador lo usa?
Muchos emuladores antiguos almacenan la contraseña en MD5 o incluso en texto plano por compatibilidad. Si el tuyo lo exige, quedas atado a ese formato para que el login funcione en el juego. Lo ideal es usar un emulador que soporte hash fuerte; si no se puede, mitiga con HTTPS, rate limiting y monitoreo, y nunca reutilices esa base de datos para nada más allá del juego.
¿Los prepared statements realmente impiden el SQL injection?
Sí, cuando se usan correctamente. Separan el comando SQL de los datos, así que la entrada del usuario nunca se interpreta como código. El error está en concatenar la entrada en la consulta aun teniendo prepared statements disponibles, o en usar prepared solo en parte de las consultas. Úsalos en todas, sin excepción.
¿Cómo impido que los bots creen miles de cuentas?
Combina varias capas: CAPTCHA en el formulario, rate limiting por IP, verificación por correo y honeypot invisible. Ninguna resuelve por sí sola, pero juntas elevan mucho el costo del ataque. Registra también los intentos para detectar patrones de abuso y bloquear IPs reincidentes.
¿Necesito HTTPS incluso en un servidor pequeño?
Sí, siempre. Sin HTTPS, la contraseña y los datos viajan en texto plano y cualquiera en la misma red captura todo. Los certificados gratuitos vía Let's Encrypt lo hacen trivial y sin costo. No existe justificación para un registro de cuenta sin HTTPS en 2024.
¿Dónde deben validarse los datos del registro?
Siempre en el servidor, en PHP. La validación en JavaScript mejora la experiencia del usuario, pero es trivial de burlar, así que sirve solo como conveniencia. Toda regla que protege la base de datos (formato, tamaño, unicidad, caracteres permitidos) debe reforzarse en el backend antes de tocar el SQL.