diff --git a/config/vufind/config.ini b/config/vufind/config.ini index 690c9e4bb44..8bb0e13b5ea 100644 --- a/config/vufind/config.ini +++ b/config/vufind/config.ini @@ -1849,6 +1849,17 @@ skip_numeric = true ; - 'username' logs the currently logged-in username, if there is one. ;reference_id = false +; A comma-separated list of event types to enable logging of audit events to the +; database audit_event table. Disabled by default. +; The following built-in event types are available (local extensions could be used +; as well): +; ils - Actions involving the library catalog (renewal, requests) +; user - User actions such as logins +; You should run the "php $VUFIND_HOME/public/index.php util expire_audit_events" +; utility periodically to purge old data in the event table unless you want +; the data to accumulate indefinitely. +;log_audit_events = "ils,user" + ; This section can be used to specify a "parent configuration" from which ; the current configuration file will inherit. You can chain multiple ; configurations together if you wish. diff --git a/module/VuFind/sql/migrations/mysql/11.0/005-add-audit-event-table.sql b/module/VuFind/sql/migrations/mysql/11.0/005-add-audit-event-table.sql new file mode 100644 index 00000000000..323500f8722 --- /dev/null +++ b/module/VuFind/sql/migrations/mysql/11.0/005-add-audit-event-table.sql @@ -0,0 +1,17 @@ +CREATE TABLE `audit_event` ( + `id` int NOT NULL AUTO_INCREMENT, + `date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `type` varchar(50) NOT NULL, + `subtype` varchar(50) NOT NULL, + `user_id` int NULL, + `session_id` varchar(128) NULL, + `username` varchar(255) NULL, + `client_ip` varchar(255) NULL, + `server_ip` varchar(255) NULL, + `server_name` varchar(255) NULL, + `message` varchar(255) NULL, + `data` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `audit_event_user_id_idx` (`user_id`), + CONSTRAINT `audit_event_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/module/VuFind/sql/migrations/pgsql/11.0/005-add-audit-event-table.sql b/module/VuFind/sql/migrations/pgsql/11.0/005-add-audit-event-table.sql new file mode 100644 index 00000000000..acb31e259c7 --- /dev/null +++ b/module/VuFind/sql/migrations/pgsql/11.0/005-add-audit-event-table.sql @@ -0,0 +1,19 @@ +CREATE TABLE audit_event ( + id SERIAL, + date timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + type varchar(50) NOT NULL, + subtype varchar(50) NOT NULL, + user_id int, + session_id varchar(128), + username varchar(255), + client_ip varchar(255), + server_ip varchar(255), + server_name varchar(255), + message varchar(255), + data json, + PRIMARY KEY (id) +); +CREATE INDEX audit_event_user_id_idx ON audit_event (user_id); + +ALTER TABLE audit_event +ADD CONSTRAINT audit_event_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL; diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql index f9ab3b11309..053a786fc02 100644 --- a/module/VuFind/sql/mysql.sql +++ b/module/VuFind/sql/mysql.sql @@ -438,3 +438,28 @@ CREATE TABLE `login_token` ( CONSTRAINT `login_token_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `audit_event` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `audit_event` ( + `id` int NOT NULL AUTO_INCREMENT, + `date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `type` varchar(50) NOT NULL, + `subtype` varchar(50) NOT NULL, + `user_id` int NULL, + `session_id` varchar(128) NULL, + `username` varchar(255) NULL, + `client_ip` varchar(255) NULL, + `server_ip` varchar(255) NULL, + `server_name` varchar(255) NULL, + `message` varchar(255) NULL, + `data` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `audit_event_user_id_idx` (`user_id`), + CONSTRAINT `audit_event_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql index c628c93777a..fe861bf3245 100644 --- a/module/VuFind/sql/pgsql.sql +++ b/module/VuFind/sql/pgsql.sql @@ -409,6 +409,27 @@ CREATE TABLE login_token ( CREATE INDEX login_token_user_id_idx ON login_token (user_id); CREATE INDEX login_token_series_idx ON login_token (series); +-- +-- Table structure for table `audit_event` +-- + +CREATE TABLE audit_event ( + id SERIAL, + date timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + type varchar(50) NOT NULL, + subtype varchar(50) NOT NULL, + user_id int, + session_id varchar(128), + username varchar(255), + client_ip varchar(255), + server_ip varchar(255), + server_name varchar(255), + message varchar(255), + data json, + PRIMARY KEY (id) +); +CREATE INDEX audit_event_user_id_idx ON audit_event (user_id); + -- -------------------------------------------------------- -- @@ -480,6 +501,7 @@ ADD CONSTRAINT feedback_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON D ADD CONSTRAINT feedback_ibfk_2 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL; +-- -- Constraints for table access_token -- ALTER TABLE access_token @@ -491,4 +513,10 @@ ADD CONSTRAINT access_token_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ALTER TABLE login_token ADD CONSTRAINT login_token_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE; +-- +-- Constraints for table audit_event +-- +ALTER TABLE audit_event +ADD CONSTRAINT audit_event_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL; + -- -------------------------------------------------------- diff --git a/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php b/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php index d3cccc299fa..e3ed177105f 100644 --- a/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php +++ b/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php @@ -32,10 +32,13 @@ use Closure; use VuFind\Config\Config; use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Service\AuditEventServiceInterface; use VuFind\Db\Service\DbServiceAwareInterface; use VuFind\Db\Service\DbServiceAwareTrait; use VuFind\Db\Service\UserCardServiceInterface; use VuFind\Db\Service\UserServiceInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; use VuFind\Exception\ILS as ILSException; use VuFind\ILS\Connection as ILSConnection; @@ -80,6 +83,13 @@ class ILSAuthenticator implements DbServiceAwareInterface */ protected $encryptionKey = null; + /** + * Audit event service (optional) + * + * @var ?AuditEventServiceInterface + */ + protected ?AuditEventServiceInterface $auditEventService = null; + /** * Constructor * @@ -138,6 +148,18 @@ public function encrypt(?string $text) return $this->encryptOrDecrypt($text, true); } + /** + * Set audit event service. + * + * @param AuditEventServiceInterface $auditEventService Audit event service + * + * @return void + */ + public function setAuditEventService(AuditEventServiceInterface $auditEventService): void + { + $this->auditEventService = $auditEventService; + } + /** * This is a central function for encrypting and decrypting so that * logic is all in one location @@ -337,17 +359,28 @@ public function storedCatalogLogin() /** * Attempt to log in the user to the ILS, and save credentials if it works. * - * @param string $username Catalog username - * @param string $password Catalog password + * @param string $username Catalog username + * @param string $password Catalog password + * @param ?UserEntityInterface $loggedInUser Logged-in user (optional, for auditing purposes) * * Returns associative array of patron data on success, false on failure. * * @return array|bool * @throws ILSException */ - public function newCatalogLogin($username, $password) + public function newCatalogLogin(string $username, string $password, ?UserEntityInterface $loggedInUser = null) { $result = $this->catalog->patronLogin($username, $password); + + if ($this->auditEventService) { + $this->auditEventService->addEvent( + AuditEventType::User, + $result ? AuditEventSubtype::ILSLogin : AuditEventSubtype::ILSLoginFailure, + $loggedInUser, + data: compact('username') + ); + } + if ($result) { $this->updateUser($username, $password, $result); return $result; @@ -358,15 +391,21 @@ public function newCatalogLogin($username, $password) /** * Send email authentication link * - * @param string $email Email address - * @param string $route Route for the login link - * @param array $routeParams Route parameters - * @param array $urlParams URL parameters + * @param string $email Email address + * @param string $route Route for the login link + * @param array $routeParams Route parameters + * @param array $urlParams URL parameters + * @param ?UserEntityInterface $loggedInUser Logged-in user (optional, for auditing purposes) * * @return void */ - public function sendEmailLoginLink($email, $route, $routeParams = [], $urlParams = []) - { + public function sendEmailLoginLink( + string $email, + string $route, + array $routeParams = [], + array $urlParams = [], + ?UserEntityInterface $loggedInUser = null + ): void { if (null === $this->emailAuthenticator) { throw new \Exception('Email authenticator not set'); } @@ -380,6 +419,18 @@ public function sendEmailLoginLink($email, $route, $routeParams = [], $urlParams $route, $routeParams ); + + if ($this->auditEventService) { + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::SendEmailLoginLink, + $loggedInUser, + data: [ + 'username' => $email, + 'email' => $userData['email'], + ] + ); + } } } diff --git a/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php b/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php index fb258dd16b6..87f99faed59 100644 --- a/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php +++ b/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php @@ -36,6 +36,8 @@ use Psr\Container\ContainerExceptionInterface as ContainerException; use Psr\Container\ContainerInterface; use VuFind\Crypt\BlockCipher; +use VuFind\Db\Service\AuditEventServiceInterface; +use VuFind\Db\Service\PluginManager as DatabaseServiceManager; /** * ILS Authenticator factory. @@ -87,6 +89,8 @@ function (string $algo) use ($container) { $container->get(\VuFind\Auth\EmailAuthenticator::class), $container->get(\VuFind\Config\PluginManager::class)->get('config') ); + $dbServiceManager = $container->get(DatabaseServiceManager::class); + $service->setAuditEventService($dbServiceManager->get(AuditEventServiceInterface::class)); return $service; } } diff --git a/module/VuFind/src/VuFind/Auth/Manager.php b/module/VuFind/src/VuFind/Auth/Manager.php index c63a96d5694..4b676ebc00e 100644 --- a/module/VuFind/src/VuFind/Auth/Manager.php +++ b/module/VuFind/src/VuFind/Auth/Manager.php @@ -36,7 +36,10 @@ use VuFind\Config\Config; use VuFind\Cookie\CookieManager; use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Service\AuditEventServiceInterface; use VuFind\Db\Service\UserServiceInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; use VuFind\Exception\Auth as AuthException; use VuFind\ILS\Connection; use VuFind\Validator\CsrfInterface; @@ -121,6 +124,7 @@ class Manager implements * @param LoginTokenManager $loginTokenManager Login Token manager * @param Connection $ils ILS connection * @param RendererInterface $viewRenderer View renderer + * @param AuditEventServiceInterface $auditEventService Event database service */ public function __construct( protected Config $config, @@ -132,7 +136,8 @@ public function __construct( protected CsrfInterface $csrf, protected LoginTokenManager $loginTokenManager, protected Connection $ils, - protected RendererInterface $viewRenderer + protected RendererInterface $viewRenderer, + protected AuditEventServiceInterface $auditEventService ) { // Initialize active authentication setting (defaulting to Database // if no setting passed in): @@ -573,6 +578,13 @@ public function logout(string $url, bool $destroy = true): string */ public function clearLoginState(bool $destroy = true): void { + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::Logout, + $this->userSession->getUserFromSession(), + $destroy ? 'logout' : 'session expired', + ); + // Reset authentication state $this->getAuth()->clearLoginState(); @@ -632,6 +644,8 @@ public function getUserObject(): ?UserEntityInterface $this->clearLoginState(); } } elseif ($user = $this->loginTokenManager->tokenLogin($this->sessionManager->getId())) { + $this->auditEventService->addEvent(AuditEventType::User, AuditEventSubtype::TokenLogin, $user); + if ($this->getAuth() instanceof ChoiceAuth) { $this->getAuth()->setStrategy($user->getAuthMethod()); } @@ -732,6 +746,12 @@ public function create(Request $request): UserEntityInterface $user = $this->getAuth()->create($request); $this->updateUser($user, $this->getSelectedAuthMethod()); $this->updateSession($user); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::Create, + $user, + data: $request->getPost()->toArray() + ); return $user; } @@ -747,6 +767,12 @@ public function updatePassword(Request $request): UserEntityInterface { $user = $this->getAuth()->updatePassword($request); $this->updateSession($user); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::Update, + $user, + data: ['password' => null] + ); return $user; } @@ -789,6 +815,15 @@ public function updateEmail(UserEntityInterface $user, string $email): void } $this->userService->persistEntity($user); $this->updateSession($user); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubType::Update, + $user, + data: [ + 'email' => $email, + 'pending' => $user->getPendingEmail() ? true : false, + ] + ); } /** @@ -805,6 +840,12 @@ public function updateUserVerifyHash(UserEntityInterface $user): void $time = str_pad(substr((string)time(), 0, 10), 10, '0', STR_PAD_LEFT); $user->setVerifyHash($hash . $time); $this->userService->persistEntity($user); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::Update, + $user, + data: ['hash' => $user->getVerifyHash()] + ); } /** @@ -854,6 +895,16 @@ public function login(Request $request): UserEntityInterface try { $user = $this->getAuth()->authenticate($request); } catch (AuthException $e) { + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::LoginFailure, + message: $e->getMessage(), + data: [ + 'main_method' => $mainAuthMethod, + 'delegate_method' => $delegate, + 'request' => $request->getPost()->toArray(), + ] + ); // Pass authentication exceptions through unmodified throw $e; } catch (\VuFind\Exception\PasswordSecurity $e) { @@ -866,6 +917,17 @@ public function login(Request $request): UserEntityInterface throw new AuthException('authentication_error_technical', 0, $e); } + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::Login, + $user, + data: [ + 'main_method' => $mainAuthMethod, + 'delegate_method' => $delegate, + 'request' => $request->getPost()->toArray(), + ] + ); + // Attempt catalog login so that any bad credentials are cleared before further processing // (avoids e.g. multiple login attempts by account AJAX checks). if ( @@ -888,6 +950,19 @@ public function login(Request $request): UserEntityInterface // prompted again; perhaps their password has changed in the // system! $user->setCatUsername(null)->setRawCatPassword(null)->setCatPassEnc(null); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::ILSLoginFailure, + $user, + data: ['cat_username' => $catUsername] + ); + } else { + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::ILSLogin, + $user, + data: ['cat_username' => $catUsername] + ); } } catch (\Exception $e) { // Ignore exceptions here so that the login can continue @@ -904,6 +979,11 @@ public function login(Request $request): UserEntityInterface $this->logError((string)$e); throw new AuthException('authentication_error_technical', 0, $e); } + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::RememberLogin, + $user + ); } // Store the user in the session: @@ -1026,6 +1106,12 @@ public function connectLibraryCard(Request $request, UserEntityInterface $user): throw new \Exception('Connecting of library cards is not supported'); } $auth->connectLibraryCard($request, $user); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::ConnectCard, + $user, + data: ['request' => $request->getPost()->toArray()] + ); } /** @@ -1043,6 +1129,15 @@ protected function updateUser(UserEntityInterface $user, ?string $authMethod): v } $user->setLastLogin(new \DateTime()); $this->userService->persistEntity($user); + $this->auditEventService->addEvent( + AuditEventType::User, + AuditEventSubtype::Update, + $user, + data: [ + 'auth_method' => $user->getAuthMethod(), + 'last_login' => $user->getLastLogin()?->format(\DateTimeInterface::ATOM), + ] + ); } /** diff --git a/module/VuFind/src/VuFind/Auth/ManagerFactory.php b/module/VuFind/src/VuFind/Auth/ManagerFactory.php index 75e633dfaff..5babe5b1678 100644 --- a/module/VuFind/src/VuFind/Auth/ManagerFactory.php +++ b/module/VuFind/src/VuFind/Auth/ManagerFactory.php @@ -70,8 +70,8 @@ public function __invoke( } // Load dependencies: $config = $container->get(\VuFind\Config\PluginManager::class)->get('config'); - $userService = $container->get(\VuFind\Db\Service\PluginManager::class) - ->get(\VuFind\Db\Service\UserServiceInterface::class); + $dbServiceManager = $container->get(\VuFind\Db\Service\PluginManager::class); + $userService = $dbServiceManager->get(\VuFind\Db\Service\UserServiceInterface::class); $sessionManager = $container->get(\Laminas\Session\SessionManager::class); $pm = $container->get(\VuFind\Auth\PluginManager::class); $cookies = $container->get(\VuFind\Cookie\CookieManager::class); @@ -79,6 +79,8 @@ public function __invoke( $loginTokenManager = $container->get(\VuFind\Auth\LoginTokenManager::class); $ils = $container->get(\VuFind\ILS\Connection::class); $viewRenderer = $container->get('ViewRenderer'); + $auditEventService = $dbServiceManager->get(\VuFind\Db\Service\AuditEventServiceInterface::class); + // Build the object and make sure account credentials haven't expired: $manager = new $requestedName( $config, @@ -90,7 +92,8 @@ public function __invoke( $csrf, $loginTokenManager, $ils, - $viewRenderer + $viewRenderer, + $auditEventService ); $manager->setIlsAuthenticator($container->get(\VuFind\Auth\ILSAuthenticator::class)); $manager->checkForExpiredCredentials(); diff --git a/module/VuFind/src/VuFind/Controller/AbstractBase.php b/module/VuFind/src/VuFind/Controller/AbstractBase.php index 320658c62f7..63c225f3547 100644 --- a/module/VuFind/src/VuFind/Controller/AbstractBase.php +++ b/module/VuFind/src/VuFind/Controller/AbstractBase.php @@ -39,6 +39,8 @@ use VuFind\Config\Feature\EmailSettingsTrait; use VuFind\Controller\Feature\AccessPermissionInterface; use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Service\AuditEventServiceInterface; +use VuFind\Db\Service\PluginManager as DatabaseServiceManager; use VuFind\Exception\Auth as AuthException; use VuFind\Exception\ILS as ILSException; use VuFind\Http\PhpEnvironment\Request as HttpRequest; @@ -100,6 +102,13 @@ class AbstractBase extends AbstractActionController implements AccessPermissionI */ protected $accessDeniedBehavior = null; + /** + * Audit event service + * + * @var ?AuditEventServiceInterface + */ + protected ?AuditEventServiceInterface $auditEventService = null; + /** * Constructor * @@ -375,7 +384,7 @@ protected function catalogLogin() { // First make sure user is logged in to VuFind: $account = $this->getAuthManager(); - if (!$account->getIdentity()) { + if (!($user = $account->getUserObject())) { return $this->forceLogin(); } @@ -383,8 +392,8 @@ protected function catalogLogin() $ilsAuth = $this->getILSAuthenticator(); $patron = null; if ( - ($username = $this->params()->fromPost('cat_username', false)) - && ($password = $this->params()->fromPost('cat_password', false)) + ($username = $this->params()->fromPost('cat_username')) + && ($password = $this->params()->fromPost('cat_password')) ) { // If somebody is POSTing credentials but that logic is disabled, we // should throw an exception! @@ -402,16 +411,15 @@ protected function catalogLogin() $routeName = $routeMatch ? $routeMatch->getMatchedRouteName() : 'myresearch-profile'; $routeParams = $routeMatch ? $routeMatch->getParams() : []; - $ilsAuth->sendEmailLoginLink($username, $routeName, $routeParams, ['catalogLogin' => 'true']); - $this->flashMessenger() - ->addSuccessMessage('email_login_link_sent'); + $ilsAuth + ->sendEmailLoginLink($username, $routeName, $routeParams, ['catalogLogin' => 'true'], $user); + $this->flashMessenger()->addSuccessMessage('email_login_link_sent'); } else { - $patron = $ilsAuth->newCatalogLogin($username, $password); + $patron = $ilsAuth->newCatalogLogin($username, $password, $user); // If login failed, store a warning message: if (!$patron) { - $this->flashMessenger() - ->addErrorMessage('Invalid Patron Login'); + $this->flashMessenger()->addErrorMessage('Invalid Patron Login'); } } } catch (ILSException $e) { @@ -896,4 +904,18 @@ protected function isLocalUrl(string $url): bool $baseUrlNorm = $this->normalizeUrlForComparison($this->getServerUrl('home')); return str_starts_with($this->normalizeUrlForComparison($url), $baseUrlNorm); } + + /** + * Get audit event service. + * + * @return AuditEventServiceInterface + */ + protected function getAuditEventService(): AuditEventServiceInterface + { + if (null === $this->auditEventService) { + $dbServiceManager = $this->serviceLocator->get(DatabaseServiceManager::class); + $this->auditEventService = $dbServiceManager->get(AuditEventServiceInterface::class); + } + return $this->auditEventService; + } } diff --git a/module/VuFind/src/VuFind/Controller/HoldsController.php b/module/VuFind/src/VuFind/Controller/HoldsController.php index 2a5ad23f6f2..88a6575823d 100644 --- a/module/VuFind/src/VuFind/Controller/HoldsController.php +++ b/module/VuFind/src/VuFind/Controller/HoldsController.php @@ -33,6 +33,8 @@ use Laminas\Cache\Storage\StorageInterface; use Laminas\ServiceManager\ServiceLocatorInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; use VuFind\Exception\ILS as ILSException; use VuFind\Validator\CsrfInterface; @@ -105,6 +107,18 @@ public function listAction() return $view->cancelResults; } + if ($view->cancelResults) { + $this->getAuditEventService()->addEvent( + AuditEventType::ILS, + AuditEventSubtype::CancelHolds, + $this->getUser(), + data: [ + 'username' => $patron['cat_username'], + 'results' => $view->cancelResults, + ] + ); + } + // Process any update request results stored in the session: $updateResultsContainer = $this->getHoldUpdateResultsContainer(); $holdUpdateResults = $updateResultsContainer->results ?? null; @@ -279,6 +293,17 @@ public function editAction() ); $this->flashMessenger()->addErrorMessage($msg); } + + $this->getAuditEventService()->addEvent( + AuditEventType::ILS, + AuditEventSubtype::UpdateHolds, + $this->getUser(), + data: [ + 'username' => $patron['cat_username'], + 'results' => $results, + ] + ); + return $this->inLightbox() ? $this->getRefreshResponse(true) : $this->redirect()->toRoute('holds-list'); diff --git a/module/VuFind/src/VuFind/Controller/HoldsTrait.php b/module/VuFind/src/VuFind/Controller/HoldsTrait.php index dbd6257a175..61115c63f95 100644 --- a/module/VuFind/src/VuFind/Controller/HoldsTrait.php +++ b/module/VuFind/src/VuFind/Controller/HoldsTrait.php @@ -29,6 +29,9 @@ namespace VuFind\Controller; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + use function count; use function in_array; use function is_array; @@ -213,6 +216,17 @@ public function holdAction() ->addWarningMessage($results['warningMessage']); } $this->getViewRenderer()->plugin('session')->put('reset_account_status', true); + + $this->getAuditEventService()->addEvent( + AuditEventType::ILS, + AuditEventSubtype::PlaceHold, + $this->getUser(), + data: [ + 'username' => $patron['cat_username'], + 'details' => $holdDetails, + ] + ); + return $this->redirectToRecord($this->inLightbox() ? '?layout=lightbox' : ''); } else { // Failure: use flash messenger to display messages, stay on diff --git a/module/VuFind/src/VuFind/Controller/ILLRequestsTrait.php b/module/VuFind/src/VuFind/Controller/ILLRequestsTrait.php index 4ae833c00ba..f79b04adda3 100644 --- a/module/VuFind/src/VuFind/Controller/ILLRequestsTrait.php +++ b/module/VuFind/src/VuFind/Controller/ILLRequestsTrait.php @@ -29,6 +29,9 @@ namespace VuFind\Controller; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + use function in_array; use function is_array; @@ -122,6 +125,17 @@ public function illRequestAction() ]; $this->flashMessenger()->addMessage($msg, 'success'); $this->getViewRenderer()->plugin('session')->put('reset_account_status', true); + + $this->getAuditEventService()->addEvent( + AuditEventType::ILS, + AuditEventSubtype::PlaceILLRequest, + $this->getUser(), + data: [ + 'username' => $patron['cat_username'], + 'details' => $details, + ] + ); + return $this->redirectToRecord($this->inLightbox() ? '?layout=lightbox' : ''); } else { // Failure: use flash messenger to display messages, stay on diff --git a/module/VuFind/src/VuFind/Controller/LibraryCardsController.php b/module/VuFind/src/VuFind/Controller/LibraryCardsController.php index a30261b8d53..3a00767dc7e 100644 --- a/module/VuFind/src/VuFind/Controller/LibraryCardsController.php +++ b/module/VuFind/src/VuFind/Controller/LibraryCardsController.php @@ -33,6 +33,8 @@ use VuFind\Db\Entity\UserEntityInterface; use VuFind\Db\Service\UserCardServiceInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; use VuFind\Exception\ILS as ILSException; /** @@ -155,6 +157,15 @@ public function deleteCardAction() if ($confirm) { $this->getDbService(UserCardServiceInterface::class)->deleteLibraryCard($user, $cardID); + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::DeleteCard, + $user, + data: [ + 'card_id' => $cardID, + ] + ); + // Success Message $this->flashMessenger()->addMessage('Library Card Deleted', 'success'); // Redirect to MyResearch library cards @@ -315,9 +326,29 @@ protected function processEditLibraryCard($user) $this->flashMessenger()->addErrorMessage('ils_connection_failed'); return false; } - if ('password' === $loginMethod && !$patron) { - $this->flashMessenger() - ->addMessage('authentication_error_invalid', 'error'); + if ($patron) { + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::EditCard, + $user, + data: [ + 'username' => $username, + 'card_id' => $id, + ] + ); + } else { + if ('password' === $loginMethod) { + $this->flashMessenger()->addErrorMessage('authentication_error_invalid'); + } + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::ILSLoginFailure, + $user, + data: [ + 'username' => $username, + 'card_id' => $id, + ] + ); return false; } if ('email' === $loginMethod) { @@ -332,6 +363,16 @@ protected function processEditLibraryCard($user) ['auth_method' => 'Email'], 'editLibraryCard' ); + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::SendCardAuthEmail, + $user, + data: [ + 'username' => $username, + 'card_id' => $id, + 'email' => $info['email'], + ] + ); } // Don't reveal the result $this->flashMessenger()->addSuccessMessage('email_login_link_sent'); @@ -348,7 +389,7 @@ protected function processEditLibraryCard($user) $password ); } catch (\VuFind\Exception\LibraryCard $e) { - $this->flashMessenger()->addMessage($e->getMessage(), 'error'); + $this->flashMessenger()->addErrorMessage($e->getMessage()); return false; } @@ -376,6 +417,16 @@ protected function processEmailLink($user, $hash) $info['cat_username'], ' ' ); + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::ConnectCardByEmail, + $user, + data: [ + 'username' => $info['cat_username'], + 'card_id' => $info['cardID'], + 'email' => $info['email'], + ] + ); } catch (\VuFind\Exception\Auth | \VuFind\Exception\LibraryCard $e) { $this->flashMessenger()->addErrorMessage($e->getMessage()); } diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php index 442f1fae4c8..73c9cfb5fdd 100644 --- a/module/VuFind/src/VuFind/Controller/MyResearchController.php +++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php @@ -49,6 +49,8 @@ use VuFind\Db\Service\UserListServiceInterface; use VuFind\Db\Service\UserResourceServiceInterface; use VuFind\Db\Service\UserServiceInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; use VuFind\Exception\Auth as AuthException; use VuFind\Exception\AuthEmailNotVerified as AuthEmailNotVerifiedException; use VuFind\Exception\AuthInProgress as AuthInProgressException; @@ -489,6 +491,14 @@ protected function setSavedFlagSecurely($searchId, $saved, $user) } $row->setUser($user); $this->getDbService(SearchServiceInterface::class)->persistEntity($row); + $this->getAuditEventService()->addEvent( + AuditEventType::User, + $saved ? AuditEventSubtype::SaveSearch : AuditEventSubtype::UnSaveSearch, + $user, + data: [ + 'search_id' => $searchId, + ] + ); } /** @@ -552,6 +562,18 @@ protected function scheduleSearch(UserEntityInterface $user, $schedule, $sid) $savedRow->setNotificationFrequency($schedule); $savedRow->setNotificationBaseUrl($baseurl); $this->getDbService(SearchServiceInterface::class)->persistEntity($savedRow); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::ScheduleSearch, + $user, + data: [ + 'search_id' => $sid, + 'notification_frequency' => $schedule, + 'base_url' => $baseurl, + ] + ); + return $this->redirect()->toRoute('search-history'); } @@ -1539,6 +1561,18 @@ public function checkedoutAction() ) : []; + if ($renewResult) { + $this->getAuditEventService()->addEvent( + AuditEventType::ILS, + AuditEventSubtype::RenewLoans, + $this->getUser(), + data: [ + 'username' => $patron['cat_username'], + 'result' => $renewResult, + ] + ); + } + // By default, assume we will not need to display a renewal form: $renewForm = false; @@ -1770,6 +1804,13 @@ protected function sendRecoveryEmail(array $recoveryData) ); $this->flashMessenger()->addSuccessMessage('recovery_email_sent'); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::SendEmailRecoveryLink, + $this->getUser(), + data: ['email' => $recoveryData['email']] + ); } catch (MailException $e) { $this->flashMessenger()->addErrorMessage($e->getDisplayMessage()); } @@ -1886,6 +1927,17 @@ protected function sendVerificationEmail($user, $change = false) if ($change) { $this->sendChangeNotificationEmail($user, $to); } + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::SendAddressVerificationEmail, + $user, + data: [ + 'email' => $user->getEmail(), + 'pending_email' => $user->getPendingEmail(), + 'change' => $change, + ] + ); } catch (MailException $e) { $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error'); } @@ -1929,6 +1981,13 @@ public function verifyAction() $view->passwordPolicy = $this->getAuthManager() ->getPasswordPolicy(); $view->setTemplate('myresearch/newpassword'); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::VerifyEmailHash, + $user, + ); + return $view; } } @@ -2026,6 +2085,13 @@ public function verifyEmailAction() $this->getDbService(UserServiceInterface::class)->persistEntity($user); $this->flashMessenger()->addMessage('verification_done', 'info'); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::VerifyEmail, + $user, + ); + return $this->redirect()->toRoute('myresearch-profile'); } } @@ -2124,6 +2190,13 @@ public function newPasswordAction() $this->getAuthManager()->login($this->request); // Return to account home $this->flashMessenger()->addMessage('new_password_success', 'success'); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::PasswordChanged, + $user, + ); + return $this->redirect()->toRoute('myresearch-home'); } @@ -2235,6 +2308,14 @@ public function deleteLoginTokenAction() } $series = $this->params()->fromPost('series', ''); $this->getAuthManager()->deleteToken($series); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::DeleteLoginToken, + $this->getUser(), + data: compact('series') + ); + return $this->redirect()->toRoute('myresearch-profile'); } @@ -2255,6 +2336,13 @@ public function deleteUserLoginTokensAction() ); } $this->getAuthManager()->deleteUserLoginTokens($this->getUser()->id); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::DeleteLoginTokens, + $this->getUser() + ); + return $this->redirect()->toRoute('myresearch-profile'); } @@ -2316,6 +2404,17 @@ public function deleteAccountAction() // After successful token verification, clear list to shrink session: $csrf->trimTokenList(0); } + + $user = $this->getUser(); + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::Delete, + $user, + data: [ + 'user_id' => $user->getId(), + ] + ); + $this->getService(UserAccountService::class)->purgeUserData( $user, $config->Authentication->delete_comments_with_user ?? true, @@ -2358,6 +2457,17 @@ public function unsubscribeAction() } $search->setNotificationFrequency(0); $searchService->persistEntity($search); + + $this->getAuditEventService()->addEvent( + AuditEventType::User, + AuditEventSubtype::SaveSearch, + $search->getUser(), + data: [ + 'search_id' => $search->getId(), + 'notification_frequency' => 0, + ] + ); + $view->success = true; } } else { diff --git a/module/VuFind/src/VuFind/Controller/StorageRetrievalRequestsTrait.php b/module/VuFind/src/VuFind/Controller/StorageRetrievalRequestsTrait.php index 38553b1b39c..78589d14edb 100644 --- a/module/VuFind/src/VuFind/Controller/StorageRetrievalRequestsTrait.php +++ b/module/VuFind/src/VuFind/Controller/StorageRetrievalRequestsTrait.php @@ -29,6 +29,9 @@ namespace VuFind\Controller; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + use function in_array; use function is_array; @@ -141,6 +144,17 @@ public function storageRetrievalRequestAction() ]; $this->flashMessenger()->addMessage($msg, 'success'); $this->getViewRenderer()->plugin('session')->put('reset_account_status', true); + + $this->getAuditEventService()->addEvent( + AuditEventType::ILS, + AuditEventSubtype::PlaceStorageRetrievalRequest, + $this->getUser(), + data: [ + 'username' => $patron['cat_username'], + 'details' => $details, + ] + ); + return $this->redirectToRecord($this->inLightbox() ? '?layout=lightbox' : ''); } else { // Failure: use flash messenger to display messages, stay on diff --git a/module/VuFind/src/VuFind/Db/Entity/AuditEvent.php b/module/VuFind/src/VuFind/Db/Entity/AuditEvent.php new file mode 100644 index 00000000000..aee0d28a2a1 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Entity/AuditEvent.php @@ -0,0 +1,406 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org Main Site + */ + +namespace VuFind\Db\Entity; + +use DateTime; +use Doctrine\ORM\Mapping as ORM; +use VuFind\Db\Feature\DateTimeTrait; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + +use function is_string; + +/** + * Entity model for audit_event table + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org Main Site + */ +#[ORM\Table(name: 'audit_event')] +#[ORM\Index(name: 'audit_event_user_id_idx', columns: ['user_id'])] +#[ORM\Entity] +class AuditEvent implements AuditEventEntityInterface +{ + use DateTimeTrait; + + /** + * Unique ID. + * + * @var int + */ + #[ORM\Column(name: 'id', type: 'integer', nullable: false)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + protected int $id; + + /** + * Date. + * + * @var DateTime + */ + #[ORM\Column(name: 'date', type: 'datetime', nullable: false, options: ['default' => 'CURRENT_TIMESTAMP'])] + protected DateTime $date; + + /** + * Event type. + * + * @var string + */ + #[ORM\Column(name: 'type', type: 'string', length: 50, nullable: false)] + protected string $type; + + /** + * Event subtype. + * + * @var string + */ + #[ORM\Column(name: 'subtype', type: 'string', length: 50, nullable: false)] + protected string $subtype; + + /** + * User. + * + * @var UserEntityInterface + */ + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[ORM\ManyToOne(targetEntity: UserEntityInterface::class)] + protected ?UserEntityInterface $user = null; + + /** + * Session ID. + * + * @var ?string + */ + #[ORM\Column(name: 'session_id', type: 'string', length: 128, nullable: true)] + protected ?string $sessionId = null; + + /** + * Username. + * + * @var ?string + */ + #[ORM\Column(name: 'username', type: 'string', length: 255, nullable: true)] + protected ?string $username = null; + + /** + * Client IP address. + * + * @var ?string + */ + #[ORM\Column(name: 'client_ip', type: 'string', length: 255, nullable: true)] + protected ?string $clientIp = null; + + /** + * Server IP address. + * + * @var ?string + */ + #[ORM\Column(name: 'server_ip', type: 'string', length: 255, nullable: true)] + protected ?string $serverIp = null; + + /** + * Server name. + * + * @var ?string + */ + #[ORM\Column(name: 'server_name', type: 'string', length: 255, nullable: true)] + protected ?string $serverName = null; + + /** + * Log message. + * + * @var ?string + */ + #[ORM\Column(name: 'message', type: 'string', length: 255, nullable: true)] + protected ?string $message = null; + + /** + * Additional data (JSON). + * + * @var ?string + */ + #[ORM\Column(name: 'data', type: 'json', nullable: true)] + protected ?string $data = null; + + /** + * Constructor + */ + public function __construct() + { + // Set the default value as a DateTime object + $this->date = new DateTime(); + } + + /** + * Get date. + * + * @return DateTime + */ + public function getDate(): Datetime + { + return $this->date; + } + + /** + * Set date. + * + * @param DateTime $dateTime Date + * + * @return static + */ + public function setDate(DateTime $dateTime): static + { + $this->date = $dateTime; + return $this; + } + + /** + * Get type. + * + * @return ?string + */ + public function getType(): ?string + { + return $this->type; + } + + /** + * Set type. + * + * @param AuditEventType $type Type + * + * @return static + */ + public function setType(AuditEventType|string $type): static + { + $this->type = is_string($type) ? $type : $type->value; + return $this; + } + + /** + * Get subtype. + * + * @return ?string + */ + public function getSubtype(): ?string + { + return $this->subtype; + } + + /** + * Set subtype. + * + * @param AuditEventSubtype|string $subtype Subtype + * + * @return static + */ + public function setSubtype(AuditEventSubtype|string $subtype): static + { + $this->subtype = is_string($subtype) ? $subtype : $subtype->value; + return $this; + } + + /** + * Get user. + * + * @return ?UserEntityInterface + */ + public function getUser(): ?UserEntityInterface + { + return $this->user; + } + + /** + * Set user. + * + * @param ?UserEntityInterface $user User + * + * @return static + */ + public function setUser(?UserEntityInterface $user): static + { + // Set user only if it's an existing one: + $this->user = $user?->getId() ? $user : null; + // Set username always: + $this->username = $user?->getUsername(); + return $this; + } + + /** + * Get username. + * + * @return ?string + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * Get session ID. + * + * @return ?string + */ + public function getSessionId(): ?string + { + return $this->sessionId; + } + + /** + * Set session ID. + * + * @param ?string $sessionId Session ID + * + * @return static + */ + public function setSessionId(?string $sessionId): static + { + $this->sessionId = $sessionId; + return $this; + } + + /** + * Get client IP address. + * + * @return ?string + */ + public function getClientIp(): ?string + { + return $this->clientIp; + } + + /** + * Set client IP address. + * + * @param ?string $clientIp Client IP address + * + * @return static + */ + public function setClientIp(?string $clientIp): static + { + $this->clientIp = $clientIp; + return $this; + } + + /** + * Get server IP address. + * + * @return ?string + */ + public function getServerIp(): ?string + { + return $this->serverIp; + } + + /** + * Set server IP address. + * + * @param ?string $serverIp Server IP address + * + * @return static + */ + public function setServerIp(?string $serverIp): static + { + $this->serverIp = $serverIp; + return $this; + } + + /** + * Get server name. + * + * @return ?string + */ + public function getServerName(): ?string + { + return $this->serverName; + } + + /** + * Set server name. + * + * @param ?string $serverName Server name + * + * @return static + */ + public function setServerName(?string $serverName): static + { + $this->serverName = $serverName; + return $this; + } + + /** + * Get message. + * + * @return ?string + */ + public function getMessage(): ?string + { + return $this->message; + } + + /** + * Set message. + * + * @param ?string $message Message + * + * @return static + */ + public function setMessage(?string $message): static + { + $this->message = $message; + return $this; + } + + /** + * Get additional data. + * + * @return ?string + */ + public function getData(): ?string + { + return $this->data; + } + + /** + * Set additional data. + * + * @param ?string $data Data + * + * @return static + */ + public function setData(?string $data): static + { + $this->data = $data; + return $this; + } +} diff --git a/module/VuFind/src/VuFind/Db/Entity/AuditEventEntityInterface.php b/module/VuFind/src/VuFind/Db/Entity/AuditEventEntityInterface.php new file mode 100644 index 00000000000..3ddd46029c7 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Entity/AuditEventEntityInterface.php @@ -0,0 +1,213 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Db\Entity; + +use DateTime; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + +/** + * Entity model interface for event table + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +interface AuditEventEntityInterface extends EntityInterface +{ + /** + * Get date. + * + * @return DateTime + */ + public function getDate(): Datetime; + + /** + * Set date. + * + * @param DateTime $dateTime Date + * + * @return static + */ + public function setDate(DateTime $dateTime): static; + + /** + * Get type. + * + * @return ?string + */ + public function getType(): ?string; + + /** + * Set type. + * + * @param AuditEventType|string $type Type + * + * @return static + */ + public function setType(AuditEventType|string $type): static; + + /** + * Get subtype. + * + * @return ?string + */ + public function getSubtype(): ?string; + + /** + * Set subtype. + * + * @param AuditEventSubtype|string $subtype Subtype + * + * @return static + */ + public function setSubtype(AuditEventSubtype|string $subtype): static; + + /** + * Get user. + * + * @return ?UserEntityInterface + */ + public function getUser(): ?UserEntityInterface; + + /** + * Set user. + * + * @param ?UserEntityInterface $user User + * + * @return static + */ + public function setUser(?UserEntityInterface $user): static; + + /** + * Get username. + * + * @return ?string + */ + public function getUsername(): ?string; + + /** + * Get session ID. + * + * @return ?string + */ + public function getSessionId(): ?string; + + /** + * Set session ID. + * + * @param ?string $sessionId Session ID + * + * @return static + */ + public function setSessionId(?string $sessionId): static; + + /** + * Get client IP address. + * + * @return ?string + */ + public function getClientIp(): ?string; + + /** + * Set client IP address. + * + * @param ?string $clientIp Client IP address + * + * @return static + */ + public function setClientIp(?string $clientIp): static; + + /** + * Get server name. + * + * @return ?string + */ + public function getServerName(): ?string; + + /** + * Set server name. + * + * @param ?string $serverName Server name + * + * @return static + */ + public function setServerName(?string $serverName): static; + + /** + * Get server IP address. + * + * @return ?string + */ + public function getServerIp(): ?string; + + /** + * Set server IP address. + * + * @param ?string $serverIp Server IP address + * + * @return static + */ + public function setServerIp(?string $serverIp): static; + + /** + * Get message. + * + * @return ?string + */ + public function getMessage(): ?string; + + /** + * Set message. + * + * @param ?string $message Message + * + * @return static + */ + public function setMessage(?string $message): static; + + /** + * Get additional data. + * + * @return ?string + */ + public function getData(): ?string; + + /** + * Set additional data. + * + * @param ?string $data Data + * + * @return static + */ + public function setData(?string $data): static; +} diff --git a/module/VuFind/src/VuFind/Db/Entity/PluginManager.php b/module/VuFind/src/VuFind/Db/Entity/PluginManager.php index f4db05e4afc..2b14a601f05 100644 --- a/module/VuFind/src/VuFind/Db/Entity/PluginManager.php +++ b/module/VuFind/src/VuFind/Db/Entity/PluginManager.php @@ -52,6 +52,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager AuthHashEntityInterface::class => AuthHash::class, ChangeTrackerEntityInterface::class => ChangeTracker::class, CommentsEntityInterface::class => Comments::class, + AuditEventEntityInterface::class => AuditEvent::class, ExternalSessionEntityInterface::class => ExternalSession::class, FeedbackEntityInterface::class => Feedback::class, LoginTokenEntityInterface::class => LoginToken::class, @@ -80,6 +81,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager AuthHash::class => InvokableFactory::class, ChangeTracker::class => InvokableFactory::class, Comments::class => InvokableFactory::class, + AuditEvent::class => InvokableFactory::class, ExternalSession::class => InvokableFactory::class, Feedback::class => InvokableFactory::class, LoginToken::class => InvokableFactory::class, diff --git a/module/VuFind/src/VuFind/Db/Service/AuditEventService.php b/module/VuFind/src/VuFind/Db/Service/AuditEventService.php new file mode 100644 index 00000000000..04790e8a7de --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Service/AuditEventService.php @@ -0,0 +1,286 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Service; + +use DateTime; +use Doctrine\ORM\EntityManager; +use VuFind\Db\Entity\AuditEventEntityInterface; +use VuFind\Db\Entity\PluginManager as EntityPluginManager; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\PersistenceManager; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + +use function in_array; +use function is_string; + +/** + * Database service for event table. + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +class AuditEventService extends AbstractDbService implements + AuditEventServiceInterface, + Feature\DeleteExpiredInterface +{ + /** + * Constructor + * + * @param EntityManager $entityManager Doctrine ORM entity manager + * @param EntityPluginManager $entityPluginManager Database entity plugin manager + * @param PersistenceManager $persistenceManager Entity persistence manager + * @param array $enabledEventTypes Event types enabled in configuration + * @param ?string $sessionId Session ID (if applicable) + * @param ?string $clientIp Client IP address (if applicable) + * @param ?string $serverIp Server IP address (if applicable) + * @param ?string $serverName Server name (if applicable) + */ + public function __construct( + protected EntityManager $entityManager, + protected EntityPluginManager $entityPluginManager, + protected PersistenceManager $persistenceManager, + protected array $enabledEventTypes, + protected ?string $sessionId, + protected ?string $clientIp, + protected ?string $serverIp, + protected ?string $serverName + ) { + } + + /** + * Create an event entity object. + * + * @return AuditEventEntityInterface + */ + public function createEntity(): AuditEventEntityInterface + { + return $this->entityPluginManager->get(AuditEventEntityInterface::class); + } + + /** + * Add an event. + * + * @param AuditEventType|string $type Event type + * @param AuditEventSubtype|string $subtype Event subtype + * @param ?UserEntityInterface $user User + * @param string $message Status message + * @param array $data Additional data + * + * @return void + */ + public function addEvent( + AuditEventType|string $type, + AuditEventSubtype|string $subtype, + ?UserEntityInterface $user = null, + ?string $message = null, + array $data = [] + ): void { + $type = is_string($type) ? $type : $type->value; + if (!in_array($type, $this->enabledEventTypes)) { + return; + } + $data = $this->scrubSecrets($data); + $data['__method'] = $this->getCallerOfParentMethod(); + $event = $this->createEntity(); + $event + ->setType($type) + ->setSubtype($subtype) + ->setUser($user) + ->setSessionId($this->sessionId) + ->setClientIp($this->clientIp) + ->setServerIp($this->serverIp) + ->setServerName($this->serverName) + ->setMessage($message) + ->setData(json_encode($data)); + $this->persistEntity($event); + } + + /** + * Get an array of events. + * + * @param ?DateTime $fromDate Start date + * @param ?DateTime $untilDate End date + * @param AuditEventType|string|null $type Event type + * @param AuditEventSubtype|string|null $subtype Event subtype + * @param UserEntityInterface|int|null $userOrId User entity or ID of user + * @param ?string $username User's username + * @param ?string $clientIp Client's IP address + * @param ?string $serverIp Server's IP address + * @param ?string $serverName Server's host name + * @param array $sort Sort order + * + * @return AuditEventEntityInterface[] + */ + public function getEvents( + ?DateTime $fromDate = null, + ?DateTime $untilDate = null, + AuditEventType|string|null $type = null, + AuditEventSubtype|string|null $subtype = null, + UserEntityInterface|int|null $userOrId = null, + ?string $username = null, + ?string $clientIp = null, + ?string $serverIp = null, + ?string $serverName = null, + array $sort = ['date DESC'] + ): array { + $user = $userOrId instanceof UserEntityInterface ? $userOrId->getId() : $userOrId; + + $dql = 'SELECT e FROM ' . AuditEventEntityInterface::class . ' e'; + $conditions = []; + $params = []; + + // Handle date limits: + if (null !== $fromDate) { + $conditions[] = 'e.date >= :fromDate'; + $params['fromDate'] = $fromDate->format(VUFIND_DATABASE_DATETIME_FORMAT); + } + if (null !== $untilDate) { + $conditions[] = 'e.date <= :untilDate'; + $params['untilDate'] = $untilDate->format(VUFIND_DATABASE_DATETIME_FORMAT); + } + if (null !== $type) { + $conditions[] = 'e.type = :type'; + $params['type'] = is_string($type) ? $type : $type->value; + } + if (null !== $subtype) { + $conditions[] = 'e.subtype = :subtype'; + $params['subtype'] = is_string($subtype) ? $subtype : $subtype->value; + } + if (null !== $user) { + $conditions[] = 'e.user = :user'; + $params['user'] = $user; + } + if (null !== $username) { + $conditions[] = 'e.username = :username'; + $params['username'] = $username; + } + if (null !== $clientIp) { + $conditions[] = 'e.clientIp = :clientIp'; + $params['clientIp'] = $clientIp; + } + if (null !== $serverIp) { + $conditions[] = 'e.serverIp = :serverIp'; + $params['serverIp'] = $serverIp; + } + if (null !== $serverName) { + $conditions[] = 'e.serverName = :serverName'; + $params['serverName'] = $serverName; + } + + if ($conditions) { + $dql .= ' WHERE ' . implode(' AND ', $conditions); + } + + if ($sort) { + $sortFields = array_map( + fn ($s) => "e.$s", + $sort + ); + $dql .= ' ORDER BY ' . implode(', ', $sortFields); + } + + return $this->entityManager + ->createQuery($dql) + ->setParameters($params) + ->getResult(); + } + + /** + * Delete expired records. Allows setting a limit so that rows can be deleted in small batches. + * + * @param DateTime $dateLimit Date threshold of an "expired" record. + * @param ?int $limit Maximum number of rows to delete or null for no limit. + * + * @return int Number of rows deleted + */ + public function deleteExpired(DateTime $dateLimit, ?int $limit = null): int + { + $subQueryBuilder = $this->entityManager->createQueryBuilder(); + $subQueryBuilder->select('a.id') + ->from(AuditEventEntityInterface::class, 'a') + ->where('a.date < :latestDate') + ->setParameter('latestDate', $dateLimit->format(VUFIND_DATABASE_DATETIME_FORMAT)); + if ($limit) { + $subQueryBuilder->setMaxResults($limit); + } + $queryBuilder = $this->entityManager->createQueryBuilder(); + $queryBuilder->delete(AuditEventEntityInterface::class, 'a') + ->where('a.id IN (:ids)') + ->setParameter('ids', $subQueryBuilder->getQuery()->getResult()); + return $queryBuilder->getQuery()->execute(); + } + + /** + * Purge all events from the database. + * + * @return void + */ + public function purgeEvents(): void + { + $this->entityManager->createQuery('DELETE ' . AuditEventEntityInterface::class . ' e') + ->execute(); + } + + /** + * Get the method that called the parent method here. + * + * @return string + */ + protected function getCallerOfParentMethod(): string + { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + $methodParts = [$backtrace[2]['class'] ?? '', $backtrace[2]['function'] ?? '']; + return implode('::', array_filter($methodParts)); + } + + /** + * Remove any secrets from details to be logged. + * + * @param array $details Details + * + * @return @rray + */ + protected function scrubSecrets(array $details): array + { + array_walk_recursive( + $details, + function (&$value, $key) { + if ('csrf' === $key || str_contains($key, 'password')) { + $value = '***'; + } + } + ); + return $details; + } +} diff --git a/module/VuFind/src/VuFind/Db/Service/AuditEventServiceFactory.php b/module/VuFind/src/VuFind/Db/Service/AuditEventServiceFactory.php new file mode 100644 index 00000000000..5f49853fa84 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Service/AuditEventServiceFactory.php @@ -0,0 +1,97 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Service; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\Session\SessionManager; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; +use VuFind\Config\Feature\ExplodeSettingTrait; +use VuFind\Net\UserIpReader; + +/** + * Audit event database service factory + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +class AuditEventServiceFactory extends AbstractDbServiceFactory +{ + use ExplodeSettingTrait; + + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + $config = $container->get(\VuFind\Config\PluginManager::class)->get('config')->toArray(); + $enabledEventTypes = $this->explodeListSetting($config['Logging']['log_audit_events'] ?? ''); + $sessionId = null; + $clientIp = null; + $serverIp = null; + $serverName = null; + if ('cli' !== PHP_SAPI) { + $sessionId = $container->get(SessionManager::class)->getId(); + $clientIp = $container->get(UserIpReader::class)->getUserIp(); + $serverParams = $container->get('Request')->getServer(); + $serverIp = $serverParams->get('SERVER_ADDR'); + $serverName = $serverParams->get('SERVER_NAME'); + } + return parent::__invoke( + $container, + $requestedName, + [ + $enabledEventTypes, + $sessionId, + $clientIp, + $serverIp, + $serverName, + ] + ); + } +} diff --git a/module/VuFind/src/VuFind/Db/Service/AuditEventServiceInterface.php b/module/VuFind/src/VuFind/Db/Service/AuditEventServiceInterface.php new file mode 100644 index 00000000000..ca09d95461a --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Service/AuditEventServiceInterface.php @@ -0,0 +1,103 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Service; + +use DateTime; +use VuFind\Db\Entity\AuditEventEntityInterface; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + +/** + * Database service interface for Event. + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +interface AuditEventServiceInterface extends DbServiceInterface +{ + /** + * Create an event entity object. + * + * @return AuditEventEntityInterface + */ + public function createEntity(): AuditEventEntityInterface; + + /** + * Add an event. + * + * @param AuditEventType|string $type Event type + * @param AuditEventSubtype|string $subtype Event subtype + * @param ?UserEntityInterface $user User + * @param string $message Status message + * @param array $data Additional data + * + * @return void + */ + public function addEvent( + AuditEventType|string $type, + AuditEventSubtype|string $subtype, + ?UserEntityInterface $user = null, + string $message = '', + array $data = [] + ): void; + + /** + * Get an array of events. + * + * @param ?DateTime $fromDate Start date + * @param ?DateTime $untilDate End date + * @param AuditEventType|string|null $type Event type + * @param AuditEventSubtype|string|null $subtype Event subtype + * @param UserEntityInterface|int|null $userOrId User entity or ID of user + * @param ?string $username User's username + * @param ?string $clientIp Client's IP address + * @param ?string $serverIp Server's IP address + * @param ?string $serverName Server's host name + * @param array $sort Sort order + * + * @return AuditEventEntityInterface[] + */ + public function getEvents( + ?DateTime $fromDate = null, + ?DateTime $untilDate = null, + AuditEventType|string|null $type = null, + AuditEventSubtype|string|null $subtype = null, + UserEntityInterface|int|null $userOrId = null, + ?string $username = null, + ?string $clientIp = null, + ?string $serverIp = null, + ?string $serverName = null, + array $sort = ['date DESC'] + ): array; +} diff --git a/module/VuFind/src/VuFind/Db/Service/PluginManager.php b/module/VuFind/src/VuFind/Db/Service/PluginManager.php index 2b5fa0578bd..f39ff7590ac 100644 --- a/module/VuFind/src/VuFind/Db/Service/PluginManager.php +++ b/module/VuFind/src/VuFind/Db/Service/PluginManager.php @@ -52,6 +52,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager AuthHashServiceInterface::class => AuthHashService::class, ChangeTrackerServiceInterface::class => ChangeTrackerService::class, CommentsServiceInterface::class => CommentsService::class, + AuditEventServiceInterface::class => AuditEventService::class, ExternalSessionServiceInterface::class => ExternalSessionService::class, FeedbackServiceInterface::class => FeedbackService::class, LoginTokenServiceInterface::class => LoginTokenService::class, @@ -81,6 +82,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager AuthHashService::class => AbstractDbServiceFactory::class, ChangeTrackerService::class => AbstractDbServiceFactory::class, CommentsService::class => AbstractDbServiceFactory::class, + AuditEventService::class => AuditEventServiceFactory::class, ExternalSessionService::class => AbstractDbServiceFactory::class, FeedbackService::class => AbstractDbServiceFactory::class, LoginTokenService::class => AbstractDbServiceFactory::class, diff --git a/module/VuFind/src/VuFind/Db/Type/AuditEventSubtype.php b/module/VuFind/src/VuFind/Db/Type/AuditEventSubtype.php new file mode 100644 index 00000000000..269b4eecd78 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Type/AuditEventSubtype.php @@ -0,0 +1,78 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Db\Type; + +/** + * Enum for representing an event subtype. + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +enum AuditEventSubtype: string +{ + // ILS + case CancelHolds = 'cancel_holds'; + case PlaceHold = 'place_hold'; + case PlaceILLRequest = 'place_ill_request'; + case PlaceStorageRetrievalRequest = 'place_storage_retrieval_request'; + case RenewLoans = 'renew_loans'; + case UpdateHolds = 'update_holds'; + + // User + case ConnectCard = 'connect_card'; + case ConnectCardByEmail = 'connect_card_by_email'; + case Create = 'create'; + case Delete = 'delete'; + case DeleteCard = 'delete_card'; + case DeleteLoginToken = 'delete_login_token'; + case DeleteLoginTokens = 'delete_login_tokens'; + case EditCard = 'edit_card'; + case VerifyEmailHash = 'verify_email_hash'; + case VerifyEmail = 'verify_email'; + case ILSLogin = 'ils_login'; + case ILSLoginFailure = 'ils_login_fail'; + case Login = 'login'; + case LoginFailure = 'login_fail'; + case Logout = 'logout'; + case PasswordChanged = 'password_changed'; + case RememberLogin = 'remember_login'; + case SaveSearch = 'save_search'; + case ScheduleSearch = 'schedule_search'; + case SendAddressVerificationEmail = 'send_address_verification_email'; + case SendCardAuthEmail = 'send_card_auth_email'; + case SendEmailLoginLink = 'send_email_login_link'; + case SendEmailRecoveryLink = 'send_email_recovery_link'; + case TokenLogin = 'token_login'; + case UnSaveSearch = 'un_save_search'; + case Update = 'update'; +} diff --git a/module/VuFind/src/VuFind/Db/Type/AuditEventType.php b/module/VuFind/src/VuFind/Db/Type/AuditEventType.php new file mode 100644 index 00000000000..7f2a3b9fb71 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Type/AuditEventType.php @@ -0,0 +1,45 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Db\Type; + +/** + * Enum for representing an event type. + * + * @category VuFind + * @package Database + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +enum AuditEventType: string +{ + case ILS = 'ils'; + case User = 'user'; +} diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AuditEventsTest.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AuditEventsTest.php new file mode 100644 index 00000000000..39c93915ad7 --- /dev/null +++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/AuditEventsTest.php @@ -0,0 +1,362 @@ + + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ + +namespace VuFindTest\Mink; + +use VuFind\Db\Service\AuditEventServiceInterface; +use VuFind\Db\Type\AuditEventSubtype; +use VuFind\Db\Type\AuditEventType; + +/** + * Mink audit event test class. + * + * Class must be final due to use of "new static()" by LiveDatabaseTrait. + * + * @category VuFind + * @package Tests + * @author Demian Katz + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ +final class AuditEventsTest extends \VuFindTest\Integration\MinkTestCase +{ + use \VuFindTest\Feature\LiveDatabaseTrait; + use \VuFindTest\Feature\UserCreationTrait; + use \VuFindTest\Feature\DemoDriverTestTrait; + + /** + * Standard setup method. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + static::failIfDataExists(); + } + + /** + * Test audit events disabled. + * + * @return void + */ + public function testEventsDisabled(): void + { + // Setup config + $this->changeConfigs( + [ + 'Demo' => [ + 'Users' => ['username1' => 'catpass'], + ], + 'config' => [ + 'Catalog' => ['driver' => 'Demo'], + 'Authentication' => [ + 'method' => 'ILS', + ], + 'Logging' => [ + 'log_audit_events' => '', + ], + ], + ] + ); + + // Purge events: + $eventService = $this->getDbService(AuditEventServiceInterface::class); + $eventService->purgeEvents(); + + // Log in: + $session = $this->getMinkSession(); + $session->visit($this->getVuFindUrl('/MyResearch/Profile')); + $page = $session->getPage(); + $this->findCssAndSetValue($page, '#login_ILS_username', 'username1'); + $this->findCssAndSetValue($page, '#login_ILS_password', 'catpass'); + $this->clickCss($page, 'input.btn.btn-primary'); + + // Log out: + $this->clickCss($page, '.logoutOptions a.logout'); + + // Check events: + $events = $eventService->getEvents(); + $this->assertEmpty($events); + } + + /** + * Test login events. + * + * @return void + */ + public function testLoginEvents(): void + { + // Setup config + $this->changeConfigs( + [ + 'Demo' => [ + 'Users' => ['username2' => 'catpass'], + ], + 'config' => [ + 'Catalog' => ['driver' => 'Demo'], + 'Authentication' => [ + 'method' => 'ILS', + 'account_deletion' => true, + ], + 'Logging' => [ + 'log_audit_events' => 'ils,user', + ], + ], + ] + ); + + // Purge events: + $eventService = $this->getDbService(AuditEventServiceInterface::class); + $eventService->purgeEvents(); + + // Log in: + $session = $this->getMinkSession(); + $session->visit($this->getVuFindUrl('/MyResearch/Profile')); + $page = $session->getPage(); + $this->findCssAndSetValue($page, '#login_ILS_username', 'username2'); + $this->findCssAndSetValue($page, '#login_ILS_password', 'catpass'); + $this->clickCss($page, 'input.btn.btn-primary'); + + // Log out: + $this->clickCss($page, '.logoutOptions a.logout'); + + // Log in again: + $session->visit($this->getVuFindUrl('/MyResearch/Profile')); + $this->findCssAndSetValue($page, '#login_ILS_username', 'username2'); + $this->findCssAndSetValue($page, '#login_ILS_password', 'catpass'); + $this->clickCss($page, 'input.btn.btn-primary'); + + // Delete the account: + $this->clickCss($page, '.fa-trash-o'); + $this->clickCss($page, '.modal #delete-account-submit'); + $this->waitForPageLoad($page); + + // Check events: + $eventService = $this->getDbService(AuditEventServiceInterface::class); + $events = $eventService->getEvents(sort: ['date ASC']); + + $expectedEvents = [ + [ + 'user', + 'login', + 'username2', + null, + '{"main_method":"ILS","delegate_method":false,"request":{"username":"username2","password":"***",' + . '"auth_method":"ILS","csrf":"***","processLogin":"Login"},' + . '"__method":"VuFind\\\\Auth\\\\Manager::login"}', + true, + true, + true, + true, + ], + [ + 'user', + 'ils_login', + 'username2', + null, + '{"cat_username":"username2","__method":"VuFind\\\\Auth\\\\Manager::login"}', + true, + true, + true, + true, + ], + [ + 'user', + 'update', + 'username2', + null, + '{"auth_method":"ils","last_login":"","__method":"VuFind\\\\Auth\\\\Manager::updateUser"}', + true, + true, + true, + true, + ], + [ + 'user', + 'logout', + 'username2', + 'logout', + '{"__method":"VuFind\\\\Auth\\\\Manager::clearLoginState"}', + true, + true, + true, + true, + ], + [ + 'user', + 'login', + 'username2', + null, + '{"main_method":"ILS","delegate_method":false,"request":{"username":"username2","password":"***",' + . '"auth_method":"ILS","csrf":"***","processLogin":"Login"},' + . '"__method":"VuFind\\\\Auth\\\\Manager::login"}', + true, + true, + true, + true, + ], + [ + 'user', + 'update', + 'username2', + null, + '{"auth_method":"ils","last_login":"","__method":"VuFind\\\\Auth\\\\Manager::updateUser"}', + true, + true, + true, + true, + ], + [ + 'user', + 'delete', + 'username2', + null, + '{"user_id":,"__method":"VuFind\\\\Controller\\\\MyResearchController::deleteAccountAction"}', + true, + true, + true, + true, + ], + [ + 'user', + 'logout', + null, + 'logout', + '{"__method":"VuFind\\\\Auth\\\\Manager::clearLoginState"}', + true, + true, + true, + true, + ], + ]; + + $eventData = array_map( + function ($event) { + $data = preg_replace('/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', '', $event->getData()); + $data = preg_replace('/"user_id":\d+/', '"user_id":', $data); + return [ + $event->getType(), + $event->getSubType(), + $event->getUsername(), + $event->getMessage(), + null !== $event->getSessionId(), + null !== $event->getClientIp(), + null !== $event->getServerIp(), + null !== $event->getServerName(), + $data, + ]; + }, + $events + ); + + $this->assertEquals($expectedEvents, $eventData); + + // Try another event search: + $events = $eventService->getEvents(username: 'username2', type: 'user', subtype: 'login'); + $this->assertCount(2, $events); + } + + /** + * Test custom events. + * + * @return void + */ + public function testCustomEvents(): void + { + // Setup config + $this->changeConfigs( + [ + 'config' => [ + 'Logging' => [ + 'log_audit_events' => 'ils,user,custom', + ], + ], + ] + ); + + // Get event service: + $eventService = $this->getDbService(AuditEventServiceInterface::class); + $this->assertInstanceOf(AuditEventServiceInterface::class, $eventService); + + // Purge events: + $eventService->purgeEvents(); + $this->assertEmpty($eventService->getEvents()); + + // Add an event with built-in type: + $eventService->addEvent( + AuditEventType::ILS, + AuditEventSubtype::SaveSearch, + null, + 'Standard', + ['foo' => 'bar'] + ); + + // Add a custom event: + $eventService->addEvent( + 'custom', + 'foobar', + null, + 'Custom', + ['foo' => 'bar'] + ); + + // Add a custom event that should not be logged: + $eventService->addEvent( + 'disabled', + 'foobar', + null, + 'Disabled event', + ['foo' => 'bar'] + ); + + // Check results: + $events = $eventService->getEvents(type: AuditEventType::ILS); + $this->assertCount(1, $events); + $this->assertEquals('Standard', $events[0]->getMessage()); + + $events = $eventService->getEvents(type: 'custom'); + $this->assertCount(1, $events); + $this->assertEquals('Custom', $events[0]->getMessage()); + + $this->assertEmpty($eventService->getEvents(type: 'disabled')); + } + + /** + * Standard teardown method. + * + * @return void + */ + public static function tearDownAfterClass(): void + { + static::removeUsers(['username1', 'username2']); + } +} diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php index 450bb340365..9285c89e624 100644 --- a/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php @@ -587,7 +587,8 @@ protected function getManager( $csrf, $loginTokenManager, $ils, - $viewRenderer + $viewRenderer, + $this->createMock(\VuFind\Db\Service\AuditEventServiceInterface::class) ); } diff --git a/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php b/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php index 505a41bc047..759af48f82e 100644 --- a/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php +++ b/module/VuFindConsole/src/VuFindConsole/Command/PluginManager.php @@ -79,6 +79,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager 'util/dedupe' => Util\DedupeCommand::class, 'util/deletes' => Util\DeletesCommand::class, 'util/expire_access_tokens' => Util\ExpireAccessTokensCommand::class, + 'util/expire_audit_events' => Util\ExpireAuditEventsCommand::class, 'util/expire_auth_hashes' => Util\ExpireAuthHashesCommand::class, 'util/expire_external_sessions' => Util\ExpireExternalSessionsCommand::class, 'util/expire_login_tokens' => Util\ExpireLoginTokensCommand::class, @@ -130,6 +131,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager Util\DedupeCommand::class => InvokableFactory::class, Util\DeletesCommand::class => Util\AbstractSolrCommandFactory::class, Util\ExpireAccessTokensCommand::class => Util\ExpireAccessTokensCommandFactory::class, + Util\ExpireAuditEventsCommand::class => Util\ExpireAuditEventsCommandFactory::class, Util\ExpireAuthHashesCommand::class => Util\ExpireAuthHashesCommandFactory::class, Util\ExpireExternalSessionsCommand::class => Util\ExpireExternalSessionsCommandFactory::class, Util\ExpireLoginTokensCommand::class => Util\ExpireLoginTokensCommandFactory::class, diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuditEventsCommand.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuditEventsCommand.php new file mode 100644 index 00000000000..180107d9f85 --- /dev/null +++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuditEventsCommand.php @@ -0,0 +1,75 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindConsole\Command\Util; + +use Symfony\Component\Console\Attribute\AsCommand; + +/** + * Console command: expire audit events. + * + * @category VuFind + * @package Console + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +#[AsCommand( + name: 'util/expire_audit_events' +)] +class ExpireAuditEventsCommand extends AbstractExpireCommand +{ + /** + * Minimum legal age of rows to delete. + * + * @var int + */ + protected $minAge = 1; + + /** + * Default age of rows to delete. $minAge is used if $defaultAge is null. + * + * @var int + */ + protected $defaultAge = 365; + + /** + * Help description for the command. + * + * @var string + */ + protected $commandDescription = 'Database audit_event table cleanup'; + + /** + * Label to use for rows in help messages. + * + * @var string + */ + protected $rowLabel = 'audit events'; +} diff --git a/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuditEventsCommandFactory.php b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuditEventsCommandFactory.php new file mode 100644 index 00000000000..6451d581a13 --- /dev/null +++ b/module/VuFindConsole/src/VuFindConsole/Command/Util/ExpireAuditEventsCommandFactory.php @@ -0,0 +1,74 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFindConsole\Command\Util; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; + +/** + * Factory for Util/ExpireAuditEventsCommand. + * + * @category VuFind + * @package Console + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class ExpireAuditEventsCommandFactory implements FactoryInterface +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + $serviceManager = $container->get(\VuFind\Db\Service\PluginManager::class); + return new $requestedName( + $serviceManager->get(\VuFind\Db\Service\AuditEventServiceInterface::class), + ...($options ?? []) + ); + } +} diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php index a0910f111a1..2b0f26c0b23 100644 --- a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php +++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/AbstractExpireCommandTest.php @@ -72,16 +72,23 @@ class AbstractExpireCommandTest extends \PHPUnit\Framework\TestCase /** * Age parameter to use when testing illegal age input. * - * @var int + * @var float */ - protected $illegalAge = 1; + protected $illegalAge = 1.0; /** * Expected minimum age in error message. * - * @var int + * @var float */ - protected $expectedMinAge = 2; + protected $expectedMinAge = 2.0; + + /** + * Expected threshold. + * + * @var float + */ + protected $expectedThreshold = 2.0; /** * Test an illegal age parameter. @@ -185,7 +192,7 @@ protected function getCommand( ->getMock(); $command->expects($this->once()) ->method('getDateThreshold') - ->with(2) + ->with($this->expectedThreshold) ->willReturn($date); return $command; } diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireAuditEventsCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireAuditEventsCommandTest.php new file mode 100644 index 00000000000..c622c988e10 --- /dev/null +++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireAuditEventsCommandTest.php @@ -0,0 +1,87 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\Command\Util; + +use VuFind\Db\Service\AuditEventService; +use VuFindConsole\Command\Util\ExpireAuditEventsCommand; + +/** + * ExpireAuditEventsCommand test. + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class ExpireAuditEventsCommandTest extends AbstractExpireCommandTest +{ + /** + * Name of class being tested + * + * @var string + */ + protected $targetClass = ExpireAuditEventsCommand::class; + + /** + * Name of a valid service class to test with + * + * @var string + */ + protected $validServiceClass = AuditEventService::class; + + /** + * Label to use for rows in help messages. + * + * @var string + */ + protected $rowLabel = 'audit events'; + + /** + * Age parameter to use when testing illegal age input. + * + * @var float + */ + protected $illegalAge = 0.9; + + /** + * Expected minimum age in error message. + * + * @var float + */ + protected $expectedMinAge = 1.0; + + /** + * Expected threshold. + * + * @var float + */ + protected $expectedThreshold = 365.0; +} diff --git a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php index c390bc53d9a..497537ff66d 100644 --- a/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php +++ b/module/VuFindConsole/tests/unit-tests/src/VuFindTest/Command/Util/ExpireSessionsCommandTest.php @@ -66,14 +66,14 @@ class ExpireSessionsCommandTest extends AbstractExpireCommandTest /** * Age parameter to use when testing illegal age input. * - * @var int + * @var float */ protected $illegalAge = 0.01; /** * Expected minimum age in error message. * - * @var int + * @var float */ protected $expectedMinAge = 0.1; }