From b8971552d7fbac1d807d93afce89c0dd888abb49 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 17 Jun 2026 21:24:38 +0200 Subject: [PATCH] Add user entity --- config/packages/security.yaml | 8 +- migrations/Version20260617184045.php | 30 +++++++ src/Entity/User.php | 114 +++++++++++++++++++++++++++ src/Repository/UserRepository.php | 35 ++++++++ tests/Entity/UserPersistenceTest.php | 85 ++++++++++++++++++++ tests/bootstrap.php | 28 ++++--- 6 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 migrations/Version20260617184045.php create mode 100644 src/Entity/User.php create mode 100644 src/Repository/UserRepository.php create mode 100644 tests/Entity/UserPersistenceTest.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8964044..68f1e5c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -5,7 +5,11 @@ security: # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email firewalls: dev: @@ -14,7 +18,7 @@ security: security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider # Activate different ways to authenticate: # https://symfony.com/doc/current/security.html#the-firewall diff --git a/migrations/Version20260617184045.php b/migrations/Version20260617184045.php new file mode 100644 index 0000000..b6cbab1 --- /dev/null +++ b/migrations/Version20260617184045.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE "user" (id UUID NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE "user"'); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..c1bd07a --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,114 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private string $password; + + public function __construct() + { + $this->id = new UuidV7(); + } + + public function getId(): UuidV7 + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3. + */ + public function __serialize(): array + { + $data = (array) $this; + $data["\0".self::class."\0password"] = hash('crc32c', $this->password); + + return $data; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..248bbd3 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,35 @@ + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } +} diff --git a/tests/Entity/UserPersistenceTest.php b/tests/Entity/UserPersistenceTest.php new file mode 100644 index 0000000..68e5f9d --- /dev/null +++ b/tests/Entity/UserPersistenceTest.php @@ -0,0 +1,85 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + + // Build the hasher directly — avoids the chicken-and-egg where + // UserPasswordHasherInterface gets compiled away before User exists. + $factory = new PasswordHasherFactory([ + User::class => new NativePasswordHasher(cost: 4), + ]); + $this->hasher = new UserPasswordHasher($factory); + } + + public function testItPersistsAUserWithUuidIdentifierAndHashedPassword(): void + { + $hasher = $this->hasher; + + $user = new User(); + $user->setEmail('alice@example.com'); + $user->setPassword($hasher->hashPassword($user, 'secret123')); + + $this->em->persist($user); + $this->em->flush(); + + $id = $user->getId(); + self::assertInstanceOf(UuidV7::class, $id); + + $this->em->clear(); + + $reloaded = $this->em->find(User::class, $id); + + self::assertNotNull($reloaded, 'User must be retrievable from the database after an identity-map clear.'); + self::assertInstanceOf(User::class, $reloaded); + self::assertSame('alice@example.com', $reloaded->getEmail()); + self::assertTrue( + $hasher->isPasswordValid($reloaded, 'secret123'), + 'Stored password hash must verify against the original plain-text password.', + ); + self::assertStringNotContainsString( + 'secret123', + $reloaded->getPassword(), + 'Plain-text password must never be stored.', + ); + } + + public function testEmailMustBeUnique(): void + { + $hasher = $this->hasher; + + $first = new User(); + $first->setEmail('bob@example.com'); + $first->setPassword($hasher->hashPassword($first, 'pass1')); + $this->em->persist($first); + $this->em->flush(); + + $this->em->clear(); + + $second = new User(); + $second->setEmail('bob@example.com'); + $second->setPassword($hasher->hashPassword($second, 'pass2')); + $this->em->persist($second); + + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index eceb9e0..994db53 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,6 +1,5 @@ boot(); $container = $kernel->getContainer(); - /** @var \Doctrine\DBAL\Connection $connection */ + /** @var Doctrine\DBAL\Connection $connection */ $connection = $container->get('doctrine')->getConnection(); // 1. Ensure the database exists. $schemaManager = $connection->createSchemaManager(); - $dbName = $connection->getDatabase(); + $dbName = $connection->getDatabase(); if (!in_array($dbName, $schemaManager->listDatabases(), true)) { $schemaManager->createDatabase($dbName); @@ -41,20 +40,25 @@ if ($_SERVER['APP_DEBUG']) { // 2. Run any pending migrations idempotently. // --no-interaction suppresses the "are you sure?" prompt. - /** @var \Symfony\Component\Console\Application $application */ - $application = new \Symfony\Bundle\FrameworkBundle\Console\Application($kernel); + /** @var Symfony\Component\Console\Application $application */ + $application = new Symfony\Bundle\FrameworkBundle\Console\Application($kernel); $application->setAutoExit(false); + // Capture output in a buffer so a failed migration surfaces its diagnostics + // on STDERR instead of being silently swallowed. + $output = new Symfony\Component\Console\Output\BufferedOutput(); + $exitCode = $application->run( - new \Symfony\Component\Console\Input\ArrayInput([ - 'command' => 'doctrine:migrations:migrate', + new Symfony\Component\Console\Input\ArrayInput([ + 'command' => 'doctrine:migrations:migrate', '--no-interaction' => true, ]), - new \Symfony\Component\Console\Output\NullOutput(), + $output, ); - if ($exitCode !== 0) { - fwrite(STDERR, "bootstrap.php: doctrine:migrations:migrate failed (exit {$exitCode})\n"); + if (0 !== $exitCode) { + fwrite(\STDERR, "bootstrap.php: doctrine:migrations:migrate failed (exit {$exitCode})\n"); + fwrite(\STDERR, $output->fetch()); exit(1); }