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

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

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

En este tutorial construirás un sistema de autenticación básico pero seguro con PHP puro: registro, login, logout, protección CSRF, almacenamiento seguro de contraseñas y buenas prácticas de sesión. Código completo, estructura de carpetas y explicación de por qué se hacen las cosas así.

Requisitos previos

  • PHP 8.0+ (idealmente 8.1/8.2)
  • Servidor web (Apache/Nginx) o PHP built-in server para pruebas
  • MySQL / MariaDB
  • Acceso para crear una base de datos
  • Conocimientos básicos de PHP, PDO y HTML

Estructura de carpetas

auth-php/
├─ public/
│  ├─ index.php        # Página pública (redirección a login)
│  ├─ register.php
│  ├─ login.php
│  ├─ dashboard.php
│  └─ logout.php
├─ src/
│  ├─ init.php        # Inicia sesión y BD
│  └─ functions.php   # Helpers: csrf, auth, util
├─ config.php         # Configuración (BD, opciones)
├─ migrations.sql     # Tabla users
└─ .htaccess          # (opcional) ajustes de seguridad para Apache

SQL: creación de la tabla users

-- migrations.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: config.php

<?php
// config.php
return [
    'db' => [
        'host' => '127.0.0.1',
        'name' => 'auth_db',
        'user' => 'dbuser',
        'pass' => 'dbpass',
        'charset' => 'utf8mb4',
    ],
    // Opciones adicionales
    'session_cookie_secure' => true, // poner true en producción con HTTPS
];

Archivo: src/init.php

<?php
// src/init.php
// Inicia sesión y crea la conexión PDO
if (session_status() === PHP_SESSION_NONE) {
    // Configuraciones de seguridad de la cookie
    $cfg = require __DIR__ . '/../config.php';
    session_set_cookie_params([
        'httponly' => true,
        'secure' => $cfg['session_cookie_secure'] ?? false,
        'samesite' => 'Lax',
    ]);
    session_start();
}

// Conexión PDO
$config = require __DIR__ . '/../config.php';
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s', $config['db']['host'], $config['db']['name'], $config['db']['charset']);
try {
    $pdo = new PDO($dsn, $config['db']['user'], $config['db']['pass'], [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
} catch (PDOException $e) {
    // En producción no devuelvas el mensaje completo
    error_log('DB connection failed: ' . $e->getMessage());
    http_response_code(500);
    echo 'Error de conexión a la base de datos.';
    exit;
}

// Exponer $pdo
$GLOBALS['pdo'] = $pdo;

Archivo: src/functions.php

<?php
// src/functions.php
require_once __DIR__ . '/init.php';

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

function verify_csrf_token(?string $token): bool
{
    if (empty($token) || empty($_SESSION['csrf_token'])) return false;
    return hash_equals($_SESSION['csrf_token'], $token);
}

function sanitize(string $s): string
{
    return htmlspecialchars(trim($s), ENT_QUOTES, 'UTF-8');
}

function get_user_by_email(string $email)
{
    $pdo = $GLOBALS['pdo'];
    $stmt = $pdo->prepare('SELECT id, email, password FROM users WHERE email = :email');
    $stmt->execute(['email' => $email]);
    return $stmt->fetch();
}

function create_user(string $email, string $password): bool
{
    $pdo = $GLOBALS['pdo'];
    $hash_algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
    $hash = password_hash($password, $hash_algo);
    $stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (:email, :password)');
    try {
        return $stmt->execute(['email' => $email, 'password' => $hash]);
    } catch (PDOException $e) {
        // duplicate email etc.
        return false;
    }
}

function login_user(int $user_id)
{
    // Regenera id de sesión para evitar fixation
    session_regenerate_id(true);
    $_SESSION['user_id'] = $user_id;
    // Opcional: almacenar fingerprint (user agent + ip parcial)
    $_SESSION['fingerprint'] = hash('sha256', ($_SERVER['HTTP_USER_AGENT'] ?? '') . (getenv('REMOTE_ADDR') ?? ''));
}

function is_authenticated(): bool
{
    if (empty($_SESSION['user_id'])) return false;
    // Verificar fingerprint si existe
    if (!empty($_SESSION['fingerprint'])) {
        $fp = hash('sha256', ($_SERVER['HTTP_USER_AGENT'] ?? '') . (getenv('REMOTE_ADDR') ?? ''));
        return hash_equals($_SESSION['fingerprint'], $_SESSION['fingerprint']);
    }
    return true;
}

function require_login()
{
    if (empty($_SESSION['user_id'])) {
        header('Location: login.php');
        exit;
    }
}

function logout()
{
    // Borra session y cookies
    $_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();
}

Archivo: public/register.php

<?php
require_once __DIR__ . '/../src/functions.php';

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

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

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

    if (empty($errors)) {
        if (get_user_by_email($email)) {
            $errors[] = 'Ya existe un usuario con ese email.';
        } else {
            $ok = create_user($email, $password);
            if ($ok) {
                header('Location: login.php?registered=1');
                exit;
            } else {
                $errors[] = 'Error al crear usuario.';
            }
        }
    }
}

$token = csrf_token();
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Registro</title>
</head>
<body>
  <h2>Registro</h2>
  <?php if ($errors): ?>
    <ul style="color:red">
      <?php foreach ($errors as $e) echo "<li>".htmlspecialchars($e)."</li>"; ?>
    </ul>
  <?php endif; ?>

  <form method="post" action="">
    <input type="hidden" name="csrf" value="<?= htmlspecialchars($token) ?>"/>
    <label>Email: <input type="email" name="email" required /></label><br/>
    <label>Contraseña: <input type="password" name="password" required /></label><br/>
    <label>Repite contraseña: <input type="password" name="password2" required /></label><br/>
    <button type="submit">Registrarse</button>
  </form>
  <p><a href="login.php">Ir a login</a></p>
</body>
</html>

Archivo: public/login.php

<?php
require_once __DIR__ . '/../src/functions.php';

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

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

    if (!$email || $password === '') {
        $errors[] = 'Email y contraseña son obligatorios.';
    }

    if (empty($errors)) {
        $user = get_user_by_email($email);
        if (!$user || !password_verify($password, $user['password'])) {
            $errors[] = 'Credenciales inválidas.';
        } else {
            login_user((int)$user['id']);
            header('Location: dashboard.php');
            exit;
        }
    }
}

