1
0
Fork 0

feat: add admin authentication

This commit is contained in:
thibaud-leclere 2026-05-05 09:56:49 +02:00
parent 02ce67049e
commit c39a24a4f2
10 changed files with 284 additions and 9 deletions

View file

@ -5,7 +5,10 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers: providers:
users_in_memory: { memory: null } app_user_provider:
entity:
class: App\Entity\User
property: username
firewalls: firewalls:
dev: dev:
@ -14,17 +17,16 @@ security:
security: false security: false
main: main:
lazy: true lazy: true
provider: users_in_memory provider: app_user_provider
custom_authenticator: App\Security\LoginFormAuthenticator
# Activate different ways to authenticate: logout:
# https://symfony.com/doc/current/security.html#the-firewall path: admin_logout
target: admin_login
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Note: Only the *first* matching rule is applied # Note: Only the *first* matching rule is applied
access_control: access_control:
# - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER }
when@test: when@test:

View 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;
}
}

View 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.');
}
}

View 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');
}
}

View file

@ -80,6 +80,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
#[\Deprecated]
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
} }

View 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',
]);
}
}

View 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');
}
}

View file

@ -0,0 +1,9 @@
{% extends 'base.html.twig' %}
{% block title %}Admin{% endblock %}
{% block body %}
<main>
<h1>Mappings</h1>
</main>
{% endblock %}

View 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 %}

View 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');
}
}