Add listeners to check expiry on authentication and hande unverifying and reverifying of email addresses

master
Lars Vierbergen 7 years ago
parent 11e22f0362
commit 5768c06f25
  1. 37
      DependencyInjection/AuthserverAutoExpireUsersExtension.php
  2. 99
      Entity/ExpiredUser.php
  3. 189
      EventListener/CheckExpiryListener.php
  4. 113
      EventListener/EmailAddressVerificationListener.php
  5. 14
      Resources/config/services.xml
  6. 3
      composer.json

@ -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);
}
}
}
}

@ -2,5 +2,17 @@
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service class="vierbergenlars\AuthserverAutoExpireUsersBundle\EventListener\CheckExpiryListener">
<argument type="service" id="doctrine.orm.entity_manager" />
<argument type="service" id="app.mailer.user.verify_email" />
<argument type="service" id="logger" />
<tag name="kernel.event_subscriber" />
<tag name="monolog.logger" channel="security" />
</service>
<service class="vierbergenlars\AuthserverAutoExpireUsersBundle\EventListener\EmailAddressVerificationListener">
<argument type="service" id="logger" />
<tag name="doctrine.event_subscriber" />
</service>
</services>
</container>

@ -12,6 +12,9 @@
"vierbergenlars\\AuthserverAutoExpireUsersBundle\\" : "."
}
},
"config" : {
"prepend-autoloader" : false
},
"require": {
"vierbergenlars/authserver-installer": "^1.2"
}