Files
CodePress/admin/src/AdminAuth.php
T
E.Noorlander 20adea7544 v2.6.0: Content backup/git versioning, plugin type system, docs update
New features:
- ContentBackup class with ZIP backup/restore and git versioning
- Admin backup & restore page (content-backup.twig) with git init/commit/log/restore
- Plugin type system: system (blue) vs content (green) with visual badges
- PluginAPIInterface + AdminPluginAPI for plugin architecture
- Essential plugin flag (cannot edit/deactivate/delete)

Improvements:
- Consolidated enabled_plugins config (removed plugins.enabled)
- Removed Analytics/Logging toggles from admin config page
- Fixed Dashboard plugin Twig comments rendered as text
- Updated 20 guide files (NL+EN): configuratie, plugins, plugin-development,
  core-classes, theme-json, layouts, scss-styling, admin-beheerder, nieuw-thema, architectuur
- Improved accessibility test script (grep -E, min/max checks)

Cleanup:
- Removed unused classes: ARIAComponents, AccessibilityManager, ContentSecurityPolicy, etc.
- Removed vendor packages: mustache/mustache, php-mqtt/client
- Removed old templates: logs.twig, statistics.twig (now plugins)
- Moved language files to language/ directory

Tests:
- Pentest: 30/30 passed, 0 vulnerabilities
- WCAG 2.1 AA: 25/25 passed, 100% compliance
2026-08-15 19:21:04 +02:00

462 lines
16 KiB
PHP

<?php
/**
* AdminAuth - File-based authentication for CodePress Admin
*/
class AdminAuth
{
private array $config;
private array $adminConfig;
private string $lockFile;
/**
* Role definitions with permissions.
* Each role maps to a list of allowed route prefixes.
* 'admin' has wildcard '*' access.
*/
public const ROLE_PERMISSIONS = [
'admin' => ['*'],
'content-manager' => ['dashboard', 'content', 'content-edit', 'content-new', 'content-delete', 'content-dir-create', 'content-dir-rename', 'content-dir-delete', 'content-move', 'content-backup', 'content-restore', 'content-git-init', 'content-git-commit', 'content-git-restore', 'guide', 'logout'],
'bi-manager' => ['dashboard', 'statistics', 'logs', 'guide', 'logout'],
'site-admin' => ['dashboard', 'theme', 'theme-new', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', 'guide', 'logout'],
];
/**
* Human-readable role labels.
*/
public const ROLE_LABELS = [
'admin' => 'Admin',
'content-manager' => 'Content Beheerder',
'bi-manager' => 'BI Beheerder',
'site-admin' => 'Site Admin',
];
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'];
$examplePath = dirname($path) . '/admin.json.example';
if (!file_exists($path)) {
if (file_exists($examplePath)) {
@copy($examplePath, $path);
} else {
$defaultAdminConfig = [
'users' => [
[
'username' => 'admin',
'password_hash' => password_hash('admin', PASSWORD_BCRYPT),
'role' => 'admin',
'created' => date('Y-m-d'),
]
],
'security' => [
'session_timeout' => 1800,
'max_login_attempts' => 5,
'lockout_duration' => 900,
]
];
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($path, json_encode($defaultAdminConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
}
$data = file_exists($path) ? json_decode(file_get_contents($path), true) : null;
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;
$isHttps = !empty($_SERVER['HTTPS'])
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
|| (isset($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on');
session_set_cookie_params([
'lifetime' => $timeout,
'path' => '/',
'secure' => $isHttps,
'httponly' => true,
'samesite' => 'Strict'
]);
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;
}
$username = $_SESSION['admin_user'];
$userData = [
'username' => $username,
'role' => $_SESSION['admin_role'] ?? 'admin'
];
// Enrich with profile fields from admin.json
$userEntry = $this->findUser($username);
if ($userEntry) {
$userData['email'] = $userEntry['email'] ?? '';
$userData['author_name'] = $userEntry['author_name'] ?? '';
$userData['author_email'] = $userEntry['author_email'] ?? '';
}
return $userData;
}
/**
* Get the role of the current user.
*/
public function getCurrentRole(): string
{
return $_SESSION['admin_role'] ?? 'admin';
}
/**
* Check if the current user has permission to access a route.
*/
public function hasPermission(string $route): bool
{
$role = $this->getCurrentRole();
$permissions = self::ROLE_PERMISSIONS[$role] ?? ['dashboard', 'logout'];
if (in_array('*', $permissions, true)) {
return true;
}
return in_array($route, $permissions, true);
}
/**
* Get available roles.
*/
public static function getRoles(): array
{
return self::ROLE_LABELS;
}
/**
* Get role label.
*/
public static function getRoleLabel(string $role): string
{
return self::ROLE_LABELS[$role] ?? $role;
}
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);
}
public function regenerateCsrfToken(): void
{
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
}
// --- User Management ---
public function getUsers(): array
{
$users = [];
foreach ($this->adminConfig['users'] ?? [] as $u) {
$role = $u['role'] ?? 'admin';
$users[$u['username']] = [
'username' => $u['username'],
'role' => $role,
'role_label' => self::getRoleLabel($role),
'created' => $u['created'] ?? '',
'email' => $u['email'] ?? '',
'author_name' => $u['author_name'] ?? '',
'author_email' => $u['author_email'] ?? '',
];
}
return $users;
}
public function addUser(string $username, string $password, string $role = 'admin', string $email = '', string $authorName = '', string $authorEmail = ''): 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.'];
}
if (!isset(self::ROLE_PERMISSIONS[$role])) {
return ['success' => false, 'message' => 'Ongeldige rol.'];
}
$this->adminConfig['users'][] = [
'username' => $username,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
'email' => $email,
'author_name' => $authorName,
'author_email' => $authorEmail,
'created' => date('Y-m-d')
];
$this->saveAdminConfig();
$this->log('info', "Gebruiker aangemaakt: {$username} (rol: {$role})");
return ['success' => true, 'message' => 'Gebruiker aangemaakt.'];
}
/**
* Update the profile (email, author_name, author_email) of a user.
*/
public function updateUserProfile(string $username, string $email = '', string $authorName = '', string $authorEmail = ''): array
{
foreach ($this->adminConfig['users'] as &$userEntry) {
if ($userEntry['username'] === $username) {
$userEntry['email'] = $email;
$userEntry['author_name'] = $authorName;
$userEntry['author_email'] = $authorEmail;
$this->saveAdminConfig();
$this->log('info', "Profiel bijgewerkt: {$username}");
return ['success' => true, 'message' => 'Profiel opgeslagen.'];
}
}
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
}
/**
* Change the role of an existing user.
*/
public function changeRole(string $username, string $role): array
{
if (!isset(self::ROLE_PERMISSIONS[$role])) {
return ['success' => false, 'message' => 'Ongeldige rol.'];
}
foreach ($this->adminConfig['users'] as &$user) {
if ($user['username'] === $username) {
$user['role'] = $role;
$this->saveAdminConfig();
$this->log('info', "Rol gewijzigd: {$username} -> {$role}");
return ['success' => true, 'message' => 'Rol gewijzigd.'];
}
}
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
}
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.'];
}
public function changeOwnPassword(string $username, string $currentPassword, string $newPassword): array
{
$user = $this->findUser($username);
if (!$user) {
return ['success' => false, 'message' => 'Gebruiker niet gevonden.'];
}
if (!password_verify($currentPassword, $user['password_hash'])) {
return ['success' => false, 'message' => 'Huidig wachtwoord is onjuist.'];
}
if (strlen($newPassword) < 8) {
return ['success' => false, 'message' => 'Nieuw wachtwoord moet minimaal 8 tekens zijn.'];
}
foreach ($this->adminConfig['users'] as &$u) {
if ($u['username'] === $username) {
$u['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
$this->saveAdminConfig();
$this->log('info', "Eigen wachtwoord gewijzigd: {$username}");
return ['success' => true, 'message' => 'Wachtwoord gewijzigd.'];
}
}
return ['success' => false, 'message' => 'Fout bij wijzigen wachtwoord.'];
}
// --- 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');
if (!class_exists('RequestLogger')) {
$loggerClass = __DIR__ . '/../../cms/core/class/RequestLogger.php';
if (file_exists($loggerClass)) {
require_once $loggerClass;
}
}
$ip = class_exists('RequestLogger') ? RequestLogger::getClientIp() : ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1');
file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND);
}
}