mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
- Add DuplicateThesisException (typed, carries existing thesis metadata) - Add Database::findDuplicateThesis(): matches on year + author + normalised title (exact, prefix, Levenshtein ≤10% of longer string) - ThesisCreateController::submit() runs duplicate check before any DB write and throws DuplicateThesisException on match - AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines log for audit purposes - App::flash/consumeFlash extended to support 'warning' flash type - admin/actions/formulaire.php: catches DuplicateThesisException, logs it, flashes an HTML warning toast with a clickable link to the existing thesis, and repopulates the form fields - partage/index.php: same catch block; surfaces a plain-text flash-warning banner on the student form with identifier, title, and year of the match; form is repopulated via session - toast.php: renders toast--warning variant - admin.css: .toast--warning + link colour rules - form.css: .flash-warning style for the partage form
177 lines
5.4 KiB
PHP
177 lines
5.4 KiB
PHP
<?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.
|
|
*
|
|
* The admin password hash is stored in the site_settings table
|
|
* (key = 'admin_password_hash').
|
|
*
|
|
* If the hash is empty/missing 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();
|
|
}
|
|
|
|
/**
|
|
* Fetch the admin password hash from site_settings.
|
|
* Returns null if not set (dev mode).
|
|
*/
|
|
private static function getStoredHash(): ?string
|
|
{
|
|
// Legacy fallback: if the old constant is still defined, honour it.
|
|
if (defined('ADMIN_PASSWORD_HASH') && ADMIN_PASSWORD_HASH !== '') {
|
|
return ADMIN_PASSWORD_HASH;
|
|
}
|
|
|
|
// Lazy-load minimal DB just for this lookup.
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
$db = new Database();
|
|
$hash = $db->getSetting('admin_password_hash');
|
|
return $hash !== '' ? $hash : null;
|
|
}
|
|
|
|
/**
|
|
* Gate every admin page.
|
|
*
|
|
* Authentication order:
|
|
* 1. No password hash configured → dev mode, pass through.
|
|
* 2. Session already authenticated → pass through.
|
|
* 3. 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).
|
|
* 4. Neither → redirect to the PHP login form.
|
|
*/
|
|
public static function requireLogin(): void
|
|
{
|
|
self::startSession();
|
|
$storedHash = self::getStoredHash();
|
|
if ($storedHash === null) {
|
|
return; // No password configured → dev / cli-server, skip.
|
|
}
|
|
if (!empty($_SESSION[self::SESSION_KEY])) {
|
|
return; // Already authenticated via session.
|
|
}
|
|
// Try to auto-authenticate from the nginx Basic Auth credentials.
|
|
if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) {
|
|
return;
|
|
}
|
|
header('Location: ' . self::LOGIN_URL);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Validate a plaintext password against the stored hash.
|
|
* On success: regenerates the session ID and marks the session authenticated.
|
|
*
|
|
* @return bool true on success, false on wrong password / no hash stored.
|
|
*/
|
|
public static function login(string $password): bool
|
|
{
|
|
$storedHash = self::getStoredHash();
|
|
if ($storedHash === null || !self::verifyHash($password, $storedHash)) {
|
|
return false;
|
|
}
|
|
self::startSession();
|
|
session_regenerate_id(true);
|
|
$_SESSION[self::SESSION_KEY] = true;
|
|
$_SESSION['admin_login_at'] = time();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Bcrypt verification wrapper.
|
|
*/
|
|
private static function verifyHash(string $password, string $hash): bool
|
|
{
|
|
return password_verify($password, $hash);
|
|
}
|
|
|
|
/**
|
|
* Update the stored admin password hash in the database.
|
|
*/
|
|
public static function setPasswordHash(string $newHash): void
|
|
{
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
$db = new Database();
|
|
$db->setSetting('admin_password_hash', $newHash);
|
|
}
|
|
|
|
/**
|
|
* Remove the stored admin password hash (revert to dev mode).
|
|
*/
|
|
public static function removePasswordHash(): void
|
|
{
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
$db = new Database();
|
|
$db->setSetting('admin_password_hash', '');
|
|
}
|
|
|
|
/**
|
|
* Check whether the current request is authenticated (without redirecting).
|
|
*/
|
|
public static function isAuthenticated(): bool
|
|
{
|
|
self::startSession();
|
|
$storedHash = self::getStoredHash();
|
|
if ($storedHash === null) {
|
|
return true; // No password configured → dev mode.
|
|
}
|
|
return !empty($_SESSION[self::SESSION_KEY]);
|
|
}
|
|
|
|
/**
|
|
* Check whether a password hash is configured in the system.
|
|
*/
|
|
public static function hasPassword(): bool
|
|
{
|
|
return self::getStoredHash() !== null;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|