Private
Public Access
new: dev: hardening the registration process with removing the not activated registrations #14
This commit is contained in:
@@ -59,3 +59,10 @@ MERCURE_SUBSCRIBER_JWT=changethis
|
||||
WEBAUTHN_RP_ID=mine.local
|
||||
WEBAUTHN_ORIGIN=https://mine.local
|
||||
###< web-auth/webauthn-framework ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
# Choose one of the transports below
|
||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
@@ -47,6 +47,45 @@ services:
|
||||
condition: service_healthy
|
||||
mail:
|
||||
condition: service_started
|
||||
scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
container_name: '${APP_NAME}-scheduler'
|
||||
command: ['php', 'bin/console', 'messenger:consume', 'scheduler_default', '--env=prod', '--no-interaction', '--memory-limit=128M']
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
APP_SECRET: ${APP_SECRET}
|
||||
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
|
||||
DATABASE_URL: >-
|
||||
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
|
||||
POSTGRES_URL: db
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
MERCURE_URL: http://localhost/.well-known/mercure
|
||||
MERCURE_PUBLIC_URL: https://${APP_PUBLIC_HOSTNAME:-localhost}/.well-known/mercure
|
||||
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
|
||||
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
|
||||
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
|
||||
MAILER_DSN: smtp://mail:25?verify_peer=0
|
||||
CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
|
||||
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
|
||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
MINIO_ENDPOINT: http://minio:9000
|
||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
|
||||
volumes:
|
||||
- app_var:/app/var
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mail:
|
||||
condition: service_started
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mercure": "^0.7",
|
||||
"symfony/mercure-bundle": "^0.4",
|
||||
"symfony/messenger": "7.4.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/scheduler": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
|
||||
Generated
+180
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "28a735d6ad91ee32b02bc9233c94145f",
|
||||
"content-hash": "a571ebed22d0d760c9585870cc15eb19",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -6107,6 +6107,100 @@
|
||||
],
|
||||
"time": "2025-11-25T12:51:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/messenger",
|
||||
"version": "v7.4.14",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/messenger.git",
|
||||
"reference": "1ebe448527c77d9efaf2d3205b23707ebaaa28c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/1ebe448527c77d9efaf2d3205b23707ebaaa28c4",
|
||||
"reference": "1ebe448527c77d9efaf2d3205b23707ebaaa28c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<7.2",
|
||||
"symfony/event-dispatcher": "<6.4",
|
||||
"symfony/event-dispatcher-contracts": "<2.5",
|
||||
"symfony/framework-bundle": "<6.4",
|
||||
"symfony/http-kernel": "<7.3",
|
||||
"symfony/lock": "<7.4",
|
||||
"symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/cache": "^1.0|^2.0|^3.0",
|
||||
"symfony/console": "^7.2|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^7.3|^8.0",
|
||||
"symfony/lock": "^7.4|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
|
||||
"symfony/routing": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0",
|
||||
"symfony/validator": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Messenger\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Samuel Roze",
|
||||
"email": "samuel.roze@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/messenger/tree/v7.4.14"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-08T20:24:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v7.4.13",
|
||||
@@ -7576,6 +7670,91 @@
|
||||
],
|
||||
"time": "2026-05-24T11:20:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/scheduler",
|
||||
"version": "v7.4.13",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/scheduler.git",
|
||||
"reference": "d8fff93e5d29af0262e5693b76376117259b4532"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/scheduler/zipball/d8fff93e5d29af0262e5693b76376117259b4532",
|
||||
"reference": "d8fff93e5d29af0262e5693b76376117259b4532",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dragonmantank/cron-expression": "^3.1",
|
||||
"symfony/cache": "^6.4.36|^7.4.8|^8.0.8",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/lock": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.1|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Scheduler\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sergey Rabochiy",
|
||||
"email": "upyx.00@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides scheduling through Symfony Messenger",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"cron",
|
||||
"schedule",
|
||||
"scheduler"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/scheduler/tree/v7.4.13"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-25T06:06:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-bundle",
|
||||
"version": "v7.4.13",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
framework:
|
||||
messenger:
|
||||
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
|
||||
# failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
# failed: 'doctrine://default?queue_name=failed'
|
||||
sync: 'sync://'
|
||||
|
||||
routing:
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
||||
# when@test:
|
||||
# framework:
|
||||
# messenger:
|
||||
# transports:
|
||||
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
|
||||
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
|
||||
# async: 'in-memory://'
|
||||
@@ -8,6 +8,11 @@ until php -r "new PDO('pgsql:host=db;port=5432;dbname=${POSTGRES_DB}', '${POSTGR
|
||||
done
|
||||
echo "[entrypoint] Database is ready."
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
echo "[entrypoint] Starting worker command: $*"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Clearing and warming Symfony cache..."
|
||||
php bin/console cache:clear
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ Quick command reference for testing:
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
### [Scheduled Tasks](./SCHEDULER.md)
|
||||
Symfony Scheduler tasks and the Docker worker that runs them.
|
||||
|
||||
### [CI/CD Integration Guide](./CI_CD.md)
|
||||
Comprehensive guide for continuous integration and deployment:
|
||||
- Gitea Actions workflows (CI and CD pipelines)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Scheduled Tasks
|
||||
|
||||
MineSeeker uses Symfony Scheduler for recurring maintenance work. The Docker
|
||||
Compose `scheduler` service runs the Scheduler worker continuously and is
|
||||
restarted automatically if it exits.
|
||||
|
||||
## Pending-account cleanup
|
||||
|
||||
`PurgeExpiredPendingUsersCommand` is scheduled every hour. It deletes only
|
||||
unverified users whose activation-token expiry has passed.
|
||||
|
||||
To inspect the registered schedule locally:
|
||||
|
||||
```bash
|
||||
php bin/console debug:scheduler
|
||||
```
|
||||
|
||||
To run the cleanup manually without deleting data:
|
||||
|
||||
```bash
|
||||
php bin/console app:users:purge-expired-pending --dry-run
|
||||
```
|
||||
|
||||
The production worker is started by Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d scheduler
|
||||
docker compose logs -f scheduler
|
||||
```
|
||||
|
||||
No host-level cron entry is required.
|
||||
@@ -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
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,18 @@
|
||||
"config/packages/mercure.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/messenger": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.0",
|
||||
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/messenger.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/mime": {
|
||||
"version": "v4.3.5"
|
||||
},
|
||||
@@ -375,6 +387,18 @@
|
||||
"ref": "cda8b550123383d25827705d05a42acf6819fe4e"
|
||||
}
|
||||
},
|
||||
"symfony/scheduler": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.2",
|
||||
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
|
||||
},
|
||||
"files": [
|
||||
"src/Schedule.php"
|
||||
]
|
||||
},
|
||||
"symfony/security": {
|
||||
"version": "v4.0.9"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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\Tests\Command;
|
||||
|
||||
use App\Command\PurgeExpiredPendingUsersCommand;
|
||||
use App\Entity\User;
|
||||
use App\Tests\Factory\UserFactory;
|
||||
use App\Tests\WebTestCase;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[TestDox('Purge Expired Pending Users Command')]
|
||||
class PurgeExpiredPendingUsersCommandTest extends WebTestCase
|
||||
{
|
||||
#[Test]
|
||||
#[TestDox('Deletes expired unverified users only')]
|
||||
public function deletesOnlyExpiredUnverifiedUsers(): void
|
||||
{
|
||||
$expiredUser = UserFactory::createOne([
|
||||
'isVerified' => false,
|
||||
'verificationToken' => 'expired-token',
|
||||
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
|
||||
]);
|
||||
$pendingUser = UserFactory::createOne([
|
||||
'isVerified' => false,
|
||||
'verificationToken' => 'valid-token',
|
||||
'verificationTokenExpiresAt' => new DateTime('+1 day'),
|
||||
]);
|
||||
$verifiedUser = UserFactory::createOne([
|
||||
'isVerified' => true,
|
||||
'verificationToken' => 'verified-token',
|
||||
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
|
||||
]);
|
||||
|
||||
$command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class);
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
self::assertNull($em->find(User::class, $expiredUser->_real()->id));
|
||||
self::assertNotNull($em->find(User::class, $pendingUser->_real()->id));
|
||||
self::assertNotNull($em->find(User::class, $verifiedUser->_real()->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestDox('Dry run keeps expired unverified users')]
|
||||
public function dryRunDoesNotDeleteExpiredUsers(): void
|
||||
{
|
||||
$expiredUser = UserFactory::createOne([
|
||||
'isVerified' => false,
|
||||
'verificationToken' => 'expired-token',
|
||||
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
|
||||
]);
|
||||
|
||||
$command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class);
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute(['--dry-run' => true]);
|
||||
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
self::assertNotNull($em->find(User::class, $expiredUser->_real()->id));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\Tests\Factory\UserFactory;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
@@ -103,6 +105,25 @@ class SecurityControllerTest extends WebTestCase
|
||||
self::assertResponseRedirects('/login');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestDox('Expired account activation token does not activate the account')]
|
||||
public function expiredActivationTokenDoesNotActivateAccount(): void
|
||||
{
|
||||
$user = UserFactory::createOne([
|
||||
'isVerified' => false,
|
||||
'verificationToken' => 'expired-activation-token',
|
||||
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
|
||||
]);
|
||||
|
||||
$this->client->request('GET', '/activate/expired-activation-token');
|
||||
|
||||
self::assertResponseRedirects('/login');
|
||||
|
||||
static::getContainer()->get(EntityManagerInterface::class)->refresh($user->_real());
|
||||
self::assertFalse($user->isVerified);
|
||||
self::assertSame('expired-activation-token', $user->verificationToken);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestDox('Password reset with invalid token redirects to forgot password')]
|
||||
public function resetPasswordWithInvalidTokenShowsError(): void
|
||||
|
||||
Reference in New Issue
Block a user