diff --git a/.env b/.env index 9926976..661df45 100644 --- a/.env +++ b/.env @@ -16,7 +16,7 @@ ###> symfony/framework-bundle ### APP_ENV=dev -APP_SECRET= +APP_SECRET=1235dsd51sdkjbsd3531dsckjsedlkknds448552se ###< symfony/framework-bundle ### ###> doctrine/doctrine-bundle ### @@ -26,18 +26,18 @@ APP_SECRET= # 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=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 ### ###> symfony/messenger ### # Choose one of the transports below # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/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/mailer ### -MAILER_DSN=null://null +MAILER_DSN='smtp://mailhog:1025' ###< symfony/mailer ### BASE_URL='http://localhost:8080' HYDRA_ADMIN_BASE_URL='http://hydra:4445' diff --git a/Dockerfile b/Dockerfile index 113be66..a74be25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,12 @@ RUN apt-get update && apt-get install -y \ 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) RUN install-php-extensions \ + sockets \ zip \ intl \ - opcache + opcache \ + openssl \ + pdo_pgsql # Définit le répertoire de travail WORKDIR /app diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7e1ee1f..4e95cb6 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -4,6 +4,7 @@ framework: # Note that the session will be started ONLY if you read or write from it. session: true + trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-prefix'] #esi: true #fragments: true diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d9..d4e1ff7 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,6 +1,7 @@ twig: file_name_pattern: '*.twig' - + form_themes: + - "bootstrap_5_layout.html.twig" when@test: twig: strict_variables: true diff --git a/config/services.yaml b/config/services.yaml index 6bbad87..049db8f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,7 +4,7 @@ # 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 parameters: - + app.2fa_remember_duration: 'P30D' services: # default configuration for services in *this* file _defaults: diff --git a/src/Controller/MainController.php b/src/Controller/MainController.php index f1d428f..a3cce21 100644 --- a/src/Controller/MainController.php +++ b/src/Controller/MainController.php @@ -2,16 +2,84 @@ 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\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Annotation\Route; 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')] public function home(Request $request): Response { return new Response('

Hello world

'); } + #[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 + ]); + } } diff --git a/src/Form/CodeType.php b/src/Form/CodeType.php new file mode 100644 index 0000000..c75e535 --- /dev/null +++ b/src/Form/CodeType.php @@ -0,0 +1,47 @@ +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'; + } + +} \ No newline at end of file diff --git a/src/Service/CodeService.php b/src/Service/CodeService.php new file mode 100644 index 0000000..b799640 --- /dev/null +++ b/src/Service/CodeService.php @@ -0,0 +1,36 @@ +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; + } +} \ No newline at end of file diff --git a/src/Service/CookieService.php b/src/Service/CookieService.php new file mode 100644 index 0000000..9bd1141 --- /dev/null +++ b/src/Service/CookieService.php @@ -0,0 +1,69 @@ +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') + ; + } +} \ No newline at end of file diff --git a/src/Service/MailerService.php b/src/Service/MailerService.php new file mode 100644 index 0000000..aa46272 --- /dev/null +++ b/src/Service/MailerService.php @@ -0,0 +1,35 @@ +from('me@me.fr') + ->to($identifier) + + ->subject('Votre code de confirmation') + ->text('Voici votre code : '.$code['code']) + ->html('

Voici votre code : '.$code['code'].'

'); + + $this->mailer->send($email); + } +} \ No newline at end of file diff --git a/src/Validator/Code.php b/src/Validator/Code.php new file mode 100644 index 0000000..5fa1e34 --- /dev/null +++ b/src/Validator/Code.php @@ -0,0 +1,10 @@ +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(); + } + } +} \ No newline at end of file diff --git a/templates/base.html.twig b/templates/base.html.twig index 3cda30f..2c70eee 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -10,8 +10,30 @@ {% block javascripts %} {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} + - {% block body %}{% endblock %} + {% block body %} + {% form_theme form 'bootstrap_5_layout.html.twig' %} + +
+ {{ form_start(form) }} + {{ form_errors(form.code) }} + {{ form_row(form.code) }} + {{form_end(form)}} +
+ {% endblock %}