db = Database::connection(); } public function attempt(string $username, string $password): bool { $statement = $this->db->prepare( 'SELECT id, username, display_name, password_hash, is_active, failed_attempts, locked_until FROM admins WHERE username = :username LIMIT 1' ); $statement->execute(['username' => $username]); $admin = $statement->fetch(); if (!$admin) { password_verify($password, password_hash('invalid-login', PASSWORD_DEFAULT)); return false; } if (!(bool) $admin['is_active']) { return false; } if ($admin['locked_until'] && strtotime($admin['locked_until']) > time()) { return false; } if (!password_verify($password, $admin['password_hash'])) { $failed = (int) $admin['failed_attempts'] + 1; $lockedUntil = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null; $update = $this->db->prepare('UPDATE admins SET failed_attempts = :failed, locked_until = :locked WHERE id = :id'); $update->execute(['failed' => $failed >= 5 ? 0 : $failed, 'locked' => $lockedUntil, 'id' => $admin['id']]); return false; } $update = $this->db->prepare('UPDATE admins SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = :id'); $update->execute(['id' => $admin['id']]); session_regenerate_id(true); $_SESSION['admin'] = [ 'id' => (int) $admin['id'], 'username' => $admin['username'], 'display_name' => $admin['display_name'], 'authenticated_at' => time(), ]; return true; } public static function check(): bool { return isset($_SESSION['admin']['id'], $_SESSION['admin']['authenticated_at']) && time() - (int) $_SESSION['admin']['authenticated_at'] < 7200; } public static function requireLogin(): void { if (!self::check()) { unset($_SESSION['admin']); redirect('/admin/login'); } $_SESSION['admin']['authenticated_at'] = time(); } public static function logout(): void { unset($_SESSION['admin']); session_regenerate_id(true); } }