Tema: Login + Sesiones (PHP + MySQL) — integrado a TU proyecto actual

Aquí NO vamos a “hacer otro proyecto”. Vamos a tomar tu estructura real: index.php + /pages + /lib + /config y le vamos a meter login/sesión/protección de páginas.

0) Qué vas a lograr al final

1) Paso 1 — Ajuste de BD (una sola vez)

En DBeaver ejecuta esto para agregar el campo del hash:

ALTER TABLE usuarios
ADD COLUMN password_hash VARCHAR(255) NULL;
Qué significa:
No vamos a guardar la contraseña real. Vamos a guardar un hash (texto) que genera PHP.

2) Paso 2 — Crear 2 carpetas nuevas

Si ya tienes /controllers, perfecto. Si no, la creas al mismo nivel donde está tu index.php.

3) Paso 3 — Crear archivos NUEVOS (login, app, logout, middleware, controlador)

Copia/pega exactamente estos archivos. Ya vienen con comentarios detallados.

3.1 lib/auth.php

Qué hace: Esta función se pone al inicio de cualquier página privada. Si no hay sesión, redirige a login.php y corta el script.

<?php
// lib/auth.php

/**
 * requireLogin()
 * - Inicia sesión si aún no está iniciada
 * - Verifica si existe $_SESSION['user_id']
 * - Si NO existe, redirige a login.php y detiene el script (exit)
 */
function requireLogin()
{
  // Sin session_start() no podemos leer $_SESSION
  if (session_status() === PHP_SESSION_NONE) {
    session_start();
  }

  // Si no existe user_id => no está autenticado
  if (!isset($_SESSION['user_id'])) {
    header("Location: /login.php");
    exit;
  }
}
3.2 controllers/AuthController.php

Qué hace: este es el “controlador” de autenticación. No es una vista: recibe la petición del form, valida, crea sesión y redirige.

  • ?action=login → procesa el POST del formulario
  • ?action=logout → destruye sesión
<?php
// controllers/AuthController.php

require_once __DIR__ . '/../lib/Conexion.php';
require_once __DIR__ . '/../lib/Functions.php';

// Necesitamos sesión porque guardaremos:
// - errores (login_error)
// - datos del usuario logueado (user_id, user_nombre, etc.)
if (session_status() === PHP_SESSION_NONE) {
  session_start();
}

$action = $_GET['action'] ?? '';

// ==========================
// LOGIN
// ==========================
if ($action === 'login') {

  // 1) Leer POST del formulario login.php
  $correo = trim($_POST['correo'] ?? '');
  $pass   = trim($_POST['password'] ?? '');

  // 2) Validación básica
  if ($correo === '' || $pass === '') {
    $_SESSION['login_error'] = "Faltan datos.";
    header("Location: /login.php");
    exit;
  }

  // 3) Buscar usuario por correo
  $functions = new Functions();

  // Nota didáctica: aquí concatenamos simple.
  // Más adelante se ve prepared statements.
  $get = $functions->execute("
    SELECT id, nombre, correo, rol, activo, password_hash
    FROM usuarios
    WHERE correo = '$correo'
    LIMIT 1
  ");

  // 4) Si falló el SQL
  if (($get['res'] ?? '') !== 'ok') {
    $_SESSION['login_error'] = $get['res'] ?? 'Error desconocido';
    header("Location: /login.php");
    exit;
  }

  // 5) Si no encontró usuario
  if ($get['query']->num_rows === 0) {
    $_SESSION['login_error'] = "Usuario no encontrado.";
    header("Location: /login.php");
    exit;
  }

  // 6) Obtener fila
  $user = $get['query']->fetch_assoc();

  // 7) Validar activo
  if ((int)$user['activo'] !== 1) {
    $_SESSION['login_error'] = "Usuario inactivo.";
    header("Location: /login.php");
    exit;
  }

  // 8) Validar password con hash
  $hash = $user['password_hash'] ?? '';
  if ($hash === '' || !password_verify($pass, $hash)) {
    $_SESSION['login_error'] = "Contraseña incorrecta.";
    header("Location: /login.php");
    exit;
  }

  // 9) Crear sesión (esto es “ya está logueado”)
  $_SESSION['user_id']     = $user['id'];
  $_SESSION['user_nombre'] = $user['nombre'];
  $_SESSION['user_correo'] = $user['correo'];
  $_SESSION['user_rol']    = $user['rol'];

  unset($_SESSION['login_error']);

  // 10) Ir al área interna
  header("Location: /app.php");
  exit;
}

// ==========================
// LOGOUT
// ==========================
if ($action === 'logout') {
  session_unset();
  session_destroy();
  header("Location: /login.php");
  exit;
}

// Si llega sin action válido:
header("Location: /login.php");
exit;
3.3 login.php

Qué hace: página pública con formulario. Si el controlador dejó un error en $_SESSION['login_error'], aquí lo mostramos.

<?php
// login.php
if (session_status() === PHP_SESSION_NONE) {
  session_start();
}

// Tomar error y borrarlo (para que no se quede pegado)
$err = $_SESSION['login_error'] ?? '';
unset($_SESSION['login_error']);
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Login</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5">
  <div class="row justify-content-center">
    <div class="col-md-6 col-lg-5">
      <div class="card shadow-sm">
        <div class="card-body p-4">
          <h3 class="mb-3 text-center">Iniciar sesión</h3>

          <?php if ($err !== '') { ?>
            <div class="alert alert-danger"><?php echo $err; ?></div>
          <?php } ?>

          <form method="post" action="/controllers/AuthController.php?action=login" class="row g-3">
            <div class="col-12">
              <label class="form-label">Correo</label>
              <input type="email" name="correo" class="form-control" required>
            </div>

            <div class="col-12">
              <label class="form-label">Contraseña</label>
              <input type="password" name="password" class="form-control" required>
            </div>

            <div class="col-12 d-grid">
              <button class="btn btn-primary" type="submit">Entrar</button>
            </div>

            <div class="col-12 text-center">
              <a href="/index.php" class="text-decoration-none">Volver al inicio</a>
            </div>
          </form>

          <hr>
          <p class="text-muted small mb-0">
            Para probar login necesitas un usuario con <code>password_hash</code> guardado en la BD.
          </p>
        </div>
      </div>
    </div>
  </div>
</div>
</body>
</html>
3.4 app.php

Qué hace: página privada. Aquí es donde realmente se “controla” el acceso: al inicio se llama requireLogin().

<?php
// app.php
require_once __DIR__ . '/lib/auth.php';
requireLogin();
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Área interna</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
  <div class="card shadow-sm">
    <div class="card-body">
      <h3 class="mb-2">Área interna</h3>
      <p class="text-muted">Si estás aquí, es porque hay sesión activa.</p>

      <div class="alert alert-success">
        Bienvenido: <strong><?php echo $_SESSION['user_nombre']; ?></strong><br>
        Correo: <?php echo $_SESSION['user_correo']; ?><br>
        Rol: <?php echo $_SESSION['user_rol']; ?>
      </div>

      <div class="d-flex gap-2">
        <a class="btn btn-primary" href="/index.php">Ir al inicio</a>
        <a class="btn btn-outline-danger" href="/logout.php">Cerrar sesión</a>
      </div>
    </div>
  </div>
</div>
</body>
</html>
3.5 logout.php

Qué hace: destruye la sesión y redirige a login.

<?php
// logout.php
if (session_status() === PHP_SESSION_NONE) {
  session_start();
}
session_unset();
session_destroy();

header("Location: /login.php");
exit;

4) Paso 4 — MODIFICAR tu index.php (para que tenga Login/App/Logout)

Este es tu index.php actual pero actualizado para: mostrar botones según sesión y mostrar un aviso si hay sesión activa.

Aquí NO protegemos index. Index sigue siendo público, pero ahora “entiende” si hay sesión.
4.1 index.php (REEMPLAZAR)
<?php
// index.php
if (session_status() === PHP_SESSION_NONE) {
  session_start();
}
$logueado = isset($_SESSION['user_id']);
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mini proyecto PHP + MySQL</title>

  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
  <link rel="stylesheet" href="assets/css/style.css">
</head>

<body class="bg-light">
<main class="container py-5">

  <div class="d-flex justify-content-between align-items-center mb-3">
    <div>
      <h1 class="mb-1">Mini proyecto PHP + MySQL</h1>
      <p class="text-muted mb-0">Accesos rápidos a ejercicios + login/sesión.</p>
    </div>

    <div class="d-flex gap-2">
      <?php if (!$logueado) { ?>
        <a class="btn btn-primary" href="login.php">Login</a>
      <?php } else { ?>
        <a class="btn btn-success" href="app.php">App</a>
        <a class="btn btn-outline-danger" href="logout.php">Logout</a>
      <?php } ?>
    </div>
  </div>

  <?php if ($logueado) { ?>
    <div class="alert alert-success">
      Sesión activa: <strong><?php echo $_SESSION['user_nombre']; ?></strong>
      (<?php echo $_SESSION['user_correo']; ?>) — Rol: <?php echo $_SESSION['user_rol']; ?>
    </div>
  <?php } else { ?>
    <div class="alert alert-secondary">
      No hay sesión iniciada. Entra a <strong>Login</strong> para acceder al área interna.
    </div>
  <?php } ?>

  <div class="row g-3">

    <div class="col-md-6">
      <div class="card h-100 shadow-sm">
        <div class="card-body">
          <span class="badge text-bg-primary mb-2">SELECT</span>
          <h5 class="card-title">Listado de usuarios</h5>
          <p class="text-muted">Página protegida (pedirá login).</p>
          <a href="pages/usuarios_list.php" class="btn btn-primary">Ver usuarios</a>
        </div>
      </div>
    </div>

    <div class="col-md-6">
      <div class="card h-100 shadow-sm">
        <div class="card-body">
          <span class="badge text-bg-warning mb-2">GET</span>
          <h5 class="card-title">Crear usuario (GET)</h5>
          <p class="text-muted">Página protegida (pedirá login).</p>
          <a href="pages/usuario_form_get.php" class="btn btn-warning">Abrir formulario GET</a>
        </div>
      </div>
    </div>

    <div class="col-md-6">
      <div class="card h-100 shadow-sm">
        <div class="card-body">
          <span class="badge text-bg-success mb-2">POST</span>
          <h5 class="card-title">Crear venta (POST)</h5>
          <p class="text-muted">Página protegida (pedirá login).</p>
          <a href="pages/venta_form_post.php" class="btn btn-success">Abrir formulario POST</a>
        </div>
      </div>
    </div>

    <div class="col-md-6">
      <div class="card h-100 shadow-sm">
        <div class="card-body">
          <span class="badge text-bg-info mb-2">Async</span>
          <h5 class="card-title">Guardar venta async</h5>
          <p class="text-muted">Se usa dentro del form de ventas (POST).</p>
          <a href="pages/venta_form_post.php" class="btn btn-info text-white">Ir a ventas</a>
        </div>
      </div>
    </div>

  </div>

