From 8613f7111253da57c1c934237fdbbcb0bf688dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Gervreau-Mercier?= Date: Sun, 8 Feb 2026 14:12:32 +0100 Subject: [PATCH] security: add PHP session auth guard for admin panel (item #2, CRITICAL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/AdminAuth.php: new class with requireLogin(), login(), logout(), isAuthenticated(); starts session with hardened cookie params (HttpOnly, SameSite=Strict, Secure, Path=/admin) β€” also resolves item #8 (session cookie hardening) - requireLogin() auto-authenticates from nginx Basic Auth credentials ($_SERVER['PHP_AUTH_PW']) so the user only sees one browser prompt; falls back to /admin/login.php if the proxy is absent/misconfigured - config/admin_credentials.php: gitignored credential store; define ADMIN_PASSWORD_HASH with a bcrypt hash to enable PHP auth - config/admin_credentials.example.php: template for the above - config/bootstrap.php: auto-loads admin_credentials.php if present - .gitignore: exclude config/admin_credentials.php - public/admin/login.php: fallback login form (shown only when nginx Basic Auth is bypassed / proxy absent) - public/admin/logout.php: session destruction + redirect to login - All 7 admin PHP files: replace session_start() with AdminAuth::requireLogin() (defence-in-depth behind nginx Basic Auth) - public/admin/inc/head.php: DΓ©connexion button when ADMIN_PASSWORD_HASH is defined - nginx/PHP_AUTH_LAYER.md: documents dual-auth architecture, UX flow, and setup instructions - docs/TODO.SECURITY.md: items #2 and #8 moved to Resolved; priority order updated (all CRITICAL done) --- .gitignore | 3 + config/admin_credentials.example.php | 13 +++ config/bootstrap.php | 5 ++ docs/TODO.SECURITY.md | 22 ++--- lib/AdminAuth.php | 121 +++++++++++++++++++++++++++ nginx/PHP_AUTH_LAYER.md | 114 +++++++++++++++++++++++++ public/admin/README.md | 11 ++- public/admin/actions/formulaire.php | 6 +- public/admin/actions/publish.php | 4 +- public/admin/add.php | 6 +- public/admin/edit.php | 5 +- public/admin/import.php | 4 +- public/admin/inc/head.php | 3 + public/admin/index.php | 5 +- public/admin/login.php | 60 +++++++++++++ public/admin/logout.php | 8 ++ public/admin/thanks.php | 4 + 17 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 config/admin_credentials.example.php create mode 100644 lib/AdminAuth.php create mode 100644 nginx/PHP_AUTH_LAYER.md create mode 100644 public/admin/login.php create mode 100644 public/admin/logout.php diff --git a/.gitignore b/.gitignore index 0ac9fc0..737b5b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Admin credentials (contains bcrypt hash β€” never commit) +config/admin_credentials.php + # Vendor directory (third-party code) vendor/ compose.lock diff --git a/config/admin_credentials.example.php b/config/admin_credentials.example.php new file mode 100644 index 0000000..5c1a259 --- /dev/null +++ b/config/admin_credentials.example.php @@ -0,0 +1,13 @@ + 0, + 'path' => '/admin', + 'secure' => (php_sapi_name() !== 'cli-server'), + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_start(); + } + + /** + * Gate every admin page. + * + * Authentication order: + * 1. Session already authenticated β†’ pass through. + * 2. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW'] + * β†’ validate it with password_verify; on success create session + * (seamless: user only sees the browser Basic Auth dialog). + * 3. Neither β†’ redirect to the PHP login form (fallback for when + * the reverse proxy is absent / misconfigured). + * + * No-op if ADMIN_PASSWORD_HASH is not defined (development / cli-server). + */ + public static function requireLogin(): void + { + self::startSession(); + if (!defined('ADMIN_PASSWORD_HASH')) { + // No password configured β†’ development / cli-server mode, skip PHP auth. + return; + } + if (!empty($_SESSION[self::SESSION_KEY])) { + return; // already authenticated via session + } + // Try to auto-authenticate from the nginx Basic Auth credentials. + // If nginx Basic Auth is bypassed, PHP_AUTH_PW won't be set and this + // branch is skipped β€” the fallback login form is shown instead. + if (isset($_SERVER['PHP_AUTH_PW']) && self::login($_SERVER['PHP_AUTH_PW'])) { + return; + } + header('Location: ' . self::LOGIN_URL); + exit; + } + + /** + * Validate a plaintext password against the stored bcrypt hash. + * On success: regenerates the session ID and marks the session authenticated. + * + * @return bool true on success, false on wrong password / no hash configured. + */ + public static function login(string $password): bool + { + $hash = defined('ADMIN_PASSWORD_HASH') ? ADMIN_PASSWORD_HASH : null; + if ($hash === null || !password_verify($password, $hash)) { + return false; + } + self::startSession(); + session_regenerate_id(true); + $_SESSION[self::SESSION_KEY] = true; + $_SESSION['admin_login_at'] = time(); + return true; + } + + /** + * Check whether the current request is authenticated (without redirecting). + */ + public static function isAuthenticated(): bool + { + self::startSession(); + return !empty($_SESSION[self::SESSION_KEY]); + } + + /** + * Destroy the session (logout). + */ + public static function logout(): void + { + self::startSession(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie( + session_name(), '', time() - 86400, + $p['path'], $p['domain'], $p['secure'], $p['httponly'] + ); + } + session_destroy(); + } +} diff --git a/nginx/PHP_AUTH_LAYER.md b/nginx/PHP_AUTH_LAYER.md new file mode 100644 index 0000000..bb37006 --- /dev/null +++ b/nginx/PHP_AUTH_LAYER.md @@ -0,0 +1,114 @@ +# PHP Session Auth Layer β€” Admin Panel + +> Addresses: **TODO item #2** (No PHP-level authentication in admin panel β€” πŸ”΄ CRITICAL) + +--- + +## Overview + +The admin panel uses **two independent authentication layers** with a single UX prompt: + +| Layer | Mechanism | Configured by | +|-------|-----------|---------------| +| **1st** | nginx HTTP Basic Auth | `/etc/nginx/.htpasswd-posterg` (see `ADMIN_USERS.md`) | +| **2nd** | PHP session guard (`lib/AdminAuth.php`) | `config/admin_credentials.php` | + +The user only sees **one prompt** (the browser Basic Auth dialog). PHP reads the +same password from `$_SERVER['PHP_AUTH_PW']` and validates it independently with +`password_verify`. On success it creates a session so subsequent requests skip +the `password_verify` call. + +--- + +## Why two layers? + +nginx Basic Auth alone is a **single point of failure**: + +- Reverse-proxy misconfiguration could expose admin routes directly. +- Local development without the proxy leaves admin unprotected. +- A misconfigured `auth_basic off` block (e.g., in a nested location) could bypass it. + +The PHP session guard (`AdminAuth::requireLogin()`) is ~100 lines of PHP stdlib +(`password_verify` + `session_regenerate_id`) with negligible attack surface. + +## Authentication flow + +``` +Browser β†’ nginx Basic Auth dialog (username + password) + β”‚ + β–Ό + nginx validates against .htpasswd β”€β”€βœ—β”€β”€β–Ά 401 + β”‚ βœ“ + β–Ό + PHP: AdminAuth::requireLogin() + β”œβ”€ session already live? β”€β”€βœ“β”€β”€β–Ά proceed + β”œβ”€ $_SERVER['PHP_AUTH_PW'] set? + β”‚ └─ password_verify(PHP_AUTH_PW, ADMIN_PASSWORD_HASH) + β”‚ β”œβ”€ βœ“ β†’ create session β†’ proceed (normal path) + β”‚ └─ βœ— β†’ redirect to login form + └─ neither β†’ redirect to login form (proxy bypass) +``` + +The login form (`/admin/login.php`) is a **fallback** for when the reverse proxy +is absent. In normal production use the user never sees it. + +--- + +## PHP auth setup (production) + +1. Generate a bcrypt hash for the admin password: + ```bash + php -r "echo password_hash('your-secret-password', PASSWORD_DEFAULT);" + ``` + +2. Create `config/admin_credentials.php` (outside the webroot, never committed): + ```php + '); + ``` + +3. The `bootstrap.php` auto-loads this file if it exists. + +If `ADMIN_PASSWORD_HASH` is not defined (development / cli-server), the PHP +auth layer is a **no-op** β€” nginx Basic Auth remains the sole guard. + +--- + +## Session cookie hardening (TODO item #8) + +`AdminAuth::startSession()` calls `session_set_cookie_params()` before +`session_start()`, applying: + +| Attribute | Value | +|-----------|-------| +| `HttpOnly` | `true` | +| `SameSite` | `Strict` | +| `Secure` | `true` (disabled on cli-server for dev) | +| `Path` | `/admin` | +| `Lifetime` | `0` (session cookie, expires on browser close) | + +This replaces all direct `session_start()` calls in admin PHP files. + +--- + +## Logout + +A **DΓ©connexion** button is shown in the admin nav when `ADMIN_PASSWORD_HASH` +is defined. It hits `/admin/logout.php` which destroys the PHP session. +nginx Basic Auth invalidation requires closing the browser tab / window. + +--- + +## Files changed + +| File | Change | +|------|--------| +| `lib/AdminAuth.php` | New β€” auth guard class | +| `config/admin_credentials.php` | New β€” credential store (gitignored) | +| `config/admin_credentials.example.php` | New β€” example / template | +| `config/bootstrap.php` | Load credentials on startup | +| `public/admin/*.php` | Replace `session_start()` with `AdminAuth::requireLogin()` | +| `public/admin/actions/*.php` | Same | +| `public/admin/login.php` | New β€” login form | +| `public/admin/logout.php` | New β€” logout handler | +| `public/admin/inc/head.php` | Logout button in nav | diff --git a/public/admin/README.md b/public/admin/README.md index 9a949a6..da06b67 100644 --- a/public/admin/README.md +++ b/public/admin/README.md @@ -57,12 +57,15 @@ Reusable HTML components: ## Security -- All pages require HTTP Basic Auth (configured in nginx) +- All pages require HTTP Basic Auth (configured in nginx) β€” primary layer +- All pages require PHP session auth (`AdminAuth::requireLogin()`) β€” defence-in-depth - CSRF tokens protect all forms - File uploads validated and sanitized - Database queries use prepared statements - Upload directory outside public/ in production +See `nginx/PHP_AUTH_LAYER.md` for details on the dual-auth architecture. + ## Templates The `inc/` folder contains shared templates: @@ -96,7 +99,8 @@ Backend actions (not directly accessed): ```php @@ -114,7 +118,8 @@ $pageTitle = "Your Page Title"; ```php + + + \ No newline at end of file diff --git a/public/admin/index.php b/public/admin/index.php index a924514..3d30dcb 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -1,9 +1,10 @@ + + + + + + <?php echo htmlspecialchars($pageTitle); ?> β€” Post-ERG Admin + + + + + +
+

+
+
+ +
+ ⚠️ +
+ +
+
+ Authentification admin + + + +
+
+
+ + diff --git a/public/admin/logout.php b/public/admin/logout.php new file mode 100644 index 0000000..6796156 --- /dev/null +++ b/public/admin/logout.php @@ -0,0 +1,8 @@ +