Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Nieto2020/portalhub/llms.txt

Use this file to discover all available pages before exploring further.

PortalHub implements role-based access control (RBAC) with three distinct roles. Every protected API endpoint calls the checkAuth() or checkRole() middleware function before executing any business logic — if the session is absent, expired, or under-privileged, the request is rejected immediately with a JSON error response and no further code runs.

Role Definitions

Roles are defined as PHP constants in backend/config/config.php and stored as integers in the cat_roles table and the usuarios.id_rol column.
// backend/config/config.php

// Definición de Roles
define('ROL_ADMIN',   1);
define('ROL_ASESOR',  2);
define('ROL_CLIENTE', 3);
Constantid_rolRole NameDescription
ROL_ADMIN1AdministradorFull platform control — user management, assignments, reporting dashboard, announcements
ROL_ASESOR2AsesorAccounting advisor — manages assigned clients, appointments, documents, and reports
ROL_CLIENTE3ClienteEnd client — views own data, uploads documents, messages their advisor, submits ratings

Middleware: auth.php

Both guard functions live in backend/middleware/auth.php and are required at the top of every protected PHP script.

checkAuth()

Validates that a valid, non-expired session exists and enforces the force-password-change gate.
function checkAuth($actualizarActividad = true) {
    if (!isset($_SESSION['id_usuario'])) {
        sendResponse(401, "No autorizado. Inicie sesión Primero.");
    }

    # Bloqueo por cambio de contraseña obligatorio
    if (isset($_SESSION['require_password_change']) && $_SESSION['require_password_change'] == 1) {
        $current_script = basename($_SERVER['PHP_SELF']);
        if ($current_script !== 'update_password.php' && $current_script !== 'logout.php' && $current_script !== 'check_session.php') {
            sendResponse(403, "Cambio de contraseña obligatorio", ["require_password_change" => true]);
        }
    }

    # Control de inactividad
    if (isset($_SESSION['ultima_actividad'])) {
        $inactividad = time() - $_SESSION['ultima_actividad'];

        if ($inactividad > SESSION_TIMEOUT) {
            session_unset();
            session_destroy();
            sendResponse(401, "Sesión expirada por inactividad. Por favor, ingrese de nuevo.");
        }
    }

    # Actualizar marca de tiempo de actividad solo si se solicita
    if ($actualizarActividad) {
        $_SESSION['ultima_actividad'] = time();
    }
}

checkRole()

Calls checkAuth() first, then verifies the session role is in the caller-supplied allowlist.
function checkRole(array $allowedRoles) {
    checkAuth();
    if (!in_array($_SESSION['id_rol'], $allowedRoles)) {
        sendResponse(403, "Acceso denegado. Permisos insuficientes.");
    }
}
Usage pattern — every restricted endpoint begins with one of these calls:
// Admins only
checkRole([ROL_ADMIN]);

// Advisors and admins
checkRole([ROL_ADMIN, ROL_ASESOR]);

// Any authenticated user
checkAuth();

Permissions Matrix

The table below summarises which roles can access each feature area. Scoping rules for partial access (🔒) are enforced in SQL WHERE clauses within each module, not in the middleware itself.
Feature / ModuleAdminAsesorCliente
User management (CRUD, status, password reset)
Assignments (create / close client–advisor pairs)🔒 view own
Appointments (schedule, reschedule, cancel)🔒 own🔒 own
Documents (upload, download, delete)🔒 own clients🔒 own
Payments (register, approve, invoice status)🔒 own clients🔒 own
Messages (chat & tickets)🔒 own conversations🔒 own conversations
Notifications (broadcast & per-user)✅ broadcast🔒 own🔒 own
Reports (create)✅ own reports only
Reports (view / list)✅ all🔒 own🔒 own (read-only)
Ratings (submit 1–5 star score)❌ receive only✅ submit
Announcements (create / toggle active)✅ create🔒 read only🔒 read only
Profile (view / edit own profile)
Dashboard / Reports overview
Legend: ✅ Full access  |  🔒 Own data only  |  ❌ No access

Session Security

Security settings are applied at the PHP level in config.php before any session is started:
// backend/config/config.php

ini_set('session.cookie_httponly', 1);

//proteccion contra formularios, CSRF, request
$isSecure = false;
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
    $isSecure = true;
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'
    || !empty($_SERVER['HTTP_FRONT_END_HTTPS']) && $_SERVER['HTTP_FRONT_END_HTTPS'] !== 'off') {
    $isSecure = true;
}
ini_set('session.cookie_secure', $isSecure ? 1 : 0);
ini_set('session.use_only_cookies', 1);
ini_set('session.cookie_samesite', 'Lax');

// Configuración de sesión (30 minutos de inactividad)
define('SESSION_TIMEOUT', 1800);

Session Timeout

SESSION_TIMEOUT is set to 1800 seconds (30 minutes). On every request, checkAuth() computes the elapsed time since $_SESSION['ultima_actividad']. If the gap exceeds 1800 seconds the session is destroyed and a 401 response is returned. The ultima_actividad timestamp is refreshed on every successful request unless checkAuth(false) is called explicitly (used by check_session.php to probe status without resetting the clock).

Session Fixation Protection

On every successful login session_regenerate_id(true) is called immediately before writing session variables. This issues a new session ID and invalidates the previous one, preventing session fixation attacks:
// backend/modules/auth/login.php (excerpt)

# Session Fixation
session_regenerate_id(true);

# Establecer sesión
$_SESSION['id_usuario'] = $usuario['id_usuario'];
$_SESSION['id_rol'] = $usuario['id_rol'];
$_SESSION['require_password_change'] = $usuario['require_password_change'];
$_SESSION['ultima_actividad'] = time();

# Generar CSRF token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

CSRF Token

A 64-character hexadecimal CSRF token is generated with bin2hex(random_bytes(32)) on each login and stored in $_SESSION['csrf_token']. State-changing frontend requests must include this token, which the backend validates before processing.

Force Password Change

New accounts created via register.php have require_password_change = 1 in the database. The value is loaded into the session at login:
$_SESSION['require_password_change'] = $usuario['require_password_change'];
On every subsequent request, checkAuth() checks this flag. If it equals 1, the only permitted endpoints are:
  • update_password.php — allows the user to set a new password
  • logout.php — allows them to exit without completing the change
  • check_session.php — passive session-status probe used by the frontend
All other endpoints respond immediately with:
{
  "status": 403,
  "message": "Cambio de contraseña obligatorio",
  "data": { "require_password_change": true }
}
The frontend detects the require_password_change: true flag in the response data and redirects the user to the password-change page automatically.
A deliberate sleep(1) is executed inside login.php whenever credentials are incorrect. This one-second delay applies whether the email does not exist or the password is wrong — both cases return the same generic "Credenciales incorrectas" message — making credential-stuffing and brute-force attacks significantly slower without revealing which field failed validation.

Admin Password Reset Audit

When an administrator resets a user’s password via reset_password_admin.php, a row is inserted into the reset_logs table recording id_admin, id_usuario_afectado, and fecha_reseteo. This provides an immutable audit trail viewable through listar_reset_logs.php (admin-only).
After an admin-initiated reset the target user’s require_password_change flag is set back to 1, so they will be forced to choose a new personal password on their very next login, exactly like a newly registered account.

Build docs developers (and LLMs) love