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"; ?>
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"; ?>
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, = htmlspecialchars($user['email']) ?>
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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación