diff --git a/config/packages/security.yaml b/config/packages/security.yaml index d142736b..54fbd6b1 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -31,7 +31,6 @@ security: # https://symfony.com/doc/current/security/form_login_setup.html form_login: - login_path: login check_path: login csrf_token_generator: security.csrf.token_manager @@ -41,6 +40,7 @@ security: logout: path: logout target: homepage + handlers: [App\EventSubscriber\LogoutListener] remember_me: secret: '%kernel.secret%' diff --git a/config/services.yaml b/config/services.yaml index db0a8adb..36fbb12a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -20,6 +20,8 @@ parameters: sender_email: 'noreply@partdb.changeme' # The email address from which all emails are sent from sender_name: 'Part-DB Mailer' # The name that will be used for all mails sent by Part-DB allow_email_pw_reset: '%env(validMailDSN:MAILER_DSN)%' # Config if users are able, to reset their password by email. By default this enabled, when a mail server is configured. + # If this option is activated, IP addresses are anonymized to be GPDR compliant + gpdr_compliance: true services: # default configuration for services in *this* file @@ -28,6 +30,7 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. bind: bool $demo_mode: '%demo_mode%' + bool $gpdr_compliance : '%gpdr_compliance%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -47,6 +50,17 @@ services: $email: '%sender_email%' $name: '%sender_name%' + App\Services\LogSystem\EventLogger: + arguments: + # By default only log events which has minimum info level (debug levels are not logged) + # 7 is lowest level (debug), 0 highest (emergency + $minimum_log_level: 6 + # Event classes specified here are not saved to DB + $blacklist: [] + # Only the event classes specified here are saved to DB (set to []) to log all events + $whitelist: [] + + Liip\ImagineBundle\Service\FilterService: alias: 'liip_imagine.service.filter' diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php index fe5474dc..492859d3 100644 --- a/src/Entity/LogSystem/AbstractLogEntry.php +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -154,7 +154,7 @@ abstract class AbstractLogEntry extends DBElement * Get the user that caused the event associated with this log entry. * @return User */ - public function getUser(): User + public function getUser(): ?User { return $this->user; } diff --git a/src/Entity/LogSystem/UserLoginLogEntry.php b/src/Entity/LogSystem/UserLoginLogEntry.php index 876260c1..cbfbc0f9 100644 --- a/src/Entity/LogSystem/UserLoginLogEntry.php +++ b/src/Entity/LogSystem/UserLoginLogEntry.php @@ -22,6 +22,7 @@ namespace App\Entity\LogSystem; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\IpUtils; /** * This log entry is created when a user logs in. @@ -32,6 +33,12 @@ class UserLoginLogEntry extends AbstractLogEntry { protected $typeString = "user_login"; + public function __construct(string $ip_address, bool $anonymize = true) + { + $this->level = self::LEVEL_INFO; + $this->setIPAddress($ip_address, $anonymize); + } + /** * Return the (anonymized) IP address used to login the user. * @return string @@ -44,10 +51,15 @@ class UserLoginLogEntry extends AbstractLogEntry /** * Sets the IP address used to login the user * @param string $ip The IP address used to login the user. + * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @return $this */ - public function setIPAddress(string $ip): self + public function setIPAddress(string $ip, bool $anonymize = true): self { + if ($anonymize) { + $ip = IpUtils::anonymize($ip); + } + $this->extra['i'] = $ip; return $this; } diff --git a/src/Entity/LogSystem/UserLogoutLogEntry.php b/src/Entity/LogSystem/UserLogoutLogEntry.php index 5bb463e8..c99c2b55 100644 --- a/src/Entity/LogSystem/UserLogoutLogEntry.php +++ b/src/Entity/LogSystem/UserLogoutLogEntry.php @@ -23,6 +23,7 @@ namespace App\Entity\LogSystem; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\IpUtils; /** * @ORM\Entity() @@ -32,6 +33,12 @@ class UserLogoutLogEntry extends AbstractLogEntry { protected $typeString = "user_logout"; + public function __construct(string $ip_address, bool $anonymize = true) + { + $this->level = self::LEVEL_INFO; + $this->setIPAddress($ip_address, $anonymize); + } + /** * Return the (anonymized) IP address used to login the user. * @return string @@ -44,11 +51,18 @@ class UserLogoutLogEntry extends AbstractLogEntry /** * Sets the IP address used to login the user * @param string $ip The IP address used to login the user. + * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @return $this */ - public function setIPAddress(string $ip): self + public function setIPAddress(string $ip, bool $anonymize = true): self { + if ($anonymize) { + $ip = IpUtils::anonymize($ip); + } + $this->extra['i'] = $ip; return $this; } + + } \ No newline at end of file diff --git a/src/EventSubscriber/LoginSuccessListener.php b/src/EventSubscriber/LoginSuccessListener.php index 3aa57029..be9bed58 100644 --- a/src/EventSubscriber/LoginSuccessListener.php +++ b/src/EventSubscriber/LoginSuccessListener.php @@ -24,6 +24,9 @@ declare(strict_types=1); namespace App\EventSubscriber; +use App\Entity\LogSystem\UserLoginLogEntry; +use App\Entity\UserSystem\User; +use App\Services\LogSystem\EventLogger; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; @@ -37,15 +40,27 @@ final class LoginSuccessListener implements EventSubscriberInterface { protected $translator; protected $flashBag; + protected $eventLogger; + protected $gpdr_compliance; - public function __construct(TranslatorInterface $translator, FlashBagInterface $flashBag) + public function __construct(TranslatorInterface $translator, FlashBagInterface $flashBag, EventLogger $eventLogger, bool $gpdr_compliance) { $this->translator = $translator; $this->flashBag = $flashBag; + $this->eventLogger = $eventLogger; + $this->gpdr_compliance = $gpdr_compliance; } public function onLogin(InteractiveLoginEvent $event): void { + $ip = $event->getRequest()->getClientIp(); + $log = new UserLoginLogEntry($ip, $this->gpdr_compliance); + $user = $event->getAuthenticationToken()->getUser(); + if ($user instanceof User) { + $log->setTargetElement($user); + } + $this->eventLogger->logAndFlush($log); + $this->flashBag->add('notice', $this->translator->trans('flash.login_successful')); } diff --git a/src/EventSubscriber/LogoutListener.php b/src/EventSubscriber/LogoutListener.php new file mode 100644 index 00000000..63f64846 --- /dev/null +++ b/src/EventSubscriber/LogoutListener.php @@ -0,0 +1,57 @@ +logger = $logger; + $this->gpdr_compliance = $gpdr_compliance; + } + + /** + * @inheritDoc + */ + public function logout(Request $request, Response $response, TokenInterface $token) + { + $log = new UserLogoutLogEntry($request->getClientIp(), $this->gpdr_compliance); + $user = $token->getUser(); + if ($user instanceof User) { + $log->setTargetElement($user); + } + + $this->logger->logAndFlush($log); + } +} \ No newline at end of file diff --git a/src/Services/LogSystem/EventLogger.php b/src/Services/LogSystem/EventLogger.php new file mode 100644 index 00000000..acbf8f5d --- /dev/null +++ b/src/Services/LogSystem/EventLogger.php @@ -0,0 +1,137 @@ +minimum_log_level = $minimum_log_level; + $this->blacklist = $blacklist; + $this->whitelist = $whitelist; + $this->em = $em; + $this->security = $security; + } + + /** + * Adds the given log entry to the Log, if the entry fullfills the global configured criterias. + * The change will not be flushed yet. + * @param AbstractLogEntry $logEntry + * @return bool Returns true, if the event was added to log. + */ + public function log(AbstractLogEntry $logEntry): bool + { + $user = $this->security->getUser(); + //If the user is not specified explicitly, set it to the current user + if (($user === null || $user instanceof User) && $logEntry->getUser() === null) { + if ($user === null) { + $repo = $this->em->getRepository(User::class); + $user = $repo->getAnonymousUser(); + } + $logEntry->setUser($user); + } + + if ($this->shouldBeAdded($logEntry)) { + $this->em->persist($logEntry); + return true; + } + + return false; + } + + /** + * Adds the given log entry to the Log, if the entry fullfills the global configured criterias and flush afterwards. + * @param AbstractLogEntry $logEntry + * @return bool Returns true, if the event was added to log. + */ + public function logAndFlush(AbstractLogEntry $logEntry): bool + { + $tmp = $this->log($logEntry); + $this->em->flush(); + return $tmp; + } + + public function shouldBeAdded( + AbstractLogEntry $logEntry, + ?int $minimum_log_level = null, + ?array $blacklist = null, + ?array $whitelist = null + ): bool { + //Apply the global settings, if nothing was specified + $minimum_log_level = $minimum_log_level ?? $this->minimum_log_level; + $blacklist = $blacklist ?? $this->blacklist; + $whitelist = $whitelist ?? $this->whitelist; + + //Dont add the entry if it does not reach the minimum level + if ($logEntry->getLevel() > $minimum_log_level) { + return false; + } + + //Check if the event type is black listed + if (!empty($blacklist) && $this->isObjectClassInArray($logEntry, $blacklist)) { + return false; + } + + //Check for whitelisting + if (!empty($whitelist) && !$this->isObjectClassInArray($logEntry, $whitelist)) { + return false; + } + + // By default all things should be added + return true; + } + + /** + * Check if the object type is given in the classes array. This also works for inherited types + * @param object $object The object which should be checked + * @param string[] $classes The list of class names that should be used for checking. + * @return bool + */ + protected function isObjectClassInArray(object $object, array $classes): bool + { + //Check if the class is directly in the classes array + if (in_array(get_class($object), $classes)) { + return true; + } + + //Iterate over all classes and check for inheritance + foreach ($classes as $class) { + if (is_a($object, $class)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/tests/Services/LogSystem/EventLoggerTest.php b/tests/Services/LogSystem/EventLoggerTest.php new file mode 100644 index 00000000..7d1fa232 --- /dev/null +++ b/tests/Services/LogSystem/EventLoggerTest.php @@ -0,0 +1,69 @@ +service = self::$container->get(EventLogger::class); + } + + public function testShouldBeAdded() + { + $event1 = new UserLoginLogEntry('127.0.0.1'); + $event2 = new UserLogoutLogEntry('127.0.0.1'); + $event2->setLevel(AbstractLogEntry::LEVEL_CRITICAL); + + + //Test without restrictions + $this->assertTrue($this->service->shouldBeAdded($event1, 7, [], [])); + + //Test minimum log level + $this->assertFalse($this->service->shouldBeAdded($event1, 2, [], [])); + $this->assertTrue($this->service->shouldBeAdded($event2, 2, [], [])); + + //Test blacklist + $this->assertFalse($this->service->shouldBeAdded($event1, 7, [UserLoginLogEntry::class], [])); + $this->assertTrue($this->service->shouldBeAdded($event2, 7, [UserLoginLogEntry::class], [])); + + //Test whitelist + $this->assertFalse($this->service->shouldBeAdded($event1, 7, [], [UserLogoutLogEntry::class])); + $this->assertTrue($this->service->shouldBeAdded($event2, 7, [], [UserLogoutLogEntry::class])); + } +}