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 tablausers
(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
yemail
: 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