From dad0d86e8c9cb58e64f5e5f9f062f60a6b57d36f Mon Sep 17 00:00:00 2001 From: nkolosnjaj Date: Wed, 3 Jul 2024 14:03:50 +0200 Subject: [PATCH] jun 3 --- .env | 2 + .platform.app.yaml | 1 + config/packages/security.yaml | 20 +++- config/secrets/dev/dev.AKISMET_KEY.ca01fb.php | 3 + config/secrets/dev/dev.decrypt.private.php | 4 + config/secrets/dev/dev.encrypt.public.php | 3 + config/secrets/dev/dev.list.php | 5 + config/secrets/prod/prod.encrypt.public.php | 3 + migrations/Version20240703113708.php | 35 ++++++ src/Controller/ConferenceController.php | 11 ++ src/Controller/SecurityController.php | 32 ++++++ src/Entity/Admin.php | 108 ++++++++++++++++++ src/Repository/AdminRepository.php | 60 ++++++++++ src/SpamChecker.php | 53 +++++++++ templates/security/login.html.twig | 41 +++++++ 15 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 config/secrets/dev/dev.AKISMET_KEY.ca01fb.php create mode 100644 config/secrets/dev/dev.decrypt.private.php create mode 100644 config/secrets/dev/dev.encrypt.public.php create mode 100644 config/secrets/dev/dev.list.php create mode 100644 config/secrets/prod/prod.encrypt.public.php create mode 100644 migrations/Version20240703113708.php create mode 100644 src/Controller/SecurityController.php create mode 100644 src/Entity/Admin.php create mode 100644 src/Repository/AdminRepository.php create mode 100644 src/SpamChecker.php create mode 100644 templates/security/login.html.twig diff --git a/.env b/.env index da73f121..b87e917f 100644 --- a/.env +++ b/.env @@ -14,6 +14,8 @@ # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration +AKISMET_KEY=f7aadd74d13f + ###> symfony/framework-bundle ### APP_ENV=dev APP_SECRET=cf9b8a37ccaf3e584b1bbb006b79af57 diff --git a/.platform.app.yaml b/.platform.app.yaml index ba543924..116a4e5d 100644 --- a/.platform.app.yaml +++ b/.platform.app.yaml @@ -35,6 +35,7 @@ web: mounts: "/var": { source: local, source_path: var } + "/public/uploads": { source: local, source_path: uploads } relationships: diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25a..6100c827 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,14 +4,26 @@ 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\Admin + property: username firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + enable_csrf: true + logout: + path: app_logout + # where to redirect after logout + # target: app_any_route # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -22,8 +34,8 @@ 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: ^/profile, roles: ROLE_USER } + - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } when@test: security: diff --git a/config/secrets/dev/dev.AKISMET_KEY.ca01fb.php b/config/secrets/dev/dev.AKISMET_KEY.ca01fb.php new file mode 100644 index 00000000..9a99454b --- /dev/null +++ b/config/secrets/dev/dev.AKISMET_KEY.ca01fb.php @@ -0,0 +1,3 @@ + null, +]; diff --git a/config/secrets/prod/prod.encrypt.public.php b/config/secrets/prod/prod.encrypt.public.php new file mode 100644 index 00000000..7da39d5a --- /dev/null +++ b/config/secrets/prod/prod.encrypt.public.php @@ -0,0 +1,3 @@ +addSql('CREATE SEQUENCE admin_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE admin (id INT NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_USERNAME ON admin (username)'); + } + + 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 admin_id_seq CASCADE'); + $this->addSql('DROP TABLE admin'); + } +} diff --git a/src/Controller/ConferenceController.php b/src/Controller/ConferenceController.php index 2bd13c9b..c3309c5b 100644 --- a/src/Controller/ConferenceController.php +++ b/src/Controller/ConferenceController.php @@ -8,6 +8,7 @@ use App\Entity\Conference; use App\Form\CommentType; use App\Repository\CommentRepository; use App\Repository\ConferenceRepository; +use App\SpamChecker; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -35,6 +36,7 @@ class ConferenceController extends AbstractController Request $request, Conference $conference, CommentRepository $commentRepository, + SpamChecker $spamChecker, ConferenceRepository $conferenceRepository, #[Autowire('%photo_dir%')] string $photoDir, ): Response { @@ -50,6 +52,15 @@ class ConferenceController extends AbstractController $comment->setPhotoFilename($filename); } $this->entityManager->persist($comment); + $context = [ + 'user_ip' => $request->getClientIp(), + 'user_agent' => $request->headers->get('user-agent'), + 'referrer' => $request->headers->get('referer'), + 'permalink' => $request->getUri(), + ]; + if (2 === $spamChecker->getSpamScore($comment, $context)) { + throw new \RuntimeException('Blatant spam, go away!'); + } $this->entityManager->flush(); return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]); diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 00000000..76bf5c4f --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,32 @@ +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/Entity/Admin.php b/src/Entity/Admin.php new file mode 100644 index 00000000..46368eb4 --- /dev/null +++ b/src/Entity/Admin.php @@ -0,0 +1,108 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + 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 + * + * @return list + */ + 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; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/src/Repository/AdminRepository.php b/src/Repository/AdminRepository.php new file mode 100644 index 00000000..0fe1e727 --- /dev/null +++ b/src/Repository/AdminRepository.php @@ -0,0 +1,60 @@ + + */ +class AdminRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Admin::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof Admin) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } + + // /** + // * @return Admin[] Returns an array of Admin objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Admin + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/SpamChecker.php b/src/SpamChecker.php new file mode 100644 index 00000000..883ca622 --- /dev/null +++ b/src/SpamChecker.php @@ -0,0 +1,53 @@ +endpoint = sprintf('https://%s.rest.akismet.com/1.1/comment-check', $akismetKey); + } + + /** + * @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam + * + * @throws \RuntimeException if the call did not work + */ + public function getSpamScore(Comment $comment, array $context): int + { + $response = $this->client->request('POST', $this->endpoint, [ + 'body' => array_merge($context, [ + 'blog' => 'https://guestbook.example.com', + 'comment_type' => 'comment', + 'comment_author' => $comment->getAuthor(), + 'comment_author_email' => $comment->getEmail(), + 'comment_content' => $comment->getText(), + 'comment_date_gmt' => $comment->getCreatedAt()->format('c'), + 'blog_lang' => 'en', + 'blog_charset' => 'UTF-8', + 'is_test' => true, + ]), + ]); + + $headers = $response->getHeaders(); + if ('discard' === ($headers['x-akismet-pro-tip'][0] ?? '')) { + return 2; + } + + $content = $response->getContent(); + if (isset($headers['x-akismet-debug-help'][0])) { + throw new \RuntimeException(sprintf('Unable to check for spam: %s (%s).', $content, $headers['x-akismet-debug-help'][0])); + } + + return 'true' === $content ? 1 : 0; + } +} \ No newline at end of file diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 00000000..9b6579ea --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,41 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+ {% if error %} +
{{ error.messageKey|trans(error.messageData, 'security') }}
+ {% endif %} + + {% if app.user %} +
+ You are logged in as {{ app.user.userIdentifier }}, Logout +
+ {% endif %} + +

Please sign in

+ + + + + + + + {# + Uncomment this section and add a remember_me option below your firewall to activate remember me functionality. + See https://symfony.com/doc/current/security/remember_me.html + +
+ + +
+ #} + + +
+{% endblock %}