Add user entity
This commit is contained in:
parent
21110c6e70
commit
b8971552d7
6 changed files with 286 additions and 14 deletions
|
|
@ -5,7 +5,11 @@ security:
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||||
providers:
|
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:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
|
|
@ -14,7 +18,7 @@ security:
|
||||||
security: false
|
security: false
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: users_in_memory
|
provider: app_user_provider
|
||||||
|
|
||||||
# Activate different ways to authenticate:
|
# Activate different ways to authenticate:
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
# https://symfony.com/doc/current/security.html#the-firewall
|
||||||
|
|
|
||||||
30
migrations/Version20260617184045.php
Normal file
30
migrations/Version20260617184045.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260617184045 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/Entity/User.php
Normal file
114
src/Entity/User.php
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Uid\UuidV7;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
|
#[ORM\Table(name: '`user`')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
|
||||||
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: 'uuid')]
|
||||||
|
private UuidV7 $id;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 180)]
|
||||||
|
private string $email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string> 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<string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Repository/UserRepository.php
Normal file
35
src/Repository/UserRepository.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<User>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Entity/UserPersistenceTest.php
Normal file
85
tests/Entity/UserPersistenceTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Uid\UuidV7;
|
||||||
|
|
||||||
|
final class UserPersistenceTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private UserPasswordHasherInterface $hasher;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Doctrine\ORM\Tools\SchemaTool;
|
|
||||||
use Symfony\Component\Dotenv\Dotenv;
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
require dirname(__DIR__).'/vendor/autoload.php';
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
@ -22,18 +21,18 @@ if ($_SERVER['APP_DEBUG']) {
|
||||||
//
|
//
|
||||||
// This runs once per phpunit invocation (not per test — DAMA wraps each test
|
// This runs once per phpunit invocation (not per test — DAMA wraps each test
|
||||||
// in a transaction and rolls it back, so the schema itself must already exist).
|
// in a transaction and rolls it back, so the schema itself must already exist).
|
||||||
(function (): void {
|
(static function (): void {
|
||||||
$kernel = new \App\Kernel('test', true);
|
$kernel = new App\Kernel('test', true);
|
||||||
$kernel->boot();
|
$kernel->boot();
|
||||||
|
|
||||||
$container = $kernel->getContainer();
|
$container = $kernel->getContainer();
|
||||||
|
|
||||||
/** @var \Doctrine\DBAL\Connection $connection */
|
/** @var Doctrine\DBAL\Connection $connection */
|
||||||
$connection = $container->get('doctrine')->getConnection();
|
$connection = $container->get('doctrine')->getConnection();
|
||||||
|
|
||||||
// 1. Ensure the database exists.
|
// 1. Ensure the database exists.
|
||||||
$schemaManager = $connection->createSchemaManager();
|
$schemaManager = $connection->createSchemaManager();
|
||||||
$dbName = $connection->getDatabase();
|
$dbName = $connection->getDatabase();
|
||||||
|
|
||||||
if (!in_array($dbName, $schemaManager->listDatabases(), true)) {
|
if (!in_array($dbName, $schemaManager->listDatabases(), true)) {
|
||||||
$schemaManager->createDatabase($dbName);
|
$schemaManager->createDatabase($dbName);
|
||||||
|
|
@ -41,20 +40,25 @@ if ($_SERVER['APP_DEBUG']) {
|
||||||
|
|
||||||
// 2. Run any pending migrations idempotently.
|
// 2. Run any pending migrations idempotently.
|
||||||
// --no-interaction suppresses the "are you sure?" prompt.
|
// --no-interaction suppresses the "are you sure?" prompt.
|
||||||
/** @var \Symfony\Component\Console\Application $application */
|
/** @var Symfony\Component\Console\Application $application */
|
||||||
$application = new \Symfony\Bundle\FrameworkBundle\Console\Application($kernel);
|
$application = new Symfony\Bundle\FrameworkBundle\Console\Application($kernel);
|
||||||
$application->setAutoExit(false);
|
$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(
|
$exitCode = $application->run(
|
||||||
new \Symfony\Component\Console\Input\ArrayInput([
|
new Symfony\Component\Console\Input\ArrayInput([
|
||||||
'command' => 'doctrine:migrations:migrate',
|
'command' => 'doctrine:migrations:migrate',
|
||||||
'--no-interaction' => true,
|
'--no-interaction' => true,
|
||||||
]),
|
]),
|
||||||
new \Symfony\Component\Console\Output\NullOutput(),
|
$output,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($exitCode !== 0) {
|
if (0 !== $exitCode) {
|
||||||
fwrite(STDERR, "bootstrap.php: doctrine:migrations:migrate failed (exit {$exitCode})\n");
|
fwrite(\STDERR, "bootstrap.php: doctrine:migrations:migrate failed (exit {$exitCode})\n");
|
||||||
|
fwrite(\STDERR, $output->fetch());
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue