Qué es un sistema de login
Un sistema de login hace dos cosas, y conviene no mezclarlas: identifica a quien está del otro lado —¿quién es?— y autoriza el acceso al contenido privado —¿puede entrar?—. Son dos preguntas distintas: alguien puede estar identificado y no tener permiso para ver cierta página. Acá se resuelve la primera, que es la puerta de entrada de todas las demás.
En el fondo, esa puerta es una comparación: lo que alguien escribe tiene que coincidir con lo que hay guardado. Todo el resto —la sesión, el hash, las redirecciones— está al servicio de hacer esa comparación bien: de forma segura, sin guardar datos de más y sin obligar al usuario a escribir su contraseña en cada página.
El recorrido tiene cuatro momentos, y el ejemplo de esta página los recorre enteros:
Los archivos del sistema
El ejemplo completo son seis archivos, y cada uno hace una sola cosa. Esa separación no es un capricho de orden: es lo que permite entender el sistema de a una parte por vez, y lo que hace que el día que algo falle se sepa dónde mirar.
| Archivo | Qué hace |
|---|---|
conexion.php | Se conecta a la base. Es el único archivo que conoce los datos de acceso. |
index.php | Muestra el formulario de usuario y contraseña. |
login.php | Recibe el formulario, valida contra la base y crea la sesión. No muestra nada: solo decide a dónde ir. |
panel.php | La página privada. Empieza con la guardia que expulsa a quien no tenga sesión. |
logout.php | Destruye la sesión y vuelve al login. |
crear_usuario.php | Da de alta usuarios nuevos, con sus validaciones. |
usuarios.sql | El esquema: crea la base login, la tabla usuarios y un usuario de prueba. |
Todos, menos el primero, el último y el .sql, siguen el mismo esquema: primero el PHP que decide, después el HTML que muestra. En index.php esa primera parte es solo la guardia de la sesión; en crear_usuario.php es todo el procesamiento del alta.
La conexión, en un archivo aparte
La conexión se escribe una sola vez y en su propio archivo. Todas las páginas lo traen con require y a partir de ahí usan la misma variable, sin repetir los datos del servidor ni abrir una conexión nueva:
<?php
// conexion.php — el único archivo que conoce los datos de acceso (XAMPP)
$host = 'localhost';
$db = 'login';
$user = 'root';
$pass = '';
try {
$conexionbd = new PDO(
"mysql:host=$host;dbname=$db;charset=utf8mb4",
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
} catch (PDOException $e) {
die("No se pudo conectar a la base de datos: " . $e->getMessage());
}
Las cuatro decisiones de ese bloque son las que se vieron en La conexión, puestas en su lugar: el DSN dice gestor, servidor, base y codificación; ERRMODE_EXCEPTION hace que los errores avisen en lugar de pasar en silencio; y FETCH_ASSOC define que cada fila llegue como ['id' => 1, 'usuario' => 'admin'], que es la forma cómoda de leerla.
El try/catch de la conexión cumple una función distinta de los que se usan al ejecutar consultas. Acá no hay nada que corregir ni que reintentar: si la base no está, el sistema entero no puede funcionar. Por eso se corta con die() y se dice por qué. Y el comentario del ejemplo —«datos de acceso de XAMPP»— anticipa algo importante: esas cuatro líneas, en un sitio publicado, no van acá. Van en un archivo de configuración que no se sube al repositorio. En la computadora de desarrollo se escriben directo para tener todo a la vista; en un servidor, sería publicar la llave de la base.
El formulario
La primera página es un formulario común y corriente, con dos detalles que no son decorativos: el method y los name.
<form action="login.php" method="POST">
<input type="text" name="usuario" id="usuario" class="form-control" required>
<input type="password" name="clave" id="clave" class="form-control" required>
<button type="submit" class="btn btn-primary w-100">Entrar</button>
</form>
Con method="POST" los datos viajan dentro de la petición, no en la dirección: no quedan escritos en la barra del navegador ni en el historial ni en los registros del servidor. Para una contraseña eso no es una preferencia estética, es la única opción razonable —con GET la contraseña quedaría a la vista en la URL—. Y los name son el contrato entre el formulario y el programa: login.php lee $_POST["usuario"] porque el campo se llama así. Si se cambia uno, hay que cambiar el otro.
El required del HTML valida en el navegador, y sirve; pero no alcanza. Cualquiera puede enviar el formulario sin pasar por la página —con una herramienta o con dos líneas de código—, así que el servidor tiene que volver a comprobar todo. La regla general: el navegador valida para hacer la vida más fácil, el servidor valida para que las cosas sean ciertas.
index.php. El formulario no tiene misterio: dos campos, un botón, y abajo el acceso al alta de usuarios.Un detalle que aparece en la página y conviene entender: el formulario no muestra el error por sí mismo, porque no sabe si los datos estaban bien. Cuando la validación falla, login.php vuelve a esta página con ?error=1 en la dirección, y es la página la que decide mostrar el aviso. Es un ejemplo de lo que se vio en Métodos de envío: las dos formas de pasar datos —POST para lo que se envía y GET para lo que se comparte por dirección— usadas cada una para lo que sirve.
La validación
login.php es el corazón del sistema. No tiene HTML: recibe, consulta, compara y decide a dónde ir. Sus primeras líneas son las que ya se conocen, y la consulta es la que corresponde:
<?php
session_start(); // para poder guardar quién entró
require "conexion.php"; // trae $conexionbd
$usuario = $_POST["usuario"];
$clave = $_POST["clave"];
$consulta = $conexionbd->prepare("SELECT * FROM usuarios WHERE usuario = ?");
$consulta->execute([$usuario]);
$fila = $consulta->fetch(); // la fila encontrada, o false
Esta consulta usa un marcador posicional ?. La diferencia con los marcadores con nombre y la regla para enviar valores se explican en Inyección SQL y consultas preparadas.
Después viene la decisión, que en una sola línea junta las dos condiciones:
if ($fila && password_verify($clave, $fila["clave"])) {
$_SESSION["usuario"] = $fila["usuario"];
header("Location: panel.php");
exit;
}
header("Location: index.php?error=1");
Las dos condiciones son las dos mitades de la pregunta. $fila es false si el usuario no existe, así que la primera parte descarta eso. La segunda compara la contraseña escrita con el hash guardado, y de eso se ocupa la sección siguiente. Vale notar que las dos se escriben juntas con && y en ese orden: si el usuario no existe, ni siquiera se intenta comparar la contraseña.
header("Location: ...") es una redirección: no devuelve una página, le dice al navegador «andá a esta otra». Y el exit que va justo después no es opcional. Sin él, PHP sigue ejecutando el resto del archivo aunque ya haya mandado la orden de irse, y ese es el camino por el que se cuelan la mitad de los errores de este tipo de sistema. La regla es simple: después de cada redirección, exit.
?error=1 y el aviso aparece arriba.El aviso dice «Usuario o contraseña incorrectos», y esa redacción es deliberada: no dice «ese usuario no existe» ni «la contraseña está mal». Decir cuál de las dos cosas falló le ahorra trabajo a quien está probando combinaciones, porque le confirma que el usuario existe. Es la misma lógica que el mensaje de error de la base de datos que no se muestra crudo: al que pregunta de buena fe le da igual, y al que pregunta de mala fe hay que darle la menor información posible.
La sesión
Validar una vez no alcanza: el usuario no va a escribir su contraseña en cada página. Hace falta que el servidor se acuerde de quién entró, y para eso está la sesión.
Una sesión es un espacio de memoria del servidor asociado a un identificador que el navegador guarda y devuelve en cada pedido. El servidor guarda ahí lo que quiera —en este caso, el nombre del usuario— y después lo consulta. La diferencia clave con una cookie es dónde vive el dato: en el servidor, no en la computadora del visitante. Al usuario solo le viaja un número; nada de lo que está adentro se puede ver desde el navegador.
Su uso tiene siempre la misma forma, y son tres instrucciones:
session_start(); // abre o retoma la sesión. Primera línea, siempre
$_SESSION["usuario"] = ...; // guarda o lee un dato de la sesión
session_destroy(); // la borra entera
session_start() va en la primera línea de todo archivo que use la sesión: en login.php para guardarla, en panel.php y logout.php para leerla o borrarla. Y tiene que ir antes de cualquier salida —antes de un echo, antes del HTML, antes de un espacio en blanco de más—, porque cuando el programa ya empezó a mandar la página, la cabecera donde se envía esa sesión ya se fue.
La guardia: proteger una página
Las páginas privadas se protegen con cuatro líneas que se copian al principio, siempre iguales. Es lo primero que hace panel.php, antes de mostrar nada:
session_start();
if (!isset($_SESSION["usuario"])) {
header("Location: index.php");
exit;
}
// A partir de acá, solo llega quien tiene sesión
Se lee como una frase: si no hay usuario en la sesión, no hay nada que ver. Y como está antes de todo lo demás, cuando alguien entra a la dirección sin estar logueado, la página ni siquiera llega a mostrarse: lo desvía al formulario. Es una guardia en la puerta, no un cartel adentro del salón.
Ese if es también el lugar exacto donde, más adelante, entraría la autorización: si además de «estar logueado» hiciera falta «ser administrador», la comprobación iría acá al lado, sobre otro dato de la sesión guardado en el login.
logout.php.Salir: borrar la sesión
Cerrar sesión es destruir ese espacio de memoria. Y como el servidor ya no se acuerda de nada, el identificador que quedó en el navegador deja de servir:
<?php
session_start(); // se retoma la que hay, para poder borrarla
session_destroy(); // el servidor la olvida
header("Location: index.php");
Lo que parece un detalle y no lo es: session_start() va también acá, aunque se vaya a destruir. Sin esa línea, PHP no sabe cuál es la sesión que hay que borrar, y el session_destroy() no hace nada. Cerrar sesión siempre empieza por retomarla.
Las contraseñas no se guardan
Acá está la decisión más importante de todo el sistema, y la que más veces se hace mal. En la base no se guarda la contraseña: se guarda un hash —el resultado de aplicarle a la contraseña una función irreversible—. La comparación tampoco se hace al revés: no se calcula el hash de lo que escribió el usuario para ver si es igual al guardado en forma directa, sino que se le pasa la contraseña y el hash a una función que sabe comparar:
// Al dar de alta el usuario: se genera el hash y eso es lo que se guarda
$hash = password_hash($clave, PASSWORD_DEFAULT);
// Al iniciar sesión: se compara lo escrito con el hash guardado
if (password_verify($clave, $fila["clave"])) { ... }
«Irreversible» quiere decir que del hash no se puede volver a la contraseña: no hay una operación inversa, ni un truco, ni una página web que lo deshaga. Por eso un archivo con los datos de una base no entrega las contraseñas, y por eso cuando un sitio avisa «olvidé mi contraseña» en realidad no la recupera: genera una nueva.
El hash de prueba del ejemplo es este, y no es un texto secreto: es lo que resulta de hashear 1234. Se puede copiar y pegar sin ningún riesgo, porque no dice nada sobre el original.
$2y$10$k9IsZwqtJpIxGV9LvsiJEeZRQ2ZwywhiaNO26UpEsKggOUxMdPFey
Ese texto tiene partes que se leen. El $2y$ inicial indica el algoritmo —bcrypt—, el 10 es el costo: cuántas veces se repite el cálculo, y por lo tanto cuánto tarda. Ese número sube solo cuando conviene, porque PASSWORD_DEFAULT siempre elige el algoritmo recomendado en ese momento: el día que aparezca uno mejor, empezará a usarlo en las contraseñas nuevas. Y las viejas siguen funcionando, porque el algoritmo y el costo están escritos dentro del propio hash, y password_verify() los lee de ahí.
md5($clave) parece lo mismo y no lo es: son funciones rápidas y se usan para verificar archivos, no para guardar contraseñas. Un algoritmo rápido es lo peor para este problema, porque quien quiere adivinar contraseñas puede probar millones por segundo. bcrypt está hecho para ser lento, y eso es una virtud: frena a quien prueba por fuerza bruta. Por eso a las contraseñas no se les aplica «un hash cualquiera»: se les aplica una función pensada para esto, y en PHP esa función es password_hash().
Crear usuarios
El último archivo del ejemplo es el alta de usuarios, y sirve para ver algo que no apareció antes: validar es una cadena de comprobaciones, y cada una rechaza un caso distinto. crear_usuario.php es, al mismo tiempo, la primera parte del CRUD que se ve en las páginas anteriores y la respuesta a la pregunta «¿y de dónde sale el hash que guarda la base?».
// Solo se procesa si el formulario se envió por POST
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$usuario = trim($_POST["usuario"]);
$clave = $_POST["clave"];
$clave2 = $_POST["clave2"];
if ($usuario === "" || $clave === "") {
$error = "Completá todos los campos.";
} elseif ($clave !== $clave2) {
$error = "Las contraseñas no coinciden.";
} elseif (strlen($clave) < 8) {
$error = "La contraseña debe tener al menos 8 caracteres.";
} else {
// ... comprobar que el usuario no exista y recién ahí insertar
}
}
Tres cosas de ese fragmento merecen atención. La primera es $_SERVER["REQUEST_METHOD"]: distingue si alguien envió el formulario (POST) o si simplemente abrió la página (GET). Es lo que permite que el mismo archivo funcione para las dos situaciones: cuando se llega con GET se baja directo al HTML y se muestra el formulario vacío; cuando se llega con POST se procesa lo que vino antes de mostrar nada.
La segunda es trim(), que saca los espacios de los costados. Parece un detalle hasta que alguien crea el usuario «admin » con un espacio invisible al final y después no puede entrar escribiendo «admin ». Y la tercera es el if/elseif: los casos se descartan de a uno y en orden, y el bloque final —el else— es el único que inserta. Nada se guarda hasta que pasaron todas las comprobaciones.
Dentro de ese else falta lo que el ejemplo hace y conviene tener presente: comprobar primero que el usuario no exista, con una consulta preparada. La columna está declarada UNIQUE, así que la base lo rechazaría igual; pero preguntar antes permite mostrar «ese usuario ya está registrado», que es un mensaje que se entiende, en lugar del error crudo del gestor.
Puesta en marcha
Todo el ejemplo cabe en cinco pasos, y los primeros dos ya se hicieron cuando se instaló el entorno:
- Arrancar los servicios. Apache y MariaDB tienen que estar andando, como se vio en Anexo → XAMPP.
- Crear la base y la tabla. El archivo
usuarios.sqlse importa desde phpMyAdmin —en la pestaña Importar— y deja la baselogincon la tablausuariosy un usuario de prueba ya cargado. - Copiar la carpeta del proyecto dentro de
htdocs, o dejarla en una carpeta propia si se configuró así el servidor —los pasos están en Dónde van los proyectos—. - Abrir la dirección del proyecto en el navegador:
http://localhost/login-php/. - Entrar con el usuario de prueba:
adminy la contraseña1234.
Y un recordatorio que vale para todo el ejemplo: la conexión usa root sin contraseña, que es lo que trae XAMPP y sirve perfectamente para trabajar en la propia computadora. Esa comodidad es exactamente lo que no puede ir a un servidor publicado, como se vio en Antes de publicar.
La tabla tiene tres columnas y ninguna es un capricho: el id que identifica cada fila, el usuario que no se puede repetir —de ahí el UNIQUE— y el clave declarado como VARCHAR(255). Esos 255 caracteres no son para una contraseña larga: son para el hash, que ocupa casi siempre unos 60. Se deja ese margen porque si algún día cambia el algoritmo, el hash puede crecer, y una columna que se queda corta obliga a modificar la tabla.
Resumen
- Un login identifica a quien entra; la autorización —qué puede ver— es la pregunta siguiente.
- Cuatro momentos: formulario → validación → sesión → salir. Solo la validación habla con la base.
- La conexión va en un archivo aparte (
conexion.php), y se trae conrequire. En producción, las credenciales van fuera del código. - El formulario usa
POSTpara que la contraseña no quede en la dirección, y losnameson el contrato con el programa. - La consulta va preparada, con
?o con:nombre: el valor viaja aparte. - La contraseña se compara con
password_verify(); en la base se guarda el hash depassword_hash(), nunca el texto. - Después de cada
header("Location: ...")va unexit. - La sesión vive en el servidor:
session_start()en la primera línea,$_SESSIONpara el dato,session_destroy()para cerrarla. - Las páginas privadas llevan la guardia al principio: si no hay sesión, redirigir antes de mostrar nada.
- Cerrar sesión empieza por
session_start(), porque hay que retomar la sesión para poder destruirla. - El aviso de error dice «usuario o contraseña incorrectos», sin aclarar cuál de las dos falló.
- Validar es una cadena: campos completos, contraseñas iguales, largo mínimo, usuario libre. Nada se guarda hasta que pasan todas.