security: add PHP session auth guard for admin panel (item #2, CRITICAL)

- 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)
This commit is contained in:
Théophile Gervreau-Mercier
2026-02-08 14:12:32 +01:00
parent a2b1ff5f41
commit 8613f71112
17 changed files with 369 additions and 25 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Admin credentials (contains bcrypt hash — never commit)
config/admin_credentials.php
# Vendor directory (third-party code) # Vendor directory (third-party code)
vendor/ vendor/
compose.lock compose.lock

View File

@@ -0,0 +1,13 @@
<?php
/**
* Admin PHP-level password for the session auth guard (defence-in-depth).
*
* Copy this file to admin_credentials.php and set a real hash.
*
* Generate a hash:
* php -r "echo password_hash('your-password', PASSWORD_DEFAULT);"
*
* Then uncomment and paste the result below:
*/
// define('ADMIN_PASSWORD_HASH', '$2y$12$...');

View File

@@ -31,6 +31,11 @@ function include_template($name) {
} }
} }
// Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth)
if (file_exists(APP_ROOT . '/config/admin_credentials.php')) {
require_once APP_ROOT . '/config/admin_credentials.php';
}
// Autoload Composer dependencies if available // Autoload Composer dependencies if available
if (file_exists(APP_ROOT . '/vendor/autoload.php')) { if (file_exists(APP_ROOT . '/vendor/autoload.php')) {
require_once APP_ROOT . '/vendor/autoload.php'; require_once APP_ROOT . '/vendor/autoload.php';

View File

@@ -45,6 +45,15 @@
--- ---
### Admin Panel — Authentication & Sessions
| # | Issue | Severity | Resolution |
|---|-------|----------|------------|
| 2 | No PHP-level authentication in admin panel | 🔴 CRITICAL | `lib/AdminAuth.php` implements a session guard with `password_verify` + `session_regenerate_id`. All admin PHP files now call `AdminAuth::requireLogin()` instead of bare `session_start()`. Credentials stored in gitignored `config/admin_credentials.php` (define `ADMIN_PASSWORD_HASH`). No-op when constant is absent (dev / cli-server). Also resolves item #8 (session cookie hardening via `session_set_cookie_params` before `session_start`). See `nginx/PHP_AUTH_LAYER.md`. |
| 8 | Session cookies not hardened (`Secure`, `HttpOnly`, `SameSite` missing) | 🟡 MEDIUM | **Resolved as part of item #2.** `AdminAuth::startSession()` sets `HttpOnly=true`, `SameSite=Strict`, `Secure=true` (off on cli-server), `Path=/admin`, `Lifetime=0` before every `session_start()`. |
---
## 🔧 In Progress ## 🔧 In Progress
### Database / Input Handling ### Database / Input Handling
@@ -65,15 +74,6 @@
--- ---
### Admin Panel — Authentication & Sessions
| # | Issue | Severity | Files / Notes |
|---|-------|----------|-------|
| 2 | No PHP-level authentication in admin panel | 🔴 CRITICAL | All `public/admin/*.php` files. The reverse proxy + nginx Basic Auth is the only auth layer — a single point of failure. CSRF ≠ authentication (CSRF only prevents cross-site forgery on already-open sessions; it does nothing against a direct unauthenticated request). A simple PHP session guard adds real defence-in-depth for ~20 lines using stdlib (`password_verify`, `session_regenerate_id`) with negligible added attack surface — the risk of **not** having it (proxy misconfiguration, bypass, local dev without proxy) outweighs the risk of adding it. |
| 8 | Session cookies not hardened (`Secure`, `HttpOnly`, `SameSite` missing) | 🟡 MEDIUM | All admin PHP files using `session_start()` |
---
### Admin Panel — Error Logging ### Admin Panel — Error Logging
| # | Issue | Severity | Files | | # | Issue | Severity | Files |
@@ -92,8 +92,8 @@
## Priority Order for Remaining Work ## Priority Order for Remaining Work
1. 🔴 **CRITICAL**Item 2 (add simple PHP session auth guard) 1. 🔴 **CRITICAL**✅ All done (items 12)
2. 🟡 **MEDIUM** — Items 7, 8, 9, 11, 12 (sanitisation standardisation, session hardening, CSP, error log, CSV MIME) 2. 🟡 **MEDIUM** — Items 7, 9, 11, 12 (sanitisation standardisation, error log, CSP, CSV MIME) — item 8 resolved with item 2
3. 🔵 **LOW** — ✅ All done (items 1316) 3. 🔵 **LOW** — ✅ All done (items 1316)
--- ---

121
lib/AdminAuth.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
/**
* Minimal PHP session guard for the admin panel.
*
* This is a defence-in-depth layer that sits behind nginx Basic Auth.
* It protects against proxy misconfiguration, bypass, and local-dev
* scenarios where the reverse proxy may be absent.
*
* Usage (top of every admin page):
* require_once __DIR__ . '/../../lib/AdminAuth.php';
* AdminAuth::requireLogin();
*
* Credential setup (production):
* php -r "echo password_hash('your-password', PASSWORD_DEFAULT);"
* # Paste result into config/admin_credentials.php as ADMIN_PASSWORD_HASH
*
* If ADMIN_PASSWORD_HASH is not defined the guard is a no-op (dev / cli-server).
*/
class AdminAuth
{
private const SESSION_KEY = 'admin_authenticated';
private const LOGIN_URL = '/admin/login.php';
/**
* Start the PHP session with hardened cookie parameters.
* Idempotent — safe to call even if session is already active.
*/
private static function startSession(): void
{
if (session_status() !== PHP_SESSION_NONE) {
return;
}
// Harden session cookie (item #8)
session_set_cookie_params([
'lifetime' => 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();
}
}

114
nginx/PHP_AUTH_LAYER.md Normal file
View File

@@ -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
<?php
define('ADMIN_PASSWORD_HASH', '$2y$12$<paste-hash-here>');
```
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 |

View File

@@ -57,12 +57,15 @@ Reusable HTML components:
## Security ## 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 - CSRF tokens protect all forms
- File uploads validated and sanitized - File uploads validated and sanitized
- Database queries use prepared statements - Database queries use prepared statements
- Upload directory outside public/ in production - Upload directory outside public/ in production
See `nginx/PHP_AUTH_LAYER.md` for details on the dual-auth architecture.
## Templates ## Templates
The `inc/` folder contains shared templates: The `inc/` folder contains shared templates:
@@ -96,7 +99,8 @@ Backend actions (not directly accessed):
```php ```php
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
session_start(); require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::requireLogin();
$pageTitle = "Your Page Title"; $pageTitle = "Your Page Title";
?> ?>
<?php include "inc/head.php" ?> <?php include "inc/head.php" ?>
@@ -114,7 +118,8 @@ $pageTitle = "Your Page Title";
```php ```php
<?php <?php
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
session_start(); require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::requireLogin();
// Verify CSRF token // Verify CSRF token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {

View File

@@ -1,15 +1,15 @@
<?php // formulaire.php <?php // formulaire.php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
// Configure error reporting // Configure error reporting
ini_set('display_errors', 0); ini_set('display_errors', 0);
ini_set('log_errors', 1); ini_set('log_errors', 1);
ini_set('error_log', 'error.log'); ini_set('error_log', 'error.log');
// Start session for CSRF protection // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
session_start(); AdminAuth::requireLogin();
// Verify CSRF token // Verify CSRF token
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) || if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||

View File

@@ -1,11 +1,13 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
/** /**
* Handle publish/unpublish actions for theses * Handle publish/unpublish actions for theses
*/ */
session_start(); // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
require_once __DIR__ . '/../../lib/Database.php'; require_once __DIR__ . '/../../lib/Database.php';

View File

@@ -1,9 +1,11 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Start session and generate CSRF token
session_start();
if (empty($_SESSION["csrf_token"])) { if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32)); $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
} }

View File

@@ -1,9 +1,10 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
// Edit thesis page // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
session_start(); AdminAuth::requireLogin();
// Generate CSRF token // Generate CSRF token
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {

View File

@@ -1,11 +1,13 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
// CSV Import page for Post-ERG thesis database // CSV Import page for Post-ERG thesis database
// This page allows importing thesis data from CSV files // This page allows importing thesis data from CSV files
session_start(); // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Generate CSRF token // Generate CSRF token
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {

View File

@@ -62,5 +62,8 @@
echo implode(' ', $navLinks); echo implode(' ', $navLinks);
?> ?>
<?php if (defined('ADMIN_PASSWORD_HASH')): ?>
<a href="/admin/logout.php"><button>🔐 Déconnexion</button></a>
<?php endif; ?>
</nav> </nav>
</header> </header>

View File

@@ -1,9 +1,10 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
// List all theses in the database // PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
session_start(); AdminAuth::requireLogin();
// Generate CSRF token // Generate CSRF token
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {

60
public/admin/login.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
require_once __DIR__ . '/../../lib/AdminAuth.php';
// If no password is configured, nothing to log into — go straight to admin.
if (!defined('ADMIN_PASSWORD_HASH')) {
header('Location: /admin/');
exit;
}
// Already authenticated — redirect to admin.
if (AdminAuth::isAuthenticated()) {
header('Location: /admin/');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if (AdminAuth::login($password)) {
header('Location: /admin/');
exit;
}
// Intentionally vague error — avoid user-enumeration.
$error = 'Mot de passe incorrect.';
}
$pageTitle = 'Connexion';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle); ?> — Post-ERG Admin</title>
<link rel="stylesheet" href="/assets/modern-normalize.min.css">
<link rel="stylesheet" href="/assets/admin.css">
<link rel="shortcut icon" href="/assets/admin_favicon.svg" type="image/svg+xml">
</head>
<body>
<header>
<h1><?php echo htmlspecialchars($pageTitle); ?></h1>
</header>
<main>
<?php if ($error): ?>
<div class="alert-error">
<strong>⚠️ <?php echo htmlspecialchars($error); ?></strong>
</div>
<?php endif; ?>
<form method="post" action="/admin/login.php">
<fieldset>
<legend>Authentification admin</legend>
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autofocus>
<button type="submit">Se connecter</button>
</fieldset>
</form>
</main>
</body>
</html>

8
public/admin/logout.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/../../config/bootstrap.php';
require_once __DIR__ . '/../../lib/AdminAuth.php';
AdminAuth::logout();
header('Location: /admin/login.php');
exit;

View File

@@ -1,6 +1,10 @@
<?php <?php
// Bootstrap application // Bootstrap application
require_once __DIR__ . "/../../config/bootstrap.php"; require_once __DIR__ . "/../../config/bootstrap.php";
require_once __DIR__ . '/../../lib/AdminAuth.php';
// PHP-level auth guard (defence-in-depth behind nginx Basic Auth)
AdminAuth::requireLogin();
// Configure error reporting // Configure error reporting
ini_set('display_errors', 0); ini_set('display_errors', 0);