diff --git a/composer.json b/composer.json index 45d168c5e8fd162a618bd073633a0493fb1d5bcf..05bd72a112080143496995b1c3e3972c8d59d9a2 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "symfony/framework-bundle": "6.4.*", "symfony/mime": "6.4.*", "symfony/runtime": "6.4.*", + "symfony/security-bundle": "6.4.*", "symfony/twig-bundle": "6.4.*", "symfony/validator": "6.4.*", "symfony/yaml": "6.4.*", diff --git a/composer.lock b/composer.lock index f4c46dc9ff7913808ae01ae84e60e0422cd67304..9b5298ecbec85d3aa7275b2032647a498e905dbe 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "61a11b439e419dc3af3548dcc5379d8e", + "content-hash": "5e36297905b6f5b970608df51cc45f6f", "packages": [ { "name": "doctrine/cache", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25a56d2decba5041becc5770b16c91b60d4..9fd14588ce450976a5482568a20fd3b02c858c03 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,14 +4,29 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # 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\Utilisateur + property: username firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + custom_authenticator: App\Security\UtilisateurAuthenticator + logout: + path: app_logout + # where to redirect after logout + # target: app_any_route + + remember_me: + secret: '%kernel.secret%' + lifetime: 604800 + path: / + always_remember_me: true # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -22,7 +37,7 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } when@test: diff --git a/migrations/Version20231207150336.php b/migrations/Version20231207150336.php new file mode 100644 index 0000000000000000000000000000000000000000..f25c1431ff00687228f268c23ca724813b700929 --- /dev/null +++ b/migrations/Version20231207150336.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace DoctrineMigrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +/** + * Auto-generated Migration: Please modify to your needs! + */ +final class Version20231207150336 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 utilisateur (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, nom VARCHAR(255) NOT NULL, prenom VARCHAR(255) NOT NULL, birth_date DATE NOT NULL, email VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_1D1C63B3F85E0677 (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE utilisateur'); + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index a6c8dd9ffe793788d563c28f41a967afd1d47d0b..32dc7eb98bdcb322454f513dbe1989ce74db6182 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -12,6 +12,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +#[isGranted('ROLE_ADMIN')] class DashboardController extends AbstractDashboardController { #[Route('/admin', name: 'admin')] diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000000000000000000000000000000000000..15b59ced09098c0fc94a78157384c6413b79269a --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,32 @@ +<?php + +namespace App\Controller; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; + +class SecurityController extends AbstractController +{ + #[Route(path: '/login', name: 'app_login')] + public function login(AuthenticationUtils $authenticationUtils): Response + { + // if ($this->getUser()) { + // return $this->redirectToRoute('target_path'); + // } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 751b7def3f0945e4b4954a660f2308a201f804d0..da7d066a2fd12dc4f299fda044e85ad3241dfe7d 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -4,11 +4,15 @@ namespace App\DataFixtures; use App\Entity\Artiste; use App\Entity\Concert; +use App\Entity\Utilisateur; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class AppFixtures extends Fixture { + public function __construct(private UserPasswordHasherInterface $passwordHasher){} + public function load(ObjectManager $manager) { $numPhoto=array(6,7,9,10,12,13,15,16); @@ -33,6 +37,20 @@ class AppFixtures extends Fixture $manager->persist($concert); } + $user = new Utilisateur('user', ['ROLE_USER'], 'user', 'user', new \DateTime('now'), 'user@user.com'); + + $admin = new Utilisateur('admin', ['ROLE_ADMIN'], 'admin', 'admin', new \DateTime('now'), 'admin@admin.com'); + + $user->setPassword($this->passwordHasher->hashPassword( + $user, + 'user' + )); + $admin->setPassword($this->passwordHasher->hashPassword( + $admin, + 'admin' + )); + $manager->persist($user); + $manager->persist($admin); $manager->flush(); } } diff --git a/src/Entity/Utilisateur.php b/src/Entity/Utilisateur.php new file mode 100644 index 0000000000000000000000000000000000000000..2935d86f3a702ad7a3ae22dfe9dc4e5bcb4d244b --- /dev/null +++ b/src/Entity/Utilisateur.php @@ -0,0 +1,179 @@ +<?php + +namespace App\Entity; + +use App\Repository\UserRepository; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +#[ORM\Entity(repositoryClass: UserRepository::class)] +class Utilisateur implements UserInterface, PasswordAuthenticatedUserInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 180, unique: true)] + private ?string $username = null; + + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + #[ORM\Column(length: 255)] + private ?string $nom = null; + + #[ORM\Column(length: 255)] + private ?string $prenom = null; + + #[ORM\Column(type: Types::DATE_MUTABLE)] + private ?\DateTimeInterface $birthDate = null; + + #[ORM\Column(length: 255)] + private ?string $email = null; + + /** + * @param string|null $username + * @param array $roles + * @param string|null $password + * @param string|null $nom + * @param string|null $prenom + * @param \DateTimeInterface|null $birthDate + * @param string|null $email + */ + public function __construct(?string $username, array $roles, ?string $nom, ?string $prenom, ?\DateTimeInterface $birthDate, ?string $email) + { + $this->username = $username; + $this->roles = $roles; + $this->nom = $nom; + $this->prenom = $prenom; + $this->birthDate = $birthDate; + $this->email = $email; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): static + { + $this->username = $username; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->username; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($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; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + public function getNom(): ?string + { + return $this->nom; + } + + public function setNom(string $nom): static + { + $this->nom = $nom; + + return $this; + } + + public function getPrenom(): ?string + { + return $this->prenom; + } + + public function setPrenom(string $prenom): static + { + $this->prenom = $prenom; + + return $this; + } + + public function getBirthDate(): ?\DateTimeInterface + { + return $this->birthDate; + } + + public function setBirthDate(\DateTimeInterface $birthDate): static + { + $this->birthDate = $birthDate; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..f1e79405109ca50ea0d6354e2eb4014a8c11e2da --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,67 @@ +<?php + +namespace App\Repository; + +use App\Entity\Utilisateur; +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<Utilisateur> + * + * @implements PasswordUpgraderInterface<Utilisateur> + * + * @method Utilisateur|null find($id, $lockMode = null, $lockVersion = null) + * @method Utilisateur|null findOneBy(array $criteria, array $orderBy = null) + * @method Utilisateur[] findAll() + * @method Utilisateur[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Utilisateur::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof Utilisateur) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } + +// /** +// * @return Utilisateur[] Returns an array of Utilisateur objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('u.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?Utilisateur +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/Security/UtilisateurAuthenticator.php b/src/Security/UtilisateurAuthenticator.php new file mode 100644 index 0000000000000000000000000000000000000000..2138583c4e8fccd703ea4427b83e8ade32f13888 --- /dev/null +++ b/src/Security/UtilisateurAuthenticator.php @@ -0,0 +1,60 @@ +<?php + +namespace App\Security; + +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\SecurityRequestAttributes; +use Symfony\Component\Security\Http\Util\TargetPathTrait; + +class UtilisateurAuthenticator extends AbstractLoginFormAuthenticator +{ + use TargetPathTrait; + + public const LOGIN_ROUTE = 'app_login'; + + public function __construct(private UrlGeneratorInterface $urlGenerator) + { + } + + public function authenticate(Request $request): Passport + { + $username = $request->request->get('username', ''); + + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $username); + + return new Passport( + new UserBadge($username), + new PasswordCredentials($request->request->get('password', '')), + [ + new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')), + new RememberMeBadge(), + ] + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + // For example: + // return new RedirectResponse($this->urlGenerator->generate('some_route')); + throw new \Exception('TODO: provide a valid redirect inside '.__FILE__); + } + + protected function getLoginUrl(Request $request): string + { + return $this->urlGenerator->generate(self::LOGIN_ROUTE); + } +} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..032b5ef9c20bae5355510f1a6a2f00aed6602515 --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +<form method="post"> + {% if error %} + <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div> + {% endif %} + + {% if app.user %} + <div class="mb-3"> + You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a> + </div> + {% endif %} + + <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1> + <label for="inputUsername">Username</label> + <input type="text" value="{{ last_username }}" name="username" id="inputUsername" class="form-control" autocomplete="username" required autofocus> + <label for="inputPassword">Password</label> + <input type="password" name="password" id="inputPassword" class="form-control" autocomplete="current-password" required> + + <input type="hidden" name="_csrf_token" + value="{{ csrf_token('authenticate') }}" + > + + <button class="btn btn-lg btn-primary" type="submit"> + Sign in + </button> +</form> +{% endblock %}