Private
Public Access
new: dev: hardening the registration process with removing the not activated registrations #14
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<?php declare(strict_types=1);
|
||||
/*
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use DateTime;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
|
||||
|
||||
/**
|
||||
* Class PurgeExpiredPendingUsersCommand
|
||||
*
|
||||
* @package App\Command
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 07. 27.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:users:purge-expired-pending',
|
||||
description: 'Delete unverified accounts whose activation token has expired.',
|
||||
)]
|
||||
#[AsPeriodicTask(frequency: '1 hour')]
|
||||
final class PurgeExpiredPendingUsersCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UserRepository $userRepository)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$now = new DateTime();
|
||||
$count = $this->userRepository->countExpiredPendingUsers($now);
|
||||
|
||||
if ($input->getOption('dry-run')) {
|
||||
$io->note(sprintf('%d expired pending account(s) would be deleted.', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($count === 0) {
|
||||
$io->success('No expired pending accounts found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$deleted = $this->userRepository->deleteExpiredPendingUsers($now);
|
||||
$io->success(sprintf('Deleted %d expired pending account(s).', $deleted));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Report how many accounts would be deleted without deleting them.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
#[AsController]
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
private const string ACTIVATION_TOKEN_TTL = '+24 hours';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RequestStack $requestStack,
|
||||
@@ -92,6 +94,7 @@ class SecurityController extends AbstractController
|
||||
|
||||
$user->isVerified = false;
|
||||
$user->verificationToken = $token;
|
||||
$user->verificationTokenExpiresAt = new DateTime(self::ACTIVATION_TOKEN_TTL);
|
||||
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
|
||||
|
||||
$this->em->persist($user);
|
||||
@@ -194,13 +197,14 @@ class SecurityController extends AbstractController
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
|
||||
|
||||
if (!$user) {
|
||||
$this->addFlash('error', 'This activation link is invalid or has already been used.');
|
||||
if (!$user || $user->verificationTokenExpiresAt === null || $user->verificationTokenExpiresAt <= new DateTime()) {
|
||||
$this->addFlash('error', 'This activation link is invalid, expired, or has already been used.');
|
||||
return $this->redirectToRoute('MineSeekerBundle_login');
|
||||
}
|
||||
|
||||
$user->isVerified = true;
|
||||
$user->verificationToken = null;
|
||||
$user->verificationTokenExpiresAt = null;
|
||||
$this->em->flush();
|
||||
|
||||
$this->activationNotificationEmail->send($user, new DateTime());
|
||||
|
||||
@@ -62,6 +62,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
|
||||
#[Column(length: 64, nullable: true)]
|
||||
public ?string $verificationToken = null;
|
||||
|
||||
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
public ?DateTime $verificationTokenExpiresAt = null;
|
||||
|
||||
#[Column(length: 64, nullable: true)]
|
||||
public ?string $resetToken = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php declare(strict_types=1);
|
||||
/*
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Migrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Class Version20260727120000
|
||||
*
|
||||
* @package App\Migrations
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 07. 27.
|
||||
*/
|
||||
final class Version20260727120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add expiry timestamps for account activation tokens.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE app_user ADD verification_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql("UPDATE app_user SET verification_token_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours' WHERE is_verified = FALSE AND verification_token IS NOT NULL");
|
||||
$this->addSql('CREATE INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY ON app_user (verification_token_expires_at) WHERE is_verified = FALSE AND verification_token_expires_at IS NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY');
|
||||
$this->addSql('ALTER TABLE app_user DROP verification_token_expires_at');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -87,6 +88,34 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
||||
}
|
||||
}
|
||||
|
||||
public function countExpiredPendingUsers(DateTimeInterface $now): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('u');
|
||||
|
||||
return (int) $qb
|
||||
->select($qb->expr()->count('u.id'))
|
||||
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
|
||||
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
|
||||
->setParameter('isVerified', false)
|
||||
->setParameter('now', $now)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function deleteExpiredPendingUsers(DateTimeInterface $now): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('u');
|
||||
|
||||
return $qb
|
||||
->delete()
|
||||
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
|
||||
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
|
||||
->setParameter('isVerified', false)
|
||||
->setParameter('now', $now)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
|
||||
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
/**
|
||||
* Class Schedule
|
||||
*
|
||||
* @package App
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 07. 27.
|
||||
*/
|
||||
#[AsSchedule]
|
||||
class Schedule implements ScheduleProviderInterface
|
||||
{
|
||||
public function __construct(private CacheInterface $cache) { }
|
||||
|
||||
public function getSchedule(): SymfonySchedule
|
||||
{
|
||||
return (new SymfonySchedule())
|
||||
->stateful($this->cache) // ensure missed tasks are executed
|
||||
->processOnlyLastMissedRun(true) // ensure only last missed task is run
|
||||
|
||||
// add your own tasks here
|
||||
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user