Add admin console with login, dashboard, content/config/plugin/user management
File-based admin panel accessible at /admin.php with: - Session-based auth with bcrypt hashing and brute-force protection - Dashboard with site statistics and quick actions - Content manager: browse, create, edit, delete files - Config editor with JSON validation - Plugin overview with status indicators - User management: add, remove, change passwords - CSRF protection on all forms, path traversal prevention - Updated README (NL/EN) and guides with admin documentation
This commit is contained in:
279
admin-console/src/AdminAuth.php
Normal file
279
admin-console/src/AdminAuth.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AdminAuth - File-based authentication for CodePress Admin
|
||||
*/
|
||||
class AdminAuth
|
||||
{
|
||||
private array $config;
|
||||
private array $adminConfig;
|
||||
private string $lockFile;
|
||||
|
||||
public function __construct(array $appConfig)
|
||||
{
|
||||
$this->config = $appConfig;
|
||||
$this->adminConfig = $this->loadAdminConfig();
|
||||
$this->lockFile = dirname($appConfig['log_file']) . '/login_attempts.json';
|
||||
$this->startSession();
|
||||
}
|
||||
|
||||
private function loadAdminConfig(): array
|
||||
{
|
||||
$path = $this->config['admin_config'];
|
||||
if (!file_exists($path)) {
|
||||
return ['users' => [], 'security' => []];
|
||||
}
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
return is_array($data) ? $data : ['users' => [], 'security' => []];
|
||||
}
|
||||
|
||||
public function saveAdminConfig(): void
|
||||
{
|
||||
file_put_contents(
|
||||
$this->config['admin_config'],
|
||||
json_encode($this->adminConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
private function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$timeout = $this->adminConfig['security']['session_timeout'] ?? 1800;
|
||||
session_set_cookie_params([
|
||||
'lifetime' => $timeout,
|
||||
'path' => '/',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
if (isset($_SESSION['admin_last_activity'])) {
|
||||
$timeout = $this->adminConfig['security']['session_timeout'] ?? 1800;
|
||||
if (time() - $_SESSION['admin_last_activity'] > $timeout) {
|
||||
$this->logout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isAuthenticated()) {
|
||||
$_SESSION['admin_last_activity'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
public function login(string $username, string $password): array
|
||||
{
|
||||
// Check brute-force lockout
|
||||
$lockout = $this->checkLockout($username);
|
||||
if ($lockout['locked']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Account tijdelijk vergrendeld. Probeer over ' . $lockout['remaining'] . ' seconden opnieuw.'
|
||||
];
|
||||
}
|
||||
|
||||
// Find user
|
||||
$user = $this->findUser($username);
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
$this->recordFailedAttempt($username);
|
||||
$this->log('warning', "Mislukte inlogpoging: {$username}");
|
||||
return ['success' => false, 'message' => 'Onjuiste gebruikersnaam of wachtwoord.'];
|
||||
}
|
||||
|
||||
// Success - clear failed attempts
|
||||
$this->clearFailedAttempts($username);
|
||||
|
||||
// Set session
|
||||
$_SESSION['admin_user'] = $username;
|
||||
$_SESSION['admin_role'] = $user['role'] ?? 'admin';
|
||||
$_SESSION['admin_last_activity'] = time();
|
||||
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
||||
|
||||
$this->log('info', "Ingelogd: {$username}");
|
||||
return ['success' => true, 'message' => 'Ingelogd.'];
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$user = $_SESSION['admin_user'] ?? 'unknown';
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params['path'], $params['domain'],
|
||||
$params['secure'], $params['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
$this->log('info', "Uitgelogd: {$user}");
|
||||
}
|
||||
|
||||
public function isAuthenticated(): bool
|
||||
{
|
||||
return isset($_SESSION['admin_user']);
|
||||
}
|
||||
|
||||
public function getCurrentUser(): ?array
|
||||
{
|
||||
if (!$this->isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'username' => $_SESSION['admin_user'],
|
||||
'role' => $_SESSION['admin_role'] ?? 'admin'
|
||||
];
|
||||
}
|
||||
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['admin_csrf_token'])) {
|
||||
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['admin_csrf_token'];
|
||||
}
|
||||
|
||||
public function verifyCsrf(string $token): bool
|
||||
{
|
||||
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
||||
}
|
||||
|
||||
// --- User Management ---
|
||||
|
||||
public function getUsers(): array
|
||||
{
|
||||
return array_map(function ($u) {
|
||||
return [
|
||||
'username' => $u['username'],
|
||||
'role' => $u['role'] ?? 'admin',
|
||||
'created' => $u['created'] ?? ''
|
||||
];
|
||||
}, $this->adminConfig['users'] ?? []);
|
||||
}
|
||||
|
||||
public function addUser(string $username, string $password, string $role = 'admin'): array
|
||||
{
|
||||
if ($this->findUser($username)) {
|
||||
return ['success' => false, 'message' => 'Gebruiker bestaat al.'];
|
||||
}
|
||||
if (strlen($password) < 8) {
|
||||
return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
|
||||
}
|
||||
|
||||
$this->adminConfig['users'][] = [
|
||||
'username' => $username,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'role' => $role,
|
||||
'created' => date('Y-m-d')
|
||||
];
|
||||
$this->saveAdminConfig();
|
||||
$this->log('info', "Gebruiker aangemaakt: {$username}");
|
||||
return ['success' => true, 'message' => 'Gebruiker aangemaakt.'];
|
||||
}
|
||||
|
||||
public function deleteUser(string $username): array
|
||||
{
|
||||
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
||||
return ['success' => false, 'message' => 'Je kunt jezelf niet verwijderen.'];
|
||||
}
|
||||
|
||||
$this->adminConfig['users'] = array_values(array_filter(
|
||||
$this->adminConfig['users'],
|
||||
fn($u) => $u['username'] !== $username
|
||||
));
|
||||
$this->saveAdminConfig();
|
||||
$this->log('info', "Gebruiker verwijderd: {$username}");
|
||||
return ['success' => true, 'message' => 'Gebruiker verwijderd.'];
|
||||
}
|
||||
|
||||
public function changePassword(string $username, string $newPassword): array
|
||||
{
|
||||
if (strlen($newPassword) < 8) {
|
||||
return ['success' => false, 'message' => 'Wachtwoord moet minimaal 8 tekens zijn.'];
|
||||
}
|
||||
foreach ($this->adminConfig['users'] as &$user) {
|
||||
if ($user['username'] === $username) {
|
||||
$user['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$this->saveAdminConfig();
|
||||
$this->log('info', "Wachtwoord gewijzigd: {$username}");
|
||||
return ['success' => true, 'message' => 'Wachtwoord gewijzigd.'];
|
||||
}
|
||||
}
|
||||
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private function findUser(string $username): ?array
|
||||
{
|
||||
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
||||
if ($user['username'] === $username) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function checkLockout(string $username): array
|
||||
{
|
||||
$attempts = $this->getFailedAttempts();
|
||||
$maxAttempts = $this->adminConfig['security']['max_login_attempts'] ?? 5;
|
||||
$lockoutDuration = $this->adminConfig['security']['lockout_duration'] ?? 900;
|
||||
|
||||
if (!isset($attempts[$username])) {
|
||||
return ['locked' => false];
|
||||
}
|
||||
|
||||
$record = $attempts[$username];
|
||||
if ($record['count'] >= $maxAttempts) {
|
||||
$elapsed = time() - $record['last_attempt'];
|
||||
if ($elapsed < $lockoutDuration) {
|
||||
return ['locked' => true, 'remaining' => $lockoutDuration - $elapsed];
|
||||
}
|
||||
// Lockout expired
|
||||
$this->clearFailedAttempts($username);
|
||||
}
|
||||
|
||||
return ['locked' => false];
|
||||
}
|
||||
|
||||
private function recordFailedAttempt(string $username): void
|
||||
{
|
||||
$attempts = $this->getFailedAttempts();
|
||||
if (!isset($attempts[$username])) {
|
||||
$attempts[$username] = ['count' => 0, 'last_attempt' => 0];
|
||||
}
|
||||
$attempts[$username]['count']++;
|
||||
$attempts[$username]['last_attempt'] = time();
|
||||
file_put_contents($this->lockFile, json_encode($attempts));
|
||||
}
|
||||
|
||||
private function clearFailedAttempts(string $username): void
|
||||
{
|
||||
$attempts = $this->getFailedAttempts();
|
||||
unset($attempts[$username]);
|
||||
file_put_contents($this->lockFile, json_encode($attempts));
|
||||
}
|
||||
|
||||
private function getFailedAttempts(): array
|
||||
{
|
||||
if (!file_exists($this->lockFile)) {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode(file_get_contents($this->lockFile), true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function log(string $level, string $message): void
|
||||
{
|
||||
$logFile = $this->config['log_file'];
|
||||
$dir = dirname($logFile);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'cli';
|
||||
file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace CodePress\Admin\Controllers;
|
||||
|
||||
use CodePress\Admin\Services\AuthService;
|
||||
use CodePress\Admin\Services\LoggerService;
|
||||
|
||||
class AuthController {
|
||||
private AuthService $authService;
|
||||
private LoggerService $logger;
|
||||
|
||||
public function __construct() {
|
||||
$this->authService = new AuthService();
|
||||
$this->logger = new LoggerService();
|
||||
}
|
||||
|
||||
public function login() {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = $_POST['username'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
$remember = isset($_POST['remember']);
|
||||
|
||||
$result = $this->authService->login($username, $password, $remember);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->logger->info("User logged in: {$username}");
|
||||
$this->jsonResponse(['success' => true, 'redirect' => '/admin/dashboard']);
|
||||
} else {
|
||||
$this->logger->warning("Failed login attempt: {$username}");
|
||||
$this->jsonResponse(['success' => false, 'message' => $result['message']]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->renderView('auth/login');
|
||||
}
|
||||
|
||||
public function logout() {
|
||||
$this->authService->logout();
|
||||
$this->logger->info("User logged out");
|
||||
header('Location: /admin/login');
|
||||
exit;
|
||||
}
|
||||
|
||||
public function profile() {
|
||||
if (!$this->authService->isAuthenticated()) {
|
||||
header('Location: /admin/login');
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = $this->authService->getCurrentUser();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = $_POST['email'] ?? '';
|
||||
$currentPassword = $_POST['current_password'] ?? '';
|
||||
$newPassword = $_POST['new_password'] ?? '';
|
||||
|
||||
$result = $this->authService->updateProfile($user['id'], $email, $currentPassword, $newPassword);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->logger->info("Profile updated: {$user['username']}");
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Profile updated successfully']);
|
||||
} else {
|
||||
$this->jsonResponse(['success' => false, 'message' => $result['message']]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->renderView('auth/profile', ['user' => $user]);
|
||||
}
|
||||
|
||||
private function jsonResponse(array $data) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function renderView(string $view, array $data = []) {
|
||||
extract($data);
|
||||
require __DIR__ . "/../../public/templates/{$view}.php";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user