diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8964044..04c2efd 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -5,7 +5,10 @@ security: # 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: username firewalls: dev: @@ -14,17 +17,16 @@ security: 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 + provider: app_user_provider + custom_authenticator: App\Security\LoginFormAuthenticator + logout: + path: admin_logout + target: admin_login # Note: Only the *first* matching rule is applied access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/admin/login$, roles: PUBLIC_ACCESS } + - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } when@test: diff --git a/src/Command/CreateAdminUserCommand.php b/src/Command/CreateAdminUserCommand.php new file mode 100644 index 0000000..bfc3bb1 --- /dev/null +++ b/src/Command/CreateAdminUserCommand.php @@ -0,0 +1,48 @@ +addArgument('username', InputArgument::REQUIRED) + ->addArgument('password', InputArgument::REQUIRED); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $username = (string) $input->getArgument('username'); + $password = (string) $input->getArgument('password'); + + $user = $this->userRepository->findOneBy(['username' => $username]) ?? (new User())->setUsername($username); + $user->setPasswordHash($this->passwordHasher->hashPassword($user, $password)); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $output->writeln(sprintf('Admin user "%s" is ready.', $username)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/AuthController.php b/src/Controller/Admin/AuthController.php new file mode 100644 index 0000000..2cb4de8 --- /dev/null +++ b/src/Controller/Admin/AuthController.php @@ -0,0 +1,36 @@ +getUser() !== null) { + return $this->redirectToRoute('admin_dashboard'); + } + + $form = $this->createForm(LoginFormType::class, [ + 'username' => $authenticationUtils->getLastUsername(), + ]); + + return $this->render('admin/login.html.twig', [ + 'loginForm' => $form, + 'error' => $authenticationUtils->getLastAuthenticationError(), + ]); + } + + #[Route('/admin/logout', name: 'admin_logout', methods: ['GET'])] + public function logout(): void + { + throw new LogicException('Logout is handled by the Symfony firewall.'); + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php new file mode 100644 index 0000000..dca6719 --- /dev/null +++ b/src/Controller/Admin/DashboardController.php @@ -0,0 +1,16 @@ +render('admin/dashboard.html.twig'); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index bec4d74..e8bc4b2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -80,6 +80,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[\Deprecated] public function eraseCredentials(): void { } diff --git a/src/Form/LoginFormType.php b/src/Form/LoginFormType.php new file mode 100644 index 0000000..18fcb7c --- /dev/null +++ b/src/Form/LoginFormType.php @@ -0,0 +1,26 @@ +add('username', TextType::class) + ->add('password', PasswordType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_token_id' => 'authenticate', + ]); + } +} diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php new file mode 100644 index 0000000..7f2bf9b --- /dev/null +++ b/src/Security/LoginFormAuthenticator.php @@ -0,0 +1,55 @@ +request->all('login_form'); + $username = trim($data['username'] ?? ''); + + $request->getSession()->set('_security.last_username', $username); + + return new Passport( + new UserBadge($username), + new PasswordCredentials($data['password'] ?? ''), + [ + new CsrfTokenBadge('authenticate', $data['_token'] ?? ''), + ], + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->urlGenerator->generate('admin_dashboard')); + } + + protected function getLoginUrl(Request $request): string + { + return $this->urlGenerator->generate('admin_login'); + } +} diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig new file mode 100644 index 0000000..e74ea46 --- /dev/null +++ b/templates/admin/dashboard.html.twig @@ -0,0 +1,9 @@ +{% extends 'base.html.twig' %} + +{% block title %}Admin{% endblock %} + +{% block body %} +
+

Mappings

+
+{% endblock %} diff --git a/templates/admin/login.html.twig b/templates/admin/login.html.twig new file mode 100644 index 0000000..19a6450 --- /dev/null +++ b/templates/admin/login.html.twig @@ -0,0 +1,19 @@ +{% extends 'base.html.twig' %} + +{% block title %}Admin login{% endblock %} + +{% block body %} +
+

Admin login

+ + {% if error %} +

{{ error.messageKey|trans(error.messageData, 'security') }}

+ {% endif %} + + {{ form_start(loginForm) }} + {{ form_row(loginForm.username) }} + {{ form_row(loginForm.password) }} + + {{ form_end(loginForm) }} +
+{% endblock %} diff --git a/tests/Controller/Admin/AuthControllerTest.php b/tests/Controller/Admin/AuthControllerTest.php new file mode 100644 index 0000000..56e3a0e --- /dev/null +++ b/tests/Controller/Admin/AuthControllerTest.php @@ -0,0 +1,63 @@ +client = self::createClient(); + + $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + $metadata = $this->entityManager->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($this->entityManager); + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->entityManager->close(); + } + + public function testAdminRedirectsToLogin(): void + { + $this->client->request('GET', '/admin'); + + self::assertResponseRedirects('/admin/login'); + } + + public function testAdminCanLogin(): void + { + $container = static::getContainer(); + + $user = (new User())->setUsername('admin'); + $hash = $container->get(UserPasswordHasherInterface::class)->hashPassword($user, 'secret-password'); + $user->setPasswordHash($hash); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $crawler = $this->client->request('GET', '/admin/login'); + $form = $crawler->selectButton('Sign in')->form([ + 'login_form[username]' => 'admin', + 'login_form[password]' => 'secret-password', + ]); + $this->client->submit($form); + + self::assertResponseRedirects('/admin'); + } +}