implémentation formulaire, cookie

This commit is contained in:
2025-09-19 11:28:56 +02:00
parent 6ee139ab5f
commit bcf31c3fbb
13 changed files with 348 additions and 8 deletions

8
.env
View File

@@ -16,7 +16,7 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
APP_ENV=dev APP_ENV=dev
APP_SECRET= APP_SECRET=1235dsd51sdkjbsd3531dsckjsedlkknds448552se
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
@@ -26,18 +26,18 @@ APP_SECRET=
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.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=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="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" DATABASE_URL="postgres://lasql:lasql@postgres:5432/lasql?sslmode=disable"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/messenger ### ###> symfony/messenger ###
# Choose one of the transports below # Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=1
###< symfony/messenger ### ###< symfony/messenger ###
###> symfony/mailer ### ###> symfony/mailer ###
MAILER_DSN=null://null MAILER_DSN='smtp://mailhog:1025'
###< symfony/mailer ### ###< symfony/mailer ###
BASE_URL='http://localhost:8080' BASE_URL='http://localhost:8080'
HYDRA_ADMIN_BASE_URL='http://hydra:4445' HYDRA_ADMIN_BASE_URL='http://hydra:4445'

View File

@@ -8,9 +8,12 @@ RUN apt-get update && apt-get install -y \
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Installe les extensions PHP nécessaires pour Symfony (pdo_mysql par exemple, si tu utilises MySQL) # Installe les extensions PHP nécessaires pour Symfony (pdo_mysql par exemple, si tu utilises MySQL)
RUN install-php-extensions \ RUN install-php-extensions \
sockets \
zip \ zip \
intl \ intl \
opcache opcache \
openssl \
pdo_pgsql
# Définit le répertoire de travail # Définit le répertoire de travail
WORKDIR /app WORKDIR /app

View File

@@ -4,6 +4,7 @@ framework:
# Note that the session will be started ONLY if you read or write from it. # Note that the session will be started ONLY if you read or write from it.
session: true session: true
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-prefix']
#esi: true #esi: true
#fragments: true #fragments: true

View File

@@ -1,6 +1,7 @@
twig: twig:
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
form_themes:
- "bootstrap_5_layout.html.twig"
when@test: when@test:
twig: twig:
strict_variables: true strict_variables: true

View File

@@ -4,7 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
app.2fa_remember_duration: 'P30D'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
_defaults: _defaults:

View File

@@ -2,16 +2,84 @@
namespace App\Controller; namespace App\Controller;
use App\Form\CodeType;
use App\Hydra\Client;
use App\Service\CodeService;
use App\Service\CookieService;
use App\Service\DeviceService;
use App\Service\MailerService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
class MainController extends AbstractController class MainController extends AbstractController
{ {
public function __construct(
private readonly CodeService $codeService,
private readonly Client $client,
private readonly CookieService $cookieService,
private readonly MailerService $mailer
){
}
#[Route('/', name: 'app_home')] #[Route('/', name: 'app_home')]
public function home(Request $request): Response public function home(Request $request): Response
{ {
return new Response('<h1>Hello world</h1>'); return new Response('<h1>Hello world</h1>');
} }
#[Route('/2fa', name: 'app_2fa')]
public function doubleFacteur(Request $request): Response
{
$loginChallenge = $request->query->get('loginchallenge');
$identifier = $request->query->get('identifier');
$res = $this->client->fetchLoginRequestInfo($loginChallenge);
$loginRequestInfo = $res->toArray();
if (200 !== $res->getStatusCode()) {
throw new BadRequestException();
}
if($this->cookieService->isValid($request, $identifier)){
$loginAcceptRes = $this->client->acceptLoginRequest($loginChallenge, [
'subject' => $identifier,
'remember' => true,
])->toArray();
return new RedirectResponse($loginAcceptRes['redirect_to']);
}
$code = $this->codeService->generateCode();
$this->mailer->send($code, $identifier);
$form = $this->createForm(CodeType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()){
$cookie = null;
if($form->get('remember_device')){
$cookie = $this->cookieService->createCookie($identifier);
}
$loginAcceptRes = $this->client->acceptLoginRequest($loginChallenge, [
'subject' => $identifier,
'remember' => true,
])->toArray();
dump($loginAcceptRes);
$response = new RedirectResponse($loginAcceptRes['redirect_to']);
null !== $cookie ?: $response->headers->setCookie($cookie);
return $response;
}
return $this->render('base.html.twig', [
'form'=>$form
]);
}
} }

