Sistema de autenticación seguro en PHP: registro, login, CSRF y sesiones

php Sistema de autenticación seguro en PHP: registro, login, CSRF y sesiones

Sistema de autenticación seguro en PHP (registro, login, CSRF, sesiones)

Este tutorial te guía paso a paso para crear un sistema de autenticación básico y seguro en PHP usando PDO, password_hash, tokens CSRF y buenas prácticas de sesión. Incluye código completo y explicaciones de por qué se toman estas decisiones.

Requisitos previos

  • PHP 7.4+ (idealmente 8.0+)
  • Servidor web (Apache/Nginx) y MySQL/MariaDB
  • Conocimientos básicos de PHP y SQL

Estructura de carpetas

project/
  public/
    index.php        
    register.php     
    dashboard.php    
    logout.php
  src/
    config.php
    db.php
    functions.php
  sql/
    users.sql

SQL: tabla de usuarios

-- sql/users.sql
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Archivo: src/config.php

<?php
// src/config.php
// Parámetros mínimos; en producción usa variables de entorno
return [
  'db_host' => '127.0.0.1',
  'db_name' => 'testdb',
  'db_user' => 'dbuser',
  'db_pass' => 'dbpass',
  'cookie_secure' => true,   // true si usas HTTPS
  'cookie_samesite' => 'Lax' // 'Strict' o 'Lax'
];

Archivo: src/db.php

<?php
// src/db.php
$config = require __DIR__ . '/config.php';

$dsn = 'mysql:host=' . $config['db_host'] . ';dbname=' . $config['db_name'] . ';charset=utf8mb4';
$options = [
  PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES => false,
];

try {
  $pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], $options);
} catch (PDOException $e) {
  // En producción loguea esto y muestra un mensaje genérico
  die('DB connection failed: ' . $e->getMessage());
}

return $pdo;

Archivo: src/functions.php

<?php
// src/functions.php

function start_secure_session(array $config)
{
  $cookieParams = session_get_cookie_params();
  session_set_cookie_params([
    'lifetime' => $cookieParams['lifetime'],
    'path' => $cookieParams['path'],
    'domain' => $cookieParams['domain'],
    'secure' => $config['cookie_secure'],
    'httponly' => true,
    'samesite' => $config['cookie_samesite'],
  ]);
  if (session_status() === PHP_SESSION_NONE) {
    session_start();
  }
}

function generate_csrf_token()
{
  if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
  }
  return $_SESSION['csrf_token'];
}

function verify_csrf_token($token)
{
  return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}

function is_logged_in()
{
  return !empty($_SESSION['user_id']);
}

function require_login()
{
  if (!is_logged_in()) {
    header('Location: index.php');
    exit;
  }
}

Archivo: public/register.php (registro)

<?php
// public/register.php
require __DIR__ . '/../src/db.php';
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);

$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  if (!verify_csrf_token($_POST['csrf'] ?? '')) {
    $errors[] = 'Token CSRF inválido.';
  }

  $email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
  $password = $_POST['password'] ?? '';

  if (!$email) {
    $errors[] = 'Email inválido.';
  }
  if (strlen($password) < 8) {
    $errors[] = 'La contraseña debe tener al menos 8 caracteres.';
  }

  if (empty($errors)) {
    // Comprobar usuario existente
    $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
    $stmt->execute([$email]);
    if ($stmt->fetch()) {
      $errors[] = 'El email ya está registrado.';
    } else {
      $hash = password_hash($password, PASSWORD_DEFAULT);
      $stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (?, ?)');
      $stmt->execute([$email, $hash]);
      header('Location: index.php?registered=1');
      exit;
    }
  }
}

$csrf = generate_csrf_token();
?>
<!doctype html>
<html lang='es'>
<head>
  <meta charset='utf-8'>
  <title>Registro</title>
</head>
<body>
  <h1>Registro</h1>
  <?php if ($errors): ?>
    <ul><?php foreach ($errors as $e) echo '<li>' . htmlspecialchars($e, ENT_QUOTES, 'UTF-8') . '</li>'; ?></ul>
  <?php endif; ?>

  <form method='post' action='register.php'>
    <label>Email:<input type='email' name='email' required></label><br>
    <label>Contraseña:<input type='password' name='password' required></label><br>
    <input type='hidden' name='csrf' value='<?php echo htmlspecialchars($csrf); ?>'>
    <button type='submit'>Registrar</button>
  </form>

  <p><a href='index.php'>Ir al login</a></p>
</body>
</html>

Archivo: public/index.php (login)

