diff --git a/composer.json b/composer.json index f6b8448..0a1dc26 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "symfony/process": "7.2.*", "symfony/property-access": "7.2.*", "symfony/property-info": "7.2.*", + "symfony/rate-limiter": "7.2.*", "symfony/runtime": "7.2.*", "symfony/security-bundle": "7.2.*", "symfony/serializer": "7.2.*", diff --git a/composer.lock b/composer.lock index f798244..9b48264 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": "ced9115d2427916ca27414514c173fb9", + "content-hash": "01c7bac1edd7f4d3c539771f00e6ae3a", "packages": [ { "name": "composer/semver", @@ -5567,6 +5567,76 @@ ], "time": "2025-03-06T16:27:19+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "bb6b14ee6c1c4d2722a30d46fb92714943526804" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/bb6b14ee6c1c4d2722a30d46fb92714943526804", + "reference": "bb6b14ee6c1c4d2722a30d46fb92714943526804", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^6.4|^7.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-09T09:29:03+00:00" + }, { "name": "symfony/routing", "version": "v7.2.3", diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7e1ee1f..085b3ae 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -1,12 +1,25 @@ # see https://symfony.com/doc/current/reference/configuration/framework.html framework: secret: '%env(APP_SECRET)%' + #csrf_protection: true + http_method_override: false + handle_all_throwables: true - # Note that the session will be started ONLY if you read or write from it. - session: true + # Enables session support. Note that the session will ONLY be started if you read or write from it. + # Remove or comment this section to explicitly disable session support. + session: + handler_id: null + cookie_secure: auto + cookie_samesite: lax + storage_factory_id: session.storage.factory.native #esi: true #fragments: true + php_errors: + log: true + + annotations: + enabled: false when@test: framework: diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..05cf4c0 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,14 +4,34 @@ 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\User + property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + login_throttling: + max_attempts: 3 # per minute + form_login: + login_path: user_signIn + check_path: user_signIn + enable_csrf: true + remember_me: + secret: '%kernel.secret%' + lifetime: 604800 # 1 week, in seconds + secure: true + samesite: strict + signature_properties: [ 'password', 'validationAdministrator', 'validationDate' ] + logout: + path: user_signOut + # where to redirect after logout + # target: app_any_route # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index b3f8f9c..adc599e 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -1,7 +1,7 @@ framework: - default_locale: en + default_locale: fr translator: default_path: '%kernel.project_dir%/translations' fallbacks: - - en + - fr providers: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d9..0413f06 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,6 @@ twig: file_name_pattern: '*.twig' + form_themes: [ 'bootstrap_5_horizontal_layout.html.twig' ] when@test: twig: diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml index dd47a6a..ab48876 100644 --- a/config/packages/validator.yaml +++ b/config/packages/validator.yaml @@ -1,5 +1,6 @@ framework: validation: + email_validation_mode: html5 # Enables validator auto-mapping support. # For instance, basic validation constraints will be inferred from Doctrine's metadata. #auto_mapping: diff --git a/importmap.php b/importmap.php index d7031fe..d121143 100644 --- a/importmap.php +++ b/importmap.php @@ -20,8 +20,17 @@ return [ 'path' => './assets/datatables.js', 'entrypoint' => true, ], + 'jqueryLocal' => [ + 'path' => './assets/jqueryLocal.js', + ], + 'utils' => [ + 'path' => './assets/utils.js', + ], + 'fontawesome' => [ + 'path' => './assets/fontawesome.js', + ], 'bootstrap' => [ - 'version' => '5.3.3', + 'version' => '5.3.6', ], '@popperjs/core' => [ 'version' => '2.11.8', @@ -29,49 +38,32 @@ return [ 'jquery' => [ 'version' => '3.7.1', ], - 'jqueryLocal' => [ - 'path' => './assets/jqueryLocal.js', - ], - 'utils' => [ - 'path' => './assets/utils.js', - ], 'datatables.net' => [ - 'version' => '2.0.7', + 'version' => '2.3.1', ], 'datatables.net-dt/css/dataTables.dataTables.min.css' => [ - 'version' => '2.0.7', + 'version' => '2.3.1', 'type' => 'css', ], 'datatables.net-bs5' => [ - 'version' => '2.0.7', + 'version' => '2.3.1', ], 'datatables.net-bs5/css/dataTables.bootstrap5.min.css' => [ - 'version' => '2.0.7', + 'version' => '2.3.1', 'type' => 'css', ], - 'fontawesome' => [ - 'path' => './assets/fontawesome.js', - ], '@fortawesome/fontawesome-svg-core' => [ - 'version' => '6.5.2', + 'version' => '6.7.2', ], '@fortawesome/free-solid-svg-icons' => [ - 'version' => '6.5.2', + 'version' => '6.7.2', ], '@fortawesome/fontawesome-svg-core/styles.min.css' => [ - 'version' => '6.5.2', + 'version' => '6.7.2', 'type' => 'css', ], - 'dynamicModal' => [ - 'path' => './assets/dynamicModal.js', - ], - '@hotwired/stimulus' => [ - 'version' => '3.2.2', - ], - '@symfony/stimulus-bundle' => [ - 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', - ], - '@hotwired/turbo' => [ - 'version' => '7.3.0', + 'bootstrap/dist/css/bootstrap.min.css' => [ + 'version' => '5.3.6', + 'type' => 'css', ], ]; diff --git a/src/Controller/CoreController.php b/src/Controller/CoreController.php new file mode 100644 index 0000000..1224c6d --- /dev/null +++ b/src/Controller/CoreController.php @@ -0,0 +1,23 @@ +render('Core/Main.html.twig'); + } +} diff --git a/src/Entity/TEntityBase.php b/src/Entity/TEntityBase.php new file mode 100644 index 0000000..063562e --- /dev/null +++ b/src/Entity/TEntityBase.php @@ -0,0 +1,45 @@ + $this->getId(), + ]; + } + + /** + * The internal id + * + * @return int|null The internal id + */ + public function getId (): ?int { + return $this->id; + } +} \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..1a0ea03 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,173 @@ +getName() ?? $this->getEmail(); + } + + /** + * The email + * + * @return string|null The email + */ + public function getEmail (): ?string { + return $this->email; + } + /** + * Change the email + * + * @param string $email The new email + * + * @return $this + */ + public function setEmail (string $email): self { + $this->email = $email; + + return $this; + } + + /** + * The hashed password + * + * @return string The hashed password + * + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword (): string { + return $this->password; + } + /** + * Change the hashed password + * + * @param string $password The new hashed password + * + * @return $this + */ + public function setPassword (string $password): self { + $this->password = $password; + + return $this; + } + + /** + * The name + * + * @return string|null The name + */ + public function getName (): ?string { + return $this->name; + } + /** + * Change the name + * + * @param string|null $name The new name + * + * @return $this + */ + public function setName (?string $name): self { + $this->name = $name; + + return $this; + } + + /** + * A visual identifier that represents this user + * + * @see UserInterface + */ + public function getUserIdentifier (): string { + return $this->email; + } + + /** + * Has a role ? + * + * @param string $role The role + * + * @return bool True if the user has the role, else False + */ + public function hasRole (string $role): bool { + return in_array($role, $this->getRoles()); + } + /** + * The roles + * + * @return string[] The roles + * + * @see UserInterface + */ + public function getRoles (): array { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + /** + * Set the roles + * + * @param array $roles The new roles + * + * @return void + */ + public function setRoles (array $roles): void { + $this->roles = $roles; + } + + /** + * Removes sensitive data from the user + * + * @see UserInterface + */ + public function eraseCredentials (): void { + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..342b5da --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,53 @@ + + * + * @method User|null find($id, $lockMode = null, $lockVersion = null) + * @method User|null findOneBy(array $criteria, array $orderBy = null) + * @method User[] findAll() + * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface { + public function __construct (ManagerRegistry $registry) { + parent::__construct($registry, User::class); + } + + public function save (User $entity, bool $flush = false): void { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove (User $entity, bool $flush = false): void { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + /** + * 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.', \get_class($user))); + } + + $user->setPassword($newHashedPassword); + + $this->save($user, true); + } +} diff --git a/src/Service/ConnectedUserService.php b/src/Service/ConnectedUserService.php new file mode 100644 index 0000000..aae37b5 --- /dev/null +++ b/src/Service/ConnectedUserService.php @@ -0,0 +1,115 @@ +security = $security; + $this->router = $router; + $this->request = $request; + } + + /** + * The connected user + * + * @return User|null The connected user + */ + public function getUser (): ?User { + $user = $this->security->getUser(); + if ($user === null) { + return null; + } + if (!$user instanceof User) { + return null; + } + return $user; + } + + /** + * Is the requested user the connected one ? + * + * @param User $requestedUser The requested user + * + * @return bool True if the requested user is the connected one, else false + */ + public function isRequestedUser (User $requestedUser): bool { + return $this->getUser()?->getId() !== $requestedUser->getId(); + } + + /** + * Check the user is NOT connected + * + * @return Response|null The response (warning and redirect) if user is connected, else Null + */ + public function checkNotConnected (): ?Response { + /** @var User|null $user */ + $user = $this->getUser(); + if ($user === null) { + return null; + } + + /** @var Session $session */ + $session = $this->request->getSession(); + $session->getFlashBag()->add( + FlashType::WARNING, + "Vous êtes déjà connecté, merci de vous router->generate('user_signOut')}\">déconnecter d'abord" + ); + return new RedirectResponse($this->router->generate('core_main'), 302); + } + /** + * Check if the requested user is the connected one or if the last has administration privileges + * + * @param User $requestedUser The requested user + * + * @return void + * + * @throws AccessDeniedException If the access is denied + */ + public function checkRequestedUserAccess (User $requestedUser): void { + if (!$this->isRequestedUser($requestedUser)) { + return; + } + if (!$this->security->isGranted('ROLE_ADMIN')) { + return; + } + + $exception = new AccessDeniedException(); + $exception->setAttributes(['ROLE_ADMIN']); + $exception->setSubject(null); + + throw $exception; + } +} \ No newline at end of file diff --git a/templates/Core/Main.html.twig b/templates/Core/Main.html.twig new file mode 100644 index 0000000..9df3a11 --- /dev/null +++ b/templates/Core/Main.html.twig @@ -0,0 +1,13 @@ +{% extends '/base.html.twig' %} + +{% block title %}Accueil - {{ parent() }}{% endblock %} + +{% block mainContent %} +

Recipe Manager

+ {% if app.user %} + Bienvenu {{ app.user }} + {% else %} +

Bienvenu sur Recipe Manager, le gestionnaire de recette de jeux vidéos.

+{#

Merci de vous connecter ou créer un compte pour commencer.

#} + {% endif %} +{% endblock %} diff --git a/templates/_flashes.html.twig b/templates/_flashes.html.twig new file mode 100644 index 0000000..fe57979 --- /dev/null +++ b/templates/_flashes.html.twig @@ -0,0 +1,15 @@ +{% block flashTag %} + {% set flashes = app.flashes %} + {% if flashes|length > 0 %} +
+ {% for flashType, flashMessages in flashes %} + {% for flashMessage in flashMessages %} +
+ {{ flashMessage|raw }} + +
+ {% endfor %} + {% endfor %} +
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/base.html.twig b/templates/base.html.twig index ce79868..3d6c311 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,17 +1,35 @@ - - - - - {% block title %}Welcome!{% endblock %} - - {% block stylesheets %} - {% endblock %} +{% extends "/symfony.html.twig" %} - {% block javascripts %} - {% block importmap %}{{ importmap('app') }}{% endblock %} - {% endblock %} - - - {% block body %}{% endblock %} - - +{% block title %}Recipe Manager{% endblock %} + +{% block headerTag %} +
+{% endblock %} +{% block headerContent %} +
+ + Recipe Manager + + + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/html.html.twig b/templates/html.html.twig new file mode 100644 index 0000000..7e88923 --- /dev/null +++ b/templates/html.html.twig @@ -0,0 +1,21 @@ +{% extends "/root.twig" %} + +{% block pageContent %} + + + {% block htmlTag %} + + {% endblock %} +{% block headTag %} + + +{% endblock %} + {% block headContent %}{% endblock %} + +{% block bodyTag %} + +{% endblock %} + {% block bodyContent %}{% endblock %} + + +{% endblock %} diff --git a/templates/root.twig b/templates/root.twig new file mode 100644 index 0000000..a8937c2 --- /dev/null +++ b/templates/root.twig @@ -0,0 +1 @@ +{% block pageContent %}{% endblock %} \ No newline at end of file diff --git a/templates/symfony.html.twig b/templates/symfony.html.twig new file mode 100644 index 0000000..1eee8ea --- /dev/null +++ b/templates/symfony.html.twig @@ -0,0 +1,63 @@ +{% extends "/html.html.twig" %} + +{% block headContent %} + {% block headContentMeta %} + + + {% endblock %} + + {% block title %}{% endblock %} + + {% block CSS %}{% endblock %} + + {% block JS_head %}{% endblock %} +{% endblock %} + +{% block bodyTag %} + + {% endblock %} + {% block bodyContent %} + {% block headerTag %} +
+ {% endblock %} + {% block headerContent %}{% endblock %} +
+ + {% block divBodyTag %} +
+ {% endblock %} + {% block asideLeft %}{% endblock %} + + {% block centerDivBodyTag %} +
+ {% endblock %} + + {% block sectionTop %}{% endblock %} + + {% include '/_flashes.html.twig' %} + + {% block sectionBefore %}{% endblock %} + {% block mainTag %} +
+ {% endblock %} + {% block mainContent %}{% endblock %} +
+ {% block sectionAfter %}{% endblock %} +
+ {% block asideRight %}{% endblock %} +
+ + {% block footerTag %} + + +
+ {% block bodyHidden %}{% endblock %} +
+ + {% block JS %} + {% block importmap %}{{ importmap('app') }}{% endblock %} + {% endblock %} + {% endblock %}