domingo, 7 de septiembre de 2008

Internacionalización en PHP usando gettext

En algunas oportunidades se hace necesario que nuestro sitio web tenga versiones en múltiples idiomas.

El concepto de internacionalización o i18n en sitios web consiste no sólo en mostrar los contenidos en el idioma del usuario, sino que también implica ajustarse a sus configuraciones regionales tal como: formatos de fechas, formatos de números y reglas de ordenamiento alfabético. En la actualidad la mayoría de las base de datos y lenguajes de programación modernos tienen soporte para i18n.

En este artículo me enfocaré en el problema de mostrar el contenido de un sitio web en múltiples idiomas, para ello les presentaré mi solución favorita basada en gettext, que es la biblioteca GNU para internacionalización. Los ejemplos que aquí daré están basados en PHP, pero son totalmente aplicables a otros lenguajes de programación como por ejemplo C.


Codificación
Como primera medida en el proceso de internacionalización, nuestros sistemas debieran ser desarrollados utilizando codificación UTF-8 en vez de la tradicional ISO-8859-1 también conocida como LATIN1.

La codificación UTF-8 prácticamente soporta la mayoría de los simbolos (y caracteres) de escritura a nivel mundial. La codificación LATIN1 es mucho más reducida en la cantidad de simbolos (o caracteres) de escritura, pero es suficiente en caso que queramos mantener nuestro sitio en español e inglés.

En caso que optemos por desarrollar un sistema con codificación LATIN1 o si tenemos que dar soporte multi-idioma a sistemas heredados que ya fueron desarrollados con LATIN1, se deberán realizar algunos pasos adicionales que iré comentando en los ejemplos.


Módulo gettext
Cuando generamos contenidos hacia el web, usualmente extraemos la información de dos fuentes: base de datos y textos estáticos.

La traducción del contenido de la base de datos se debe realizar caso a caso de acuerdo a las características de cada sistema y no es cuerto en este artículo.

La traducción de los textos estáticos se realizará usando gettext que usualmente ya está habilitada en sistemas LINUX. En Windows basta con editar el archivo de configuración de PHP y descomentar (o escribir) la siguiente linea:
extension=php_gettext.dll

Adicionalmente, para generar las traducciones es necesario tener instalado gettext, el cual normalmente ya viene instalado en LINUX y puede ser descargado para Windows en la página del proyecto GNU para Windows.



Manos a la obra
Usualmente en nuestro código tenemos instrucciones como las siguientes:
echo "Hola mundo";
print "Hola mundo";
printf("Hola %s", $nombre);

Todas esas instrucciones deberán ser modificadas para ser procesadas por la función gettext() o mejor aún por un alias de esa función llamado simplemente _(), con lo que tendremos los siguientes reemplazos:
echo _("Hola mundo");
print _("Hola mundo");
printf(_("Hola %s"), $nombre);

Esta función no realiza ningún cambio en los textos. Deberemos aplicar estos cambios a todo el contenido de nuestro sistema lo que va a implicar cambiar el estilo de programación, como por ejemplo en los siguientes ejemplos:
echo "Bienvenido $nombre";

echo "
<h1>Página principal</h1>
<p>Bienvenido $nombre</p>
";

hacemos uso de una de las características más amigables de PHP que consiste en incrustar variables directamente en un string. Para trabajar con gettext, deberemos reescribir este trozo de código. Como recomendación, les suguiero utilizar las funciones printf() y sprintf():
printf(_("Bienvenido %s"), $nombre);

printf("<h1>%s</h1><p>%s $nombre</p>",
_("Página principal"), _("Bienvenido"));



Generar traducciones
Una vez que se hayan intervenido todos los textos del sistema, se deberá ejecutar un programa que escanea todo el código fuente buscando las intervenciones que realizamos, esto crea un archivo de traducciones donde se consolidan todos los textos encontrados, dicho archivo de texto deberá ser traducido (existen programas que ayudan a hacerlo) y luego se generará un archivo binario que contiene todas las traducciones.

El primer paso es escanear todos los archivos involcados, por ejemplo para buscar todos los archivos con extensión .php y .inc se ejecuta el siguiente comando:
find . -name \*.php -o -name \*.inc > messages.txt

A continuación realizamos el proceso de escaneo de textos a traducir mediante el comando xgettext:
xgettext --language=PHP --from-code=ISO-8859-1 -o messages.po -f messages.txt

Por defecto, este comando trabaja en UTF-8, por lo que si nuestro código está en LATIN1 (ISO-8859-1) debemos utilizar el parámetro --from-code=ISO-8859-1.

La salida de este comando nos generará un archivo de texto que deberá ser traducido, por ejemplo al inglés. La edición puede ser realizada manualmente o por medio de algún programa como por ejemplo Poedit.

Una vez que tengamos nuestro archivo de traducción, deberemos convertirlo (compilarlo) a formato .mo mediante el comando msgfmt:
msgfmt messages.po -o messages.mo

Y con esto ya hemos generado nuestro archivo de traducción. Eventualmente podemos generar muchos archivos de idiomas. Ahora sólo nos falta decirle a PHP que reconozca estos archivos de idioma.


Habilitar traducciones
Debemos hacerle saber al sistem dónde encontrar nuestro archivo .mo. Los sistemas operativos tienen directorios predefinidos para estos fines, sin embargo, para evitar problemas de permisos y privilegios, prefiero configurar el sistema para indicarle manualmente dónde buscarlos.

Como en este ejemplo hemos traducido los textos al idioma inglés, pondremos el archivo de traducción en la siguiente ubicación:
/tmp/locale/en_US/LC_MESSAGES/messages.mo

Para habilitar la traducción de los textos utilizaremos el siguiente código donde la variable $locale define el idioma que será utilizado que en este caso será en_US:
$locale = 'en_US';
if (!defined('LC_MESSAGES')) define('LC_MESSAGES', 6);
setlocale(LC_MESSAGES, $locale);
bindtextdomain('facturanet', '/tmp/locale');
textdomain('messages');

En caso de que $locale indique un idioma no definido, el sistema mostrará los textos en su idioma original. Por supuesto que deberás cambiar el directorio /tmp/locale por uno más apropiado.

Los archivos binarios .mo son guardados en cache, por lo que luego de actualizar el archivo se recomienda reiniciar el servidor web.


Selección de idioma
Debido a que la variable $locale es la que determina el idioma que se presentará al usuario, les recomiendo que sea una variable de sesión de manera que el sistema recuerde el idioma del usuario entre cada página. Se debería crear una página PHP en que el usuario pueda cambiar de idioma.

Para mejorar la experiencia del usuario, es posible autodetectar el idioma del usuario consultando la variable del servidor $_SERVER['HTTP_ACCEPT_LANGUAGE'].



Otras características
Hay otras características que no alcanzaré a revisar ahora y que permiten un mejor trabajo con la boblioteca gettext:

  • En caso de que modifiquemos nuestro sistema y tengamos que realizar nuevas traducciones, no es necesario repetir todo el proceso, gettext provee comandos para incorporar nuevas palabras a un archivo ya existente mediante msgmerge.
  • Además de la función _() existe otras funciones gettext para realizar traducciones más complejas, como por ejemplo diferenciación de singuales y plurales.

jueves, 19 de junio de 2008

El performance de Google App Engine

Hace unos días estuve probando la nueva plataforma Google App Engine la cual permite hostear en los servidores de Google aplicaciones WEB desarrolladas en Python.

El modelo de negocio de Google es similar al ofrecido por Amazon, en el cual se cobra de acuerdo al tráfico, almacenamiento de datos y tiempo de CPU efectivamente utilizados. Sin embargo, Google va más allá (o provee un servicio más acotado, según el punto de vista) al proveer de una plataforma de desarrollo basada en Python.

Además de la estabilidad y disponibidad de las aplicaciones desarrolladas sobre Google App Engine, uno de las características importantes es la escalabilidad de su plataforma.

Construí una aplicación básica que hace un par de consultas a la base de datos y que genera un documento HTML de aproximadamente 1KB de información. Luego, con la herramienta Apache Benchmark (ab) realicé pruebas de carga utilizando un enlace a Internet dedicado. La prueba consistió en cargar dicha página de manera concurrente durante 5 segundos. En cada prueba modifiqué el nivel de concurrencia desde 1 hasta 100 requests en paralelo.

En promedio el tiempo de carga de la página completa demoraba 500 milisegundos, lo cual es bastante tiempo para una página liviana y que no hace más de 10 consultas SQL. Sin embargo, las pruebas de concurrencia me dejaron impresionado ya que el nivel de servicio prácticamente no se vió afectado aunque haya aumentado a 100 conexiones en paralelo, tal como lo muestro en el siguiente gráfico.


Sin concurrencia se alcanzó un nivel de 2.33 requests por segundo, al utilizar una concurrencia de 10 conexiones simultaneas se logró un nivel de 23.98 requests por segundo, finalmente, al alcanzar un nivel de 100 conexiones simultaneas se logró un nivel del 192.38 requests por segundo, es decir el servicio proveyó de un escalamiento lineal prácticamente de 1:1. Hasta ahora, disponer de enlace y hardware capaces de brindar dicho nivel de escalabilidad era extremadamente caro.

Sin duda, este nuevo producto de Google será un gran producto para los desarrolladores de software, brindando un gran nivel de escalabilidad a un costo bastante bajo.

miércoles, 11 de junio de 2008

Cool-php-captcha

En vista que había dedicado varias horas a implementar un sistema de captcha, aproveché de invertir un par de horas más y crear un proyecto que llamé cool-php-captcha publicado en Google Code bajo licencia GPLv3.

Los invito a visitar la página del proyecto http://code.google.com/p/cool-php-captcha y darme sus opiniones.

lunes, 2 de junio de 2008

Crear un CAPTCHA como el de Google

Esta es la segunda parte de mi artículo sobre la creación de CAPTCHAS. En el artículo anterior vimos el procedimiento para crear un CAPTCHA básico al cual le agregamos algunas mejoras para aumentar su complejidad y así disminuir su vulnerabilidad.

En esta segunda parte vamos a realizar algunas mejoras en la generación de imágenes, incorporaremos nuevas tipografías (la mayoría son OpenSource o Freeware) y optimizaremos el código.

Con estos cambios obtendremos un CAPTCHA de muy buena calidad sin recurrir al uso de "artefactos" o imágenes de fondo que conviertan el CAPTCHA en algo horrible, como los siguientes que son utilizados por algunos de los grandes sitios de internet: Delicious, Facebook, Fotolog, Live Journal y Microsoft Live:


Más bien, el resultado final que vamos a obtener va a ser un CAPTCHA muy parecido a los generados por Google:
Si bien este tipo de imagen no es de las más seguros, es bastante mejor que la mayoría de los captchas que están disponibles en la red.



Texto a utilizar en el CAPTCHA
En el ejemplo del artículo anterior utilizabamos un generador de textos aleatorios que intercalaba vocales y consonantes con el fin de generar palabras medianamente pronunciables.

En esta versión final inicialmente desarrollé una mejora de dicha función ya que la original ponía las vocales siempre en las mismas posiciones lo cual hacía más fácil la decodificación del captcha.

Esta nueva función genera textos aleatorios alternando consonantes y vocales al azar:

function getCaptchaText() {
$length = rand(5,7);
$consonants = "abcdefghijlmnopqrstvwyz";
$vocals = "aeiou";
$text = "";
$vocal = rand(0,1);
for ($i=0; $i<$length; $i++) {
if ($vocal) {
$text .= substr($vocals, mt_rand(0, 4), 1);
} else {
$text .= substr($consonants, mt_rand(0, 22), 1);
}
$vocal = !$vocal;
}
return $text;
}


Palabras de diccionario
Sin embargo, al realizar algunas pruebas me dí cuenta que al utilizar palabras de diccionario disminuye considerablemente el tiempo de ingreso y la tasa de error de usuarios. Además, el hecho de utilizar palabras "conocidas por el usuario" permite generar caracteres con mayor deformación.

Para la generación de palabras de diccionario, descargué un archivo de palabras en español (también puede ser en inglés u otro idioma según el público objetivo) desde http://www.word-list.com. Solamente mantuve las palabras entre 5 y 7 caracteres de longitud lo cual me dejó con solo 20.645 palabras diferentes.

No debemos olvidar quitar del diccionario palabras que podrían ser ofensivas para nuestros usuarios como por ejemplo: "zorra", "tonto", "mierda", "violador", "suicida" o "asesino".

En principio, para escoger la palabra de diccionario a utilizar, se debiera escoger un número al azar y luego recorrer el archivo de palabras línea por línea mediante fgets() hasta llegar a la línea buscada. Dicho procesamiento es altamente ineficiente por lo que hice algunos ajustes para hacer uso de fseek(): Cada una de las lineas del archivo de palabras las rellené con espacios en blanco hasta totalizar 7 caracteres más el caracter de salto de linea (\n), con lo cual quedan 8 bytes por cada palabra. Por lo tanto, si quisiera obtener la palabra de la posición 388, se debería hacer fseek(387*8) para luego leer la linea fgets().

function getDictionaryCaptchaText() {
$fp = fopen("words-es.txt", "r");
$linea = rand(0, (filesize("words-es.txt")/8)-1);
fseek($fp, 8*$linea);
$text = trim(fgets($fp));
fclose($fp);
return $text;
}


La desventaja de utilizar textos de diccionario es que el universo de palabras a utilizar disminuye de millones (entre 2 y 40 millones al utilizar hasta 7 caracteres) a miles (20 mil al utilizar palabras entre 5 y 7 caracteres).

Como una opción para solucionar este problema introduje una modificación que cambia al azar algunas de las vocales de una palabra, por ejemplo la palabra "caviar" puede cambiar a "cuvier", "covoar", "cavour", etc.

function getDictionaryCaptchaText($extended = true) {
$fp = fopen("words-es.txt", "r");
$linea = rand(0, (filesize("words-es.txt")/8)-1);
fseek($fp, 8*$linea);
$text = trim(fgets($fp));
fclose($fp);


// Cambio vocales al azar
if ($extended) {
$text = str_split($text, 1);
$vocals = array('a','e','i','o','u');
foreach ($text as $i => $char) {
if (mt_rand(0,1) && in_array($char, $vocals)) {
$text[$i] = $vocals[mt_rand(0,4)];
}
}
$text = implode('', $text);
}
return $text;
}

Como opción personal voy a utilizar simplemente las palabras de diccionario. Además, como mejora podría modificarse el script para que utilice como diccionario palabras en el idioma del usuario. Esto se puede realizar determinando el país del usuario de acuerdo a su dirección IP o mediante el parámetro HTTP HTTP_ACCEPT_LANGUAGE el cual indica el idioma de preferencia del usuario.




Generación de la imagen
En la generación de la imagen se van a aplicar las siguientes transformaciones:
  1. Disminuir el espaciado entre caracteres, incluso traslapando algunos caracteres.
  2. Variación de tamaños de caracteres.
  3. Diversidad de tipografías (pero utilizando una sola tipografía por cada imágen).
  4. Alternación de color de textos.
  5. Ondulación del texto en los ejes X e Y.

1. Disminuir el espaciado entre caracteres
Las tipografías están construidas para mantener una sepación específica entre cada caracter para aumentar la legibilidad. Sin embargo, esto es precisamente lo que no se debe hacer al generar un captcha. Al juntar los caracteres y permitir su traslape se dificulta el proceso de separación caractar a caracter, esto es muy importante porque la extracción de cada uno de los caracteres es uno de los pasos escenciales en el proceso de decodificación de captchas.

En las siguientes imagenes vemos cómo el texto de la derecha tiene sus caracteres muy juntos.
Para lograr disminuir el espaciado entre cada caracter tuve que imprimir en la imagen caracter por caracter con el siguiente código:

// Texto a imprimir
$text = getDictionaryCaptchaText($extended);
// Definiciones de latipografía a utilizar
// - font: archivo TTF
// - condensation: Cantidad de pixeles que se quitará entre cada caracter
$fontcfg = $fonts[mt_rand(0, sizeof($fonts)-1)];
$fontsize = 32;
$x = 20;
for ($i=0; $i<=6; $i++) {
$coords = imagettftext($im, $fontsize, 0,
$x, 47, $fg_color, 'fonts/'.$fontcfg['font'], substr($text, $i, 1));
$x += ($coords[2]-$x)-$fontcfg['condensation'];
}





2. Variación de tamaños de caracteres
La primera alteración a realizar es el cambio del tamaño de cada caracter como se muestra a continuación:

// Texto a imprimir
$text = getDictionaryCaptchaText($extended);

// Definiciones de latipografía a utilizar
// - font: archivo TTF
// - minSize: tamaño de fuente mínimo a utilizar
// - maxSize: tamaño de fuente máximo a utilizar
// - condensation: Cantidad de pixeles que se quitará entre cada caracter
$fontcfg = $fonts[mt_rand(0, sizeof($fonts)-1)];

$x = 20;
for ($i=0; $i<=6; $i++) {
$coords = imagettftext($im, rand($fontcfg['minSize'],$fontcfg['maxSize']), 0,
$x, 47, $fg_color, 'fonts/'.$fontcfg['font'], substr($text, $i, 1));
$x += ($coords[2]-$x)-$fontcfg['condensation'];
}


3. Diversidad de tipografías
Escogí un conjunto de tipografías que alterno entre cada generación. En principio quise alternar la tipografía al generar cada caracter, pero el resultado no era muy vistoso, por lo tanto ahora utilizo la misma tipografía para todos los caracteres:

No debemos olvidar que la mayoría de las tipografías que están instaladas en nuestros computadores están protegidas por copyright, por lo que buscando en internet descargué varias tipografías OpenSource o Freeware desde el sitio http://www.urbanfonts.com. Con un poco de paciencia se pueden encontrar tipografías que permiten generar captchas más originales:



4. Alternación de color de textos
Principalmente por un tema estético introduje alternación entre 3 colores posibles para el captcha. Algunos podrían tentarse de poner cada caracter en diferente color, pero esto sería un error porque facilitaría al decodificador separar cada caracter.


Para definir el color, defino un arreglo con los colores RGB y luego obtengo el color escogido al azar:

$colors = array(
array(27,78,181), // azul
array(22,163,35), // verde
array(214,36,7), // rojo
);

$color = $colors[mt_rand(0, sizeof($colors)-1)];
$fg_color = imagecolorallocate($im, $color[0], $color[1], $color[2]);



5. Ondulación del texto en los ejes X e Y Finalmente aplicamos una transformación al texto para en base a una función senoidal. Esto lo hacemos en el eje X e Y. Como una imágen vale más que mil palabras, en el siguiente ejemplo muestro el proceso de transformación: se comienza con una imagen plana (en color rojo) a la cual le aplico un filtro "wave" en el eje X (color verde) y en el eje Y (color azul). Una vez que apliquemos este filtro en ambos ejes obtenemos como resultado la imagen de la derecha (en color verde):

La generación de ondulación se realiza mediante la función imagecopy() que va desplazando linea por linea la imagen de acuerdo a una función senoidal. Esto se realiza en el eje X y luego en el eje Y. Se define una variable aleaoria $k para definir la "fase" o ángulo inicial de la ondulación. La forma de la onda senoidal se define por la amplitud (valor mas alto y bajo de la onda) y el período que define la "frecuencia":

// Genero ondas verticales (eje X)
$period = $scale*$periodoX;
$amplitude = $scale*$amplitudX;
$k = rand(0,100);
for ($i = 0;$i < ($width*$scale);$i++) {
imagecopy($im,$im,
$i-1, sin($k+$i/$period) * $amplitude,
$i,0,
1,$height*$scale);
}

// Genero ondas horizontales (eje Y)
$period = $scale*$periodoY;
$amplitude = $scale*$amplitudY;
$k = rand(0,100);
for ($i = 0;$i < ($height*$scale);$i++) {
imagecopy($im,$im,
sin($k+$i/$period) * $amplitude, $i-1,
0,$i,
$width*$scale,1);
}








Juntando las piezas

Una vez que se junten todos estos pasos y parametrizando algunas cosas obtenemos el siguiente resultado:


Como se pueden dar cuenta -por pura coincidencia- se generaron varias palabras potencialmente ofensivas que idealmente no debieran presentarse al usuario.


El programa final es el siguiente:
(recuerda descargar los archivos de tipografía desde algún sitio como por ejemplo http://www.urbanfonts.com)


<?php
/**
* Script para la generación de CAPTCHAS
*
* Autor: José Rodríguez jose.rodriguez at exec.cl
* http://joserodriguez.cl
* http://www.exec.cl
*
* En caso que hagas uso de este código o una variación,
* te pido que me lo hagas saber.
*
* Esta obra está licenciada bajo Creative Commons
* Reconocimiento - No comercial - Compartir bajo la misma licencia 3.0 Unported License.
* http://creativecommons.org/licenses/by-nc-sa/3.0/
*
*/



// Alto y ancho de la imagen
$width = 200;
$height = 66;

// Nombre de la variable de sesion
$session_var = "captcha";

// Colores
$colors = array(
array(27,78,181), // azul
array(22,163,35), // verde
array(214,36,7), // rojo
);

/**
* Configuración de tipografías
* - font: archivo TTF
* - condensation: cantidad de pixeles que se juntará cada caracter
* - minSize: tamaño minimo del texto
* - maxSize: tamaño máximo del texto
*/
$fonts = array(
array('font' => 'Danoisemedium.ttf','condensation' => 2, 'minSize' => 28, 'maxSize' => 40),
array('font' => 'HEINEKEN.TTF', 'condensation' => 2.5, 'minSize' => 24, 'maxSize' => 40),
array('font' => 'VeraSeBd.ttf', 'condensation' => 3.5, 'minSize' => 20, 'maxSize' => 33),
array('font' => 'VeraSe.ttf', 'condensation' => 4, 'minSize' => 26, 'maxSize' => 40),
array('font' => 'CrazyHarold.ttf', 'condensation' => 2, 'minSize' => 20, 'maxSize' => 28),
array('font' => 'Duality.ttf', 'condensation' => 2, 'minSize' => 28, 'maxSize' => 48),
/*
// Otras tipografías
array('font' => 'BeyondWonderland.ttf', 'condensation' => 3, 'minSize' => 28, 'maxSize' => 39),
array('font' => 'BennyBlanco.ttf', 'condensation' => 1, 'minSize' => 24, 'maxSize' => 30),
array('font' => 'freak.ttf', 'condensation' => 2, 'minSize' => 32, 'maxSize' => 54),
*/
);

// Configuración de ondulacion del texto
// Periodo y amplitud en ejes X e Y
$periodoY = 15;
$amplitudY = 16;
$periodoX = 12;
$amplitudX = 4;

/**
* Factor de resolución con que se trabajará internamente
* Se prefiere manipular la imagen al doble de su tamaño
* para evitar pérdida de calidad al aplicar filtro wave.
* Valores posibles: 1, 2 o 3.
*/
$scale = 2;

// Utilizar palabras inexistentes?
$extended = false;


// Permite habilitar depurado
$debug = false;
$ini = microtime(true);












session_start();



// Creo la imagen
$im = imagecreatetruecolor($width*$scale, $height*$scale);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$color = $colors[mt_rand(0, sizeof($colors)-1)];
$fg_color = imagecolorallocate($im, $color[0], $color[1], $color[2]);
imagefilledrectangle($im, 0, 0, $width*$scale, $height*$scale, $bg_color);




// Genero el texto, caracter por caracter
$text = getDictionaryCaptchaText($extended);
$fontcfg = $fonts[mt_rand(0, sizeof($fonts)-1)];
$x = 20*$scale;
for ($i=0; $i<=6; $i++) {
$coords = imagettftext($im, rand($fontcfg['minSize'],$fontcfg['maxSize'])*$scale, 0,
$x, 47*$scale, $fg_color, 'fonts/'.$fontcfg['font'], substr($text, $i, 1));
$x += ($coords[2]-$x)-$fontcfg['condensation']*$scale;
}




// Genero ondas verticales (eje X)
$period = $scale*$periodoX;
$amplitude = $scale*$amplitudX;
$k = rand(0,100);
for ($i = 0;$i < ($width*$scale);$i++) {
imagecopy($im,$im,
$i-1, sin($k+$i/$period) * $amplitude,
$i,0,
1,$height*$scale);
}

// Genero ondas horizontales (eje Y)
$period = $scale*$periodoY;
$amplitude = $scale*$amplitudY;
$k = rand(0,100);
for ($i = 0;$i < ($height*$scale);$i++) {
imagecopy($im,$im,
sin($k+$i/$period) * $amplitude, $i-1,
0,$i,
$width*$scale,1);
}




// Reduzco el tamaño de la imagen
$imResampled = imagecreatetruecolor($width, $height);
imagecopyresampled ($imResampled,$im,0,0,0,0,$width, $height,$width*$scale,$height*$scale);
imagedestroy($im);


// Guardo el texto en sesión
$_SESSION[$session_var] = $text;


if ($debug) {
imagestring($imResampled, 1, 1, $height-8, "$text k:$k ".$fontcfg['font'].' '.round((microtime(true)-$ini)*1000), $fg_color);
}


header("Content-type: image/jpeg");
imagejpeg($imResampled, null, 80);

// Limpieza
imagedestroy($imResampled);





/**
* Retorna un texto de diccionario aleatorio
*/
function getDictionaryCaptchaText($extended = false) {
$fp = fopen("words-es.txt", "r");
$linea = rand(0, (filesize("words-es.txt")/8)-1);
fseek($fp, 8*$linea);
$text = trim(fgets($fp));
fclose($fp);


// Cambio vocales al azar
if ($extended) {
$text = str_split($text, 1);
$vocals = array('a','e','i','o','u');
foreach ($text as $i => $char) {
if (mt_rand(0,1) && in_array($char, $vocals)) {
$text[$i] = $vocals[mt_rand(0,4)];
}
}
$text = implode('', $text);
}

return $text;
}







/**
* Retorna un texto aleatorio
*/
function getCaptchaText() {
$length = rand(5,7);
$consonants = "abcdefghijlmnopqrstvwyz";
$vocals = "aeiou";
$text = "";
$vocal = rand(0,1);
for ($i=0; $i<$length; $i++) {
if ($vocal) {
$text .= substr($vocals, mt_rand(0, 4), 1);
} else {
$text .= substr($consonants, mt_rand(0, 22), 1);
}
$vocal = !$vocal;
}
return $text;
}



?>

miércoles, 28 de mayo de 2008

Creación de un CAPTCHA (o test de turing)

A esta altura, la mayoría de nosotros nos hemos encontrado en situaciones que cuando ingresamos información en un formulario para registrarnos en algún servicio -como por ejemplo crear una casilla de correo o descargar un archivo de servicios como rapidshare- el sitio nos pide que escribamos el texto de una imagen que vemos en pantalla.


Estos mecanismos de validación permiten -en teoría- distinguir entre humanos y computadores, y así prevenir abusos como por ejemplo la publicación de spams en los comentarios de blogs y la creación masiva de casillas de correo para spamming.

En los últimos años han proliferado diversos mecanismos de CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart), como por ejemplo resolver un problema matemático, indicar el animal que ves es una fotografía o -el más popular- identificar el texto que ves en una imágen.

Una de las desventajas de los CAPTCHAs es que tienen serias deficiencias de accesibilidad. Además existen algunos casos extremadamente molestos para el usuario como el siguiente ejemplo utilizado en Rapidshare:

Debido a que la mayoria de los desarrollos de software en que he participado son sistemas cerrados o que no son un potencial blanco de ataques de spamming, no me he visto en la necesidad de implementar mecanismos de CAPTCHA, sin embargo, haciendo algunos experimentos he llegado a implementar un sistema sencillo en PHP que les describo a continuación.


Implementación de CAPTCHA en PHP
En este ejercicio voy a generar un CAPTCHA que le presente al usuario una imagen con letras que el usuario deberá escribir en un formulario. Debido al uso de imágenes, me apoyaré en la biblioteca GD, por lo que para estos ejemplos PHP debe tener soporte para GD.

Mi objetivo es generar un captcha que nos brinde un nivel de seguridad mínima y en ningún caso desarrollar un sistema infalible. Detrás de la generación (y decodificación) de los captchas existen teorías de procesamiento de imágenes y reconocimiento de caracteres que no son parte de este artículo.

El modelo general consiste en construir un script PHP que retorne una imagen con un texto aleatorio. El texto original será almacenado en una variable de sesión. Al procesar el formulario se deberá comparar el texto ingresado por el usuario con el texto almacenado en la variable de sesión.


Versión 1
Mi primera versión consiste en una implementación básica sólo para desarrollar la funcionalidad mínima del sistema:
<?php

// Alto y ancho de la imagen
$width = 250;
$height = 60;
// Tipografia
$font = "arial.ttf";
// Nombre de la variable de sesion
$session_var = "captcha";




// Genero la imagen
$im = imagecreatetruecolor($width, $height);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$fg_color = imagecolorallocate($im, 0, 0, 0);
imagefilledrectangle($im, 0, 0, $width, $height, $bg_color);

// Genero el texto
$text = "secret";
imagettftext($im, 20, 0, 11, 21, $fg_color, $font, $text);

// Guardo el texto en sesión
session_start();
$_SESSION[$session_var] = $text;


// Genero la imagen
header("Content-type: image/png");
imagepng($im);

// Limpieza
imagedestroy($im);

?>

Este script genera una imagen de 250x60 pixeles, con color de fondo blango y texto arial negro. El texto utilizado en la imagen se guarda en la variable de sesión captcha. El resultado de este script es la siguiente imagen:

En el script que procesa el formulario, basta comparar el valor de $_SESSION['captcha'] con el texto ingresado en el formulario para confirmar que el captcha haya sido ingresado satisfactoriamente. Para reducir la tasa de error, es posible quitar los eventuales espacios en blanco y convertir el texto a minúculas para hacer una comparación case-insensitive.

Ahora que contamos con el código base podemos continuar mejorando nuestro script.


Versión 2
Las mejoras que introduciremos en esta versión son las siguientes:
  1. Generación de un texto aleatorio.
  2. Generar texto con distintas tipografías.
  3. Agregar transformaciones simples al texto.
  4. Aumentar el tamaño y darle un color más amigable.

La generación de un texto aleatorio puede ser algo tan sencillo como extraer los primeros 6 caracteres de un valor MD5 como el siguiente: substr(md5(uniqid(),0,6) el cual genera un string con números y letras entre la "a" y la "f".

Sin embargo construiremos algo más elaborado donde podamos controlar los caracteres a generar e intentaremos que el texto sea medianamente pronunciable con el fin de ser más amigable con el usuario. Una manera simple de generar texto pronunciable es asegurándonos de tener suficientes vocales y quitando algunas letras poco usadas en el español como la "x" y la "k". Definí arbitrariamente la longitud del texto a 6 caracteres lo cual minimiza la generación de palabras mal vistas como "pene", "puta" o "malo" y da una cantidad de combinaciones de letras apropiada.

Para ello elaboré una función que retorna textos aleatorios intercalando una consonante y una vocal:
<?php

function getCaptchaText($length = 6) {
$consonants = "bcdfghjlmnpqrstvwyz";
$vocals = "aeiou";

$text = "";
$imax = $length/2;
for ($i=0; $i<$imax; $i++) {
$text .= substr($consonants, mt_rand(0, 18), 1);
$text .= substr($vocals, mt_rand(0, 4), 1);
}
return substr($text, 0, $length);
}

?>
Por lo tanto, en el script original reemplazamos:
$text = "secret";
por:
$text = getCaptchaText(6);
Con este cambio ahora generamos captchas como los siguientes:



Para hacer el captcha más complejo vamos a generar el texto con distintas tipografías, agregarle rotación independiente a cada letra y juntar (o condensar) las letras para que se produzca un traslape que hará más dificil su decodificación.

Extraje algunos archivos .ttf desde mi directorio C:\WINDOWS\Fonts y luego reemplacé:
$font = "arial.ttf";
por:
$fonts = array('timesbi.ttf', 'calibriz.ttf', 'cambriaz.ttf');
$font = $fonts[mt_rand(0, sizeof($fonts)-1)];
Aumentamos el tamaño, le agregamos color y ahora obtenemos las siguientes imagenes donde podemos apreciar el cambio de tipografía en cada ejecución:

Hasta este punto hemos generado un captcha básico que es extremadamente fácil de decodificar.

Para hacer la imágen mas compleja vamos a generar letra por letra. Cada letra tendrá una rotación distinta y acercaremos las letras para lograr un traslape.

Modificamos el código:
imagettftext($im, 20, 0, 11, 21, $fg_color, $font, $text);
por:
$x = 3;
for ($i=0; $i<=6; $i++) {
$angulo = rand(-12, 12);
$coords = imagettftext($im, 38, $angulo, $x, 47, $fg_color, $font, substr($text, $i, 1));
$x += ($coords[2]-$x) - 4;
}

Este código va generando letra por letra y en cada iteración va aumentando la coordenada X restandole algunos pixeles para lograr que el texto se traslape. La rotación de cada letra tendrá un valor comprendido entre el intervalo [-12,12].

Se deberá buscar el valor exacto de traslape con el fin de hacer difícil su decodificación, pero permitiendo que el texto sea legible. Un refinamiento de la generación de estos textos podría ser que cada letra se genere con una tipografía distinta.

Al aplicar estos cambios obtenemos las siguientes imagenes:


Una vez que juntemos todos estos cambios obtendremos el siguiente código:

<php


// Alto y ancho de la imagen
$width = 250;
$height = 65;
// Tipografia
$fonts = array('timesbi.ttf', 'calibriz.ttf', 'cambriaz.ttf');
// Nombre de la variable de sesion
$session_var = "captcha";
// Condensacion entre cada letra
$condensacion = 4;


// Genero la imagen
$im = imagecreatetruecolor($width, $height);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$fg_color = imagecolorallocate($im, 33, 67, 165);
imagefilledrectangle($im, 0, 0, $width, $height, $bg_color);

// Genero el texto
$font = $fonts[mt_rand(0, sizeof($fonts)-1)];
$text = getCaptchaText(6);
$x = 3;
for ($i=0; $i<=6; $i++) {
$angulo = rand(-12, 12);
$coords = imagettftext($im, 38, $angulo, $x, 47, $fg_color, $font, substr($text, $i, 1));
$x += ($coords[2]-$x)-$condensacion;
}

// Guardo el texto en sesión
session_start();
$_SESSION[$session_var] = $text;

// Genero la imagen
header("Content-type: image/png");
imagepng($im);

// Limpieza
imagedestroy($im);

/**
* Retorna un texto aleatorio
*
* @param int $length Longitud del texto
* @return string Texto aleatorio
*/
function getCaptchaText($length = 6) {
$consonants = "bcdfghjlmnpqrstvwyz";
$vocals = "aeiou";
$text = "";
$imax = $length/2;
for ($i=0; $i<$imax; $i++) {
$text .= substr($consonants, mt_rand(0, 18), 1);
$text .= substr($vocals, mt_rand(0, 4), 1);
}
return substr($text, 0, $length);
}

?>



Versión 3
Para dificultar aún más la decodificación le aplicaremos a la imagen un filtro "wave" que genere ondulaciones al texto.

Adicionalmente para aumentar el "ruido" de la imagen la generaré como JPEG en vez de PNG.

Existen otras técnicas bastante eficientes para la mayoría de los casos que consiste en introducir imágenes de fondo y ruido a la imágen como por ejemplo líneas aleatorias o pintar pixeles al alzar. Sin embargo dichas técnicas disminuyen en gran medida la estética del captcha por lo que las descartaré para estos ejemplos.

Para la generación de ondas en la imágen, existen excelentes biblitecas gráficas que permiten hacer esto como por ejemplo ImageMagick. Sin embargo, para continuar con la simplicidad del script utilizaremos una combinación de funciones de GD y funciones trigonométricas. La función de a continuación está basada en un ejemplo publicado en el manual de PHP:

/**
* Filtro wave
*
* Obtenido desde ejemplo publicado en el manual de PHP:
* http://www.php.net/manual/en/function.imagecopy.php#72393
*
*/
function wave_region($img, $x, $y, $width, $height,$amplitude = 4.5,$period = 30) {
// Make a copy of the image twice the size
$mult = 2;
$img2 = imagecreatetruecolor($width * $mult, $height * $mult);
imagecopyresampled ($img2,$img,0,0,$x,$y,$width * $mult,$height * $mult,$width, $height);

// Wave it
$k = rand(-60,60);
for ($i = 0;$i < ($width * $mult);$i += 2) {
imagecopy($img2,$img2,
$x + $i - 2,$y + sin($k+$i / $period) * $amplitude,
$x + $i,$y,
2,($height * $mult));
}

// Resample it down again
imagecopyresampled ($img,$img2,$x,$y,0,0,$width, $height,$width * $mult,$height * $mult);
imagedestroy($img2);
}


Esta función la llamaremos inmediatamente después de generar el texto con los siguientes argumentos:
// Aplico filtros
wave_region($im, 0, 0, $width, $height, mt_rand(7, 22), mt_rand(25,40));
Una vez que apliquemos este cambio se generarán las siguientes imágenes de referencia:


Al juntar estos últimos cambios y ajustar el tamaño definitivo de la imágen obtendremos el siguiente código:

<php



// Alto y ancho de la imagen
$width = 180;
$height = 65;
// Tipografia
$fonts = array('timesbi.ttf', 'calibriz.ttf', 'cambriaz.ttf');
// Nombre de la variable de sesion
$session_var = "captcha";
// Condensacion entre cada letra
$condensacion = 4;



// Genero la imagen
$im = imagecreatetruecolor($width, $height);
$bg_color = imagecolorallocate($im, 255, 255, 255);
$fg_color = imagecolorallocate($im, 33, 67, 165);
imagefilledrectangle($im, 0, 0, $width, $height, $bg_color);

// Genero el texto
$font = $fonts[mt_rand(0, sizeof($fonts)-1)];
$text = getCaptchaText(6);

//$text = "captcha"; $font="cambriaz.ttf"; $angulo = 0;

$x = 3;
for ($i=0; $i<=6; $i++) {
$angulo = rand(-12, 12);
$coords = imagettftext($im, 38, $angulo, $x, 47, $fg_color, $font, substr($text, $i, 1));
$x += ($coords[2]-$x)-$condensacion;
}

// Aplico filtros
wave_region($im, 0, 0, $width, $height, mt_rand(7, 22), mt_rand(25,40));

// Guardo el texto en sesión
session_start();
$_SESSION[$session_var] = $text;

// Genero la imagen
header("Content-type: image/jpeg");
imagejpeg($im);

// Limpieza
imagedestroy($im);

/**
* Retorna un texto aleatorio
*
* @param int $length Longitud del texto
* @return string Texto aleatorio
*/
function getCaptchaText($length = 6) {
$consonants = "bcdfghjlmnpqrstvwyz";
$vocals = "aeiou";
$text = "";
$imax = $length/2;
for ($i=0; $i<$imax; $i++) {
$text .= substr($consonants, mt_rand(0, 18), 1);
$text .= substr($vocals, mt_rand(0, 4), 1);
}
return substr($text, 0, $length);
}

/**
* Filtro wave
*
* Obtenido desde ejemplo publicado en el manual de PHP:
* http://www.php.net/manual/en/function.imagecopy.php#72393
*
*/
function wave_region($img, $x, $y, $width, $height,$amplitude = 4.5,$period = 30) {
// Make a copy of the image twice the size
$mult = 2;
$img2 = imagecreatetruecolor($width * $mult, $height * $mult);
imagecopyresampled ($img2,$img,0,0,$x,$y,$width * $mult,$height * $mult,$width, $height);

// Wave it
$k = rand(-60,60);
for ($i = 0;$i < ($width * $mult);$i += 2) {
imagecopy($img2,$img2,
$x + $i - 2,$y + sin($k+$i / $period) * $amplitude,
$x + $i,$y,
2,($height * $mult));
}

// Resample it down again
imagecopyresampled ($img,$img2,$x,$y,0,0,$width, $height,$width * $mult,$height * $mult);
imagedestroy($img2);
}

?>

La evolución del captcha construido fue la siguiente:

Como mejoras a este captcha se podría realizar una seleccón mas exuastiva de las tipografías utilizadas, se le podría añadir una imagen de fondo aleatoria, lineas aleatorias o ruido.

En el siguiente artículo vamos a optimizar el código y aumentar la complejidad del captcha sin perder su estética (es decir no le incorporaré lineas aleatorias, imágenes de fondo, etc.) de una manera similar a los captchas utilizados por Google.

Actualizado el día domingo 1 de junio de 2008.

miércoles, 21 de mayo de 2008

Protección básica contra Cross-site scripting (XSS)

Los ataques Cross-site scripting (XSS) o de HTML injection pueden ser prevenidos de manera bastante simple tomando algunas medidas de seguridad básicas que debemos tener siempre presentes.

Este tipo de ataque consiste en incrustar código HTML en un sitio web con el objetivo de poder ejecutar código Javascript arbitraro o incluir código HTML con el fin de -por ejemplo- realizar ataques de phishing.

La preveción de este tipo de ataque se realiza filtrando la infomación que se ingresa y publica en nuestro sitio, por lo que tenemos dos puntos de control:
  • Filtrar datos ingresados
  • Escapar datos publicados en la WEB

Filtrar datos ingresados
La media de prevención más sencilla es impedir que los usuarios del sitio puedan publicar información, como por ejemplo cuando se hacen comentarios en un blog o en formularos de contacto.

En caso que el sitio deba permitir el ingreso de información, se debe definir si se aceptará código HTML. En caso que se opte por no aceptar código HTML será necesario filtrar todo el contenido recibido desde formularios para quitar eventuales tags HTML que ingrese el usuario. Esto se puede realizar con la siguiente función:
$comentario = strip_tags($comentario);
La función strip_tags() no realiza validación de que el código HTML esté bien formado, por lo que potencialmente se podría perder información -mal- ingresada por el usuario. Se puede realizar una validación más apropiada por medio de tidy.

En caso que sea necesario permitir el ingreso de código HTML, se deberán restringir los tags HTML que serán permitidos. Usualmente se permiten los tags de texto básico como <p>, <br>, <i>, <b>, <strong>, <em>, <blockquote> y tags de listas como <ul>, <ol> y <li>. Especialmente se debe evitar la inclusión de tags HTML que permitan la inclusión de contenidos externos como por ejemplo <script>, <frame>, <iframe>, <object> y <a>.

El filtrado de los tags HTML permitidos se puede realizar con el siguiente comando:
$comentario = strip_tags($comentario, "<p><br><strong><em>");
Sin embargo, nuevamente la función strip_tags() puede presentar problemas porque los tags admitidos podrían contener atributos peligrosos como onload y onmouseover. Por lo que se debería recurrir a una combinación de tidy y expresiones regulares.

Las tareas de filtrado se pueden enfrentar de dos maneras: Manteniendo una lista negra de tags y atributos no permitidos o manteniendo una lista blanca con los tags y atributos permitidos. En vista de evitar problemas de seguridad, es más recomendable mantener una lista blanca que contenga los elementos admitidos ya que es mucho menos grave restringir el ingreso de un dato inofensivo (falso positivo) que permitir el ingreso de un dato potencialmente peligroso.


Escapar datos ingresados
Como segunda medida de contención se puede agregar validación antes de publicar información en la WEB.

Se debe considerar que la información que ingresa a nuestras bases de datos (y archivos de caché) es introducida por diversos medios, como por ejemplo WebServices, migraciones de datos o SQL Injection, por lo tanto aunque se mantenga absoluta seguridad en nuestros formularios WEB, siempre existe la posibilidad de contar con datos peligrosos. Debido a esto, seimpre es recomendable filtrar la información que se publica en nuestro sitio.

En primer lugar debemos distinguir si la información que publicamos puede contener HTML o no.

En general la gran mayoría de la información que se publica en la web -como por ejemplo nombres, fechas, títulos- no contiene código HTML. En estos casos la información a imprimir en una página web debe ser escapada para reemplazar los caracteres especiales por sus entidades respectivas, esto se realiza con el siguiente comando:
echo htmlspecialchars($nombre);
Esta función tiene la ventaja que imprime correctamente textos como "Barnes & Noble" y "A < B < C" que en caso de no ser escapados no se visualizarían correctamente.

En el caso de publicar contenidos con HTML, se debe aplicar la misma función de filtrado que se utiliza al momento de ingresar datos.

Con estas simples medidas se aumenta en gran medida el nivel de seguridad ante ataques XSS.

lunes, 5 de mayo de 2008

Javascript dentro de HTML

Para incrustar código Javascript dentro de HTML se debe utilizar la siguiente etiqueta HTML:
<script type="text/javascript">
// Aquí va el código...
</script>
Sin embargo, en la actualidad es mejor no utilizar Javascript incrustado sino que más bien utilizar archivos Javascript externos. Esto permite separar el contenido de la programación y además permite una navegación más rápida ya que se pueden guardar en cache los archivos .js externos. El código para referenciar archivos Javascript es el siguiente:
<script type="text/javascript" src="filename.js"></script>
El sentido común nos dice que las referencias a archivos Javascript debieran realizarse en el encabezado del documeno HTML, es decir, dentro de los tags HEAD.

Es mejor hacer lo contrario: por un tema de performance se recomienda -en la medida de lo posible- poner dichas referencias al final del documento HTML ya que esto acelera el proceso de rendering del documento HTML.

Graceful degradation y Unobtrusive Javascript
Cuando se construyen sitios que utilicen Javascript es importante tener en mente mantener la semántica del sitio web y ser conscientes que no todos nuestros visitantes tendrán Javascript habilitado, por ejemplo bots de los motores de búsqeda, usuarios que han deshabilitado javascript por motivos de seguridad, usuarios de algunos dispositivos móviles, microformatos, herramientas semánticas como Yahoo pipes, clientes en modo de sólo texto o -el problema más común- usuarios que visitan nuestro sitio con un navegador que es pacialmente incompatible con nuestro sitio ya sea porque es un navegador antiguo o una versión más moderna. Todos estos usuarios debieran poder navegar por nuestro sitio sin mayores problemas.

El concepto de Unobtrusive Javascript consiste en seguir las buenas prácticas en la programación de Javascript y poder separar el comportamiento (código javascript) del contenido del sitio, es decir que el código Javascript sea lo menos invasivo posible.

El concepto de Graceful degradation consiste en que un sitio web tenga la capacidad de continuar operando con clientes incompatibles proveyendo un nivel de servicios reducido en vez de que este no pueda operar.

Por ejemplo, a continuación se muestran algunos links que hacen uso de javascript de manera incorrecta:
<a href="javascript:link('products')">Productos</a>

<a href="javascript:submitForm('form1')">Enviar formulario</a>

<a href="products.html" onclick="check()">Productos</a>
El primer ejemplo utiliza un llamado a la función link(). Este código no funcionará en navegadores sin soporte a Javascript y hace que sea muy dificil que un robot pueda navegar por el sitio para indexarlo, además atenta contra la usabilidad como por ejemplo hacer clic con el botón central del mouse para abrir el link en una nueva pestaña no funcionará. Este es el caso de Falabella donde no puedo abrir las fichas de productos en distintas pestañas para poder compararlos.

El segundo ejemplo realiza el envío de un formulario mediante código Javascript cuando lo semánticamente correcto habría sido utilizar el elemento HTML <input type="submit"> y en caso de requerir validación del formulario agregar el evento onsubmit al formulario.

El tercer ejemplo ejecuta una función de validación al momento de hacer clic en el link mediante el atributo onclick.

El código correcto para estos 3 ejemplos sería:
<a href="/show/products">Productos</a>

<input type="submmit" id="submitForm" value="Enviar formulario"/>
<a href="products.html" id="linkProducts">Productos</a>
En el primer ejemplo simplemente se reemplazó el código javascript por un link. Se pueden utilizar técnicas como mod_rewrite de Apache para mantener links amigables. En el segundo y tercer caso se utiliza HTML estándar y se referencian los elementos mediante el atributo id mediante el cual podrán ser manipulados por javascript.

Finalmente, agregamos los event handler mediante el siguiente código Javascript:
document.getElementById('linkProducts').onclick = check;
document.getElementById('form1').onsubmit = checkForm;
(NOTA: estos son ejemplos Javascript simplificados que contienen errores de malas práctias como por ejemplo definir directamente los eventos en vez de utilizar una función para agregar eventos en cascada)