self::COOKIE_LIFETIME_SECONDS, 'path' => '/admin', 'secure' => (php_sapi_name() !== 'cli-server'), 'httponly' => true, 'samesite' => 'Strict', ]); session_start(); } /** * Enforce server-side idle + absolute timeouts on an authenticated session. * * Called on every gated request. If the session has been idle longer than * IDLE_TIMEOUT_SECONDS, or has existed longer than ABSOLUTE_TIMEOUT_SECONDS * since login, the session is destroyed and the caller is redirected. * * Only acts on authenticated sessions; leaves unauthenticated sessions * (including throttle counters) untouched. */ private static function enforceSessionTimeout(): void { if (empty($_SESSION[self::SESSION_KEY])) { return; // Not authenticated — nothing to time out. } $now = time(); $loginAt = (int) ($_SESSION['admin_login_at'] ?? $now); $activity = (int) ($_SESSION['admin_last_activity'] ?? $loginAt); $idle = $now - $activity; $absolute = $now - $loginAt; if ($idle > self::IDLE_TIMEOUT_SECONDS || $absolute > self::ABSOLUTE_TIMEOUT_SECONDS) { self::logout(); header('Location: ' . self::LOGIN_URL); exit; } // Rotate the session ID periodically to limit fixation/replay window. if ($absolute > 0 && $absolute >= self::IDLE_TIMEOUT_SECONDS && $absolute % self::IDLE_TIMEOUT_SECONDS === 0) { session_regenerate_id(true); } // Refresh the activity timestamp on every authenticated request. $_SESSION['admin_last_activity'] = $now; } /** * 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(); self::enforceSessionTimeout(); $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(); $_SESSION['admin_last_activity'] = 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 = <<

Réinitialisation du mot de passe

Une demande de réinitialisation du mot de passe administrateur a été effectuée.

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.

Définir un nouveau mot de passe

Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.

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(); self::enforceSessionTimeout(); $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(); } }