Skip to content

Commit ee84a79

Browse files
committed
feat(admin-log): added logging for security-relevant actions (#3833)
1 parent 9df3a1b commit ee84a79

File tree

7 files changed

+193
-10
lines changed

7 files changed

+193
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
1111
- changed PHP requirement to PHP 8.4 or later (Thorsten)
1212
- added Symfony Router for frontend (Thorsten)
1313
- added API for glossary definitions (Thorsten)
14+
- improved audit and activity log with comprehensive security event tracking (Thorsten)
1415
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
1516
- migrated codebase to use PHP 8.4 language features (Thorsten)
1617

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020
namespace phpMyFAQ\Controller\Administration\Api;
2121

22-
use phpMyFAQ\Controller\AbstractController;
2322
use phpMyFAQ\Core\Exception;
23+
use phpMyFAQ\Enums\AdminLogType;
2424
use phpMyFAQ\Enums\PermissionType;
2525
use phpMyFAQ\Session\Token;
2626
use phpMyFAQ\Translation;
@@ -30,7 +30,7 @@
3030
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
3131
use Symfony\Component\Routing\Attribute\Route;
3232

33-
final class ConfigurationController extends AbstractController
33+
final class ConfigurationController extends AbstractAdministrationApiController
3434
{
3535
/**
3636
* @throws Exception|\Exception
@@ -84,6 +84,8 @@ public function activateMaintenanceMode(Request $request): JsonResponse
8484

8585
$this->configuration->set('main.maintenanceMode', 'true');
8686

87+
$this->adminLog->log($this->currentUser, AdminLogType::SYSTEM_MAINTENANCE_MODE_ENABLED->value);
88+
8789
return $this->json(['success' => Translation::get(key: 'healthCheckOkay')], Response::HTTP_OK);
8890
}
8991
}

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,102 @@ public function save(Request $request): JsonResponse
194194
$this->configuration->update($newConfigValues);
195195

196196
$changedKeys = array_keys(array_diff_assoc($newConfigValues, $oldConfigurationData));
197+
198+
// General configuration change log
197199
$this->adminLog->log($this->currentUser, AdminLogType::CONFIG_CHANGE->value . ':' . implode(',', $changedKeys));
198200

201+
// Specific security-related configuration change logs
202+
$this->logSecurityConfigChanges($changedKeys, $oldConfigurationData, $newConfigValues);
203+
199204
return $this->json(['success' => Translation::get(key: 'ad_config_saved')], Response::HTTP_OK);
200205
}
201206

207+
/**
208+
* Log specific security-related configuration changes
209+
*/
210+
private function logSecurityConfigChanges(array $changedKeys, array $oldConfig, array $newConfig): void
211+
{
212+
// Maintenance mode changes
213+
if (in_array('main.maintenanceMode', $changedKeys)) {
214+
if ($newConfig['main.maintenanceMode'] === 'false' && $oldConfig['main.maintenanceMode'] === 'true') {
215+
$this->adminLog->log($this->currentUser, AdminLogType::SYSTEM_MAINTENANCE_MODE_DISABLED->value);
216+
} elseif ($newConfig['main.maintenanceMode'] === 'true' && $oldConfig['main.maintenanceMode'] === 'false') {
217+
$this->adminLog->log($this->currentUser, AdminLogType::SYSTEM_MAINTENANCE_MODE_ENABLED->value);
218+
}
219+
}
220+
221+
// Security configuration keys
222+
$securityKeys = [
223+
'security.permLevel',
224+
'security.enableLoginOnly',
225+
'security.enableRegistration',
226+
'security.useSslForLogins',
227+
'security.useSslOnly',
228+
'security.forcePasswordUpdate',
229+
'security.enableWebAuthnSupport',
230+
'security.bannedIPs',
231+
'security.loginWithEmailAddress',
232+
'security.enableSignInWithMicrosoft',
233+
'security.domainWhiteListForRegistrations',
234+
];
235+
236+
$securityChanges = array_intersect($changedKeys, $securityKeys);
237+
if ($securityChanges !== []) {
238+
$details = [];
239+
foreach ($securityChanges as $key) {
240+
$details[] = $key . ':' . ($oldConfig[$key] ?? 'null') . '->' . ($newConfig[$key] ?? 'null');
241+
}
242+
$this->adminLog->log(
243+
$this->currentUser,
244+
AdminLogType::CONFIG_SECURITY_CHANGED->value . ':' . implode(';', $details),
245+
);
246+
}
247+
248+
// LDAP configuration keys
249+
$ldapKeys = [
250+
'ldap.ldapSupport',
251+
'ldap.ldap_server',
252+
'ldap.ldap_port',
253+
'ldap.ldap_base',
254+
'ldap.ldap_groupSupport',
255+
];
256+
257+
$ldapChanges = array_intersect($changedKeys, $ldapKeys);
258+
if ($ldapChanges !== []) {
259+
$this->adminLog->log(
260+
$this->currentUser,
261+
AdminLogType::CONFIG_LDAP_CHANGED->value . ':' . implode(',', $ldapChanges),
262+
);
263+
}
264+
265+
// SSO configuration keys
266+
$ssoKeys = [
267+
'security.ssoSupport',
268+
'security.ssoLogoutRedirect',
269+
];
270+
271+
$ssoChanges = array_intersect($changedKeys, $ssoKeys);
272+
if ($ssoChanges !== []) {
273+
$this->adminLog->log(
274+
$this->currentUser,
275+
AdminLogType::CONFIG_SSO_CHANGED->value . ':' . implode(',', $ssoChanges),
276+
);
277+
}
278+
279+
// Encryption configuration keys
280+
$encryptionKeys = [
281+
'security.encryptionType',
282+
];
283+
284+
$encryptionChanges = array_intersect($changedKeys, $encryptionKeys);
285+
if ($encryptionChanges !== []) {
286+
$this->adminLog->log(
287+
$this->currentUser,
288+
AdminLogType::CONFIG_ENCRYPTION_CHANGED->value . ':' . implode(',', $encryptionChanges),
289+
);
290+
}
291+
}
292+
202293
/**
203294
* @throws \Exception
204295
*/

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/SessionController.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
namespace phpMyFAQ\Controller\Administration\Api;
2222

2323
use Exception;
24-
use phpMyFAQ\Controller\AbstractController;
24+
use phpMyFAQ\Enums\AdminLogType;
2525
use phpMyFAQ\Enums\PermissionType;
2626
use phpMyFAQ\Session\Token;
2727
use phpMyFAQ\Translation;
@@ -32,7 +32,7 @@
3232
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
3333
use Symfony\Component\Routing\Attribute\Route;
3434

35-
final class SessionController extends AbstractController
35+
final class SessionController extends AbstractAdministrationApiController
3636
{
3737
/**
3838
* @throws Exception
@@ -57,10 +57,13 @@ public function export(Request $request): BinaryFileResponse|JsonResponse
5757
$file = fopen($filePath, mode: 'w');
5858
if ($file) {
5959
foreach ($data as $row) {
60-
fputcsv($file, [$row['ip'], $row['time']], separator: ',', enclosure: '"', escape: '\\', eol: PHP_EOL);
60+
fputcsv($file, [$row['ip'], $row['time']], separator: ',', enclosure: '"', eol: PHP_EOL);
6161
}
6262

6363
fclose($file);
64+
65+
$this->adminLog->log($this->currentUser, AdminLogType::DATA_EXPORT_SESSIONS->value);
66+
6467
$binaryFileResponse = new BinaryFileResponse($filePath);
6568
$binaryFileResponse->setContentDisposition(
6669
ResponseHeaderBag::DISPOSITION_INLINE,

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/UserController.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ public function csvExport(): Response
131131

132132
fclose($handle);
133133

134+
$this->adminLog->log($this->currentUser, AdminLogType::DATA_EXPORT_USERS->value);
135+
134136
$response = new Response($content);
135137
$response->headers->set(key: 'Content-Type', values: 'text/csv');
136138
$response->headers->set(key: 'Content-Disposition', values: 'attachment; filename="users.csv"');
@@ -420,19 +422,34 @@ public function editUser(Request $request): JsonResponse
420422
$user->getUserById($userId, allowBlockedUsers: true);
421423

422424
$stats = $user->getStatus();
425+
$wasSuperAdmin = $user->isSuperAdmin();
423426

424427
// reset two-factor authentication if required
425428
if ($deleteTwoFactor) {
426429
$user->setUserData(['secret' => '', 'twofactor_enabled' => 0]);
430+
$this->adminLog->log($this->currentUser, AdminLogType::AUTH_2FA_RESET->value . ':' . $userId);
427431
}
428432

429433
// set a new password and sent email if a user is switched to active
430434
if ($stats === 'blocked' && $userStatus === 'active' && !$user->activateUser()) {
431435
$userStatus = 'invalid_status';
432436
}
433437

434-
// Set the super-admin flag
438+
// Log status change
439+
if ($stats !== $userStatus) {
440+
$this->adminLog->log(
441+
$this->currentUser,
442+
AdminLogType::USER_STATUS_CHANGED->value . ':' . $userId . ' (' . $stats . ' -> ' . $userStatus . ')',
443+
);
444+
}
445+
446+
// Set the super-admin flag and log changes
435447
$user->setSuperAdmin((bool) $isSuperAdmin);
448+
if (!$wasSuperAdmin && (bool) $isSuperAdmin) {
449+
$this->adminLog->log($this->currentUser, AdminLogType::USER_SUPERADMIN_GRANTED->value . ':' . $userId);
450+
} elseif ($wasSuperAdmin && !(bool) $isSuperAdmin) {
451+
$this->adminLog->log($this->currentUser, AdminLogType::USER_SUPERADMIN_REVOKED->value . ':' . $userId);
452+
}
436453

437454
if (!$user->userdata->set(array_keys($userData), array_values($userData)) || !$user->setStatus($userStatus)) {
438455
return $this->json(['error' => 'ad_msg_mysqlerr'], Response::HTTP_BAD_REQUEST);

phpmyfaq/src/phpMyFAQ/Controller/Administration/AuthenticationController.php

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
namespace phpMyFAQ\Controller\Administration;
2121

2222
use phpMyFAQ\Core\Exception;
23+
use phpMyFAQ\Enums\AdminLogType;
2324
use phpMyFAQ\Filter;
2425
use phpMyFAQ\Session\Token;
2526
use phpMyFAQ\Translation;
@@ -33,7 +34,7 @@
3334
final class AuthenticationController extends AbstractAdministrationController
3435
{
3536
#[Route(path: '/authenticate', name: 'admin.auth.authenticate', methods: ['POST'])]
36-
public function authenticate(Request $request): \Symfony\Component\HttpFoundation\RedirectResponse
37+
public function authenticate(Request $request): RedirectResponse
3738
{
3839
if ($this->currentUser->isLoggedIn()) {
3940
return new RedirectResponse(url: './');
@@ -63,12 +64,18 @@ public function authenticate(Request $request): \Symfony\Component\HttpFoundatio
6364
try {
6465
$this->currentUser = $userAuthentication->authenticate($username, $password);
6566
if ($userAuthentication->hasTwoFactorAuthentication()) {
67+
$this->adminLog->log(
68+
$this->currentUser,
69+
AdminLogType::AUTH_LOGIN_SUCCESS->value . ' (2FA required):' . $username,
70+
);
6671
return new RedirectResponse(url: './token?user-id=' . $this->currentUser->getUserId());
6772
}
73+
74+
$this->adminLog->log($this->currentUser, AdminLogType::AUTH_LOGIN_SUCCESS->value . ':' . $username);
6875
} catch (Exception) {
6976
$this->adminLog->log(
7077
$this->currentUser,
71-
'Login-error\nLogin: ' . $username . '\nErrors: '
78+
AdminLogType::AUTH_LOGIN_FAILED->value . ':' . $username . ' - '
7279
. implode(separator: ', ', array: $this->currentUser->errors),
7380
);
7481
return new RedirectResponse(url: './login');
@@ -124,7 +131,7 @@ public function login(Request $request): Response
124131
* @throws \Exception
125132
*/
126133
#[Route(path: '/logout', name: 'admin.auth.logout', methods: ['GET'])]
127-
public function logout(Request $request): \Symfony\Component\HttpFoundation\RedirectResponse
134+
public function logout(Request $request): RedirectResponse
128135
{
129136
$this->userIsAuthenticated();
130137

@@ -137,6 +144,11 @@ public function logout(Request $request): \Symfony\Component\HttpFoundation\Redi
137144
return $redirectResponse->send();
138145
}
139146

147+
$this->adminLog->log(
148+
$this->currentUser,
149+
AdminLogType::AUTH_LOGOUT->value . ':' . $this->currentUser->getLogin(),
150+
);
151+
140152
$this->currentUser->deleteFromSession(deleteCookie: true);
141153
$ssoLogout = $this->configuration->get(item: 'security.ssoLogoutRedirect');
142154
if ($this->configuration->get(item: 'security.ssoSupport') && (string) $ssoLogout !== '') {
@@ -179,7 +191,7 @@ public function token(Request $request): Response
179191
* @throws \Exception
180192
*/
181193
#[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])]
182-
public function check(Request $request): \Symfony\Component\HttpFoundation\RedirectResponse
194+
public function check(Request $request): RedirectResponse
183195
{
184196
if ($this->currentUser->isLoggedIn()) {
185197
return new RedirectResponse(url: './');
@@ -197,8 +209,11 @@ public function check(Request $request): \Symfony\Component\HttpFoundation\Redir
197209

198210
if ($result) {
199211
$user->twoFactorSuccess();
212+
$this->adminLog->log($user, AdminLogType::AUTH_2FA_SUCCESS->value . ':' . $user->getLogin());
200213
return new RedirectResponse(url: './');
201214
}
215+
216+
$this->adminLog->log($user, AdminLogType::AUTH_2FA_FAILED->value . ':' . $user->getLogin());
202217
}
203218

204219
return new RedirectResponse('./token?user-id=' . $userId);

phpmyfaq/src/phpMyFAQ/Enums/AdminLogType.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,71 @@ enum AdminLogType: string
5454

5555
// Configuration
5656
case CONFIG_CHANGE = 'config-change';
57+
case CONFIG_SECURITY_CHANGED = 'config-security-changed';
58+
case CONFIG_LDAP_CHANGED = 'config-ldap-changed';
59+
case CONFIG_SSO_CHANGED = 'config-sso-changed';
60+
case CONFIG_ENCRYPTION_CHANGED = 'config-encryption-changed';
5761

5862
// User management
5963
case USER_ADD = 'user-add';
6064
case USER_EDIT = 'user-edit';
6165
case USER_DELETE = 'user-delete';
6266
case USER_CHANGE_PASSWORD = 'user-change-password';
6367
case USER_CHANGE_PERMISSIONS = 'user-change-permissions';
68+
case USER_PASSWORD_RESET_REQUESTED = 'user-password-reset-requested';
69+
case USER_PASSWORD_RESET_COMPLETED = 'user-password-reset-completed';
70+
case USER_STATUS_CHANGED = 'user-status-changed';
71+
case USER_SUPERADMIN_GRANTED = 'user-superadmin-granted';
72+
case USER_SUPERADMIN_REVOKED = 'user-superadmin-revoked';
6473

6574
// Group management
6675
case GROUP_ADD = 'group-add';
6776
case GROUP_EDIT = 'group-edit';
6877
case GROUP_DELETE = 'group-delete';
6978
case GROUP_CHANGE_PERMISSIONS = 'group-change-permissions';
79+
80+
// Authentication & Authorization
81+
case AUTH_LOGIN_SUCCESS = 'auth-login-success';
82+
case AUTH_LOGIN_FAILED = 'auth-login-failed';
83+
case AUTH_LOGOUT = 'auth-logout';
84+
case AUTH_SESSION_TIMEOUT = 'auth-session-timeout';
85+
case AUTH_SESSION_TERMINATED = 'auth-session-terminated';
86+
87+
// Two-Factor Authentication
88+
case AUTH_2FA_ENABLED = 'auth-2fa-enabled';
89+
case AUTH_2FA_DISABLED = 'auth-2fa-disabled';
90+
case AUTH_2FA_SUCCESS = 'auth-2fa-success';
91+
case AUTH_2FA_FAILED = 'auth-2fa-failed';
92+
case AUTH_2FA_RESET = 'auth-2fa-reset';
93+
94+
// WebAuthn
95+
case AUTH_WEBAUTHN_REGISTER = 'auth-webauthn-register';
96+
case AUTH_WEBAUTHN_LOGIN_SUCCESS = 'auth-webauthn-login-success';
97+
case AUTH_WEBAUTHN_LOGIN_FAILED = 'auth-webauthn-login-failed';
98+
case AUTH_WEBAUTHN_REMOVED = 'auth-webauthn-removed';
99+
100+
// Security Events
101+
case SECURITY_UNAUTHORIZED_ACCESS = 'security-unauthorized-access';
102+
case SECURITY_CSRF_VIOLATION = 'security-csrf-violation';
103+
case SECURITY_PERMISSION_VIOLATION = 'security-permission-violation';
104+
case SECURITY_SUSPICIOUS_ACTIVITY = 'security-suspicious-activity';
105+
case SECURITY_RATE_LIMIT_EXCEEDED = 'security-rate-limit-exceeded';
106+
107+
// Data Exports
108+
case DATA_EXPORT_USERS = 'data-export-users';
109+
case DATA_EXPORT_SESSIONS = 'data-export-sessions';
110+
case DATA_EXPORT_FAQS = 'data-export-faqs';
111+
case DATA_EXPORT_LOGS = 'data-export-logs';
112+
113+
// API Security
114+
case API_KEY_CREATED = 'api-key-created';
115+
case API_KEY_REVOKED = 'api-key-revoked';
116+
case API_UNAUTHORIZED_ACCESS = 'api-unauthorized-access';
117+
118+
// System Security
119+
case SYSTEM_MAINTENANCE_MODE_ENABLED = 'system-maintenance-mode-enabled';
120+
case SYSTEM_MAINTENANCE_MODE_DISABLED = 'system-maintenance-mode-disabled';
121+
case SYSTEM_UPDATE_STARTED = 'system-update-started';
122+
case SYSTEM_UPDATE_COMPLETED = 'system-update-completed';
123+
case SYSTEM_UPDATE_FAILED = 'system-update-failed';
70124
}

0 commit comments

Comments
 (0)