Add listeners to check expiry on authentication and hande unverifying and reverifying of email addresses
parent
11e22f0362
commit
5768c06f25
@ -0,0 +1,37 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* Authserver, an OAuth2-based single-signon authentication provider written in PHP. |
||||||
|
* |
||||||
|
* Copyright (C) 2017 Lars Vierbergen |
||||||
|
* |
||||||
|
* his program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU Affero General Public License as |
||||||
|
* published by the Free Software Foundation, either version 3 of the |
||||||
|
* License, or (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU Affero General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU Affero General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
namespace vierbergenlars\AuthserverAutoExpireUsersBundle\DependencyInjection; |
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerBuilder; |
||||||
|
use Symfony\Component\Config\FileLocator; |
||||||
|
use Symfony\Component\HttpKernel\DependencyInjection\Extension; |
||||||
|
use Symfony\Component\DependencyInjection\Loader; |
||||||
|
|
||||||
|
class AuthserverAutoExpireUsersExtension extends Extension |
||||||
|
{ |
||||||
|
|
||||||
|
public function load(array $configs, ContainerBuilder $container) |
||||||
|
{ |
||||||
|
$servicesDirectory = __DIR__ . '/../Resources/config'; |
||||||
|
$fileLocator = new FileLocator($servicesDirectory); |
||||||
|
$xmlLoader = new Loader\XmlFileLoader($container, $fileLocator); |
||||||
|
$xmlLoader->load('services.xml'); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,99 @@ |
|||||||
|
<?php |
||||||
|
namespace vierbergenlars\AuthserverAutoExpireUsersBundle\Entity; |
||||||
|
|
||||||
|
use App\Entity\User; |
||||||
|
use Doctrine\ORM\Mapping\Entity; |
||||||
|
use Doctrine\ORM\Mapping\Table; |
||||||
|
use Doctrine\ORM\Mapping\OneToOne; |
||||||
|
use Doctrine\ORM\Mapping\Id; |
||||||
|
use Doctrine\ORM\Mapping\Column; |
||||||
|
|
||||||
|
/** |
||||||
|
* @Entity() |
||||||
|
* @Table(name="vierbergenlars_expire_users") |
||||||
|
*/ |
||||||
|
class ExpiredUser |
||||||
|
{ |
||||||
|
|
||||||
|
/** |
||||||
|
* @OneToOne(targetEntity="App\Entity\User") |
||||||
|
* @Id() |
||||||
|
* |
||||||
|
* @var User |
||||||
|
*/ |
||||||
|
private $user; |
||||||
|
|
||||||
|
/** |
||||||
|
* @Column(name="last_login", type="datetime", nullable=true) |
||||||
|
* |
||||||
|
* @var \DateTime|null |
||||||
|
*/ |
||||||
|
private $lastLogin; |
||||||
|
|
||||||
|
/** |
||||||
|
* @Column(name="expired_at", type="datetime", nullable=true) |
||||||
|
* |
||||||
|
* @var \DateTime|null |
||||||
|
*/ |
||||||
|
private $expiredAt; |
||||||
|
|
||||||
|
public function __construct(User $user) |
||||||
|
{ |
||||||
|
$this->user = $user; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @return \App\Entity\User |
||||||
|
*/ |
||||||
|
public function getUser() |
||||||
|
{ |
||||||
|
return $this->user; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @return \DateTime|null |
||||||
|
*/ |
||||||
|
public function getLastLogin() |
||||||
|
{ |
||||||
|
return $this->lastLogin; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @param \DateTime|null $lastLogin |
||||||
|
*/ |
||||||
|
public function setLastLogin($lastLogin) |
||||||
|
{ |
||||||
|
$this->lastLogin = $lastLogin; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @return \DateTime|null |
||||||
|
*/ |
||||||
|
public function getExpiredAt() |
||||||
|
{ |
||||||
|
return $this->expiredAt; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @param \DateTime|null $expiredAt |
||||||
|
*/ |
||||||
|
public function setExpiredAt($expiredAt) |
||||||
|
{ |
||||||
|
$this->expiredAt = $expiredAt; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if the user is expired (expiry date is not null and in the past) |
||||||
|
* |
||||||
|
* @return boolean |
||||||
|
*/ |
||||||
|
public function isExpired() |
||||||
|
{ |
||||||
|
return $this->expiredAt !== null && $this->expiredAt < new \DateTime(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,189 @@ |
|||||||
|
<?php |
||||||
|
namespace vierbergenlars\AuthserverAutoExpireUsersBundle\EventListener; |
||||||
|
|
||||||
|
use App\AppEvents; |
||||||
|
use App\Event\UserCheckerEvent; |
||||||
|
use App\Entity\User; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Doctrine\ORM\EntityRepository; |
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface; |
||||||
|
use Symfony\Component\Security\Core\Exception\AccountExpiredException; |
||||||
|
use Symfony\Component\Security\Core\AuthenticationEvents; |
||||||
|
use Symfony\Component\Security\Core\Event\AuthenticationEvent; |
||||||
|
use vierbergenlars\AuthserverAutoExpireUsersBundle\Entity\ExpiredUser; |
||||||
|
use vierbergenlars\AuthserverStatsBundle\Event\StatsEvent; |
||||||
|
use Symfony\Component\VarDumper\VarDumper; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use App\Mail\PrimedTwigMailer; |
||||||
|
use Symfony\Component\Security\Core\Role\SwitchUserRole; |
||||||
|
|
||||||
|
class CheckExpiryListener implements EventSubscriberInterface |
||||||
|
{ |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @var EntityManagerInterface |
||||||
|
*/ |
||||||
|
private $em; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @var LoggerInterface |
||||||
|
*/ |
||||||
|
private $logger; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @var PrimedTwigMailer |
||||||
|
*/ |
||||||
|
private $mailer; |
||||||
|
|
||||||
|
public static function getSubscribedEvents() |
||||||
|
{ |
||||||
|
return [ |
||||||
|
AppEvents::SECURITY_USER_CHECK_POST => [ |
||||||
|
'onUserCheck' |
||||||
|
], |
||||||
|
AuthenticationEvents::AUTHENTICATION_SUCCESS => [ |
||||||
|
'onAuthenticationSuccess' |
||||||
|
], |
||||||
|
StatsEvent::class => [ |
||||||
|
'getExpiryStats', |
||||||
|
-10 |
||||||
|
] |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public function __construct(EntityManagerInterface $em, PrimedTwigMailer $mailer, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->em = $em; |
||||||
|
$this->mailer = $mailer; |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @return EntityRepository |
||||||
|
*/ |
||||||
|
private function getRepository() |
||||||
|
{ |
||||||
|
return $this->em->getRepository(ExpiredUser::class); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @param User $user |
||||||
|
* @return ExpiredUser|null |
||||||
|
*/ |
||||||
|
private function getExpiredUserForUser(User $user) |
||||||
|
{ |
||||||
|
return $this->getRepository()->findOneBy([ |
||||||
|
'user' => $user |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
public function onUserCheck(UserCheckerEvent $event) |
||||||
|
{ |
||||||
|
$user = $event->getUser(); |
||||||
|
$this->logger->debug("Checking expiry of user", [ |
||||||
|
'user' => $user |
||||||
|
]); |
||||||
|
if (!($user instanceof User)) { |
||||||
|
$this->logger->debug('Not applicable to this type of user', [ |
||||||
|
'user' => $user |
||||||
|
]); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
/* @var $user User */ |
||||||
|
$expiredUser = $this->getExpiredUserForUser($user); |
||||||
|
|
||||||
|
$this->logger->debug('Fetched user expiry record', [ |
||||||
|
'expired_user' => $expiredUser, |
||||||
|
'is_expired' => $expiredUser->isExpired() |
||||||
|
]); |
||||||
|
|
||||||
|
if ($expiredUser !== null && $expiredUser->isExpired()) { |
||||||
|
$emailAddresses = $user->getEmailAddresses()->toArray(); |
||||||
|
$this->logger->info('User marked as expired. Unverifying all email addresses.', [ |
||||||
|
'user' => $user, |
||||||
|
'email_addresses' => $emailAddresses |
||||||
|
]); |
||||||
|
foreach ($emailAddresses as $emailAddress) { |
||||||
|
/* @var $emailAddress \App\Entity\EmailAddress */ |
||||||
|
$emailAddress->setVerified(false); |
||||||
|
if (!$this->mailer->sendMessage($emailAddress->getEmail(), $emailAddress)) { |
||||||
|
$this->logger->error('Verification email could not be sent.', [ |
||||||
|
'email_address' => $emailAddress |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
$this->em->flush(); |
||||||
|
throw new AccountExpiredException('Account has expired.'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function onAuthenticationSuccess(AuthenticationEvent $event) |
||||||
|
{ |
||||||
|
$this->logger->debug("Updating last login time of user", [ |
||||||
|
'token' => $event->getAuthenticationToken(), |
||||||
|
'user' => $event->getAuthenticationToken() |
||||||
|
->getUser() |
||||||
|
]); |
||||||
|
$token = $event->getAuthenticationToken(); |
||||||
|
foreach ($token->getRoles() as $role) { |
||||||
|
if ($role instanceof SwitchUserRole) { |
||||||
|
$this->logger->info('Authentication success event is caused by an impersonation. Not registering a new login.', [ |
||||||
|
'role' => $role |
||||||
|
]); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
$user = $token->getUser(); |
||||||
|
|
||||||
|
if (!($user instanceof User)) { |
||||||
|
$this->logger->debug('Not applicable to this type of user', [ |
||||||
|
'user' => $user |
||||||
|
]); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$expiredUser = $this->getExpiredUserForUser($user); |
||||||
|
|
||||||
|
$this->logger->debug('Fetched user expiry record', [ |
||||||
|
'expired_user' => $expiredUser, |
||||||
|
'is_expired' => $expiredUser->isExpired() |
||||||
|
]); |
||||||
|
|
||||||
|
if ($expiredUser === null) { |
||||||
|
$this->logger->debug('No user expiry record exists. Creating a new one.'); |
||||||
|
$expiredUser = new ExpiredUser($user); |
||||||
|
$this->em->persist($expiredUser); |
||||||
|
} |
||||||
|
|
||||||
|
$expiredUser->setLastLogin(new \DateTime()); |
||||||
|
|
||||||
|
$this->em->flush($expiredUser); |
||||||
|
} |
||||||
|
|
||||||
|
public function getExpiryStats(StatsEvent $event) |
||||||
|
{ |
||||||
|
if (!$event->isEnabled('autoexpire')) |
||||||
|
return; |
||||||
|
|
||||||
|
$event->setMuninConfig('user', $event->getMuninConfig('user') + [ |
||||||
|
'autoexpire.label' => 'Expired users', |
||||||
|
'autoexpire.draw' => 'LINE' |
||||||
|
]); |
||||||
|
|
||||||
|
$expiredUsers = $this->getRepository() |
||||||
|
->createQueryBuilder('e') |
||||||
|
->select('count(e.expiredAt)') |
||||||
|
->where('e.expiredAt IS NOT NULL AND e.expiredAt < :now') |
||||||
|
->setParameter('now', new \DateTime()) |
||||||
|
->getQuery() |
||||||
|
->getSingleScalarResult(); |
||||||
|
|
||||||
|
$event->addStatistic('user.autoexpire', $expiredUsers); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,113 @@ |
|||||||
|
<?php |
||||||
|
namespace vierbergenlars\AuthserverAutoExpireUsersBundle\EventListener; |
||||||
|
|
||||||
|
use App\AppEvents; |
||||||
|
use App\Event\UserCheckerEvent; |
||||||
|
use App\Entity\EmailAddress; |
||||||
|
use App\Entity\User; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Doctrine\ORM\EntityRepository; |
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface; |
||||||
|
use Symfony\Component\Security\Core\Exception\AccountExpiredException; |
||||||
|
use Symfony\Component\Security\Core\AuthenticationEvents; |
||||||
|
use Symfony\Component\Security\Core\Event\AuthenticationEvent; |
||||||
|
use vierbergenlars\AuthserverAutoExpireUsersBundle\Entity\ExpiredUser; |
||||||
|
use vierbergenlars\AuthserverStatsBundle\Event\StatsEvent; |
||||||
|
use Symfony\Component\VarDumper\VarDumper; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use App\Mail\PrimedTwigMailer; |
||||||
|
use Doctrine\Common\EventSubscriber; |
||||||
|
use Doctrine\ORM\Events; |
||||||
|
use Doctrine\ORM\Event\PreUpdateEventArgs; |
||||||
|
use Doctrine\ORM\Event\PreFlushEventArgs; |
||||||
|
use Doctrine\ORM\Event\OnFlushEventArgs; |
||||||
|
use Doctrine\ORM\UnitOfWork; |
||||||
|
use Doctrine\ORM\EntityManager; |
||||||
|
|
||||||
|
class EmailAddressVerificationListener implements EventSubscriber |
||||||
|
{ |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @var EntityManagerInterface |
||||||
|
*/ |
||||||
|
private $em; |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @var LoggerInterface |
||||||
|
*/ |
||||||
|
private $logger; |
||||||
|
|
||||||
|
public function getSubscribedEvents() |
||||||
|
{ |
||||||
|
return [ |
||||||
|
Events::onFlush |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public function __construct(LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
public function onFlush(OnFlushEventArgs $event) |
||||||
|
{ |
||||||
|
$uow = $event->getEntityManager()->getUnitOfWork(); |
||||||
|
$updates = $uow->getScheduledEntityUpdates(); |
||||||
|
foreach ($updates as $update) { |
||||||
|
$this->processEntity($update, $event->getEntityManager(), $uow); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function processEntity($entity, EntityManager $em, UnitOfWork $uow) |
||||||
|
{ |
||||||
|
if (!($entity instanceof EmailAddress)) |
||||||
|
return; |
||||||
|
|
||||||
|
$changeSet = $uow->getEntityChangeSet($entity); |
||||||
|
|
||||||
|
/* @var $entity EmailAddress */ |
||||||
|
|
||||||
|
// verification status of the email address has changed and it is not the primary email address |
||||||
|
if (isset($changeSet['verified']) && $entity->isVerified()) { |
||||||
|
$expiredUser = $em->find(ExpiredUser::class, $entity->getUser()); |
||||||
|
$this->logger->debug('User expiration record for user.', [ |
||||||
|
'user' => $entity->getUser(), |
||||||
|
'expired_user' => $expiredUser, |
||||||
|
'is_expired' => $expiredUser ? $expiredUser->isExpired() : null |
||||||
|
]); |
||||||
|
if (!$expiredUser || !$expiredUser->isExpired()) |
||||||
|
return; |
||||||
|
|
||||||
|
/* @var $expiredUser ExpiredUser */ |
||||||
|
$this->logger->info('An email address has been verified, clearing expiration for user.', [ |
||||||
|
'user' => $entity->getUser(), |
||||||
|
'expired_user' => $expiredUser |
||||||
|
]); |
||||||
|
|
||||||
|
$expiredUserMeta = $em->getMetadataFactory()->getMetadataFor(ExpiredUser::class); |
||||||
|
$expiredUser->setExpiredAt(null); |
||||||
|
$uow->recomputeSingleEntityChangeSet($expiredUserMeta, $expiredUser); |
||||||
|
|
||||||
|
if (!$entity->isPrimary()) { |
||||||
|
$primaryAddress = $entity->getUser()->getPrimaryEmailAddress(); |
||||||
|
if ($primaryAddress->isVerified()) |
||||||
|
return; |
||||||
|
|
||||||
|
$this->logger->info('An email address has been verified, but the primary email address has not been verified. Switching primary email address to the verified email address.', [ |
||||||
|
'user' => $entity->getUser(), |
||||||
|
'old_primary_address' => $primaryAddress, |
||||||
|
'verified_address' => $entity |
||||||
|
]); |
||||||
|
|
||||||
|
$emailAddressMeta = $em->getMetadataFactory()->getMetadataFor(EmailAddress::class); |
||||||
|
|
||||||
|
$primaryAddress->setPrimary(false); |
||||||
|
$uow->recomputeSingleEntityChangeSet($emailAddressMeta, $primaryAddress); |
||||||
|
$entity->setPrimary(true); |
||||||
|
$uow->recomputeSingleEntityChangeSet($emailAddressMeta, $entity); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Reference in new issue