feat: add admin authentication
This commit is contained in:
parent
02ce67049e
commit
c39a24a4f2
10 changed files with 284 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
48
src/Command/CreateAdminUserCommand.php
Normal file
48
src/Command/CreateAdminUserCommand.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
#[AsCommand(name: 'app:admin:create', description: 'Create or update an admin user.')]
|
||||
final class CreateAdminUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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;
|
||||
}
|
||||
}
|
||||
36
src/Controller/Admin/AuthController.php
Normal file
36
src/Controller/Admin/AuthController.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\LoginFormType;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
|
||||
final class AuthController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
|
||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||
{
|
||||
if ($this->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.');
|
||||
}
|
||||
}
|
||||
16
src/Controller/Admin/DashboardController.php
Normal file
16
src/Controller/Admin/DashboardController.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class DashboardController extends AbstractController
|
||||
{
|
||||
#[Route('/admin', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function __invoke(): Response
|
||||
{
|
||||
return $this->render('admin/dashboard.html.twig');
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
#[\Deprecated]
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
}
|
||||
|
|
|
|||
26
src/Form/LoginFormType.php
Normal file
26
src/Form/LoginFormType.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class LoginFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('username', TextType::class)
|
||||
->add('password', PasswordType::class);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'csrf_token_id' => 'authenticate',
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Security/LoginFormAuthenticator.php
Normal file
55
src/Security/LoginFormAuthenticator.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?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\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
/** @var array{username?: string, password?: string, _token?: string} $data */
|
||||
$data = $request->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');
|
||||
}
|
||||
}
|
||||
9
templates/admin/dashboard.html.twig
Normal file
9
templates/admin/dashboard.html.twig
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Admin{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main>
|
||||
<h1>Mappings</h1>
|
||||
</main>
|
||||
{% endblock %}
|
||||
19
templates/admin/login.html.twig
Normal file
19
templates/admin/login.html.twig
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Admin login{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main>
|
||||
<h1>Admin login</h1>
|
||||
|
||||
{% if error %}
|
||||
<p>{{ error.messageKey|trans(error.messageData, 'security') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{{ form_start(loginForm) }}
|
||||
{{ form_row(loginForm.username) }}
|
||||
{{ form_row(loginForm.password) }}
|
||||
<button type="submit">Sign in</button>
|
||||
{{ form_end(loginForm) }}
|
||||
</main>
|
||||
{% endblock %}
|
||||
63
tests/Controller/Admin/AuthControllerTest.php
Normal file
63
tests/Controller/Admin/AuthControllerTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
final class AuthControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue