PHP Login Script... the right way.

No es la primera vez que me encuentro con este tipo de código, incluso lo he escrito varias veces y es por eso que me gustaría hacer, de alguna manera, un aporte de lo que aprendí en este tiempo que llevo como desarrollador web.

Si bien el código que está a continuación es eficaz, es decir que cumple su propósito (funciona), tiene algunos detalles que me gustaría aclarar y que lo haría más eficiente, es decir, hace lo que tiene que hacer (funciona) y lo hace bien.

Quizás esto no importe para aplicaciones de menor tamaño, pero cuando los sistemas tienen éxito y crecen tanto un buen diseño como una buena estructura en la implementación de la base de datos juegan un papel crucial a la hora de hacer la aplicación escalable.

La Manera Incorrecta.

Primero voy a recrear el error en este tipo procesos para luego remarcar las correcciones. Así que a continuación expongo el código que no debería volver a usarse, al menos después de haber entendido lo que expongo en este post.

<?php
$sql = "SELECT id, first_name, last_name FROM users WHERE username = '$username' AND password = '$password'";
$result = mysql_query ($sql);
if ($row = mysql_fetch_array ($result)){
	echo "Bienvenido " . $row["first_name"] . " " . $row["first_name" ]. " a... hmmm... ésto.";
}else{
	echo"Cuenta inválida.";
}
?>

Presupongo que si estás leyendo ésto es porque tenés noción de la utilización tanto de PHP como de MySQL. Por ende, también presupongo que la estructura de la tabla de tu base de datos es similar a la siguiente:

 CREATE TABLE `users` (
`id` MEDIUMINT(8) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(20) NOT NULL,
`password` CHAR(32) NOT NULL,
`first_name` VARCHAR(100) NOT NULL,
`last_name` VARCHAR(100) NOT NULL,
`email` VARCHAR(255) NOT NULL
)
ENGINE = myisam;

Aunque estoy presuponiendo muchas cosas :), voy a detallar el por qué de la estructura de esta tabla ya que mucho de lo que voy a explicar posteriormente está relacionado con la manera en que se guardan los datos en la misma.

En primer lugar lo que estoy haciendo es crear la tabla users con los siguientes campos:

  • id: este campo está definido como Clave Primaria de la tabla users (los datos son indexados y además no se repiten, son únicos). Su valor entra en el rango de los enteros positivos. UNSIGNED implica justamente que los registros en este campo no contendrán signo - UNSIGNED = sin signo ;).
  • username: este campo permite valores tipo string de longitud variable no mayor a 20 caracteres.
  • password: este campo permite valores tipo string con una longitud fija de 32 caracteres que es el tamaño que ocupan los hash MD5.
  • first_name, last_name y email: están también definidos como strings de longitud variable cada una con sus correspondiente longitud 100, 100 y 255 respectivamente.

Siguiendo con los índices ahora es el turno de explicar lo siguiente:

ALTER TABLE `users` ADD UNIQUE(`username`);

Dos usuarios no deberían tener el mismo nombre, es por eso que al campo username se le asigna el tipo de índice UNIQUE. Lo que significa que además de estar indexados los registros en este campo, tales registros no pueden repetirse y en caso de enviar una consulta tipo INSERT con datos repetidos esta no se llevaría a cabo produciendo un error.

ALTER TABLE `users` ADD INDEX(`password`);

Esta parte es justamente la que no está bien. Si mirás la consulta SQL más arriba lo que dicha consulta está pidiendo son los datos de un usuario cuyo nombre de usuario Y contraseña concuerden con los valores de las variables $username y $password. Si se encuentra un registro que concuerde con esos datos entonces se envia un mensaje de bienvenida al usuario, sino, le aclaramos que los datos ingresados no produjeron ningún match.

La Manera Correcta.

El código anterior es eficaz, o sea, hace lo que tiene que hacer, pero no es eficiente porque justamente no lo hace de la manera correcta.

Tomando como punto de partida la consulta SQL voy a ir descendiendo en todos los errores que existen a partir de ésta.

Se están buscando los datos de algún usuario cuyo cuyo nombre de usuario Y contraseña coincidan con los que están almacenados en las variables correspondientes.

En primer lugar esta consulta no tiene un límite asignado lo que significa que, sin importar el hecho de haber encontrado al usuario que concuerde con los datos que pedimos, MySQL va a seguir buscando la misma coincidencia en el resto de los registros.

Este código funciona bien porque con la sentencia if sólamente estáriamos procesando el primer registro encontrado, pero de todas maneras MySQL va a seguir escaneando todos y cada uno de los restantes registros de la tabla users porque eso es lo que el programador le está pidiendo.

Afortunadamente esta consulta se va a ejecutar lo suficientemente rápido como para que no nos demos cuenta ya que los datos están indexados y no se va a notar ningún retraso mayor, pero es muy común ver tablas sin índices definidos (!) lo que provoca que MySQL tenga que examinar uno por uno todos y cada uno de los registros de la tabla en busca de las coincidencias que el programador le ordena. A esto se lo llama full scan o escaneo completo de una tabla.

Obviamente, como nosotros le dijimos a MySQL que los datos en el campo username no se repiten, la consulta va a devolver solamente un solo registro, pero si los índices no están bien definidos (tipo INDEX en vez de UNIQUE) y hubo algún error en la programación que permitió tener usuarios duplicados esta consulta devolvería más resultados (siempre y cuando haya un match entre username y password).

Lo dicho en el párrafo anterior es muy poco probable, pero eso no quita el hecho de que esté mal programado.

El segundo error está también dentro de la consulta SQL y también está relacionado a los datos que queremos traer.

Le estamos dando la orden a MySQL de buscar a sólamente un usuario que concuerde con la contraseña X.

Ok, todos los registros de nombres de usuarios son diferentes lo que implica que la consulta va a devolver a lo sumo un solo resultado pero existe una mejor manera de hacer las cosas en vez de pedirle a MySQL que haga esta comprobación.

Si el tipo de storage engine que elegido para la aplicación es MyISAM, MySQL sporta un máximo de 64 índices por cada tabla (y en el caso de índices multicolumna, permite hasta 16 columnas en cada uno de estos índices). Esto significa que tenemos un límite a la hora de crear índices y lo más probable es que nuestra aplicación le dé la posibilidad tanto a los usuarios como a los administradores de buscar diferentes tipos de datos en las tablas de nuestra base de datos. No creo que el password esté dentro de estas cosas que los usuarios puedan buscar.

Justamente, para que las búsquedas se ejecuten rápidamente hay que establecer los índices apropiados de la manera apropiada y como los índices son un bien escaso lo que vamos a hacer a continuación es eliminar el índice password de la tabla de users ya que su existencia es innecesaria para este tipo de procesos.

ALTER TABLE `users` DROP INDEX `password`;

El resultado final de nuestro código, la manera correcta, es el siguiente.

<?php
$username = mysql_real_escape_string ($_POST['username']);
$password = md5 ($_POST['password']);

$sql = sprintf ('SELECT id, password, first_name, last_name FROM users WHERE username = \'%s\' LIMIT 1', $username);
$result = mysql_query ($sql);
$row = mysql_fetch_assoc ($result);
if ($row['password'] === $password){
	echo 'Bienvenido ' . $row['first_name'] . ' ' . $row['first_name'] . ' a... hmmm... ésto.';
}else{
	echo 'Cuenta inválida.';
}
?>

Quiero remarcar que en este ejemplo, además de ejecutar la consulta SQL de una manera diferente, estoy utilizando funciones adicionales y comillas simples (') . No me voy a poner a explicar el por qué de cada cosa porque esto se está haciendo demasiado extenso. Si llegaste hasta acá leyendo y tenés ganas, queda de tarea :P, por mi parte lo dejo para otro post.

Lo que esa porción de código hace es muy similar al primer ejemplo, pero voy a traducirlo para que se entienda el punto.

Lo que la consulta SQL pedía en el primer ejemplo era lo siguiente:

"Traeme los datos de todos los usuarios en la tabla de users donde el nombre de usuario sea igual $username y el passwod se corresponda con $password"

La consulta SQL mejorada pide lo siguiente:

"Traeme los datos, incluido el password, de un usuario llamado $username"

Entre esos datos le estamos pidiendo a MySQL que nos devuelva el password del usuario correspondiente y la comparación la hacemos posteriormente con PHP. De esta manera tenemos un código y una consulta SQL eficientes que pide los datos de solamente un registro buscando estos datos en un campo que no contiene registros duplicados.

Otro detalle con el campo password es que contiene cadenas de longitud fija de 32 caracteres y esto conlleva a que MySQL tenga que realizar trabajo adicional a la hora de INSERTar los datos asi como también para encontrarlos cada vez que realizábamos este tipo de búsquedas.

Notas extra:

El algoritmo MD5 para cifrar passwords es ampliamente utilizado pero es obsoleto desde hace pocos años y ya no es considerado una alternativa segura. El hecho de que todavía muchas aplicaciones lo estén utilizando está, entre otras cosas, relacionado a que no existe una manera, exeptuando mecanismos de ataque por fuerza bruta, de revertir cada hash a su significado original. En su lugar debería utilizarse alguna de las variantes SHA (SHA-1 también fue comprometido por lo que tampoco es seguro) o algo mejor conjuntamente con la implementación de Salts.

Obviamente lo expuesto aquí no es una verdad absoluta y depende de las necesidades de cada aplicación y la forma en que éstas guardan los distitntos datos, pero de todas maneras considero que cubre una gran porcion de la torta de casos. Cualquier suejerencia o crítica constructiva es siempre bienvenida.

<Code /> is poetry

Comentarios:

  1. "...relacionado a que no existe una manera lo suficienmente rápida de revertir cada hash a su significado original..."

    Es imposible revertir un MD5 en todo caso podes atacar el hash por fuerza bruta hasta encontrar el original o alguna colision. Seguramente lo sabias, pero presta a confusion para usuarios nuevos :)

    Enviado por Pablo el 29/11/2006 08:29:58 (#).

  2. Vamos por partes, la estructura de la tabla de por sí no es correcta. MySQL al intercalar campos CHAR y VARCHAR convierte los campos CHAR a VARCHAR y lo mismo sucede si hacés un VARCHAR de menos de 4 caracteres, esto se debe a que la ganancia de velocidad de un char es relativo a la estructura en sí de la tabla, no solo del campo.
    Poner un campo CHAR de tamaño fijo con campos de tamaño variable hace que la consulta se ejecute igual de lento que siendo VARCHAR... en definitiva, cero ganancia y mas espacio utilizado por la DB.

    http://dev.mysql.com/doc/refman/5.0/es/silent-column-changes.html <-- leer! :P

    "como nosotros le digimos" ... digimon

    Y arreglá el capcha que anda como el orto + iva

    Saludos.

    Enviado por Leo el 29/11/2006 09:29:13 (#).

  3. Y ahora que se ve la consulta completa, carece de sentido usar sprintf para darle formato de salida a algo tan sencillo, si bien es una función sencilla y optimizada se podría resolver de la misma forma concatenando directamente la variable al string, ahorrando proceso (mierda, pero proceso al fin).
    Como para sumar algo nomas.


    Saludos.

    Enviado por Leo el 29/11/2006 11:29:14 (#).

  4. @Pablo: gracias, ya está corregido.

    @Leo: una masa lo de los tipos de datos en MySQL, no estaba al tanto de eso.

    Lo de "digimon" no lo entendí y el captcha anda para atrás porque utiliza las sesiones de PHP y si te colgás escribiendo los comentarios, las sesiones expiran y tenés que volver a cargar los datos :(. Estoy buscándole una solución, pero sin mucho apuro en realidad.

    Siguiendo con lo de sprintf (), tenés toda la razón, el uso de sprintf no es necesario en esa consulta SQL, pero lo puse justamente para que el tipo que caiga a leer esto, si está interesado, investigue por qué, dónde y cuándo usar esta función... no creo que la complique demasiado.

    Enviado por Juan el 29/11/2006 11:29:50 (#).

  5. Su codigo es muy bueno y esta en lo cierto este es el unico codigo que he utilizado que me ha servido los demas no explican el por que de la funcion de assoc, Gracias!!!

    Enviado por Pablo el 11/12/2006 12:11:59 (#).

  6. "Justamente, para que las búsquedas se ejecuten rápidamente hay que establecer los índices apropiados de la manera apropiada y como los índices son un bien escaso lo que vamos a hacer a continuación es eliminar el índice password de la tabla de users ya que su existencia es innecesaria para este tipo de procesos." ...


    A ver si le agrego mi granito de arena a esto ...


    Estoy un poco en desacuerdo con esa frase. El tema es que si una tabla llega al limite de los 16 indices es xq con seguridad el planteo de la estructura de la DB o del sistema que la utiliza es erroneo.

    El punto es que los indices no solo se crearon para obtener datos y que el usuario pueda interactuar con ellos mas rapido, sino tambien para realizar tareas de ""sistema"" como podria ser esta, sin importar si el usuario necesita ese dato o no.


    Otra es que no es necesario utilizar el operador de comparacion === ya que siempre resulta el mismo tipo, $password resulta de tipo string por el hasheo con md5 y en la base de datos siempre tenes un tipo CHAR(32) lo que resulta en un string tambien, con == es suficiente.


    Otra es el tema de que veo por todos lados que MD5 ya no es seguro y tiene colisiones etc etc. Respecto a esto, como siempre cabe destacar que la seguridad en si de una contraseña esta dada por su longitud y variedad de los caracteres que la componen, por lo tanto, si el usuario elige "lalala" de password, es muy facil, y si elige "a58ehu668qbhdg2" de contraseña la tenemos exponencialmente jodida. MD5 sigue siendo "seguro", xq las colisiones son en realidad un set de datos que obtienen el mismo hash que para otro set de datos distintos, pero el poder de calculo necesario para obtener la colision para ese set de datos "corrupto" es inmenso o en el peor de los casos, casi directamente proporcional a la calidad de la cadena usada para generar el hash, lo cual hace en la practica este tipo de ataques bastante locos. Esto sin tener en cuenta que lo que (probablemente, no en todos los casos) nos interesaria en ese caso es generar un set de datos que valide con el hash, pero que ademas sea UTIL para joder cierta aplicacion.


    Un caso en el cual no importaria el contenido del set de datos corrupto generado es en el caso de las discograficas envenenando las redes p2p, ya que lo que intentan es meter basura simplemente.


    Es mentira que por tener colisiones ya no es seguro y que viene roberto perez con su P4 a 2 GHz y encuentra colisiones a dos manos, de ser esto cierto las agencias gubernamentales del norte habrian marcado como INACEPTABLE MD5 (y no como inseguro) como algoritmo para hashear datos.


    Ya es diferente cuando estamos hablando de equipamiento dedicado como arrays de FPGA's (google) para realizar esta tarea ya que se recorta muchisimo el tiempo con muy poco dinero hoy en dia. Usar una PC para esta tarea es definitivamente la peor opcion si estamos queriendo ser serios en crackear esto.


    Se podria tambien evitar colocar el hash dentro de $password y pasarlo directamente en el sprintf, y ya que es unique, si tengo un recordcount de exactamente 1 (o que no es falso gracias al LIMIT 1) de resultado validar el usuario o no en vez de crear un array con un fetchrow y luego comparar el resultado.


    En fin, el codigo quedaria mas o menos como esto:



    <?php
    /* por supuesto indexar el campo password tambien */
    $sql = sprintf('SELECT id, first_name, last_name FROM users WHERE username = \'%s\' AND password = \'%s\' LIMIT 1', mysql_real_escape_string($_POST['username']), md5($_POST['password']));
    $result = mysql_query($sql);
    unset($sql);
    /* tonce los hackeos y las usuarios/contrasenias mal puestas las paras con una simple operacion de cuenta en vez de hacer siempre el fetch_row */

    if(mysql_num_rows($result) != FALSE){
    $row = mysql_fetch_row($result);
    echo 'Bienvenido ' . $row['first_name'] . ' ' . $row['first_name'] . ' a... hmmm... ésto.';
    }else{
    echo 'Cuenta inválida.';
    }
    ?>


    Igualmente, me parece una chafadez optimizar tanto algo como esto


    Enviado por Carlos Castillo el 17/01/2007 11:17:23 (#).

  7. Ok, Coke, como ya sabés, no estoy de acuerdo en muchos de los puntos que planteás y después de la larga discusión que tuvimos sobre esto quedamos en que hay que hacer un benchmark y ver cuál es más rápida.

    Vuelvo a aclarar algo que digo en el artículo: depende de la aplicación que estemos desarrollando.

    Sí te copa pongo un log(censurado) de lo que charlamos en Gtalk. En este momento estoy too roto como para seguir comentando.

    Y, a propósito, ni bien tenga los resultados del benchmak los posteo, pero pueden pasar siglos :P

    Enviado por Juan el 17/01/2007 11:17:34 (#).

  8. Totalmente de acuerdo sobre el MD5 y sobre el comparador, una cosa es querer "asegurar" el código y otra ser un estúpido paranoico.

    Olviden el benchmark (yo tambien tuve la misma duda)....
    El result del query está seteado en ambos casos, y la adaptación de ese recurso tanto para contarlo como para devolverlo ordenado debería llevar muy poco proceso y tiempo.
    Lo que no veo bien es el cambio de la consulta, incluso teniendo un indice uniendo los dos campos (deberían eliminar el campo único del nombre o bien hacer dos indices separados, pero la búsqueda sería mas pesada), estoy seguro que hacer una consulta a los dos campos y luego contarlos es mas lento que hacer una consulta sobre un indice único para un campo único y luego procesar el resultado, ya que el nombre es él único campo que puede ser único (¿?).

    Saludos

    Enviado por Lio el 04/02/2007 07:4:31 (#).

  9. "...ya que el nombre es él único campo que puede ser único..." exácto, distintos usuarios pueden compartir la misma contraseña (Y otros datos, pero como estamos hablando sobre conceptos de login solo tenemos como datos USER y PASS)

    Para darle un poco de fundamento a lo que dice Lio respecto a la velocidad, que es absolutamente cierto, el tema es que salvo que tengas demasiada RAM los datos de las tablas normalmente estan en el disco y es sabido por todos que la RAM es miles (si no mas) de veces mas rapida que el disco, por tanto comparar 2 strings en memoria es mucho mas rapido que pedirselo al motor SQL. (Aqui el que quiera hace benchmarks y se saca la duda)

    Saludos!

    Enviado por Pablo el 09/02/2007 10:9:42 (#).

  10. muito bom esse sistema de login eu tinha visto esse exemplo no manual do php

    mais gostei pq eu tinha perdido esse exemplo la

    Enviado por kakaroto el 21/02/2007 11:21:35 (#).

  11. Que dice el chabon este?

    Enviado por Lio el 21/02/2007 02:21:11 (#).

  12. Re kakaroto: Doy fe que no es copiado, pero es fácil decirlo cuando el que lo escribió soy yo.

    Lo que sí puedo decir es que la idea de eliminar el índice del campo password está basada en la manera en que phpBB2 gestiona los logins.

    Re Leo: Dice que muy bien por el sistema de login este pero que ya lo había visto en el manual de php y que le gustó más todavía porque lo habia perdido.

    PS: Leo, ¿vos no ponés bien tu email sólo para molestarme o_O?

    Enviado por Juan el 21/02/2007 04:21:00 (#).

  13. "Sin importar el tipo de storage engine que elegido para la aplicación, MySQL sporta un máximo de 16 índices por cada tabla."

    Corregido. Las tablas MyISAM soportan hasta 64 índices por tabla y, en el caso de los índices multicolumna, hasta 16 columnas.

    Enviado por Juan el 27/04/2007 11:27:26 (#).