From e3d80d1093aa8179741adcbc85eb46290e6d4937 Mon Sep 17 00:00:00 2001 From: dece Date: Sat, 9 Sep 2023 22:48:14 +0200 Subject: [PATCH] Add security 1. Users 2. Login/logout controllers 3. Podcasts can have an owner to restrict their access in the backoffice --- .env | 2 +- README.md | 4 +- config/packages/security.yaml | 32 ++-- config/routes.yaml | 4 + migrations/Version20230909134846.php | 35 +++++ migrations/Version20230909202203.php | 36 +++++ src/Command/OwnOrphanPodcastsCommand.php | 55 +++++++ .../Admin/PodcastCrudController.php | 1 - src/Controller/LoginController.php | 23 +++ src/Entity/Podcast.php | 16 ++ src/Entity/User.php | 142 ++++++++++++++++++ src/Repository/UserRepository.php | 83 ++++++++++ templates/base.html.twig | 1 - templates/feed/index.html.twig | 3 +- templates/feed/podcast.html.twig | 2 + templates/login/index.html.twig | 25 +++ 16 files changed, 442 insertions(+), 22 deletions(-) create mode 100644 migrations/Version20230909134846.php create mode 100644 migrations/Version20230909202203.php create mode 100644 src/Command/OwnOrphanPodcastsCommand.php create mode 100644 src/Controller/LoginController.php create mode 100644 src/Entity/User.php create mode 100644 src/Repository/UserRepository.php create mode 100644 templates/login/index.html.twig diff --git a/.env b/.env index f7fa301..764b688 100644 --- a/.env +++ b/.env @@ -26,7 +26,7 @@ APP_SECRET=c798512b7c48614d5278e8e47bb556ce # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -DATABASE_URL="postgresql://lsbc:dev@127.0.0.1:5432/lsbc?serverVersion=15&charset=utf8" +DATABASE_URL="postgresql://dev:dev@127.0.0.1:5432/lsbc?serverVersion=15&charset=utf8" ###< doctrine/doctrine-bundle ### ###> symfony/messenger ### diff --git a/README.md b/README.md index ad6339a..71d75e0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ LSBC ==== +> Lightweight Symfony Broadcast Client, probably. + A small platform to create podcasts and episodes, host the audio files and share the RSS feed, with external sources download abilities. @@ -11,7 +13,7 @@ Install This project requires: -- PHP 8 +- PHP 8.2 - PostgreSQL 15 and its PHP driver For production: diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..db1155d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,34 +4,32 @@ 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 } + 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 - - # activate different ways to authenticate - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true - - # Easy way to control access for large sections of your site - # Note: Only the *first* access control that matches will be used + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + enable_csrf: true + logout: + path: app_logout + target: app_index + role_hierarchy: + ROLE_ADMIN: ROLE_USER access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/admin, roles: ROLE_USER } when@test: security: password_hashers: - # By default, password hashers are resource intensive and take time. This is - # important to generate secure password hashes. In tests however, secure hashes - # are not important, waste resources and increase test times. The following - # reduces the work factor to the lowest possible values. Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto cost: 4 # Lowest possible value for bcrypt diff --git a/config/routes.yaml b/config/routes.yaml index 41ef814..8e642ae 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -3,3 +3,7 @@ controllers: path: ../src/Controller/ namespace: App\Controller type: attribute + +app_logout: + path: /logout + methods: GET diff --git a/migrations/Version20230909134846.php b/migrations/Version20230909134846.php new file mode 100644 index 0000000..416177d --- /dev/null +++ b/migrations/Version20230909134846.php @@ -0,0 +1,35 @@ +addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE "user" (id INT 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_8D93D649E7927C74 ON "user" (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP SEQUENCE "user_id_seq" CASCADE'); + $this->addSql('DROP TABLE "user"'); + } +} diff --git a/migrations/Version20230909202203.php b/migrations/Version20230909202203.php new file mode 100644 index 0000000..d941c03 --- /dev/null +++ b/migrations/Version20230909202203.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE podcast ADD owner_id INT'); + $this->addSql('ALTER TABLE podcast ADD CONSTRAINT FK_D7E805BD7E3C61F9 FOREIGN KEY (owner_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_D7E805BD7E3C61F9 ON podcast (owner_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE podcast DROP CONSTRAINT FK_D7E805BD7E3C61F9'); + $this->addSql('DROP INDEX IDX_D7E805BD7E3C61F9'); + $this->addSql('ALTER TABLE podcast DROP owner_id'); + } +} diff --git a/src/Command/OwnOrphanPodcastsCommand.php b/src/Command/OwnOrphanPodcastsCommand.php new file mode 100644 index 0000000..52bc5cc --- /dev/null +++ b/src/Command/OwnOrphanPodcastsCommand.php @@ -0,0 +1,55 @@ +addArgument('id', InputArgument::REQUIRED, 'Owner ID'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $userId = $input->getArgument('id'); + + $user = $this->userRepository->find($userId); + if ($user === null) { + $io->error("No user with ID $userId."); + return Command::FAILURE; + } + + $orphanPodcasts = $this->podcastRepository->findBy(['owner' => null]); + foreach ($orphanPodcasts as $podcast) { + $podcast->setOwner($user); + $this->entityManager->persist($podcast); + } + $this->entityManager->flush(); + + $io->success('Orphan podcasts successfully updated.'); + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/PodcastCrudController.php b/src/Controller/Admin/PodcastCrudController.php index 692ba15..3d05b03 100644 --- a/src/Controller/Admin/PodcastCrudController.php +++ b/src/Controller/Admin/PodcastCrudController.php @@ -22,7 +22,6 @@ class PodcastCrudController extends AbstractCrudController { return [ TextField::new('name'), - TextField::new('slug'), UrlField::new('website'), TextEditorField::new('description'), TextField::new('author'), diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php new file mode 100644 index 0000000..c63fb32 --- /dev/null +++ b/src/Controller/LoginController.php @@ -0,0 +1,23 @@ +getLastAuthenticationError(); + $lastUsername = $authUtils->getLastUsername(); + + return $this->render('login/index.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } +} diff --git a/src/Entity/Podcast.php b/src/Entity/Podcast.php index 827b074..6b815cf 100644 --- a/src/Entity/Podcast.php +++ b/src/Entity/Podcast.php @@ -44,6 +44,10 @@ class Podcast #[ORM\Column(length: 255, nullable: true)] private ?string $logoFilename; + #[ORM\ManyToOne(inversedBy: 'podcasts')] + #[ORM\JoinColumn(nullable: true)] + private ?User $owner = null; + public function __construct() { $this->episodes = new ArrayCollection(); @@ -177,4 +181,16 @@ class Podcast return $this; } + + public function getOwner(): ?User + { + return $this->owner; + } + + public function setOwner(?User $owner): static + { + $this->owner = $owner; + + return $this; + } } diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..ff381ac --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,142 @@ +podcasts = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $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); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + /** + * @return Collection + */ + public function getPodcasts(): Collection + { + return $this->podcasts; + } + + public function addPodcast(Podcast $podcast): static + { + if (!$this->podcasts->contains($podcast)) { + $this->podcasts->add($podcast); + $podcast->setOwner($this); + } + + return $this; + } + + public function removePodcast(Podcast $podcast): static + { + if ($this->podcasts->removeElement($podcast)) { + // set the owning side to null (unless already changed) + if ($podcast->getOwner() === $this) { + $podcast->setOwner(null); + } + } + + return $this; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..9b60483 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,83 @@ + + * + * @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); + } + +// /** +// * @return User[] Returns an array of User 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): ?User +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index bf1895f..dba00ec 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -4,7 +4,6 @@ {% block title %}LSBC{% endblock %} - {# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #} {% block stylesheets %}