All notable changes to this project are documented in this file.
Format based on Keep a Changelog.
Note: Entries before
1.3.1may reference legacy paths (config/,controllers/,model/) that were moved toapp/Config/,app/Controller/, andapp/Model/.
- Login con Google (OAuth 2.0 + OpenID Connect), flujo Authorization Code implementado en PHP puro con cURL, sin librerías externas ni CDN:
- Nuevo servicio
app/Service/GoogleOAuthService.phpque encapsula el HTTP hacia Google: construcción de la URL de autorización, intercambio de código por tokens (POST https://oauth2.googleapis.com/token) y obtención del userinfo (https://www.googleapis.com/oauth2/v3/userinfo), con cURL endurecido (SSL verify, timeouts, elclient_secretsolo viene deenv()y nunca se loguea). AuthController::googleLogin()(GET /auth/google): genera unstatealeatorio de un solo uso (bin2hex(random_bytes(32))guardado en sesión), arma la URL de autorización (openid email profile,prompt=select_account) y redirige.AuthController::googleCallback()(GET /auth/google/callback): validastateconhash_equals()y lo consume antes de cualquier efecto secundario; intercambia el código, obtiene el userinfo (rechaza emails no verificados), resuelve o crea la cuenta y completa el ciclo de login (regeneración de id de sesión, fila enuser_sessionsconsession_token, activity log, flash de bienvenida).- Resolución de cuenta en
resolveOrCreateAccount(): link OAuth existente (oauth_provider+oauth_id) → email verificado ya registrado (se enlaza el proveedor) → usuario nuevo conpassword = NULLy username único auto-generado (generateUniqueUsername()). - Botón "Continue with Google" en la vista de login (SVG inline, accesible, controlado por feature flag).
- Nuevas variables de entorno:
OAUTH_GOOGLE_ENABLED,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_GOOGLE_REDIRECT_URI. - Esquema
users:passwordahora NULLABLE; nuevas columnasoauth_providerVARCHAR(20) NULL yoauth_idVARCHAR(100) NULL con índice únicouq_oauth_provider_id. - Cuentas híbridas: los usuarios creados por Google pueden fijar una contraseña desde el perfil (funcionan ambos logins, por password y por Google).
- 24 tests nuevos (95 en total):
tests/Unit/UserOAuthTest.php(derivación de username, alta/consulta/link OAuth,rejectPasswordLogin) ytests/Integration/AuthGoogleCallbackTest.php(resolución de cuenta vía reflection, manejo de feature flag +stateusando un controller que captura redirecciones, guards de integración); sin credenciales reales de Google ni red.
- Nuevo servicio
- Cookie de sesión
SameSitedeStrictaLaxensession_start_secure()—Strictretiene la cookie en la navegación cross-site de nivel superior que hace el callback de OAuth (accounts.google.com→localhost), perdiendo elstatede un solo uso. Auth::verifyCredentials()/passwordLoginErrorMessage()rechazan cuentas OAuth sin contraseña antes de llegar apassword_verify().forgotPassword()/consumeResetToken()rechazan cuentas OAuth (no se envía email de recuperación ni token a cuentas solo-Google).User::linkOAuthProvider()devuelvefalsecuando no actualiza ninguna fila (antes era!== -1).
- Parámetro
statevalidado conhash_equals()y consumido una sola vez antes de cualquier efecto secundario. - Enlazado de cuentas solo por email verificado por Google (
email_verified = true). - cURL endurecido:
CURLOPT_SSL_VERIFYPEER/VERIFYHOST, timeouts,client_secretsolo desdeenv(), nunca logueado.
- Old-input retention on user create/edit validation errors —
UserController::create()andUserController::edit()now stash submitted (non-password) fields in$_SESSION['old']on validation failure and rehydrate the form on redirect, so admins no longer have to retype the whole form after a single field error - Accessibility pass across
home,profile,session,activity-loganduserviews:views/profile/index.phpandviews/session/index.phpnow open with a real<h1>(was<h2>, leaving the page without a top-level heading) and mark decorative FontAwesome iconsaria-hidden="true"views/home/index.phpstat-card and header icons markedaria-hidden="true"views/activity-log/index.php: the collapsible filters header is keyboard-operable (role="button" tabindex="0",aria-expanded/aria-controlskept in sync viashown.bs.collapse/hidden.bs.collapseinactivity-logs-table.js), the Filtrar button announces a busy state (aria-busy, label swaps to "Filtrando…") while DataTables reloads, and anaria-live="polite"status region reports the result count after each fetchviews/user/index.phpedit/delete row action icon-buttons now carryaria-label="Edit/Delete user <username>"since their only visible content is an iconactivity-logs-table.jsexport/colvis buttons carryaria-label/titleAttrfor their icon-only rendering
public/css/estilo.css::focus-visibleoutline (accent color) on the collapsible filters header and on plaininputelements, replacing an unconditional:focusoutline
views/user/create.php/views/user/edit.php: password fields carryautocomplete="new-password"public/css/style.css: removed the now-unused.viewwrapper class;views/auth/login.phpfolds the password-visibility toggle button into.input-div.pass(position: relative) instead of a separate.viewcontainer;.verPasswordrepositioned withtop/rightinstead of a hardcoded negativemargin-top; added amax-width: 340pxbreakpoint tweak for very narrow viewports
- Accessibility enhancements in the auth forms (
login,forgot-password,reset-password) targeting WCAG 2.1 AA:- Real
<label for>elements replace the<h5>floating-label placeholders; the unique page heading was promoted from<h2>to<h1>on every auth view autocompleteattributes on all fields —username,current-password,email,new-password— so password managers fill correctly- The password-visibility toggle is now a real
<button type="button" aria-pressed aria-label>(was a<div>with inlineonclick) wired through amain2.jsevent listener, with a visible:focus-visibleoutline - Decorative images and FontAwesome icons marked
alt="" aria-hidden="true"; decorative<i>wrappers wrapped inaria-hidden="true" - A
:focus-visibleaccent outline on inputs and submit button — keyboard focus never depends on JS prefers-reduced-motion: reducemedia query disables transitions/animations globally instyle.css- Remember-me checkbox enlarged from 15×15 to 24×24 px (WCAG 2.5.8 minimum target size)
- Contrast fixes: labels/links use
--color-muted(#5f6b73, ≈5.19:1 AA); the submit button switches to dark text on the accent background (white-on-accent was ≈2.8:1, below AA)
- Real
- Password-manager support for the reset form — a visually-hidden
autocomplete="username"field (tabindex="-1",aria-hidden="true") lets password managers associate the account, silencing Chromium's "Password forms should have a username field" warning - Design tokens in
style.css:root(--color-accent,--color-dark,--color-muted,--color-bg,--color-border,--color-text,--color-white) replacing hardcoded hex values in auth CSS
- JS payload slimming in auth views —
login.php,forgot_password.phpandreset_password.phpnow load onlymain.js+main2.js; jQuery, Popper, Bootstrap JS and the unconditionalsweetalert2.all.min.jsscript tags were removed.sweetalert2.all.min.jsnow loads conditionally fromviews/layouts/messages.php, only when a session flash message exists bodyusesoverflow-x: hiddeninstead ofoverflow: hiddenand the form containermin-height: 100vh, eliminating horizontal scroll on narrow (375 px) viewportspublic/js/main.js/public/js/main2.jscleaned up (formatting, IIFE scope for the toggle, trailing newline);main2.jstargets#passwordby id instead of the previous#input
- Dead CSS rules from
style.css: the unused.input-div>div>selectrule and the.errorblock - jQuery, Popper and Bootstrap JS dependencies from the standalone auth views
CLAUDE.md— auth-module section notes the palette tokens, accessible toggle, keyboard-focus baseline and the hidden username field; new "E2E / Browser testing" section covering theplaywright-cliskill (Firefox headed / Brave via CDP)- New
AGENTS.md— lean "things you'd get wrong" list (XAMPP serving, real-DB test prereqs, namespace mapping, POST-only + CSRF conventions, browser e2e) complementingCLAUDE.md
- Weak password bypass via password reset —
AuthController::resetPassword()only checked thatnew_passwordmatchedconfirm_password, never enforcing the 8-character minimum already required byProfileController::changePassword()andUserController::validateUser(). A valid reset token could be used to set an empty or trivially short password. Now enforcesstrlen($newPassword) >= 8before callingAuth::consumeResetToken().
CLAUDE.md— removed two Notes bullets duplicating facts already covered in the Key Files table and Security Patterns section (session_start_secure()usage,AuthMiddleware::session()wiring); documented the resetPassword fix aboveREADME.md— restructured for a public-facing audience:- Added Table of Contents and a Screenshots section (title + description + image per feature, no tables)
Featuresreorganized into three grouped categories instead of a 25-bullet list that duplicated theSecuritysection's implementation detail; cross-linked toSecurityfor specificsInstallationno longer pastes the full.envcontents — points to.env.exampleinsteadProject Structurereduced from a fully-annotated recursive tree to a 2-level overview, with a pointer toCLAUDE.mdfor the file-by-file breakdown- Fixed a stale/vague comment in the
Testingsetup instructions referencing a non-existent ".env.testingsection in docs"
- Active sessions management — users can view and revoke their own logged-in sessions across devices:
- New table
user_sessions(id, user_id, token_hash, ip_address, user_agent, via_remember, created_at, last_activity);token_hashis a UNIQUE SHA-256 hash of a random session token — the raw token is stored only in$_SESSION['session_token'], never in the DB - A row is created on every successful login, both password login and remember-me auto-login (
Auth::restoreFromCookie()/AuthController::login()), viaUserSession::create()/UserSession::createTo() - New
AuthMiddleware::session(\mysqli)guard — validates the current session's hash is still active (existsActive()againstSESSION_TIMEOUT) and toucheslast_activity; if the row was deleted (revoked elsewhere), it destroys the session and redirects to/loginwith a warning flash - New
GET /sessions— lists all of the user's active sessions (device/browser, IP, created, last activity, current-session badge) viaApp\Controller\SessionController::index() - New
POST /sessions/revoke— revokes a single session by id, scoped to the authenticateduser_id(cannot revoke another user's session even by guessing an id) - New
POST /sessions/revoke-others— revokes every session except the current one (revokeAllExcept()) - Session row deleted on logout (
AuthController::logout()) and cascades on user deletion (ON DELETE CASCADE) - New env var
ACTIVE_SESSIONS_ENABLED(defaulttrue) — disables the revocation check inAuthMiddleware::session()without removing session tracking - Both revoke actions log
ActivityLog::EVENT_SESSION_REVOKED - 13 new tests in
tests/Unit/UserSessionTest.php— hash storage (never raw token),via_rememberflag,existsActive()TTL boundary,getForUser()ordering andis_currentflag,revoke()ownership scoping,revokeAllExcept()count and exclusion,deleteByTokenHash(),purgeExpired(); 71 tests in total
- New table
app/Core/Auth.php— issuessession_tokenand creates theuser_sessionsrow on remember-me auto-login (restoreFromCookie())app/Controller/AuthController.php— issuessession_tokenand creates theuser_sessionsrow on password login; deletes the row by token hash on logoutapp/Middleware/AuthMiddleware.php— new staticsession(\mysqli $connection): voidroutes/web.php— new routesGET /sessions,POST /sessions/revoke,POST /sessions/revoke-othersdatabase/schema.sql/database/schema_test.sql— newuser_sessionstableapp/Model/ActivityLog.php— new event constantEVENT_SESSION_REVOKED
- Filtros server-side en Audit Log —
/activity-logsahora usa DataTables server-side processing con filtros por evento, usuario y rango de fechas:- Nuevo endpoint
GET /activity-logs/dataque devuelve JSON para DataTables (protocolodraw/recordsTotal/recordsFiltered/data) - Formulario de filtros colapsable (Bootstrap collapse) sobre la tabla: select de evento, input de username (match parcial), inputs
date_from/date_to; badge warning en el toggle cuando hay filtros activos - DataTables reconfigured to
serverSide: true— la tabla empieza vacía y carga datos vía AJAX;searching: falsedesactiva el input nativo de DT (reemplazado por el formulario propio) - Botones de export (Copy, PDF, Excel, CSV, Print) y ColVis conservados; exportan la página visible (comportamiento esperado con server-side processing)
- Seguridad:
eventvalidado con allow-list de constantesEVENT_*; fechas validadas estrictamente conDateTime::createFromFormat;usernamecontrim()+ cap de 100 caracteres;lengthrestringido a[10, 25, 50, 100]; XSS en JSON mitigado conhtmlspecialchars()por celda - 8 nuevos tests en
tests/Unit/ActivityLogTest.php— filtro por evento, match parcial de username, rango de fechas, LIMIT/OFFSET,getTotalCount()con y sin filtros, combinación AND; 58 tests en total
- Nuevo endpoint
app/Model/ActivityLog.php—getAll()reescrito con prepared statements y WHERE dinámico; nueva firmagetAll(array $filters = [], ?int $limit = null, ?int $offset = null): array; nuevo métodogetTotalCount(array $filters = []): int; método privadobuildWhere(array $filters): arrayapp/Controller/ActivityLogController.php— nuevo métododata(): void(endpoint JSON); método privadosanitizeFilters(array $input): array;index()sin cambios de lógicaroutes/web.php— nueva rutaGET /activity-logs/dataregistrada antes de/activity-logsviews/activity-log/index.php—<tbody>vacío (DataTables llena vía AJAX); formulario de filtros colapsable añadido;$hasActiveFiltersbadge en togglepublic/js/activity-logs-table.js— migrado aserverSide: true;ajax.datacallback pasa los valores del formulario; botón "Filtrar" llamaajax.reload()
- Dashboard con métricas reales — home reemplaza las feature cards genéricas con datos en vivo:
- 4 stat-cards: usuarios totales (
User::getTotalCount()), logins exitosos hoy (ActivityLog::getCountTodayByEvent(EVENT_LOGIN_SUCCESS)), intentos fallidos hoy (getCountTodayByEvent(EVENT_LOGIN_FAILED)), cuentas bloqueadas ahora (LoginAttempt::getLockedCount()) - Tabla Bootstrap de los últimos 5 eventos del audit log (
ActivityLog::getRecentEvents(5)) con LEFT JOIN ausers; usuario sin registro muestra "Anónimo"; enlace "Ver todo" a/activity-logsvisible solo para admins - Sin DataTables — tabla simple Bootstrap para mantener la home ligera
- Todos los outputs de BD escapados con
htmlspecialchars(); contadores emitidos como(int)sin escape innecesario - Fechas calculadas en MySQL (
CURDATE(),NOW()) — sin drift PHP/MySQL
- 4 stat-cards: usuarios totales (
app/Model/User.php— nuevo métodogetTotalCount(): intapp/Model/ActivityLog.php— nuevos métodosgetCountTodayByEvent(string $event): intygetRecentEvents(int $limit = 5): arrayapp/Model/LoginAttempt.php— nuevo métodogetLockedCount(): intapp/Controller/HomeController.php— instancia los tres modelos y pasa las 5 variables de métricas a la vistaviews/home/index.php— rediseñada de feature cards estáticas a dashboard con stat-cards + tabla de actividad reciente
- DataTables Buttons + ColVis — exportación y visibilidad de columnas en
/usersy/activity-logs:- Botones agrupados bajo colección "Reports": Copy, PDF, Excel, CSV, Print; selector de columnas "Columns" separado (ColVis)
- PDF con
customize: encabezado bold centrado, subtítulo italic, fecha de generación, footer con paginación por página; colores de paleta del proyecto (#142e3d) - Excel con
messageTop,messageBottomyfilenamecon fecha ISO - Print con
table-stripedyfont-size: 12pxviacustomize - Clase
no-exporten<th>y<td>de la columna Actions en/users— excluida de todos los exports y del ColVis - Assets self-hosted en
public/DataTables/(Buttons 2.4.2, compatible con DataTables 1.11.x):dataTables.buttons.min.js,buttons.bootstrap4.min.js,buttons.bootstrap4.min.css,buttons.html5.min.js,buttons.print.min.js,buttons.colVis.min.js,jszip.min.js,pdfmake.min.js,vfs_fonts.js - Carga integrada en el flag
$useDataTables—header.phpyfooter.phpcargan todos los assets de Buttons automáticamente cuandouseDataTables: true; ninguna otra variable de layout introducida
- Audit log — registro completo de eventos de seguridad y administración:
- Nueva tabla
activity_logs(id,user_idnullable con FKON DELETE SET NULL,event,description,ip_address,created_at) con índices encreated_atyuser_id App\Model\ActivityLog— constantes de evento (EVENT_LOGIN_SUCCESS,EVENT_LOGIN_FAILED,EVENT_LOGOUT,EVENT_PASSWORD_CHANGED,EVENT_PASSWORD_RESET,EVENT_USER_CREATED,EVENT_USER_UPDATED,EVENT_USER_DELETED); método estáticolog()(usa singleton DB) y helperlogTo(\mysqli)para inyección en tests;getAll()con LEFT JOIN ausersyCOALESCEpara mostrar "Anónimo" cuandouser_ides NULL; ordenado porcreated_at DESC; sin cachéActivityLogController::index()— guarda conAuthMiddleware::timeout()+AuthMiddleware::admin(); solo GET- Ruta
GET /activity-logsregistrada enroutes/web.php - Vista
views/activity-log/index.php— tabla Bootstrap con DataTables client-side; badges de color por tipo de evento;htmlspecialchars()en todas las celdas public/js/activity-logs-table.js— inicialización DataTables conorderpor fecha descendente ypageLength: 25- Enlace "Activity Log" en nav solo visible para admins (
$_SESSION['is_admin']) - Instrumentación en controllers:
AuthController: login exitoso, login fallido (user_id NULL), logout, reset de contraseña por emailProfileController: cambio de contraseña exitosoUserController: crear, editar y eliminar usuario (con username del objetivo en la descripción)
log()envuelto en try/catch conerror_log()— un fallo de auditoría nunca aborta el flujo principal- IP registrada desde
$_SERVER['REMOTE_ADDR']; nunca X-Forwarded-For - 10 nuevos tests en
tests/Unit/ActivityLogTest.php— cubrelogTo()con/sin user_id, IP presente/ausente, caracteres especiales, acumulación de filas,getAll()vacío, JOIN con nombre, COALESCE "Anónimo" y orden DESC — 50 tests en total
- Nueva tabla
- Perfil de usuario — nueva sección
/profileaccesible para cualquier usuario autenticado (sin requisito de admin):ProfileControllercon métodosprofile()(editar info) ychangePassword()(cambiar contraseña)- Vista unificada
views/profile/index.phpcon dos formularios independientes, cada uno con su propio token CSRF - Form 1 (
POST /profile): editafirst_name,last_name,email,username; valida unicidad excluyendo el propio ID; actualiza$_SESSION['name']si cambia el nombre - Form 2 (
POST /profile/password): requiere contraseña actual conpassword_verify(); valida coincidencia y mínimo 8 caracteres; usaupdatePasswordProfile()independiente del flujo de reset por email User::updateProfile()— UPDATE limitado afirst_name,last_name,email,username; sin acceso apasswordniis_admin(previene escalada de privilegios vía IDOR)User::getPasswordById()— SELECT solo del hash para verificar la contraseña actual sin cargar la fila completaUser::updatePasswordProfile()— UPDATE de contraseña porid(no por email), independiente deupdatePassword()que sigue siendo exclusivo del flujo de reset- Nombre de usuario en el nav convertido en enlace a
/profile(visible para todos los usuarios autenticados) - Ambas operaciones invalidan la caché
users.all
- HTTP Security Headers —
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,X-XSS-Protection: 1; mode=block,Permissions-PolicyyContent-Security-Policybase (default-src 'self',form-action 'self',frame-ancestors 'none') agregados enpublic/.htaccessviamod_headers; HSTS comentado listo para activar en HTTPS - Eliminación de dependencias externas en vistas auth — jQuery y Google Fonts cargaban desde CDN externo (
code.jquery.com,fonts.googleapis.com) sin SRI en las vistas de login, forgot-password y reset-password; reemplazados por assets self-hosted para eliminar vector de supply-chain y fuga de token de reset via cabeceraReferer - Cookie de sesión segura —
session_start()centralizado en helpersession_start_secure()(enapp/Config/autoload.php) que aplicahttponly=true,samesite=Strictysecurecondicional (HTTPS) en todos los puntos donde se inicia sesión: bootstrap inicial, logout y timeout de inactividad - Logout cambiado de GET a POST con CSRF — la ruta
/logoutera unGETsin protección, explotable con<img src="...">para cerrar sesión ajena; ahora esPOSTcon token CSRF verificado enAuthController::logout(); el link enheader.phpfue reemplazado por un formulario con botón estilizado - Eliminación de user enumeration en forgot-password —
AuthController::forgotPassword()retornaba mensajes distintos según si el email existía o no; ahora responde siempre con el mismo mensaje genérico independientemente del resultado, enviando el email silenciosamente si el token se creó - Rotación de token CSRF —
Csrf::verify()ahora invalida el token de sesión tras cada verificación exitosa (unset($_SESSION['csrf_token'])), forzando regeneración en el siguiente request - Protección contra auto-eliminación y auto-degradación de admin —
UserController::delete()bloquea la eliminación del propio usuario autenticado;UserController::edit()bloquea quitar el propio rolis_admin - Error de conexión DB no expone detalles internos —
Database::getConnection()registra el error viaerror_log()y muestra la vista de error 500 en lugar del mensaje crudo de MySQLi con host/puerto
- Páginas de error personalizadas — nuevas vistas en
views/errors/:404.php(ruta no encontrada),403.php(acceso denegado),500.php(error de servidor); compartenlayout.phpstandalone (sin depender del layout de la app ni de la DB) con el gradiente y paleta de colores del proyecto Router::dispatch()renderizaviews/errors/404.phpen lugar de imprimir el path internoAuthMiddleware::admin()devuelve HTTP 403 con la vista de error en lugar de redirigir silenciosamente al home
- Alineación del botón Logout en la navbar — reemplazado inline styles por clase CSS
.btn-logout-navenestilo.css storage/.htaccessconRequire all deniedpara proteger explícitamente los archivos de caché
- Account lockout — bloqueo automático de cuenta tras 5 intentos de login fallidos consecutivos (configurable):
- Nueva tabla
login_attemptsconidentifiercomoPRIMARY KEY(sin surrogate id) — sin enumeración de usuarios: solo se registran intentos para usernames que existen en DB - Nuevo modelo
App\Model\LoginAttempt—registerFailure()atómico viaINSERT ... ON DUPLICATE KEY UPDATEen SQL (sin race conditions),lockedSecondsRemaining()yclear()con operaciones temporales en MySQL (NOW(),DATE_ADD,TIMESTAMPDIFF) para evitar drift PHP/MySQL App\Core\Auth— 4 métodos nuevos:lockedSecondsRemaining(),registerFailedAttempt(),clearFailedAttempts(),userExists(); limpieza de lockout integrada enconsumeResetToken()(limpia por email y por username)AuthController::login()— check de bloqueo antes deverifyCredentials(): contraseña correcta no levanta el bloqueo durante la ventana; mensaje con minutos restantes (ceil)- Login exitoso elimina la fila de intentos (
DELETE); reset de contraseña exitoso limpia lockout por ambos identificadores posibles - Controlado por
LOGIN_LOCKOUT_ENABLED,LOGIN_MAX_ATTEMPTS(default 5),LOGIN_LOCKOUT_MINUTES(default 15)
- Nueva tabla
- 7 nuevos tests en
tests/Unit/LoginAttemptTest.phpy 4 nuevos casos entests/Integration/AuthTest.php— 40 tests en total
- CSRF protection — nueva clase
App\Core\Csrfcon métodos estáticostoken()/verify(); todos los formularios POST incluyen un campo oculto_csrfvalidado en cada controlador via el nuevo helperController::verifyCsrf(); el token se almacena en$_SESSION['csrf_token']y se compara conhash_equals()para evitar timing attacks - XSS en mensajes flash —
$icony$messageenviews/layouts/messages.phpse interpolaban directamente en un string JavaScript; reemplazados conjson_encode()para que comillas, barras o saltos de línea no puedan romper el contexto JS - Delete de usuario cambiado de GET a POST — la ruta
/users/deleteyUserController::delete()ahora requieren POST;users-delete.jscrea y envía un form dinámicamente con el token CSRF al confirmar, en lugar de hacerwindow.location.href; elimina explotación CSRF con<img>o un solo clic - Session fixation en login —
session_regenerate_id(true)se llama inmediatamente después depassword_verify()exitoso, antes de escribir variables de sesión - Tokens de reset de contraseña hasheados en DB —
Auth::createPasswordResetToken()ahora almacenahash('sha256', $token)en la tablapassword_resets(mismo patrón que los tokens de remember-me);Auth::consumeResetToken()hashea el token entrante antes de la búsqueda en DB; el token raw solo viaja en la URL del email
- Null dereference en
User::update()— cuandogetById($id)retornabanull, acceder a['password']en el resultado causaba un fatal TypeError en PHP 8.x;update()ahora llamagetById()una vez, retornafalsetemprano si el usuario no existe, y reutiliza el resultado para el fallback de contraseña User::update()éxito falso —affected_rows >= 0trataba un UPDATE sin filas coincidentes (ID no encontrado) como éxito; cambiado aaffected_rows !== -1para distinguir correctamente un error de DB (-1) de una actualización idempotente (0filas cambiadas)FileCache::remember()no cacheaba null —get()retornanulltanto para un cache miss como para una entrada expirada/corrupta;remember()ahora verificais_file()primero para distinguir un miss real de un valor null cacheado$faviconsin escapar en header —views/layouts/header.phpahora pasa$faviconporhtmlspecialchars(), consistente con$pageTitley$bodyClass
- Integration test suite — PHPUnit ^11.0 against a real MySQL test database (
login_test):tests/Unit/UserTest.php— 14 tests covering allApp\Model\Userpublic methods (CRUD, remember token, password hashing)tests/Integration/AuthTest.php— 14 tests coveringApp\Core\Auth(credential verification, remember-me token lifecycle, password reset token lifecycle)tests/TestCase.php— abstract base with direct\mysqliconnection, schema bootstrap, per-test table truncation, andcreateUser()helpertests/bootstrap.php— minimal bootstrap: populates$_ENVfrom.env.testingbefore Composer autoload, never starts session
phpunit.xml— PHPUnit 11 config withUnitandIntegrationsuites,failOnWarning=true, random execution orderdatabase/schema_test.sql— table-only schema for test DB (noCREATE DATABASE/USEstatements).github/workflows/tests.yml— GitHub Actions CI: MySQL 8.0 service with health check,setup-php@v2, Composer cache, PHPUnit run on push/PR tomastercomposer.jsonscripts:test,test:unit,test:integration
libs/Cache/FileCache::forget()now respects the$enabledflag — previously attemptedunlink()even when cache was disabled, causing permission errors in test environmentsapp/Config/cache.php—appCache()short-circuits immediately whenCACHE_ENABLED=false, skipping directory writability checks that triggered warnings in CIapp/Config/config.php— changed->load()to->safeLoad()so the app boots without a.envfile present (required for CI where.env.testingis injected at runtime)
App\Config\Databasesingleton class —Database::getConnection()returns the same\mysqliinstance across the entire request;$connectionvariable preserved for backward compatibilityAPP_VERSIONenvironment variable displayed in the shared footer (views/layouts/footer.php)- Per-page asset injection in shared layouts:
$pageStyles— array of CSS paths injected in<head>(after DataTables CSS)$pageScripts— array of JS paths injected in footer (after DataTables JS)
$pageTitle,$favicon,$bodyClassvariables accepted byviews/layouts/header.php$bodyClasssuppressesmt-3on<main>when set (used by dashboard's hero section)
views/home/index.phpmigrated from standalone HTML file to shared layout (protected: true) — contains only content markup nowviews/layouts/header.phpgeneralized: accepts$pageTitle,$favicon,$bodyClass; nav now shared (Home, Users if admin, username, Logout) using$_SESSIONdirectly$useDataTablesdefaults tofalse— opt-in per controller; DataTables CSS/JS only loads onUserController::index()users-table.jsandusers-delete.jsmoved fromfooter.phptoUserController::index()via$pageScriptsbootstrap.cssnow loads beforeestilo.cssinheader.phpso.btn-app-primarycorrectly overrides Bootstrap defaultsHomeControllerpassesbodyClass: 'dashboard',favicon, andpageTitleexplicitlyUserControllerpasses descriptivepageTitlefor each action (Users, Create User, Edit User)- Dashboard feature cards updated to reflect current MVC architecture (Router, Middleware, Composer, remember-me, session timeout)
composer.json— removed staleapp/Config/view_helpers.phpfromfilesautoload array
- Remember Me — persistent login via secure cookie:
- Checkbox "Remember me" on the login form (
views/auth/login.php) - On login with checkbox: generates
bin2hex(random_bytes(32))token, stores SHA-256 hash inusers.remember_tokenwith expiry, emitsHttpOnly/SameSite=Strictcookie - On every request without an active session:
AuthController::restoreFromCookie()looks up the token hash and silently restores the session - On logout or session expiry: token cleared from DB and cookie deleted from client
- Controlled by
REMEMBER_ME_ENABLEDandREMEMBER_ME_TTLenv vars
- Checkbox "Remember me" on the login form (
- Session Timeout — automatic expiry after inactivity:
$_SESSION['last_activity']recorded on login and updated on every protected requestAuthController::checkSessionTimeout()called inhome.phpandUserController::requireAuth()— destroys session and redirects to/loginwith a warning toast ifSESSION_TIMEOUTseconds have elapsed- On timeout: remember token also cleared so cookie-based restore does not immediately re-log the user in
- Controlled by
SESSION_TIMEOUTenv var (default 1800 s = 30 min)
- New columns in
userstable:remember_token VARCHAR(64) NULL,remember_token_expires DATETIME NULL, indexidx_remember_token - New model methods in
App\Model\User:setRememberToken(),getByRememberToken(),clearRememberToken() - New env vars:
REMEMBER_ME_ENABLED,REMEMBER_ME_TTL,SESSION_TIMEOUT - Migration script:
database/migrations/2026_05_02_add_remember_me_to_users.sql(idempotent ALTER TABLE for existing installations) .remember-labelCSS class inpublic/css/style.cssfor styled checkbox label in auth forms
session_start()moved frompublic/index.phptoapp/Config/autoload.phpso it runs beforerestoreFromCookie()on every requestapp/Config/autoload.phpnow requiresAuthController.phpand callsrestoreFromCookie()after session start
- Reorganized project structure under
app/:config/→app/Config/controllers/→app/Controller/model/→app/Model/
- Updated front controller routing in
public/index.phpto load delegators fromapp/Controller/*. - Updated relative paths after the directory move (autoload, views, cache path, PHPMailer includes, and model includes).
- Updated project documentation to reflect the new
app/structure.
- SweetAlert2 toast notification system for all CRUD and authentication actions:
- Centralized notification logic in
views/layouts/messages.php - Integrated
sweetalert2.all.min.jsin all views (protected and standalone) - Welcome toast message upon successful login with user's first name
- Centralized notification logic in
- Unified session-based notification keys:
$_SESSION['message']and$_SESSION['icon']
- Refactored
UserControllerandAuthControllerto use the new session-based toast system:- Removed reliance on URL query parameters (
?message=,?error=) for feedback - Replaced legacy
$_SESSION['flash_error']/$_SESSION['flash_message']with unified keys
- Removed reliance on URL query parameters (
- Cleaned up views (
views/user/index.php,create.php,edit.php) by removing manual alert display blocks - Updated
AuthController::logoutto include a success notification - User delete confirmation in
views/user/index.phpnow uses SweetAlert2 viapublic/js/users-delete.jsinstead of per-row Bootstrap modals
- Improved user feedback consistency across all modules (Login, Reset Password, User Management)
- File-based cache infrastructure:
libs/Cache/FileCache.php(get/set/forget/remember with TTL)config/cache.php(appCache()helper)storage/cache/.gitignorefor runtime cache files
- Environment settings for cache control:
CACHE_ENABLEDCACHE_TTL_USERS
- Apache rewrite support for clean URLs via
.htaccess - Shared rendering helpers in
config/view_helpers.php:renderView()renderProtectedView()
- New protected-layout assets:
public/css/layout-protected.csspublic/js/users-table.js
model/User.phpnow cachesgetAll()user listing with keyusers.all- Cache invalidation added on user writes (
create,update,delete,updatePassword) config/autoload.phpnow loads cache bootstrap before DB usage.gitignoreupdated to ignore runtime cache files (storage/cache/*.cache)index.phproute resolution prioritizes clean path-based URLs (fallback fromREQUEST_URI) instead of relying only on?page=- Protected views now render through
renderProtectedView()inUserController(centralized header/footer include) - Shared templates moved from project-root
templates/toviews/templates/ - DataTables setup for users list moved from inline footer script to
public/js/users-table.js
- Prevented HTTP 500 on
/userswhen cache directory is not writable:- cache now falls back to disabled mode for the request
- warning is logged instead of throwing a fatal runtime exception
- Login POST check: changed
!empty($_POST['btningresar'])toisset()—<button>without avalueattribute submits an empty string, which!empty()rejects - Error and success messages now use session flash (
$_SESSION['flash_error']/$_SESSION['flash_message']) instead of URL query params — messages disappear on page refresh and the URL stays clean - Flash message blocks moved inside
<form>in all auth views so they render within the form's 360px width instead of beside it as flex siblings
<input type="submit">replaced with<button type="submit">inlogin.php,forgot_password.php, andreset_password.php- Added
.btn-anchorclass inpublic/css/style.cssfor<a>elements styled as buttons — providesline-height: 40pxandtext-align: centerwithout affecting native<button>elements - Seed passwords corrected to known values: Admin/Luca/Martins/Gus →
123456; Juan/Sofy/Mary →0000 - Default admin credentials documented in
README.mdanddatabase/seeds.sql
- CSS variables
--color-dark(#142e3d) and--color-accent(#04a1fc) inpublic/css/estilo.cssfor a consistent color palette across all views - Utility classes in
estilo.css:.btn-app-primary,.hero,.feature-icon,body.dashboard
- Dashboard (
views/index.php) redesigned: replaced carousel and placeholder content with a hero section and three feature cards describing the project's security capabilities - Hero gradient simplified to use only palette tokens (
--color-dark→--color-accent), eliminating the off-palette intermediate color - Navbar and card headers now render in navy
#142e3dinstead of Bootstrap's default#343a40via CSS override - Body background changed from
rgb(218,216,216)to#f8f9fa(Bootstrap light gray) - FontAwesome migrated from SVG/JS bundle (
fontawesome.js) to CSS + webfonts (all.min.css) - Dashboard inline
<style>block extracted toestilo.css;<body>getsclass="dashboard"to scope the flex layout
- Unused public assets:
public/css/fontawesome.min.css,public/js/fontawesome.js,public/js/bootstrap.bundle.js,public/js/bootstrap.js,public/DataTables/datatables.min.css,public/DataTables/datatables.min.js,public/img/1.jpg,public/img/bg.svg
- Introduced
AuthController(controllers/auth/AuthController.php, namespaceApp\Controller\Auth) with methodslogin(),logout(),forgotPassword(),resetPassword() - Introduced
UserController(controllers/user/UserController.php, namespaceApp\Controller\User) with methodsindex(),create(),edit(),delete()and private guardsrequireAuth()/requireAdmin() - Individual action files (
login.php,reset.php, etc.) are now thin delegators that instantiate the module controller and call the corresponding method — all logic lives in the controller class
- Front controller (
index.php) routing all pages via?page=query parameter — no more scattered entry-point files at root controllers/auth/— login, logout, reset, update_password (each handles GET + POST)controllers/user/— index, create, edit, delete (admin-only CRUD)controllers/home.php— dashboard controllermodel/User.php— OOP model (App\Model\Usernamespace) with MySQLi prepared statements for all user operationsdatabase/schema.sql— canonical DB schema with English table/column names (users,password_resets)database/seeds.sql— sample data with bcrypt-hashed passwordspublic/directory consolidating all static assets (CSS, JS, images, DataTables, webfonts)libs/PHPMailer/— PHPMailer moved fromPHPMailer-master/tolibs/views/auth/— login, forgot_password, reset_password (pure HTML, no logic)views/user/— index, create, edit (pure HTML, no logic)views/index.php— dashboard view
- Translated entire codebase to English: directories, filenames, PHP variables, session keys, HTML text, and DB schema
- Session keys:
$_SESSION["ID"]→$_SESSION['user_id'],$_SESSION["Nombre"]→$_SESSION['name'],$_SESSION["EsAdmin"]→$_SESSION['is_admin'] - DB table
usuario→users; columnsNombres/Apellidos/correo/Usuario/Clave/EsAdmin→first_name/last_name/email/username/password/is_admin $conexion→$connectioninconfig/database.php- PHPMailer reset link now points to
/?page=reset-password&token=...instead ofreset_password.php?token=... templates/header.phpno longer callssession_start()(front controller handles it); redirect updated to/?page=login- All form actions and nav links updated to use
/?page=...URLs
- SQL injection in login: replaced string interpolation with MySQLi prepared statement (via
User::getByUsername()) window.locationJS redirects in password reset replaced withheader()+exit- Bug in
update_password:$stmt->close()was called on variables that didn't exist in theelsebranch — fixed by scopingclose()inside each branch
login.php,forgot_password.php,reset_password.phpfrom project root (logic moved tocontrollers/auth/, views toviews/auth/)controlador/directory (all Spanish legacy controllers)model/conexion.php(replaced byconfig/database.php+model/User.php)model/usuario/directory (replaced bycontrollers/user/+views/user/+model/User.php)login.sqlfrom project root (replaced bydatabase/schema.sql)PHPMailer-master/(moved tolibs/PHPMailer/)css/,js/,img/,webfonts/,DataTables/from root (moved topublic/)
config/directory with separation of concerns:config/config.php— loads.envwithloadEnv()+env(), definesAPP_URLand$urlconfig/database.php— MySQLi connection usingenv()config/autoload.php— single bootstrap entry point
- Environment variables
APP_URLandAPP_TIMEZONEin.envand.env.example emailandis_adminfields in create/edit user forms- UNIQUE constraints on
emailandusernamecolumns
- All asset paths use
APP_URLconstant instead of fragile relative paths - All redirects in controllers use
APP_URL - User CRUD converted to MySQLi prepared statements
- Editing a user with a blank password field keeps the current password
- Password recovery email not sending:
ENCRYPTION_SMTPSon port 587 corrected toENCRYPTION_STARTTLS - Session security vulnerability: session variables now set only after successful
password_verify() reset_password.phpPHP warning:$_GET['token']accessed withoutisset()— fixed with??operator- Silent failure on user creation: missing
email/is_admincolumns in INSERT - Missing
exitafterheader()redirects in user module