mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
331 lines
11 KiB
PHP
331 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Minimal PHP session guard for the admin panel.
|
|
*
|
|
* Password-only authentication via an HTML login form.
|
|
*
|
|
* 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';
|
|
|
|
// Throttle: max 5 attempts before mandatory delay, cooldown 15 min.
|
|
private const MAX_ATTEMPTS = 5;
|
|
private const COOLDOWN_MINUTES = 15;
|
|
|
|
/** Test hook: override the Database connection (e.g. temp DB in smoke tests). */
|
|
private static ?Database $dbOverride = null;
|
|
|
|
/**
|
|
* Return the Database used by AdminAuth. Overridable for tests.
|
|
*/
|
|
private static function db(): Database
|
|
{
|
|
if (self::$dbOverride !== null) {
|
|
return self::$dbOverride;
|
|
}
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
return new Database();
|
|
}
|
|
|
|
/**
|
|
* Inject a Database instance (used by tests / smoke scripts).
|
|
*/
|
|
public static function setDatabase(Database $db): void
|
|
{
|
|
self::$dbOverride = $db;
|
|
}
|
|
|
|
/**
|
|
* 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' => 604800,
|
|
'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.
|
|
$db = self::db();
|
|
$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. 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.
|
|
}
|
|
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.
|
|
*
|
|
* Throttling: after MAX_ATTEMPTS consecutive failures, a mandatory delay is
|
|
* enforced (incremental: 1s, 2s, 4s, … up to 60s). Returns the same `false`
|
|
* result as a wrong password so the attacker cannot distinguish the reason.
|
|
*
|
|
* @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) {
|
|
return false;
|
|
}
|
|
|
|
self::startSession();
|
|
$alreadyAuthed = !empty($_SESSION[self::SESSION_KEY]);
|
|
|
|
// ── Throttle: only on unauthenticated login attempts ────────────────
|
|
if (!$alreadyAuthed) {
|
|
$attempts = (int) ($_SESSION['auth_attempts'] ?? 0);
|
|
$firstAt = (int) ($_SESSION['auth_first_attempt'] ?? 0);
|
|
$now = time();
|
|
|
|
// Cooldown window — reset after COOLDOWN_MINUTES
|
|
if ($attempts > 0 && ($now - $firstAt) > self::COOLDOWN_MINUTES * 60) {
|
|
$attempts = 0;
|
|
$firstAt = 0;
|
|
unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']);
|
|
}
|
|
|
|
if ($attempts >= self::MAX_ATTEMPTS) {
|
|
$extra = $attempts - self::MAX_ATTEMPTS;
|
|
$delay = min(1 << min($extra, 6), 60); // 1s → 2s → 4s … → 60s cap
|
|
sleep($delay);
|
|
}
|
|
}
|
|
|
|
if (!self::verifyHash($password, $storedHash)) {
|
|
if (!$alreadyAuthed) {
|
|
if ($attempts === 0) {
|
|
$_SESSION['auth_first_attempt'] = $now;
|
|
}
|
|
$_SESSION['auth_attempts'] = $attempts + 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Success: clear throttling, create/refresh session ──────────────
|
|
unset($_SESSION['auth_attempts'], $_SESSION['auth_first_attempt']);
|
|
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
|
|
{
|
|
$db = self::db();
|
|
$db->setSetting('admin_password_hash', $newHash);
|
|
}
|
|
|
|
/**
|
|
* Remove the stored admin password hash (revert to dev mode).
|
|
*/
|
|
public static function removePasswordHash(): void
|
|
{
|
|
$db = self::db();
|
|
$db->setSetting('admin_password_hash', '');
|
|
}
|
|
|
|
/**
|
|
* Issue a single-use password-reset token (plaintext, to be emailed).
|
|
*
|
|
* Uses the shared OneTimeToken model (purpose 'password_reset').
|
|
*
|
|
* @param int $ttlSeconds Lifetime in seconds (default 30 minutes).
|
|
* @return string The plaintext token to send to the admin.
|
|
*/
|
|
public static function issuePasswordResetToken(int $ttlSeconds = 1800): string
|
|
{
|
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
|
$db = self::db();
|
|
$ot = new OneTimeToken($db->getPDO());
|
|
return $ot->issue('password_reset', $ttlSeconds);
|
|
}
|
|
|
|
/**
|
|
* Initiate a password reset: issue a one-time token and email the link
|
|
* to the admin notification address.
|
|
*
|
|
* Uses the shared OneTimeToken model + SmtpRelay. Returns true when the
|
|
* email was accepted for delivery (false if no address is configured or
|
|
* sending fails).
|
|
*/
|
|
public static function requestPasswordReset(): bool
|
|
{
|
|
require_once APP_ROOT . '/src/SmtpRelay.php';
|
|
$db = self::db();
|
|
|
|
$to = SmtpRelay::getNotifyEmail($db);
|
|
if ($to === '') {
|
|
error_log('[AdminAuth] password reset requested but no notify email configured');
|
|
return false;
|
|
}
|
|
|
|
$token = self::issuePasswordResetToken();
|
|
$host = $_SERVER['HTTP_HOST'] ?? 'xamxam.erg.be';
|
|
$url = "https://{$host}/admin/password-reset.php?token={$token}";
|
|
|
|
$subject = 'Réinitialisation du mot de passe — XAMXAM';
|
|
$body = <<<HTML
|
|
<html><body style="font-family:sans-serif;color:#222;max-width:600px;margin:0 auto;padding:24px">
|
|
<h1 style="font-size:1.4rem;border-bottom:2px solid #c00;padding-bottom:8px">Réinitialisation du mot de passe</h1>
|
|
<p>Une demande de réinitialisation du mot de passe administrateur a été effectuée.</p>
|
|
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.</p>
|
|
<p><a href="{$url}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#c00;color:#fff;text-decoration:none;border-radius:4px">Définir un nouveau mot de passe</a></p>
|
|
<p style="font-size:.85rem;color:#666">Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.</p>
|
|
</body></html>
|
|
HTML;
|
|
|
|
try {
|
|
return SmtpRelay::send($db, $to, $subject, $body);
|
|
} catch (\Throwable $e) {
|
|
error_log('[AdminAuth] password-reset email failed: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redeem a password-reset token and install a new password.
|
|
*
|
|
* Validates the new password length, consumes the one-time token, stores
|
|
* a fresh bcrypt hash (cost 12), and invalidates the session so the admin
|
|
* must log in with the new password.
|
|
*
|
|
* @return bool True on success; false when the token is invalid or the
|
|
* password is too short (< 12 chars).
|
|
*/
|
|
public static function redeemPasswordResetToken(string $token, string $newPassword): bool
|
|
{
|
|
if (strlen($newPassword) < 12) {
|
|
return false;
|
|
}
|
|
|
|
require_once APP_ROOT . '/src/OneTimeToken.php';
|
|
$db = self::db();
|
|
$ot = new OneTimeToken($db->getPDO());
|
|
|
|
$context = $ot->redeem('password_reset', $token);
|
|
if ($context === null) {
|
|
return false;
|
|
}
|
|
|
|
$hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
|
|
if ($hash === false) {
|
|
return false;
|
|
}
|
|
|
|
self::setPasswordHash($hash);
|
|
self::logout(); // invalidate any existing admin session
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
}
|
|
if (!empty($_SESSION[self::SESSION_KEY])) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// No session in this request (e.g. CLI) — nothing to destroy.
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
return;
|
|
}
|
|
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();
|
|
}
|
|
}
|