388 lines
14 KiB
PHP
388 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 ) {
|
||
|
// 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)));
|
||
|
}
|
||
|
|
||
|
}
|