En este artículo de programación con PHP y MySQL te mostraré cómo implementar un login, autenticación o inicio de sesión con límite de intentos, teniendo a MySQL como base de datos.
Con el límite de intentos me refiero a que cuando el usuario coloque la contraseña incorrecta, se irá aumentando un contador de errores o de intentos fallidos. Si llega a determinado número, se bloqueará el acceso hasta que el contador se reinicie.
Este ejemplo completo de código que te mostraré tiene el módulo de login, de creación de usuarios y de usuarios en donde se pueden borrar los intentos fallidos de cualquier usuario.
Además, solo los usuarios que hayan iniciado sesión pueden acceder a la administración de usuarios del sistema. Recuerda que para guardar todos los datos vamos a usar MySQL y vamos a usar PHP como lenguaje de programación.
Base de datos
Comencemos viendo la base de datos. Como en varios de mis proyectos, las credenciales de acceso residen en un archivo llamado env.php
que tú debes crear, basándote en el archivo env.ejemplo.php
. El mío se ve así:
; <?php exit; ?>
MYSQL_DATABASE_NAME = "login"
MYSQL_USER = "root"
MYSQL_PASSWORD = ""
Fíjate en que mi base de datos se llama login
. Configura el nombre de la base de datos, misma que debe existir en tu sistema, y luego importa las tablas:
CREATE TABLE usuarios(
id BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
correo VARCHAR(255) NOT NULL,
palabra_secreta VARCHAR(255) NOT NULL
);
/*
Solo necesitamos el id del usuario, vamos a insertar
un valor cada vez que haya un login no exitoso, y
luego vamos a contar cuántos valores hay
*/
CREATE TABLE intentos_usuarios(
id_usuario BIGINT UNSIGNED NOT NULL,
FOREIGN KEY (id_usuario) REFERENCES usuarios(id) ON DELETE CASCADE ON UPDATE CASCADE
);
En este caso tenemos solo dos tablas. Una que guardará todos los datos de los usuarios y otra que guardará cuántos intentos fallidos tiene un usuario, misma que podemos usar para saber si el usuario puede acceder o no, y que iremos alimentando cada vez que haya un error de autenticación.
La tabla de intentos está relacionada a la tabla de usuarios correctamente. Ya después leemos el archivo y obtenemos una conexión con las siguientes funciones:
<?php
function obtenerBaseDeDatos()
{
$password = obtenerVariableDelEntorno("MYSQL_PASSWORD");
$user = obtenerVariableDelEntorno("MYSQL_USER");
$dbName = obtenerVariableDelEntorno("MYSQL_DATABASE_NAME");
$database = new PDO('mysql:host=localhost;dbname=' . $dbName, $user, $password);
$database->query("set names utf8;");
$database->setAttribute(PDO::ATTR_EMULATE_PREPARES, FALSE);
$database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$database->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
return $database;
}
function obtenerVariableDelEntorno($clave)
{
if (defined("_ENV_CACHE")) {
$vars = _ENV_CACHE;
} else {
$archivo = "env.php";
if (!file_exists($archivo)) {
throw new Exception("El archivo de las variables de entorno ($archivo) no existe. Favor de crearlo");
}
$vars = parse_ini_file($archivo);
define("_ENV_CACHE", $vars);
}
if (isset($vars[$clave])) {
return $vars[$clave];
} else {
throw new Exception("La clave especificada (" . $clave . ") no existe en el archivo de las variables de entorno");
}
}
Formulario para login
Comencemos viendo el formulario en donde se inicia sesión, pues ahí se hace todo. El mismo solo solicita el correo y la contraseña. También muestra un mensaje en caso de que el mismo esté presente en la URL:
<?php include_once "encabezado.php" ?>
<div class="col-12">
<h1>Login</h1>
<form action="login.php" method="post">
<div class="form-group">
<label for="correo">Correo electrónico:</label>
<input class="form-control" id="correo" type="email" name="correo" placeholder="Correo electrónico" required>
</div>
<div class="form-group">
<label for="palabraSecreta">Contraseña:</label>
<input class="form-control" id="palabraSecreta" type="password" name="palabraSecreta" required placeholder="Contraseña">
</div>
<?php
# si hay un mensaje, mostrarlo
if (isset($_GET["mensaje"])) { ?>
<div class="alert alert-info">
<?php echo $_GET["mensaje"] ?>
</div>
<?php } ?>
<br>
<button class="btn btn-success" type="submit">Iniciar sesión</button>
</form>
</div>
<?php include_once "encabezado.php" ?>
Cuando el formulario sea enviado se va a procesar en login.php
que va a revisar que los datos estén presentes y después va a invocar a la función hacerLogin
, misma que veremos más tarde. Por ahora los comentarios explican todo:
<?php
# Simple validación
if (!isset($_POST["correo"]) || !isset($_POST["palabraSecreta"])) {
exit("Faltan datos");
}
$correo = $_POST["correo"];
$palabraSecreta = $_POST["palabraSecreta"];
include_once "funciones.php";
$valor = hacerLogin($correo, $palabraSecreta);
if ($valor == 0) {
# Correo o contraseña incorrectos
header("Location: formulario_login.php?mensaje=Usuario o contraseña incorrectos. Se ha registrado el intento fallido");
} else if ($valor == 2) {
header("Location: formulario_login.php?mensaje=Límite de intentos alcanzado. Contactar a administrador para reiniciar");
} else {
#Todo bien. Iniciar sesión y redireccionar a la página
iniciarSesionDeUsuario();
header("Location: usuarios.php");
}
Como puedes ver hacemos simples redirecciones, y en caso de que todo vaya bien, iniciamos la sesión para ir a la página de administración de usuarios.
Comprobando límite y registrando errores
Vayamos a la función que hace la magia, misma que vimos anteriormente y que es hacerLogin
. Aquí es en donde se verifica la contraseña como los intentos, y se guarda el intento fallido en caso de existir:
<?php
/*
Regresa valores numéricos
0 en caso de que el usuario no exista o la contraseña sea incorrecta
1 en caso de que todo esté bien
2 en caso de que haya alcanzado el límite de intentos
*/
function hacerLogin($correo, $palabraSecreta)
{
$bd = obtenerBaseDeDatos();
$sentencia = $bd->prepare("SELECT id, correo, palabra_secreta FROM usuarios WHERE correo = ?");
$sentencia->execute([$correo]);
$registro = $sentencia->fetchObject();
if ($registro == null) {
# No hay registros que coincidan, y no hay a quién culpar (porque el usuario no existe)
return 0;
} else {
# Sí hay registros, pero no sabemos si ya ha alcanzado el límite de intentos o si la contraseña es correcta
$conteoIntentosFallidos = obtenerConteoIntentosFallidos($registro->id);
if ($conteoIntentosFallidos >= MAXIMOS_INTENTOS) {
# Ha superado el límite
return 2;
} else {
# Extraer la correcta de la base de datos
$palabraSecretaCorrecta = $registro->palabra_secreta;
# Comparar con la proporcionada:
# Nota: esto es por simplicidad, en la vida real debes hashear las contraseñas
# https://parzibyte.me/blog/2017/11/13/cifrando-comprobando-contrasenas-en-php/
if ($palabraSecretaCorrecta === $palabraSecreta) {
# Todo correcto. Borramos todos los intentos, pues ya hizo uno exitoso
eliminarIntentos($registro->id);
return 1;
} else {
# Agregamos un intento fallido
agregarIntentoFallido($registro->id);
return 0;
}
}
}
}
Lo primero que hacemos es obtener el usuario de la base de datos. Si no existe, no se lo indicamos (pues es un riesgo de seguridad), solo le decimos que su contraseña o correo es incorrecto.
Después obtenemos los intentos fallidos que tiene y, si supera el máximo, lo indicamos, ya que no puede iniciar sesión si supera el máximo de intentos.
Finalmente en caso de que pueda acceder, comprobamos su contraseña. La comparamos y si todo va bien entonces el login es correcto, así que borramos los intentos fallidos.
Caso contrario, si la contraseña es incorrecta y por lo tanto hay un intento fallido, agregamos un intento fallido a ese id de usuario.
Registro de usuarios
El sistema cuenta con un módulo simple de registro de usuarios, que solo está para no tener que ingresar los datos manualmente. El código del formulario es el siguiente:
<?php include_once "encabezado.php" ?>
<div class="col-12">
<h1>Registro de usuario</h1>
<form action="registrar_usuario.php" method="post">
<div class="form-group">
<label for="correo">Correo electrónico:</label>
<input class="form-control" id="correo" type="email" name="correo" placeholder="Correo electrónico" required>
</div>
<div class="form-group">
<label for="palabraSecreta">Contraseña:</label>
<input class="form-control" id="palabraSecreta" type="password" name="palabraSecreta" required placeholder="Contraseña">
</div>
<div class="form-group">
<label for="palabraSecretaConfirmar">Confirmar contraseña:</label>
<input class="form-control" id="palabraSecretaConfirmar" type="password" name="palabraSecretaConfirmar" required placeholder="Confirmar contraseña">
</div>
<?php
# si hay un mensaje, mostrarlo
if (isset($_GET["mensaje"])) { ?>
<div class="alert alert-info">
<?php echo $_GET["mensaje"] ?>
</div>
<?php } ?>
<br>
<button class="btn btn-success" type="submit">Iniciar sesión</button>
</form>
</div>
<?php include_once "encabezado.php" ?>
Cuando se envía, los datos se procesan en registrar_usuario.php
cuyo código es el siguiente, en donde se invoca, de nuevo, a una función:
<?php
if (!isset($_POST["correo"]) || !isset($_POST["palabraSecreta"]) || !isset($_POST["palabraSecretaConfirmar"])) {
exit("Faltan datos");
}
include_once "funciones.php";
if ($_POST["palabraSecreta"] !== $_POST["palabraSecretaConfirmar"]) {
header("Location: formulario_registro.php?mensaje=Las contraseñas no coinciden");
exit;
}
registrarUsuario($_POST["correo"], $_POST["palabraSecreta"]);
header("Location: formulario_login.php?mensaje=Usuario creado. Inicia sesión");
El código de la función queda así:
<?php
function registrarUsuario($correo, $palabraSecreta)
{
$bd = obtenerBaseDeDatos();
$sentencia = $bd->prepare("INSERT INTO usuarios(correo, palabra_secreta) VALUES (?, ?)");
$sentencia->execute([$correo, $palabraSecreta]);
}
Administración de usuarios
Si el usuario inició sesión correctamente se le muestra una página en donde puede reiniciar el conteo de intentos fallidos gracias a un enlace que aparece junto al correo y el conteo de los usuarios. El código que dibuja la tabla y el enlace para el reseteo es:
<?php
include_once "encabezado.php";
include_once "funciones.php";
# Si no hay usuario logueado, salir inmediatamente
if (!usuarioEstaLogueado()) {
header("Location: formulario_login.php?mensaje=Inicia sesión para acceder a la página protegida");
exit; // <- Es muy importante terminar el script
}
# Si llegamos aquí, es que el usuario inició sesión anteriormente
$usuarios = obtenerUsuariosConIntentosFallidos();
?>
<div class="col-12">
<h1>Usuarios</h1>
<table class="table">
<thead>
<tr>
<th>Correo</th>
<th>Intentos fallidos</th>
<th>Reiniciar</th>
</tr>
</thead>
<tbody>
<?php foreach ($usuarios as $usuario) { ?>
<tr>
<td><?php echo $usuario->correo ?></td>
<td><?php echo $usuario->intentos_fallidos ?></td>
<td>
<a href="reiniciar_conteo.php?id=<?php echo $usuario->id ?>" class="btn btn-danger">Reiniciar</a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
<?php ?>
</div>
<?php include_once "pie.php"; ?>
Como te puedes dar cuenta, se verifica si el usuario ha iniciado sesión antes, por lo que se puede decir que esta página está protegida. Yo sé que no tiene sentido que un usuario pueda reiniciar el contador de intentos fallidos, pero recuerda, es un simple ejemplo, puedes adaptarlo a tus necesidades.
Ya para la parte de reiniciar el conteo, se hace lo siguiente:
<?php
include_once "funciones.php";
if (!usuarioEstaLogueado()) {
header("Location: formulario_login.php");
exit;
}
if (!isset($_GET["id"])) {
exit("Se necesita el parámetro id en la url");
}
$idUsuario = $_GET["id"];
eliminarIntentos($idUsuario);
header("Location: usuarios.php");
Siguiendo el patrón de todas las demás acciones, este archivo simplemente incluye el archivo de funciones.php
e invoca a la que es necesaria. La función luce así:
<?php
function eliminarIntentos($idUsuario)
{
$bd = obtenerBaseDeDatos();
$sentencia = $bd->prepare("DELETE FROM intentos_usuarios WHERE id_usuario = ?");
$sentencia->execute([$idUsuario]);
}
Poniendo todo junto
Así queda este proyecto completo en donde se implementa un límite de intentos y no se permite el acceso hasta que el administrador reinicia el contador de intentos fallidos.
No puedo colocar aquí todo el código e ir explicando paso por paso cada cosa, pues el artículo se haría realmente largo.
Aquí solo te he explicado el código relevante, ya que en otros posts he explicado lo que es la conexión de PHP a MySQL, la plantilla de Bootstrap, la subconsulta con count o un login básico con PHP.
Te dejo el código completo en un repositorio de GitHub en donde encontrarás el archivo de funciones completas, el esquema, etcétera. Y también te dejo más posts sobre PHP.