387 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			387 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?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 ) {
 | 
						|
            $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)));
 | 
						|
    }
 | 
						|
 | 
						|
}
 |