<?php
namespace MedBrief\MSR\EventSubscriber;
use DateTime;
use DeviceDetector\DeviceDetector;
use Doctrine\ORM\EntityManagerInterface;
use GeoIp2\Database\Reader;
use MedBrief\MSR\Entity\AuditRecord\User as UserAuditRecord;
use MedBrief\MSR\Entity\Firm;
use MedBrief\MSR\Entity\User;
use MedBrief\MSR\Entity\UserLogin;
use MedBrief\MSR\Event\AuditRecordEvent;
use MedBrief\MSR\Factory\AuditRecordEventFactory;
use MedBrief\MSR\Factory\AuditRecordFactory;
use MedBrief\MSR\Security\Exception\GenericOAuthException;
use MedBrief\MSR\Service\EntityHelper\UserHelper;
use MedBrief\MSR\Service\Notification\Generators\UserLoginNotification;
use Override;
use Ramsey\Uuid\Uuid;
use function Sentry\captureException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Component\Security\Core\AuthenticationEvents;
use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Throwable;
class SecurityEventSubscriber implements EventSubscriberInterface
{
public const LOGIN_NEW_IP = 'ip';
public const LOGIN_NEW_DEVICE = 'device';
public function __construct(protected EntityManagerInterface $entityManager, protected Reader $reader, protected UserLoginNotification $loginNotificationGenerator, protected UserHelper $userHelper, protected RequestStack $request, protected EventDispatcherInterface $eventDispatcher)
{
}
/**
* @inheritDoc
*/
#[Override]
public static function getSubscribedEvents(): array
{
return [
LoginSuccessEvent::class => [
['logUserLogin', 0],
],
SecurityEvents::SWITCH_USER => [
['logUserSimulation', 0],
],
AuthenticationEvents::AUTHENTICATION_FAILURE => [
['logFailedUserLogin', 0],
],
KernelEvents::RESPONSE => [
['setDeviceId', 50],
],
];
}
/**
*
*
*
* @param AuthenticationFailureEvent $event
*
* @throws \Exception
*/
public function logFailedUserLogin(AuthenticationFailureEvent $event): void
{
// Because OAuth exceptions cannot be traced to a specific user, skip them
if (!$event->getAuthenticationException() instanceof GenericOAuthException) {
// Grab the user account that the failure was logged for
$email = $this->request->getMasterRequest()->get('username');
$user = $this->entityManager->getRepository(User::class)
->findOneBy(['email' => $email])
;
// If we actually have a user
if ($user instanceof UserInterface) {
// Grab the user's IP address
$ipAddress = $this->request->getMasterRequest()->getClientIp();
// Grab the user's browser
$dd = new DeviceDetector($this->request->getMasterRequest()->headers->get('User-Agent'));
$dd->parse();
$browser = $dd->getClient();
$os = $dd->getOs();
// Get the user's location
try {
$record = $this->reader->city($ipAddress);
// City, County/State, Country
$location = sprintf('%s, %s, %s', $record->city->name, $record->mostSpecificSubdivision->name, $record->country->name);
} catch (Throwable $exception) {
captureException($exception);
$location = 'Unknown Location';
}
// Create a UserLogin object
$login = new UserLogin($user, $ipAddress, $browser, $os, $location, false);
$this->entityManager->persist($login);
$this->entityManager->flush();
}
}
}
/**
* Logs a user's login (login success event) and persists the details to the database to ensure an auditable track
* of IP addresses they accessed from as well as what platform and browser, including emailing users when a new
* login is detected.
*
*
*
* @todo: Correctly log DocSorter logins rather than bypassing them
*
* @param LoginSuccessEvent $loginEvent
*
* @throws \Exception
*/
public function logUserLogin(LoginSuccessEvent $loginEvent): void
{
// Grab the path and make sure we are not doing this check on anything but login events
$path = $loginEvent->getRequest()->getRequestUri();
// Putting this logic back for now, until we have time to prevent the interactive login happening on every DocSorter request.
if (preg_match("/^\/api\/sort\/(?!login).*$/", $path)) {
return;
}
// Grab the User
/** @var User $user */
$user = $loginEvent->getUser();
if ($user instanceof Firm) {
// Firms do not get logged here (at the moment)
return;
}
// Grab the device identifier cookie, if it exists
$deviceId = $loginEvent->getRequest()->cookies->get('msr_dev_id');
if ($deviceId !== null) {
$deviceId = Uuid::fromString($deviceId);
}
// Grab the user's IP address
$ipAddress = $loginEvent->getRequest()->getClientIp();
// Grab the user's browser
$dd = new DeviceDetector($loginEvent->getRequest()->headers->get('User-Agent'));
$dd->parse();
$browser = $dd->getClient();
$os = $dd->getOs();
// Get the user's location
try {
$record = $this->reader->city($ipAddress);
// City, County/State, Country
$location = sprintf('%s, %s, %s', $record->city->name, $record->mostSpecificSubdivision->name, $record->country->name);
} catch (Throwable $exception) {
captureException($exception);
$location = 'Unknown Location';
}
// Create a UserLogin object
$login = new UserLogin($user, $ipAddress, $browser, $os, $location, true, $deviceId);
// Set the device ID in the attributes to persist it to cookies
$loginEvent->getRequest()->attributes->set('msr_dev_id', $login->getDevice()->toString());
// First time user has logged in. Set firstLoginDate
if ($user->getFirstLoginDate() === null) {
$user->setFirstLoginDate(new DateTime('NOW'));
}
// Set the last login date for the user as well
$user->setLastLogin(new DateTime('NOW'));
// This user has never logged in before or we can't match so...
// Register this login
$this->entityManager->persist($user);
$this->entityManager->persist($login);
$this->entityManager->flush();
// ... and email the user
// @TODO: This caused issues with clients who were spammed.
// $this->sendAlertNotification($login, self::LOGIN_NEW_DEVICE);
}
/**
* Logs the simulation of users
*
* @param SwitchUserEvent $event
*/
public function logUserSimulation(SwitchUserEvent $event): void
{
$token = $event->getToken();
if ($token instanceof SwitchUserToken) {
$user = $event->getToken()->getOriginalToken()->getUser();
$targetUser = $event->getTargetUser();
$verb = UserAuditRecord::VERB_SIMULATE;
} else {
$user = $event->getToken()->getUser();
$targetUser = null;
$verb = UserAuditRecord::VERB_SIMULATE_EXIT;
}
$auditRecord = AuditRecordFactory::create(
UserAuditRecord::class,
$user,
$targetUser,
$verb
);
$this->eventDispatcher->dispatch(
AuditRecordEventFactory::create($auditRecord),
AuditRecordEvent::AUDIT
);
}
/**
* Sets a device ID cookie during the kernel.response
*
* @param ResponseEvent $event
*/
public function setDeviceId(ResponseEvent $event): void
{
$attr = $event->getRequest()->attributes->get('msr_dev_id');
if ($attr !== null) {
$event->getResponse()->headers->setCookie(Cookie::create('msr_dev_id', $attr, new DateTime('+2 year')));
}
}
/**
* Sends a login alert notification to the user
*
* @param $type
* @param UserLogin $login
*/
protected function sendAlertNotification(UserLogin $login, $type)
{
// Disable notification temporarily, until there is a way to unsubscribe
// $this->loginNotificationGenerator->generate($login, $type);
}
}