Proyecto: Sistema de autenticación seguro en PHP (PDO, sesiones, CSRF, Remember‑Me)

php Proyecto: Sistema de autenticación seguro en PHP (PDO, sesiones, CSRF, Remember‑Me)

Proyecto: Sistema de autenticación seguro en PHP

En este tutorial vas a construir un sistema de autenticación seguro en PHP usando PDO, password_hash, protección CSRF, gestión segura del "remember‑me" (tokens rotativos) y un control simple de intentos fallidos. Se entrega código completo y explicaciones de por qué se toman ciertas decisiones.

Requisitos previos

  • PHP 7.4+ (preferible 8+).
  • MySQL/MariaDB.
  • Extensión PDO habilitada.
  • Servidor web (Apache/Nginx) o PHP built‑in server para pruebas.
  • Conocimientos básicos de sesiones y cookies en PHP.

Estructura de carpetas


project/
├─ public/                # Archivos públicos
│  ├─ index.php           # Redirige a login o dashboard
│  ├─ login.php
│  ├─ register.php
│  ├─ logout.php
│  └─ dashboard.php
├─ src/
│  ├─ config.php         # Configuración
│  ├─ db.php             # Conexión PDO
│  ├─ functions.php      # Helpers (auth, csrf, rate limit)
│  └─ middleware.php     # Reglas de acceso
└─ sql/
   └─ migrations.sql

SQL: migraciones

-- sql/migrations.sql
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE auth_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  selector CHAR(32) NOT NULL,
  token_hash CHAR(64) NOT NULL,
  expires_at DATETIME NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  UNIQUE (selector)
);

CREATE TABLE failed_logins (
  id INT AUTO_INCREMENT PRIMARY KEY,
  ip VARBINARY(16) NOT NULL,
  attempts INT NOT NULL DEFAULT 0,
  last_attempt DATETIME NOT NULL,
  UNIQUE(ip)
);

Archivo: src/config.php

<?php
// src/config.php
// Ajusta estos valores para tu entorno
return [
    'db' => [
        'host' => '127.0.0.1',
        'dbname' => 'auth_demo',
        'user' => 'dbuser',
        'pass' => 'dbpass',
        'charset' => 'utf8mb4',
    ],
    'cookie' => [
        'name' => 'remember',
        'lifetime' => 60 * 60 * 24 * 30, // 30 días
        'path' => '/',
        'domain' => '',
        'secure' => true, // true en producción (HTTPS)
        'httponly' => true,
        'samesite' => 'Lax',
    ],
    'session' => [
        'name' => 'app_session',
        'cookie_lifetime' => 0, // hasta cerrar navegador
    ],
    'rate_limit' => [
        'max_attempts' => 5,
        'window_seconds' => 900, // 15 minutos
    ],
];

Archivo: src/db.php

<?php
// src/db.php
$config = require __DIR__ . '/config.php';
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s',
    $config['db']['host'],
    $config['db']['dbname'],
    $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,
    ]);
} catch (PDOException $e) {
    // En producción registra el error en lugar de mostrarlo
    die('DB connection failed');
}

return $pdo;

Archivo: src/functions.php

<?php
// src/functions.php
session_start();

$config = require __DIR__ . '/config.php';
$pdo = require __DIR__ . '/db.php';

// --- Helpers generales ---
function redirect($url) {
    header('Location: ' . $url);
    exit;
}

// --- CSRF ---
function csrf_token() {
    if (empty($_SESSION['_csrf'])) {
        $_SESSION['_csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['_csrf'];
}

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

// --- Rate limiting (por IP) ---
function ip_bin() {
    // IPv4/IPv6 support, store packed binary
    return inet_pton($_SERVER['REMOTE_ADDR']);
}

function rate_get($pdo) {
    $ip = ip_bin();
    $stmt = $pdo->prepare('SELECT attempts, last_attempt FROM failed_logins WHERE ip = :ip');
    $stmt->execute(['ip' => $ip]);
    return $stmt->fetch();
}

function rate_increment($pdo, $config) {
    $ip = ip_bin();
    $now = date('Y-m-d H:i:s');
    $existing = rate_get($pdo);
    if ($existing) {
        $stmt = $pdo->prepare('UPDATE failed_logins SET attempts = attempts + 1, last_attempt = :now WHERE ip = :ip');
        $stmt->execute(['now' => $now, 'ip' => $ip]);
    } else {
        $stmt = $pdo->prepare('INSERT INTO failed_logins (ip, attempts, last_attempt) VALUES (:ip, 1, :now)');
        $stmt->execute(['ip' => $ip, 'now' => $now]);
    }
}

function rate_clear($pdo) {
    $ip = ip_bin();
    $stmt = $pdo->prepare('DELETE FROM failed_logins WHERE ip = :ip');
    $stmt->execute(['ip' => $ip]);
}

function rate_check_blocked($pdo, $config) {
    $entry = rate_get($pdo);
    if (!$entry) return false;
    $attempts = (int)$entry['attempts'];
    $last = strtotime($entry['last_attempt']);
    if ($attempts >= $config['rate_limit']['max_attempts']) {
        $window = $config['rate_limit']['window_seconds'];
        if (time() - $last < $window) return true; // bloqueado
        // si ya pasó la ventana, resetea
        $stmt = $pdo->prepare('UPDATE failed_logins SET attempts = 0 WHERE ip = :ip');
        $stmt->execute(['ip' => ip_bin()]);
        return false;
    }
    return false;
}

// --- Autenticación y remember-me ---
function register_user($pdo, $email, $password) {
    $hash = password_hash($password, PASSWORD_DEFAULT);
    $stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :ph)');
    $stmt->execute(['email' => $email, 'ph' => $hash]);
    return $pdo->lastInsertId();
}

function find_user_by_email($pdo, $email) {
    $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
    $stmt->execute(['email' => $email]);
    return $stmt->fetch();
}

function login_user($pdo, $user_id) {
    session_regenerate_id(true);
    $_SESSION['user_id'] = $user_id;
}

function logout_user($pdo) {
    // eliminar remember token si existe
    global $config;
    if (isset($_COOKIE[$config['cookie']['name']])) {
        list($selector, $validator) = explode(':', $_COOKIE[$config['cookie']['name']]);
        $stmt = $pdo->prepare('DELETE FROM auth_tokens WHERE selector = :sel');
        $stmt->execute(['sel' => $selector]);
        setcookie($config['cookie']['name'], '', time() - 3600, $config['cookie']['path']);
    }
    $_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();
}

function create_remember_token($pdo, $user_id, $config) {
    // selector (16 bytes hex) + validator (32 bytes hex) -> store hashed validator
    $selector = bin2hex(random_bytes(16));
    $validator = bin2hex(random_bytes(32));
    $token_hash = hash('sha256', $validator);
    $expires = date('Y-m-d H:i:s', time() + $config['cookie']['lifetime']);

    // guarda en DB
    $stmt = $pdo->prepare('INSERT INTO auth_tokens (user_id, selector, token_hash, expires_at) VALUES (:uid, :sel, :hash, :exp)');
    $stmt->execute(['uid' => $user_id, 'sel' => $selector, 'hash' => $token_hash, 'exp' => $expires]);

    // establece cookie como selector:validator
    $cookieVal = $selector . ':' . $validator;
    setcookie(
        $config['cookie']['name'],
        $cookieVal,
        time() + $config['cookie']['lifetime'],
        $config['cookie']['path'],
        $config['cookie']['domain'] ?: null,
        $config['cookie']['secure'],
        $config['cookie']['httponly']
    );
}

function validate_remember_cookie($pdo, $config) {
    if (empty($_COOKIE[$config['cookie']['name']])) return false;
    $val = $_COOKIE[$config['cookie']['name']];
    if (!strpos($val, ':')) return false;
    list($selector, $validator) = explode(':', $val);
    if (!ctype_xdigit($selector) || !ctype_xdigit($validator)) return false;

    $stmt = $pdo->prepare('SELECT * FROM auth_tokens WHERE selector = :sel');
    $stmt->execute(['sel' => $selector]);
    $row = $stmt->fetch();
    if (!$row) return false;
    if (strtotime($row['expires_at']) < time()) {
        // expirado: borra
        $stmt = $pdo->prepare('DELETE FROM auth_tokens WHERE id = :id');
        $stmt->execute(['id' => $row['id']]);
        return false;
    }

    if (hash_equals($row['token_hash'], hash('sha256', $validator))) {
        // válido: loguea y rota token (evita reuso)
        login_user($pdo, $row['user_id']);
        // rotación: borra el token antiguo y crea uno nuevo
        $stmt = $pdo->prepare('DELETE FROM auth_tokens WHERE id = :id');
        $stmt->execute(['id' => $row['id']]);
        create_remember_token($pdo, $row['user_id'], $config);
        return true;
    }
    // posible ataque: borra todos los tokens con ese selector
    $stmt = $pdo->prepare('DELETE FROM auth_tokens WHERE selector = :sel');
    $stmt->execute(['sel' => $selector]);
    setcookie($config['cookie']['name'], '', time() - 3600, $config['cookie']['path']);
    return false;
}

function require_auth() {
    global $pdo, $config;
    if (!empty($_SESSION['user_id'])) return $_SESSION['user_id'];
    // intentar remember-me
    if (validate_remember_cookie($pdo, $config)) {
        return $_SESSION['user_id'] ?? null;
    }
    redirect('/public/login.php');
}

function current_user($pdo) {
    if (empty($_SESSION['user_id'])) return null;
    $stmt = $pdo->prepare('SELECT id, email, created_at FROM users WHERE id = :id');
    $stmt->execute(['id' => $_SESSION['user_id']]);
    return $stmt->fetch();
}

Archivo: public/register.php

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

$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!csrf_validate($_POST['_csrf'] ?? '')) {
        $errors[] = 'Token CSRF inválido.';
    }
    $email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
    $password = $_POST['password'] ?? '';
    $password2 = $_POST['password_confirm'] ?? '';

    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 (find_user_by_email($pdo, $email)) {
            $errors[] = 'El email ya está registrado.';
        } else {
            $userId = register_user($pdo, $email, $password);
            login_user($pdo, $userId);
            redirect('/public/dashboard.php');
        }
    }
}

?>

Register

Registro

    $e"; ?>



Ir a login

Archivo: public/login.php

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

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

    if (rate_check_blocked($pdo, $config)) {
        $errors[] = 'Demasiados intentos. Intenta de nuevo más tarde.';
    } else {
        $email = $_POST['email'] ?? '';
        $password = $_POST['password'] ?? '';
        $remember = isset($_POST['remember']);

        $user = find_user_by_email($pdo, $email);
        if ($user && password_verify($password, $user['password_hash'])) {
            rate_clear($pdo);
            login_user($pdo, $user['id']);
            if ($remember) create_remember_token($pdo, $user['id'], $config);
            redirect('/public/dashboard.php');
        } else {
            rate_increment($pdo, $config);
            $errors[] = 'Email o contraseña incorrectos.';
        }
    }
}

?>

Login

Login

    $e"; ?>



Crear cuenta

Archivo: public/logout.php

<?php
require_once __DIR__ . '/../src/functions.php';
logout_user($pdo);
redirect('/public/login.php');

Archivo: public/dashboard.php

<?php
require_once __DIR__ . '/../src/functions.php';
$user_id = require_auth();
$user = current_user($pdo);
?>

Dashboard

Dashboard

Bienvenido,

Cerrar sesión

Archivo: public/index.php

<?php
// Redirige rápido 
header('Location: /public/login.php');
exit;

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

  • PDO con prepared statements: evita inyección SQL y es portable.
  • password_hash / password_verify: usa bcrypt/Argon2 según configuración de PHP, manejo seguro de contraseñas.
  • CSRF token en sesión: previene solicitudes cruzadas al cambiar estado (login/register form).
  • Remember‑me por tokens rotativos: en lugar de guardar password en cookie, se usa selector+validator. El validator se almacena hasheado en DB y se rota en cada uso para mitigar robo y replay.
  • Rate limiting por IP y ventana temporal ayuda contra fuerza bruta; se puede mejorar por cuenta o con captcha.
  • session_regenerate_id al loguear: evita fijación de sesión.
  • Cookie flags (Secure, HttpOnly, SameSite): protegen contra robo de cookies y CSRF según configuración.

Notas prácticas

  • En desarrollo, setea secure=false en config.php si pruebas sin HTTPS, pero en producción debe ser true.
  • Almacena las credenciales DB en variables de entorno o un archivo que no esté en repo.
  • Considera limpiar tokens expirados periódicamente con un CRON (DELETE FROM auth_tokens WHERE expires_at < NOW()).

Consejo avanzado: integra autenticación por factores (TOTP) y utiliza Content Security Policy + Strict-Transport-Security en el servidor. Importante: revisa y limita los privilegios de la cuenta DB y registra accesos fallidos para detectar patrones anómalos.

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