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;
}



?>

7 comentarios:

Amblenias dijo...

¿Tienes alguna direccion para descargar el ejemplo con fuentes e imagenes? gracias.

Anónimo dijo...

Man podrías poner un enlace con el ejemplo para descargar...?

Te agradezco mucho y gracias de antemano.

Andaba buscando algo así, solo que no se como implementarlo en mi formulario.

José Rodríguez dijo...

Tengo un proyecto en Google Code en el que puedes ver ejemplos y descargar una aplicación ya desarrollada:

http://code.google.com/p/cool-php-captcha

Anónimo dijo...

va genial, pero tengo una duda problema, como puedo hacer para validar la entrada en otra pagina, es decir q introduzca el codigo, q mire si es correcto, y si lo es q aparezca un formulario con unos datos a rellenar

Anónimo dijo...

Estimado Jose,

al menos aqui hasta el momentos has tenido 4 comentarios.... yo puse uno en tu artículo anterior. Excelente, el mismo nivel que el primer artículo y con la misma simplicidad de un buen docente. Gracias por tu doble aporte ... ambos me resultaron muy aleccionadores.

Anónimo dijo...

Excelente tu captcha, lo he utilizado y funciona a la perfeccion, un problema he tenido al cargarlo en el servidor (hosting) no funciona, son las librerias, que librerias debe tener activado el hosting para que funcione, aparte de GD.

gracias

Anónimo dijo...

Muchas gracias por los dos artículos, excelentes y muy didácticos.
Ahora voy a ponerlos en práctica.
Un saludo desde España