Tema
Registro, login y logout
Sistema de autenticación completo con sesiones de PHP, contraseñas cifradas con password_hash, protección CSRF y regeneración del id de sesión.
Tabla necesaria
sql
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
rol ENUM('admin','editor','cliente') NOT NULL DEFAULT 'cliente',
activo TINYINT(1) NOT NULL DEFAULT 1,
ultimo_login DATETIME NULL,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;Archivos base
helpers.php contiene utilidades que usarás en todos los demás snippets: escapar HTML, CSRF, redirecciones y mensajes flash.
php
<?php
// Inicia la sesión con cookies seguras (una sola vez)
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']), // sólo HTTPS en producción
'httponly' => true, // JS no puede leer la cookie
'samesite' => 'Lax',
]);
session_start();
}
/** Escapa texto para imprimirlo en HTML (evita XSS) */
function e(?string $texto): string
{
return htmlspecialchars($texto ?? '', ENT_QUOTES, 'UTF-8');
}
function redirect(string $url): never
{
header('Location: ' . $url);
exit;
}
/** Token CSRF: uno por sesión */
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf" value="' . csrf_token() . '">';
}
function csrf_check(): void
{
$enviado = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $enviado)) {
http_response_code(419);
exit('La sesión expiró. Recarga la página e inténtalo de nuevo.');
}
}
/** Mensajes de una sola lectura: flash('ok', 'Guardado') / flash('ok') */
function flash(string $clave, ?string $mensaje = null): ?string
{
if ($mensaje !== null) {
$_SESSION['flash'][$clave] = $mensaje;
return null;
}
$valor = $_SESSION['flash'][$clave] ?? null;
unset($_SESSION['flash'][$clave]);
return $valor;
}
/** Recupera el valor anterior de un campo tras un error de validación */
function old(string $campo): string
{
return e($_SESSION['old'][$campo] ?? '');
}php
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/helpers.php';
/** Devuelve el usuario logueado o null */
function usuario(): ?array
{
static $usuario = false;
if ($usuario === false) {
$usuario = null;
if (!empty($_SESSION['usuario_id'])) {
$stmt = db()->prepare('SELECT id, nombre, email, rol FROM usuarios WHERE id = ? AND activo = 1');
$stmt->execute([$_SESSION['usuario_id']]);
$usuario = $stmt->fetch() ?: null;
}
}
return $usuario;
}
function login(array $usuario): void
{
session_regenerate_id(true); // evita fijación de sesión
$_SESSION['usuario_id'] = $usuario['id'];
db()->prepare('UPDATE usuarios SET ultimo_login = NOW() WHERE id = ?')->execute([$usuario['id']]);
}
function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/** Corta la ejecución si no hay sesión */
function requiere_login(): array
{
$u = usuario();
if (!$u) {
flash('error', 'Inicia sesión para continuar.');
redirect('login.php?volver=' . urlencode($_SERVER['REQUEST_URI']));
}
return $u;
}Registro
php
<?php
require __DIR__ . '/includes/auth.php';
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$nombre = trim($_POST['nombre'] ?? '');
$email = strtolower(trim($_POST['email'] ?? ''));
$pass = $_POST['password'] ?? '';
$pass2 = $_POST['password2'] ?? '';
// Validaciones en servidor (las del navegador se pueden saltar)
if (mb_strlen($nombre) < 2) $errores['nombre'] = 'Escribe tu nombre.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errores['email'] = 'Correo no válido.';
if (strlen($pass) < 8) $errores['password'] = 'Mínimo 8 caracteres.';
if ($pass !== $pass2) $errores['password2'] = 'Las contraseñas no coinciden.';
if (!$errores) {
$stmt = db()->prepare('SELECT 1 FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
$errores['email'] = 'Ese correo ya está registrado.';
}
}
if (!$errores) {
$stmt = db()->prepare('INSERT INTO usuarios (nombre, email, password_hash) VALUES (?, ?, ?)');
$stmt->execute([$nombre, $email, password_hash($pass, PASSWORD_DEFAULT)]);
login(['id' => (int) db()->lastInsertId()]);
flash('ok', '¡Bienvenido! Tu cuenta fue creada.');
redirect('panel.php');
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Crear cuenta</title></head>
<body>
<h1>Crear cuenta</h1>
<form method="post" novalidate>
<?= csrf_field() ?>
<label>Nombre <input name="nombre" value="<?= e($_POST['nombre'] ?? '') ?>" required></label>
<small><?= $errores['nombre'] ?? '' ?></small>
<label>Correo <input type="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" required></label>
<small><?= $errores['email'] ?? '' ?></small>
<label>Contraseña <input type="password" name="password" minlength="8" required></label>
<small><?= $errores['password'] ?? '' ?></small>
<label>Repite la contraseña <input type="password" name="password2" required></label>
<small><?= $errores['password2'] ?? '' ?></small>
<button>Registrarme</button>
</form>
<p>¿Ya tienes cuenta? <a href="login.php">Inicia sesión</a></p>
</body>
</html>Login con límite de intentos
php
<?php
require __DIR__ . '/includes/auth.php';
if (usuario()) redirect('panel.php');
$error = flash('error');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
// Freno simple contra fuerza bruta: 5 intentos cada 15 minutos por sesión
$_SESSION['intentos'] = array_filter($_SESSION['intentos'] ?? [], fn($t) => $t > time() - 900);
if (count($_SESSION['intentos']) >= 5) {
$error = 'Demasiados intentos. Espera 15 minutos.';
} else {
$email = strtolower(trim($_POST['email'] ?? ''));
$pass = $_POST['password'] ?? '';
$stmt = db()->prepare('SELECT id, password_hash FROM usuarios WHERE email = ? AND activo = 1');
$stmt->execute([$email]);
$u = $stmt->fetch();
if ($u && password_verify($pass, $u['password_hash'])) {
// Si PHP mejoró su algoritmo, actualiza el hash automáticamente
if (password_needs_rehash($u['password_hash'], PASSWORD_DEFAULT)) {
db()->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([password_hash($pass, PASSWORD_DEFAULT), $u['id']]);
}
unset($_SESSION['intentos']);
login($u);
// Sólo redirige a rutas internas (evita "open redirect")
$volver = $_GET['volver'] ?? 'panel.php';
if (!str_starts_with($volver, '/') || str_starts_with($volver, '//')) $volver = 'panel.php';
redirect($volver);
}
$_SESSION['intentos'][] = time();
$error = 'Correo o contraseña incorrectos.'; // mensaje genérico a propósito
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Iniciar sesión</title></head>
<body>
<h1>Iniciar sesión</h1>
<?php if ($error): ?><p style="color:#b91c1c"><?= e($error) ?></p><?php endif; ?>
<form method="post">
<?= csrf_field() ?>
<label>Correo <input type="email" name="email" required autofocus></label>
<label>Contraseña <input type="password" name="password" required></label>
<button>Entrar</button>
</form>
<p><a href="olvide.php">¿Olvidaste tu contraseña?</a> · <a href="registro.php">Crear cuenta</a></p>
</body>
</html>Página protegida y logout
php
<?php
require __DIR__ . '/includes/auth.php';
$u = requiere_login();
$ok = flash('ok');
?>
<h1>Hola, <?= e($u['nombre']) ?></h1>
<?php if ($ok): ?><p><?= e($ok) ?></p><?php endif; ?>
<!-- El logout va por POST con CSRF para que nadie te cierre la sesión con un enlace -->
<form method="post" action="logout.php">
<?= csrf_field() ?>
<button>Cerrar sesión</button>
</form>php
<?php
require __DIR__ . '/includes/auth.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
csrf_check();
logout();
redirect('login.php');Cómo integrarlo
- Asegúrate de tener la conexión PDO.
- Crea la tabla
usuarios. - Copia
helpers.phpyauth.phpenincludes/. - En cualquier página privada, la primera línea es
require 'includes/auth.php'; $u = requiere_login();.
Buena práctica
Nunca guardes la contraseña ni el hash en $_SESSION; guarda sólo el id. Así, si desactivas a un usuario en la base de datos, pierde el acceso en su siguiente petición.
Error común
Mostrar "el correo no existe" o "la contraseña es incorrecta" por separado. Eso le dice a un atacante qué correos están registrados. Usa siempre un mensaje genérico.