934 lines
33 KiB
PHP
934 lines
33 KiB
PHP
<?php
|
|
|
|
/**
|
|
* File-based authenticatie en autorisatie voor het CodePress admin paneel.
|
|
*
|
|
* Beheert gebruikerssessies, rollen met permissies, CSRF-tokens, brute-force
|
|
* lockout en gebruikersbeheer. Alle data wordt opgeslagen in JSON-bestanden
|
|
* (geen database).
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
class AdminAuth
|
|
{
|
|
/**
|
|
* Applicatie-configuratie uit config.json.
|
|
*
|
|
* @since 2.6.4
|
|
* @var array Applicatie-configuratie.
|
|
*/
|
|
private array $config;
|
|
|
|
/**
|
|
* Admin-configuratie geladen uit admin.json (gebruikers en security-instellingen).
|
|
*
|
|
* @since 2.6.4
|
|
* @var array Admin-configuratie met keys 'users' en 'security'.
|
|
*/
|
|
private array $adminConfig;
|
|
|
|
/**
|
|
* Pad naar het JSON-bestand met mislukte inlogpogingen voor brute-force lockout.
|
|
*
|
|
* @since 2.6.4
|
|
* @var string Absoluut pad naar login_attempts.json.
|
|
*/
|
|
private string $lockFile;
|
|
|
|
/**
|
|
* Tijdelijk gegenereerd wachtwoord bij eerste installatie.
|
|
*
|
|
* Wordt gevuld wanneer loadAdminConfig() een nieuwe admin.json aanmaakt
|
|
* met een cryptografisch veilig willekeurig wachtwoord. De login-pagina
|
|
* toont dit wachtwoord eenmalig via getGeneratedPassword().
|
|
*
|
|
* @since 2.6.4
|
|
* @var string Leeg tenzij net aangemaakt bij eerste installatie.
|
|
*/
|
|
private string $generatedPassword = '';
|
|
|
|
/**
|
|
* Permissies per rol.
|
|
*
|
|
* Elke rol mapt naar een lijst van toegestane route-prefixen. De rol
|
|
* 'admin' heeft wildcard '*' toegang tot alle routes.
|
|
*
|
|
* @since 2.6.4
|
|
* @var array<string, string[]> Map van rol-sleutel naar lijst met toegestane routes.
|
|
*/
|
|
public const ROLE_PERMISSIONS = [
|
|
'admin' => ['*'],
|
|
'content-manager' => ['dashboard', 'content', 'content-list', 'content-files', 'content-edit', 'content-new', 'content-delete', 'content-file-upload', 'content-file-delete', 'content-file-move', 'content-dir-create', 'content-dir-create-in', 'content-dir-rename', 'content-dir-rename-in', 'content-dir-delete', 'content-dir-delete-in', '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', 'theme-edit', 'theme-file-upload', 'theme-file-delete', 'theme-file-move', 'theme-activate', 'theme-delete', 'theme-scss', 'plugins', 'plugins-new', 'plugins-edit', 'plugins-config', 'plugins-toggle', 'plugins-delete', 'statistics', 'logs', 'update', 'guide', 'logout'],
|
|
];
|
|
|
|
/**
|
|
* Leesbare rol-labels voor weergave in de admin-interface.
|
|
*
|
|
* @since 2.6.4
|
|
* @var array<string, string> Map van rol-sleutel naar label.
|
|
*/
|
|
public const ROLE_LABELS = [
|
|
'admin' => 'Admin',
|
|
'content-manager' => 'Content Beheerder',
|
|
'bi-manager' => 'BI Beheerder',
|
|
'site-admin' => 'Site Admin',
|
|
];
|
|
|
|
/**
|
|
* Constructor: initialiseer authenticatie met de applicatie-configuratie.
|
|
*
|
|
* Laadt de admin-configuratie, bepaalt het pad naar het lockout-bestand en
|
|
* start de sessie met de juiste security-instellingen.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param array $appConfig Applicatie-configuratie; vereist keys 'log_file' en 'admin_config'.
|
|
*/
|
|
public function __construct(array $appConfig)
|
|
{
|
|
$this->config = $appConfig;
|
|
$this->adminConfig = $this->loadAdminConfig();
|
|
$this->lockFile = dirname($appConfig['log_file']) . '/login_attempts.json';
|
|
$this->startSession();
|
|
}
|
|
|
|
/**
|
|
* Laadt de admin-configuratie uit admin.json.
|
|
*
|
|
* Als het bestand nog niet bestaat wordt het aangemaakt vanuit
|
|
* admin.json.example, of indien die ook ontbreekt met een standaard admin-
|
|
* gebruiker. Geeft een lege structuur terug als de JSON ongeldig is.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return array Admin-configuratie met keys 'users' en 'security'.
|
|
*/
|
|
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 {
|
|
// Genereer een cryptografisch veilig wachtwoord bij eerste
|
|
// installatie. Het wachtwoord wordt nooit in de broncode
|
|
// opgeslagen; de hash wordt weggeschreven naar admin.json
|
|
// en het plaintext wachtwoord éénmalig getoond op het
|
|
// inlogscherm via getGeneratedPassword().
|
|
$plainPassword = $this->generateRandomPassword();
|
|
$this->generatedPassword = $plainPassword;
|
|
|
|
$defaultAdminConfig = [
|
|
'users' => [
|
|
[
|
|
'username' => 'admin',
|
|
'password_hash' => password_hash($plainPassword, 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' => []];
|
|
}
|
|
|
|
/**
|
|
* Genereer een cryptografisch veilig willekeurig wachtwoord.
|
|
*
|
|
* Gebruik random_bytes() (CSPRNG) met een mix van alfanumerieke tekens
|
|
* en een vaste lengte, zodat het wachtwoord voldoende entropie heeft
|
|
* en veilig te typen is.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return string Willekeurig wachtwoord van 16 alfanumerieke tekens.
|
|
*/
|
|
private function generateRandomPassword(int $length = 16): string
|
|
{
|
|
$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
$max = strlen($alphabet) - 1;
|
|
$password = '';
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$password .= $alphabet[random_int(0, $max)];
|
|
}
|
|
return $password;
|
|
}
|
|
|
|
/**
|
|
* Geef het bij eerste installatie gegenereerde wachtwoord terug.
|
|
*
|
|
* De login-pagina gebruikt dit om het wachtwoord eenmalig te tonen
|
|
* zodra admin.json is aangemaakt. Na de eerste succesvolle login
|
|
* wordt het veld geleegd. Geeft een lege string terug wanneer er
|
|
* geen nieuw wachtwoord is gegenereerd in deze request.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return string Plaintext wachtwoord of lege string.
|
|
*/
|
|
public function getGeneratedPassword(): string
|
|
{
|
|
return $this->generatedPassword;
|
|
}
|
|
|
|
/**
|
|
* Slaat de huidige admin-configuratie op naar admin.json.
|
|
*
|
|
* Schrijft de interne admin-configuratie weg als pretty-printed JSON met
|
|
* behoud van unicode-tekens.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return void
|
|
*/
|
|
public function saveAdminConfig(): void
|
|
{
|
|
file_put_contents(
|
|
$this->config['admin_config'],
|
|
json_encode($this->adminConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Start de sessie met veilige cookie-parameters en handhaaft de session timeout.
|
|
*
|
|
* Stelt cookie-flags in (HttpOnly, SameSite=Strict, Secure bij HTTPS), start
|
|
* de sessie en beëindigt deze bij inactiviteit langer dan de geconfigureerde
|
|
* timeout. Vernieuwt de activiteit-timestamp bij geauthenticeerde gebruikers.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return void
|
|
*/
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifieert inloggegevens en start een admin-sessie bij succes.
|
|
*
|
|
* Controleert eerst of het account niet is vergrendeld door brute-force
|
|
* protection. Bij een fout wachtwoord wordt een mislukte poging geregistreerd
|
|
* en gelogd. Bij succes worden mislukte pogingen gewist en de sessie
|
|
* gevuld met gebruiker, rol en CSRF-token.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @param string $password Wachtwoord (plaintext).
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
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.'];
|
|
}
|
|
|
|
/**
|
|
* Logt de huidige gebruiker uit en vernietigt de sessie.
|
|
*
|
|
* Maakt de sessie-array leeg, verwijdert het sessie-cookie en vernietigt de
|
|
* sessie. De uitlogactie wordt gelogd.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return void
|
|
*/
|
|
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}");
|
|
}
|
|
|
|
/**
|
|
* Controleert of de huidige bezoeker is ingelogd.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return bool True indien een admin-gebruiker in de sessie staat.
|
|
*/
|
|
public function isAuthenticated(): bool
|
|
{
|
|
return isset($_SESSION['admin_user']);
|
|
}
|
|
|
|
/**
|
|
* Geeft de gegevens van de ingelogde gebruiker, verrijkt met profielvelden.
|
|
*
|
|
* Levert username en rol uit de sessie en vult deze aan met email,
|
|
* author_name en author_email uit admin.json indien aanwezig.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return array|null Gebruikersdata als array, of null indien niet ingelogd.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Geeft de huidige rol van de ingelogde gebruiker.
|
|
*
|
|
* Retourneert de override-rol indien ingesteld (voor test-doeleinden), anders
|
|
* de werkelijke rol.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return string Rol-sleutel.
|
|
*/
|
|
public function getCurrentRole(): string
|
|
{
|
|
if (isset($_SESSION['admin_role_override'])) {
|
|
return $_SESSION['admin_role_override'];
|
|
}
|
|
return $_SESSION['admin_role'] ?? 'admin';
|
|
}
|
|
|
|
/**
|
|
* Geeft de werkelijke rol van de ingelogde gebruiker, eventuele override negerend.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return string Rol-sleutel.
|
|
*/
|
|
public function getRealRole(): string
|
|
{
|
|
return $_SESSION['admin_role'] ?? 'admin';
|
|
}
|
|
|
|
/**
|
|
* Controleert of de ingelogde gebruiker een actieve rol-override heeft.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return bool True indien een override-rol is ingesteld.
|
|
*/
|
|
public function hasRoleOverride(): bool
|
|
{
|
|
return isset($_SESSION['admin_role_override']);
|
|
}
|
|
|
|
/**
|
|
* Wisselt tijdelijk naar een andere rol voor test-doeleinden.
|
|
*
|
|
* Alleen werkelijke admins kunnen wisselen; admins kunnen niet wisselen naar
|
|
* 'admin' (zinloos) en enkel naar lagere rollen. Slaat de override op in de
|
|
* sessie. De actie wordt gelogd.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $role Doel-rol-sleutel.
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
public function switchRole(string $role): array
|
|
{
|
|
if (!$this->isAuthenticated()) {
|
|
return ['success' => false, 'message' => 'Niet ingelogd.'];
|
|
}
|
|
// Only real admins can switch roles
|
|
$realRole = $this->getRealRole();
|
|
if ($realRole !== 'admin') {
|
|
return ['success' => false, 'message' => 'Alleen admins kunnen van rol wisselen.'];
|
|
}
|
|
if (!isset(self::ROLE_PERMISSIONS[$role])) {
|
|
return ['success' => false, 'message' => 'Ongeldige rol.'];
|
|
}
|
|
// Admins cannot switch to 'admin' (no point) — only to lower roles for testing
|
|
if ($role === 'admin') {
|
|
return ['success' => false, 'message' => 'Je bent al admin.'];
|
|
}
|
|
$_SESSION['admin_role_override'] = $role;
|
|
$this->log('info', "Rol-switch: {$_SESSION['admin_user']} -> {$role} (test)");
|
|
return ['success' => true, 'message' => 'Rol gewijzigd naar ' . self::getRoleLabel($role) . '.'];
|
|
}
|
|
|
|
/**
|
|
* Reset de rol-override terug naar de werkelijke admin-rol.
|
|
*
|
|
* Verwijdert de override uit de sessie indien aanwezig en logt de actie.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
public function resetRole(): array
|
|
{
|
|
if (isset($_SESSION['admin_role_override'])) {
|
|
unset($_SESSION['admin_role_override']);
|
|
$this->log('info', "Rol-switch gereset: {$_SESSION['admin_user']}");
|
|
return ['success' => true, 'message' => 'Rol teruggezet naar admin.'];
|
|
}
|
|
return ['success' => false, 'message' => 'Geen rol-override actief.'];
|
|
}
|
|
|
|
/**
|
|
* Controleert of de huidige gebruiker een bepaalde route mag openen.
|
|
*
|
|
* Gebruikt de permissies van de huidige rol; de wildcard '*' geeft toegang
|
|
* tot alles. Onbekende rollen krijgen enkel 'dashboard' en 'logout'.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $route Route-sleutel die gecontroleerd moet worden.
|
|
* @return bool True indien de gebruiker de route mag benaderen.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Geeft alle beschikbare rollen met hun leesbare labels.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return array<string, string> Map van rol-sleutel naar label.
|
|
*/
|
|
public static function getRoles(): array
|
|
{
|
|
return self::ROLE_LABELS;
|
|
}
|
|
|
|
/**
|
|
* Geeft het leesbare label voor een rol.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $role Rol-sleutel.
|
|
* @return string Leesbaar label, of de rol-sleutel zelf indien onbekend.
|
|
*/
|
|
public static function getRoleLabel(string $role): string
|
|
{
|
|
return self::ROLE_LABELS[$role] ?? $role;
|
|
}
|
|
|
|
/**
|
|
* Geeft het huidige CSRF-token, of genereert een nieuw token indien afwezig.
|
|
*
|
|
* Slaat een cryptografisch willekeurig token op in de sessie en retourneert
|
|
* dit voor gebruik in admin-formulieren.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return string 64-tekens hexadecimaal CSRF-token.
|
|
*/
|
|
public function getCsrfToken(): string
|
|
{
|
|
if (!isset($_SESSION['admin_csrf_token'])) {
|
|
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
return $_SESSION['admin_csrf_token'];
|
|
}
|
|
|
|
/**
|
|
* Verifieert een meegegeven CSRF-token tegen het token in de sessie.
|
|
*
|
|
* Gebruikt hash_equals om timing-attacks te voorkomen.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $token Token uit het formulier of verzoek.
|
|
* @return bool True indien het token overeenkomt met het sessie-token.
|
|
*/
|
|
public function verifyCsrf(string $token): bool
|
|
{
|
|
return isset($_SESSION['admin_csrf_token']) && hash_equals($_SESSION['admin_csrf_token'], $token);
|
|
}
|
|
|
|
/**
|
|
* Regeneert het CSRF-token in de sessie.
|
|
*
|
|
* Maakt een nieuw cryptografisch willekeurig token aan, nuttig na
|
|
* privilege-wijzigingen of na het verwerken van een formulier.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return void
|
|
*/
|
|
public function regenerateCsrfToken(): void
|
|
{
|
|
$_SESSION['admin_csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// --- User Management ---
|
|
|
|
/**
|
|
* Geeft alle gebruikers met hun profiel- en rolinformatie.
|
|
*
|
|
* Levert een array geïndexeerd op gebruikersnaam, aangevuld met het
|
|
* leesbare rol-label en eventuele profielvelden.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return array<string, array> Map van gebruikersnaam naar gebruikersdata.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Voegt een nieuwe gebruiker toe aan de admin-configuratie.
|
|
*
|
|
* Controleert op dubbele gebruikersnamen, minimum wachtwoordlengte (8) en
|
|
* geldigheid van de rol. Het wachtwoord wordt opgeslagen als bcrypt-hash.
|
|
* Wijzigingen worden direct weggeschreven en gelogd.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @param string $password Wachtwoord (plaintext, minimaal 8 tekens).
|
|
* @param string $role Rol-sleutel. Default 'admin'.
|
|
* @param string $email E-mailadres van de gebruiker. Default ''.
|
|
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
|
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
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.'];
|
|
}
|
|
|
|
/**
|
|
* Werkt de profielvelden (email, author_name, author_email) van een gebruiker bij.
|
|
*
|
|
* Zoekt de gebruiker op naam en overschrijft de profielvelden, slaat de
|
|
* configuratie op en logt de wijziging.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @param string $email E-mailadres. Default ''.
|
|
* @param string $authorName Naam voor auteur-metadata. Default ''.
|
|
* @param string $authorEmail E-mail voor auteur-metadata. Default ''.
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
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.'];
|
|
}
|
|
|
|
/**
|
|
* Wijzigt de rol van een bestaande gebruiker.
|
|
*
|
|
* Een gebruiker kan nooit zijn eigen rol wijzigen (security guard). De rol
|
|
* moet een bekende sleutel uit ROLE_PERMISSIONS zijn. Wijzigingen worden
|
|
* opgeslagen en gelogd.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @param string $role Nieuwe rol-sleutel.
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
public function changeRole(string $username, string $role): array
|
|
{
|
|
if ($username === ($_SESSION['admin_user'] ?? '')) {
|
|
return ['success' => false, 'message' => 'Je kunt je eigen rol niet wijzigen.'];
|
|
}
|
|
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.'];
|
|
}
|
|
|
|
/**
|
|
* Verwijdert een gebruiker uit de admin-configuratie.
|
|
*
|
|
* Een gebruiker kan zichzelf niet verwijderen. De gebruikerslijst wordt
|
|
* gefilterd, opgeslagen en de actie gelogd.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
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.'];
|
|
}
|
|
|
|
/**
|
|
* Zet een nieuw wachtwoord voor een gebruiker.
|
|
*
|
|
* Controleert de minimum wachtwoordlengte (8), hasht het wachtwoord met de
|
|
* standaard PHP-hash en slaat de wijziging op.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
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.'];
|
|
}
|
|
|
|
/**
|
|
* Wijzigt het eigen wachtwoord na verificatie van het huidige wachtwoord.
|
|
*
|
|
* Verifieert eerst het huidige wachtwoord tegen de opgeslagen hash, controleert
|
|
* de lengte van het nieuwe wachtwoord (minimaal 8) en slaat de nieuwe hash op.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @param string $currentPassword Huidig wachtwoord (plaintext).
|
|
* @param string $newPassword Nieuw wachtwoord (plaintext, minimaal 8 tekens).
|
|
* @return array Resultaat met keys 'success' (bool) en 'message' (string).
|
|
*/
|
|
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 ---
|
|
|
|
/**
|
|
* Zoekt een gebruiker op gebruikersnaam in de admin-configuratie.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @return array|null Gebruikersdata als array, of null indien niet gevonden.
|
|
*/
|
|
private function findUser(string $username): ?array
|
|
{
|
|
foreach ($this->adminConfig['users'] ?? [] as $user) {
|
|
if ($user['username'] === $username) {
|
|
return $user;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Controleert of een gebruiker momenteel is vergrendeld door brute-force protection.
|
|
*
|
|
* Vergelijkt het aantal mislukte pogingen en de verstreken tijd sinds de
|
|
* laatste poging met de geconfigureerde limieten. Verloopt de lockout dan
|
|
* worden de pogingen gewist.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @return array Resultaat met keys 'locked' (bool) en optioneel 'remaining' (int, seconden).
|
|
*/
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* Registreert een mislukte inlogpoging voor een gebruiker.
|
|
*
|
|
* Verhoogt de teller en actualiseert de timestamp van de laatste poging,
|
|
* waarna de gegevens worden weggeschreven naar het lockout-bestand.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @return void
|
|
*/
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Wist alle mislukte inlogpogingen voor een gebruiker.
|
|
*
|
|
* Verwijdert de gebruiker uit het lockout-bestand en slaat dit op.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @return void
|
|
*/
|
|
private function clearFailedAttempts(string $username): void
|
|
{
|
|
$attempts = $this->getFailedAttempts();
|
|
unset($attempts[$username]);
|
|
file_put_contents($this->lockFile, json_encode($attempts));
|
|
}
|
|
|
|
/**
|
|
* Wist de lockout voor een gebruiker (publiek, gebruikt door CLI reset).
|
|
*
|
|
* Wrapper rond clearFailedAttempts die ook van buiten de class kan worden
|
|
* aangeroepen, bijvoorbeeld door een CLI-tool voor het resetten van vergrendelingen.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $username Gebruikersnaam.
|
|
* @return void
|
|
*/
|
|
public function clearLockout(string $username): void
|
|
{
|
|
$this->clearFailedAttempts($username);
|
|
}
|
|
|
|
/**
|
|
* Leest alle mislukte inlogpogingen uit het lockout-bestand.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @return array<string, array{count: int, last_attempt: int}> Map van gebruikersnaam naar poging-data.
|
|
*/
|
|
private function getFailedAttempts(): array
|
|
{
|
|
if (!file_exists($this->lockFile)) {
|
|
return [];
|
|
}
|
|
$data = json_decode(file_get_contents($this->lockFile), true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
/**
|
|
* Schrijft een bericht naar het logbestand met timestamp, level en IP-adres.
|
|
*
|
|
* Maakt de logdirectory aan indien nodig. Het IP-adres wordt via RequestLogger
|
|
* bepaald indien beschikbaar, anders via REMOTE_ADDR met een fallback.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $level Log-niveau (bijv. 'info', 'warning').
|
|
* @param string $message Bericht dat gelogd moet worden.
|
|
* @return void
|
|
*/
|
|
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);
|
|
}
|
|
}
|