<?php
// public/index.php
require __DIR__ . '/../src/db.php';
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);

$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  if (!verify_csrf_token($_POST['csrf'] ?? '')) {
    $errors[] = 'Token CSRF inválido.';
  }

  $email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
  $password = $_POST['password'] ?? '';

  if (!$email) {
    $errors[] = 'Email inválido.';
  }

  if (empty($errors)) {
    $stmt = $pdo->prepare('SELECT id, password FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $user = $stmt->fetch();

    if ($user && password_verify($password, $user['password'])) {
      // Login exitoso
      session_regenerate_id(true);
      $_SESSION['user_id'] = $user['id'];
      header('Location: dashboard.php');
      exit;
    } else {
      $errors[] = 'Credenciales incorrectas.';
    }
  }
}

$csrf = generate_csrf_token();
?>
<!doctype html>
<html lang='es'>
<head>
  <meta charset='utf-8'>
  <title>Login</title>
</head>
<body>
  <h1>Login</h1>
  <?php if (isset($_GET['registered'])) echo '<p>Registro completado, ya puedes iniciar sesión.</p>'; ?>
  <?php if ($errors): ?>
    <ul><?php foreach ($errors as $e) echo '<li>' . htmlspecialchars($e, ENT_QUOTES, 'UTF-8') . '</li>'; ?></ul>
  <?php endif; ?>

  <form method='post' action='index.php'>
    <label>Email:<input type='email' name='email' required></label><br>
    <label>Contraseña:<input type='password' name='password' required></label><br>
    <input type='hidden' name='csrf' value='<?php echo htmlspecialchars($csrf); ?>'>
    <button type='submit'>Entrar</button>
  </form>

  <p><a href='register.php'>Crear cuenta</a></p>
</body>
</html>

Archivo: public/dashboard.php (área privada)

<?php
// public/dashboard.php
require __DIR__ . '/../src/db.php';
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);
require_login();

// Obtener datos del usuario
$stmt = $pdo->prepare('SELECT email, created_at FROM users WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
?>
<!doctype html>
<html lang='es'>
<head>
  <meta charset='utf-8'>
  <title>Dashboard</title>
</head>
<body>
  <h1>Dashboard</h1>
  <p>Bienvenido <?php echo htmlspecialchars($user['email'], ENT_QUOTES, 'UTF-8'); ?>.</p>
  <p>Cuenta creada: <?php echo htmlspecialchars($user['created_at'], ENT_QUOTES, 'UTF-8'); ?>.</p>
  <p><a href='logout.php'>Cerrar sesión</a></p>
</body>
</html>

Archivo: public/logout.php

<?php
// public/logout.php
$config = require __DIR__ . '/../src/config.php';
require __DIR__ . '/../src/functions.php';
start_secure_session($config);

// Destruir sesión de forma segura
$_SESSION = [];
if (ini_get('session.use_cookies')) {
  $params = session_get_cookie_params();
  setcookie(session_name(), '', time() - 42000,
    $params['path'], $params['domain'], $params['secure'], $params['httponly']
  );
}
session_destroy();

header('Location: index.php');
exit;

Por qué tomamos estas decisiones

  • PDO con prepared statements: evita inyección SQL y es portable.
  • password_hash / password_verify: maneja sal y algoritmos seguros (bcrypt/Argon2 si disponible).
  • session_regenerate_id(true): previene fijación de sesión tras login.
  • HttpOnly + Secure + SameSite en cookies: reduce riesgo de XSS/CSRF y robo de sesión.
  • Tokens CSRF: protegen las operaciones por POST de solicitudes forjadas.
  • Validación y saneamiento: evita datos inválidos o maliciosos en la entrada.

Notas prácticas y mejoras recomendadas

  • Usa HTTPS siempre en producción (tene en cuenta 'cookie_secure' = true).
  • Implementa límites de intentos de login y bloqueo temporal para mitigar fuerza bruta.
  • Considera Argon2 (password_hash soporta PASSWORD_ARGON2ID) si está disponible en tu entorno.
  • Añade verificación por email y 2FA para mayor seguridad.
  • En producción, gestiona la configuración sensible vía variables de entorno y no en archivos de texto.

Siguiente paso: integra un sistema de bloqueo y registrador de intentos de login, y habilita autenticación multifactor para dotar al sistema de una capa extra de seguridad.

Advertencia de seguridad: este ejemplo es una base didáctica. Antes de desplegar a producción, revisa políticas de cifrado, protección contra ataques automatizados y auditoría de dependencias.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión