<?php /** * Clase base para la gestión de formularios. * * Gestión de token CSRF está basada en: https://www.owasp.org/index.php/PHP_CSRF_Guard */ abstract class Form { /** * @var string Sufijo para el nombre del parámetro de la sesión del usuario donde se almacena el token CSRF. */ const CSRF_PARAM = 'csrf'; /** * @var string Identificador utilizado para construir el atributo "id" de la etiqueta <form> como <code>$tipoFormulario.$formId</code>. */ private $formId; /** * @var string Valor del parámetro enctype del formulario. */ private $enctype; /** * @var string Valor del atributo "class" de la etiqueta <form> asociada al formulario. Si este parámetro incluye la cadena "nocsrf" no se generá el token CSRF para este formulario. */ private $classAtt; /** * @var string Parámetro de la petición utilizado para comprobar que el usuario ha enviado el formulario.. */ private $tipoFormulario; /** * @var string URL asociada al atributo "action" de la etiqueta <form> del fomrulario y que procesará el * envío del formulario. */ private $action; private $printed; /** * @var bool Almacena si la interacción con el formulario va a realizarse a través de AJAX <code>true</code> o * <code>false</code> en otro caso. */ private $ajax; /** * Crea un nuevo formulario. * * Posibles opciones: * <table> * <thead> * <tr> * <th>Opción</th> * <th>Valor por defecto</th> * <th>Descripción</th> * </tr> * </thead> * <tbody> * <tr> * <td>action</td> * <td><code>$_SERVER['PHP_SELF']</code></td> * <td>URL asociada al atributo "action" de la etiqueta <form> del fomrulario y que procesará el envío del formulario.</td> * </tr> * <tr> * <td>class</td> * <td>""</td> * <td>Valor del atributo "class" de la etiqueta <form> asociada al formulario. Si este parámetro incluye la cadena * "nocsrf" no se generá el token CSRF para este formulario.</td> * </tr> * <tr> * <td>enctype</td> * <td>""</td> * <td>Valor del parámetro enctype del formulario.</td> * </tr> * <tr> * <td>ajax</td> * <td><code>false</code></td> * <td>Configura si el formulario se gestionará a través de AJAX.</td> * </tr> * </tbody> * </table> * @param string $tipoFormulario Parámetro de la petición utilizado para comprobar que el usuario ha enviado el formulario. * @param string $formId (opcional) Identificador utilizado para construir el atributo "id" de la etiqueta <form> como <code>$tipoFormulario.$formId</code>. * * @param array $opciones (ver más arriba). */ public function __construct($tipoFormulario, $opciones = array(), $formId = 1) { $this->tipoFormulario = $tipoFormulario; $this->formId = $tipoFormulario.$formId; $opcionesPorDefecto = array( 'ajax' => false, 'action' => null, 'class' => null, 'enctype' => null ); $opciones = array_merge($opcionesPorDefecto, $opciones); $this->ajax = $opciones['ajax']; $this->action = $opciones['action']; $this->classAtt = $opciones['class']; $this->enctype = $opciones['enctype']; if ( !$this->action ) { // Cambiar por << $this->action = htmlentities($_SERVER['REQUEST_URI']); >> para mantener los parámetros de la URL. $this->action = htmlentities($_SERVER['PHP_SELF']); } } /** * Se encarga de orquestar todo el proceso de gestión de un formulario. * * El proceso es el siguiente: * <ul> * <li>O bien se quiere mostrar el formulario (petición GET)</li> * <li>O bien hay que procesar el formulario (petición POST) y hay dos situaciones: * <ul> * <li>El formulario se ha procesado correctamente y se devuelve un <code>string</code> en {@see Form::procesaFormulario()} * que será la URL a la que se rederigirá al usuario. Se redirige al usuario y se termina la ejecución del script.</li> * <li>El formulario NO se ha procesado correctamente (errores en los datos, datos incorrectos, etc.) y se devuelve * un <code>array</code> con entradas (campo, mensaje) con errores específicos para un campo o (entero, mensaje) si el mensaje * es un mensaje que afecta globalmente al formulario. Se vuelve a generar el formulario pasándole el array de errores.</li> * </ul> * </li> * </ul> */ public function gestiona() { if ( ! $this->formularioEnviado($_POST) ) { return $this->generaFormulario(); } else { // Valida el token CSRF si es necesario (hay un token en la sesión asociada al formulario) $tokenRecibido = $_POST['CSRFToken'] ?? FALSE; $errores = $this->csrfguard_ValidateToken($this->tipoFormulario, $tokenRecibido); // limpia los tokens CSRF que no han sido utilizados en esta petición self::limpiaCsrfTokens(); // Sin AJAX. /** * $result = $this->procesaFormulario($_POST); * if ( is_array($result) ) { * return $this->generaFormulario($_POST, $result); * } else { * header('Location: '.$result); * exit(); * } */ // Con AJAX. if ( $errores !== TRUE ) { if ( ! $this->ajax ) { return $this->generaFormulario($_POST, $errores); } else { return $this->generaHtmlErrores($errores); } } else { $result = $this->procesaFormulario($_POST); if ( is_array($result) ) { // Error al procesar el formulario, volvemos a mostrarlo if ( ! $this->ajax ) { return $this->generaFormulario($_POST, $result); } else { return $this->generaHtmlErrores($result); } } else { if ( ! $this->ajax ) { header('Location: '.$result); exit(); } else { return $result; } } } } } /** * Genera el HTML necesario para presentar los campos del formulario. * * Si el formulario ya ha sido enviado y hay errores en {@see Form::procesaFormulario()} se llama a este método * nuevamente con los datos que ha introducido el usuario en <code>$datosIniciales</code> y los errores al procesar * el formulario en <code>$errores</code> * * @param string[] $datosIniciales Datos iniciales para los campos del formulario (normalmente <code>$_POST</code>). * * @param string[] $errores (opcional)Lista / Tabla asociativa de errores asociados al formulario. * * @return string HTML asociado a los campos del formulario. */ protected function generaCamposFormulario($datosIniciales, $errores = array()) { return ''; } /** * Procesa los datos del formulario. * * @param string[] $datos Datos enviado por el usuario (normalmente <code>$_POST</code>). * * @return string|string[] Devuelve el resultado del procesamiento del formulario, normalmente una URL a la que * se desea que se redirija al usuario, o un array con los errores que ha habido durante el procesamiento del formulario. */ protected function procesaFormulario($datos) { return array(); } /** * Función que verifica si el usuario ha enviado el formulario. * * Comprueba si existe el parámetro <code>$formId</code> en <code>$params</code>. * * @param string[] $params Array que contiene los datos recibidos en el envío formulario. * * @return boolean Devuelve <code>true</code> si <code>$formId</code> existe como clave en <code>$params</code> */ private function formularioEnviado(&$params) { return isset($params['action']) && $params['action'] == $this->tipoFormulario; } /** * Función que genera el HTML necesario para el formulario. * * @param string[] $datos (opcional) Array con los valores por defecto de los campos del formulario. * * @param string[] $errores (opcional) Array con los mensajes de error de validación y/o procesamiento del formulario. * * @return string HTML asociado al formulario. */ private function generaFormulario(&$datos = array(), &$errores = array()) { $htmlCamposFormularios = $this->generaCamposFormulario($datos, $errores); $classAtt=''; if ( $this->classAtt ) { $classAtt = " class=\"{$this->classAtt}\""; } $enctypeAtt=''; if ( $this->enctype ) { $enctypeAtt = " enctype=\"{$this->enctype}\""; } // Se genera el token CSRF si el usuario no solicita explícitamente lo contrario. $tokenCSRF = ''; if ( ! $this->classAtt || strpos($this->classAtt, 'nocsrf') === false ) { $tokenValue = $this->csrfguard_GenerateToken($this->tipoFormulario); $tokenCSRF = "<input type='hidden' name='CSRFToken' value='$tokenValue' />"; } /* <<< Permite definir cadena en múltiples líneas. * Revisa https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc */ $htmlForm = "<form method='POST' action='{$this->action}' id='{$this->formId}'{$classAtt}{$enctypeAtt} > <input type='hidden' name='action' value='{$this->tipoFormulario}' /> ".$tokenCSRF.$htmlCamposFormularios." </form>"; return $htmlForm; } /** * Genera la lista de mensajes de errores globales (no asociada a un campo) a incluir en el formulario. * * @param string[] $errores (opcional) Array con los mensajes de error de validación y/o procesamiento del formulario. * * @param string $classAtt (opcional) Valor del atributo class de la lista de errores. * * @return string El HTML asociado a los mensajes de error. */ protected static function generaListaErroresGlobales($errores = array(), $classAtt='') { $html=''; $clavesErroresGenerales = array_filter(array_keys($errores), function ($elem) { return is_numeric($elem); }); $numErrores = count($clavesErroresGenerales); if ($numErrores > 0) { $html = "<ul class=\"$classAtt\">"; if ( $numErrores == 1 ) { $html .= "<li>$errores[0]</li>"; } else { foreach($clavesErroresGenerales as $clave) { $html .= "<li>$errores[$clave]</li>"; } $html .= "</li>"; } $html .= '</ul>'; } return $html; } /** * Crea una etiqueta para mostrar un mensaje de error. Sólo creará el mensaje de error * si existe una clave <code>$idError</code> dentro del array <code>$errores</code>. * * @param string[] $errores (opcional) Array con los mensajes de error de validación y/o procesamiento del formulario. * @param string $idError (opcional) Clave dentro de <code>$errores</code> del error a mostrar. * @param string $htmlElement (opcional) Etiqueta HTML a crear para mostrar el error. * @param array $atts (opcional) Tabla asociativa con los atributos a añadir a la etiqueta que mostrará el error. */ protected static function createMensajeError($errores=array(), $idError='', $htmlElement='span', $atts = array()) { $html = ''; if (isset($errores[$idError])) { $att = ''; foreach($atts as $key => $value) { $att .= "$key=$value"; } $html = " <$htmlElement $att>{$errores[$idError]}</$htmlElement>"; } return $html; } /** * Método para eliminar los tokens CSRF almecenados en la petición anterior que no hayan sido utilizados en la actual. */ public static function limpiaCsrfTokens() { foreach(array_keys($_SESSION) as $key) { if (strpos($key, self::CSRF_PARAM) === 0) { unset($_SESSION[$key]); } } } private function csrfguard_GenerateToken($formParameter) { if ( ! session_id() ) { throw new \Exception('La sesión del usuario no está definida.'); } $paramSession = self::CSRF_PARAM.'_'.$formParameter; if (isset($_SESSION[$paramSession])) { $token = $_SESSION[$paramSession]; } else { if ( function_exists('hash_algos') && in_array('sha512', hash_algos()) ) { $token = hash('sha512', mt_rand(0, mt_getrandmax())); } else { $token=' '; for ($i=0;$i<128;++$i) { $r=mt_rand(0,35); if ($r<26){ $c=chr(ord('a')+$r); } else{ $c=chr(ord('0')+$r-26); } $token.=$c; } } $_SESSION[$paramSession]=$token; } return $token; } private function csrfguard_ValidateToken($formParameter, $tokenRecibido) { if ( ! session_id() ) { throw new \Exception('La sesión del usuario no está definida.'); } $result = TRUE; $paramSession = self::CSRF_PARAM.'_'.$formParameter; if ( isset($_SESSION[$paramSession]) ) { if ( $_SESSION[$paramSession] !== $tokenRecibido ) { $result = array(); $result[] = 'Has enviado el formulario dos veces'; } $_SESSION[$paramSession] = ' '; unset($_SESSION[$paramSession]); } else { $result = array(); $result[] = 'Formulario no válido'; } return $result; } //Test some form input. protected function test_input($input){ return htmlspecialchars(trim(strip_tags($input))); } }