47
src/Form/CodeType.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Form;
use App\Validator\Code;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class CodeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('code', TextType::class, [
'label' => 'Entrez le code reçu par mail',
'required' => true,
'mapped' => false,
'constraints' => [
new NotBlank(['message' => 'Le code ne peut pas être vide.']),
new Code(),
],
])
->add('remember_device', CheckboxType::class, [
'label'=>('Se souvenir de cet ordinateur'),
'required'=> false,
'mapped'=>false,
])
->add('submit', SubmitType::class, ['label' => 'Valider']);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => null,
]);
}
public function getBlockPrefix(): string
{
return 'code';
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Service;
use Symfony\Component\HttpFoundation\RequestStack;
class CodeService
{
private const int VALIDITY_TIME = 1800;
public function __construct(
private readonly RequestStack $requestStack
){
}
public function generateCode(): array
{
$code = $this->requestStack->getSession()->get('code2fa');
if($code){
$createdAt = $code['createdAt'] ?? null;
$maxTime = $createdAt + self::VALIDITY_TIME;
if ($maxTime < time()) {
return $code;
}
}
$code = [
'code' => '0005',
'createdAt' => time(),
];
$this->requestStack->getSession()->set('code2fa', $code);
return $code;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Service;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
class CookieService
{
public function __construct(
private readonly ParameterBagInterface $params
){}
private const COOKIE_2FA = 'user_info-2fa';
public function isValid(Request $request, string $login): bool
{
$cookieValue = $request->cookies->get(self::COOKIE_2FA);
if(!$cookieValue){
return false;
}
[$encodedData, $signature] = explode('.', $cookieValue);
$dataJson = base64_decode($encodedData);
$secret = $this->params->get('kernel.secret');
if (hash_hmac('sha256', $dataJson, $secret) !== $signature) {
return false; // Signature invalide
}
$data = json_decode($dataJson, true);
if (!$data || $data['login'] !== $login) {
return false; // Login non correspondant
}
// Recalculer la validité avec la durée paramétrable actuelle
$duration = new \DateInterval($this->params->get('app.2fa_remember_duration')); // ex. P30D
$expirationTime = $data['created_at'] + $duration->format('%s'); // Convertir en secondes
if (time() > $expirationTime) {
return false; // Expiré selon la durée actuelle
}
return true;
}
public function generateSignedRememberCookie(string $login): string
{
$data = json_encode([
'login' => $login,
'created_at' => time(),
]);
$secret = $this->params->get('kernel.secret'); // Utilise framework.secret ou une clé dédiée
$signature = hash_hmac('sha256', $data, $secret);
return base64_encode($data) . '.' . $signature; // Base64 pour éviter les caractères spéciaux
}
public function createCookie(string $identifier): Cookie
{
$cookieValue = $this->generateSignedRememberCookie($identifier);
$duration = new \DateInterval($this->params->get('app.2fa_remember_duration'));
$expiresAt = (new \DateTime())->add($duration)->getTimestamp();
return Cookie::create('remember_2fa')
->withValue($cookieValue)
->withExpires($expiresAt) // 1 an max (mais validité dynamique)
->withSecure($this->params->get('kernel.environment') === 'prod')
->withHttpOnly(true)
->withSameSite('Lax')
->withPath('/hydra-otp')
;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Service;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class MailerService
{
private const int VALIDITY_TIME = 1800;
public function __construct(
private readonly MailerInterface $mailer
){}
public function send(array $code, $identifier): void
{
$createdAt = $code['createdAt'] ?? null;
$maxTime = $createdAt + self::VALIDITY_TIME;
if ($maxTime < time()) {
return;
}
$email = (new Email())
->from('me@me.fr')
->to($identifier)
->subject('Votre code de confirmation')
->text('Voici votre code : '.$code['code'])
->html('<p>Voici votre code : '.$code['code'].'</p>');
$this->mailer->send($email);
}
}

10
src/Validator/Code.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
class Code extends Constraint
{
public string $message = 'Le code est invalide.';
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Validator;
use Symfony\Component\HttpFoundation\File\Exception\UnexpectedTypeException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class CodeValidator extends ConstraintValidator
{
private const int VALIDITY_TIME = 1800;
public function __construct(
readonly RequestStack $requestStack
){}
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof Code) {
throw new UnexpectedTypeException($constraint, Code::class);
}
// Récupérer la session
$session = $this->requestStack->getSession();
$codeDatas = $session->get('code2fa');
// Vérifier si les données de session existent
if (!$codeDatas || !isset($codeDatas['code'], $codeDatas['createdAt'])) {
$this->context->buildViolation('Aucun code de validation trouvé. Veuillez demander un nouveau code.')
->addViolation();
return;
}
// Vérifier l'expiration
$createdAt = $codeDatas['createdAt'];
$maxTime = $createdAt + self::VALIDITY_TIME;
if ($maxTime < time()) {
$this->context->buildViolation('Le code a expiré.')
->addViolation();
return; // Arrêter ici pour éviter une double erreur
}
// Comparer les codes (égalité non stricte pour éviter les problèmes de typage)
if ($value != $codeDatas['code']) {
$this->context->buildViolation('Le code est incorrect.')
->addViolation();
}
}
}

View File

@@ -10,8 +10,30 @@
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
<style>
body {
display: flex;
justify-content: center; /* centre horizontalement */
align-items: center; /* centre verticalement */
min-height: 100vh; /* prend toute la hauteur de la fenêtre */
margin: 0; /* évite les marges par défaut */
}
div {
text-align: center; /* centre le contenu dans le bloc */
}
</style>
</head> </head>
<body> <body>
{% block body %}{% endblock %} {% block body %}
{% form_theme form 'bootstrap_5_layout.html.twig' %}
<div syle="">
{{ form_start(form) }}
{{ form_errors(form.code) }}
{{ form_row(form.code) }}
{{form_end(form)}}
</div>
{% endblock %}
</body> </body>
</html> </html>