$token = csrf_token();
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Login</title>
</head>
<body>
  <h2>Login</h2>
  <?php if (!empty($_GET['registered'])) echo '<p style="color:green">Registro completado, ya puedes iniciar sesión.</p>'; ?>
  <?php if ($errors): ?>
    <ul style="color:red">
      <?php foreach ($errors as $e) echo "<li>".htmlspecialchars($e)."</li>"; ?>
    </ul>
  <?php endif; ?>

  <form method="post" action="">
    <input type="hidden" name="csrf" value="<?= htmlspecialchars($token) ?>"/>
    <label>Email: <input type="email" name="email" required /></label><br/>
    <label>Contraseña: <input type="password" name="password" required /></label><br/>
    <button type="submit">Entrar</button>
  </form>
  <p><a href="register.php">Registrarse</a></p>
</body>
</html>

Archivo: public/dashboard.php

<?php
require_once __DIR__ . '/../src/functions.php';
require_login();

// Obtener datos usuario si deseas
$pdo = $GLOBALS['pdo'];
$stmt = $pdo->prepare('SELECT email, created_at FROM users WHERE id = :id');
$stmt->execute(['id' => $_SESSION['user_id']]);
$user = $stmt->fetch();
?>
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Dashboard</title>
</head>
<body>
  <h2>Dashboard</h2>
  <p>Has iniciado sesión como <?= htmlspecialchars($user['email'] ?? 'usuario') ?></p>
  <p>Miembro desde: <?= htmlspecialchars($user['created_at'] ?? '') ?></p>
  <p><a href="logout.php">Cerrar sesión</a></p>
</body>
</html>

Archivo: public/logout.php

<?php
require_once __DIR__ . '/../src/functions.php';
logout();
header('Location: login.php');
exit;

Archivo: public/index.php

<?php
header('Location: login.php');
exit;

.htaccess (opcional para Apache)

# .htaccess
# Forzar headers de seguridad básicos (ajusta según tu infraestructura)
<ifModule mod_headers.c>
  Header always set X-Content-Type-Options: "nosniff"
  Header always set X-Frame-Options: "SAMEORIGIN"
  Header always set X-XSS-Protection: "1; mode=block"
</ifModule>

Por qué estas decisiones (no solo cómo)

  • PDO con prepared statements: evita SQL injection y es portable.
  • password_hash/password_verify: almacena hashes con sal automática y algoritmos robustos (Argon2 si está disponible).
  • session_regenerate_id(true) al iniciar sesión: evita session fixation (un atacante reusa una sesión conocida).
  • CSRF token por formulario: protege acciones que cambian estado (registro, login). Aunque el login CSRF es menos crítico, incluirlo es buena práctica.
  • session cookie flags (httponly, secure, samesite): reducen riesgos de XSS y CSRF en cookies.
  • Validación del lado servidor (email, longitud de contraseña): evita datos incorrectos o intentos de abuso.
  • No mostrar errores de BD al usuario: evita leak de información sensible.

Pruebas rápidas

  1. Importa migrations.sql en tu BD y configura config.php.
  2. Coloca la carpeta public/ como root web (DocumentRoot) o usa php -S localhost:8000 -t public
  3. Accede a /register.php, crea usuario y prueba login/logout.

Limitaciones y siguientes pasos

  • Este ejemplo no implementa rate-limiting ni bloqueo de cuenta tras intentos fallidos: agrega throttling (Redis o tablas) en producción.
  • Considera 2FA (TOTP) para mayor seguridad.
  • Usa HTTPS en todo el sitio y HSTS en servidor.
  • Para aplicaciones grandes, separa lógica en capas (MVC), usa un framework y librerías maduras para autenticación y CSRF.

Advertencia de seguridad:

En producción, asegúrate de servir siempre por HTTPS, habilitar las banderas de cookie apropiadas, limitar intentos de login y almacenar las credenciales de BD fuera del repositorio (variables de entorno o un gestor de secretos).

Si quieres, puedo mostrar cómo añadir bloqueo por intentos fallidos, integración con Redis para sesiones y rate-limiting, o migrar este ejemplo a un micro-framework como Slim o Symfony para escalabilidad y pruebas automatizadas.

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