</main>
</body>
</html>

5) Paso 5 — PROTEGER tus páginas actuales en /pages

Aquí está el punto que te faltaba: no es “a ver tú decide”. Nosotros vamos a proteger TODAS tus páginas funcionales para que el proyecto “jale” con login.

¿Cómo se protege? Pegando estas 2 líneas al inicio (antes de HTML) de cada página:
require_once __DIR__ . '/../lib/auth.php';
requireLogin();

Archivos a modificar (todos en /pages):

5.1 Snippet de protección

Qué haces: lo pegas al inicio de cada archivo en /pages.

require_once __DIR__ . '/../lib/auth.php';
requireLogin();
En venta_save_async.php no conviene redirigir (porque lo consume fetch). Ahí, si no hay sesión, regresamos JSON con ok=false.

6) Paso 6 — Ajuste especial para venta_save_async.php (si no hay sesión → JSON)

6.1 pages/venta_save_async.php (REEMPLAZAR)
<?php
// pages/venta_save_async.php
header('Content-Type: application/json; charset=utf-8');

// Para endpoints async: iniciamos sesión y validamos.
// Si no hay sesión NO redirigimos (fetch no entiende eso bien),
// respondemos JSON con ok=false.

if (session_status() === PHP_SESSION_NONE) {
  session_start();
}

if (!isset($_SESSION['user_id'])) {
  echo json_encode(["ok" => false, "msg" => "No autorizado. Inicia sesión."]);
  exit;
}

require_once __DIR__ . '/../lib/Conexion.php';
require_once __DIR__ . '/../lib/Functions.php';

$functions = new Functions();

$usuario_id  = (int)($_POST['usuario_id'] ?? 0);
$producto_id = (int)($_POST['producto_id'] ?? 0);
$cantidad    = (int)($_POST['cantidad'] ?? 0);

if ($usuario_id <= 0 || $producto_id <= 0 || $cantidad <= 0) {
  echo json_encode(["ok" => false, "msg" => "Faltan datos válidos."]);
  exit;
}

$getPrecio = $functions->execute("SELECT precio FROM productos WHERE id = $producto_id LIMIT 1");
if ($getPrecio['res'] !== 'ok') {
  echo json_encode(["ok" => false, "msg" => $getPrecio['res']]);
  exit;
}
if ($getPrecio['query']->num_rows === 0) {
  echo json_encode(["ok" => false, "msg" => "Producto no encontrado."]);
  exit;
}

$row = $getPrecio['query']->fetch_assoc();
$precio = (float)$row['precio'];
$total = $precio * $cantidad;

$ins = $functions->execute("
  INSERT INTO ventas (usuario_id, producto_id, cantidad, total, fecha)
  VALUES ($usuario_id, $producto_id, $cantidad, $total, NOW())
");

if ($ins['res'] === 'ok') {
  echo json_encode(["ok" => true, "msg" => "Venta guardada. ID: " . $ins['lastId']]);
} else {
  echo json_encode(["ok" => false, "msg" => $ins['res']]);
}

7) Paso 7 — Crear usuario de prueba para poder loguearse

Para probar login necesitas un usuario existente con un hash guardado. Hacemos 2 pasos: generar hash y actualizarlo en la BD.

A) Generar hash (PHP temporal):

<?php
echo password_hash("123456", PASSWORD_DEFAULT);

B) Pegarlo en un UPDATE (DBeaver):

UPDATE usuarios
SET password_hash = 'PEGA_AQUI_EL_HASH'
WHERE correo = 'ana.lopez@mail.com';
Contraseña de prueba: 123456. (La que generaste el hash).

8) Prueba final (en orden)

  1. Ejecuta el ALTER TABLE para password_hash.
  2. Genera hash con PHP temporal y pégalo en un usuario.
  3. Entra a /login.php con ese correo + 123456.
  4. Debe mandarte a /app.php.
  5. Ahora entra a /pages/usuarios_list.php: debe dejarte (porque ya hay sesión).
  6. Haz logout y vuelve a entrar a /pages/usuarios_list.php: debe mandarte a login.