feat(altcha): add altcha validation layer to login
Some checks are pending
Cadoles/hydra-sql/pipeline/pr-develop Build started...

This commit is contained in:
2025-03-24 17:20:17 +01:00
parent 1cb5ae6bc3
commit 12523398f6
51 changed files with 2417 additions and 1137 deletions

View File

@ -0,0 +1,48 @@
<?php
namespace App\Altcha;
use App\Altcha\Form\AltchaModel;
use Symfony\Component\Form\DataTransformerInterface;
class AltchaTransformer implements DataTransformerInterface
{
/**
* {@inheritDoc}
*/
public function reverseTransform($value): AltchaModel
{
if (empty($value)) {
return new AltchaModel();
}
$decodedValue = base64_decode($value);
$data = json_decode($decodedValue);
$model = new AltchaModel();
foreach ($data as $property => $value) {
$model->{$property} = $value;
}
return $model;
}
/**
* {@inheritDoc}
*/
public function transform($value): string
{
if (empty($value)) {
return '';
}
$json = json_encode($value);
if (false === $json) {
return '';
}
return base64_encode($json);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Altcha;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class AltchaValidator
{
public function __construct(
private readonly string $altchaHost,
private readonly string $altchaBaseUrl,
private readonly HttpClientInterface $httpClient,
private readonly TranslatorInterface $translator
) {
}
public function validate(FormEvent $formEvent): void
{
$form = $formEvent->getForm();
$data = $form->getData();
$response = $this->httpClient->request(
'POST',
$this->altchaHost.$this->altchaBaseUrl.'/verify',
[
'body' => json_encode($data),
'headers' => [
'Content-Type' => 'application/json',
],
],
);
if (Response::HTTP_OK !== $response->getStatusCode()) {
$form->addError(new FormError($this->translator->trans('altcha.validator.server_validation_error', [], 'form')));
return;
}
$content = $response->getContent();
$parsedResponse = json_decode($content);
if (true !== $parsedResponse->success) {
$form->addError(new FormError($this->translator->trans('altcha.validator.server_validation_error', [], 'form')));
return;
}
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Altcha\Form;
use Symfony\Component\Validator\Constraints as Assert;
class AltchaModel
{
/**
* @Assert\NotBlank()
* @Assert\Regex("/^(SHA-1|SHA-256|SHA-512)$/")
*/
public string $algorithm;
/**
* @Assert\NotBlank()
*/
public string $challenge;
/**
* @Assert\NotBlank()
*/
public string $salt;
/**
* @Assert\NotBlank()
*/
public int $number;
/**
* @Assert\NotBlank()
*/
public string $signature;
/**
* @Assert\NotBlank()
*/
public int $took;
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Altcha\Form;
use App\Altcha\AltchaTransformer;
use App\Altcha\AltchaValidator;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class AltchaType extends AbstractType
{
public function __construct(
private readonly string $altchaHost,
private readonly string $altchaBaseUrl,
private readonly string $altchaDebug,
private readonly string $altchaWorkers,
private readonly string $altchaDelay,
private readonly string $altchaMockError,
private readonly AltchaValidator $altchaValidator,
private readonly HttpClientInterface $httpClient,
private readonly TranslatorInterface $translator
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new AltchaTransformer());
$builder->addEventListener(FormEvents::POST_SUBMIT, [$this->altchaValidator, 'validate']);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$translations = [
'label' => $this->translator->trans('altcha.widget.label', [], 'form'),
'verified' => $this->translator->trans('altcha.widget.verified', [], 'form'),
'verifying' => $this->translator->trans('altcha.widget.verifying', [], 'form'),
'waitAlert' => $this->translator->trans('altcha.widget.waitalert', [], 'form'),
'error' => $this->translator->trans('altcha.widget.error', [], 'form'),
'expired' => $this->translator->trans('altcha.widget.expired', [], 'form'),
];
$view->vars['translations'] = json_encode($translations);
$view->vars['challengeJson'] = $this->requestChallenge();
$view->vars['debug'] = $this->altchaDebug;
$view->vars['workers'] = $this->altchaWorkers;
$view->vars['delay'] = $this->altchaDelay;
$view->vars['mockError'] = $this->altchaMockError;
}
private function requestChallenge(): string
{
$resp = $this->httpClient->request('GET', $this->altchaHost.$this->altchaBaseUrl.'/request');
if (Response::HTTP_OK === $resp->getStatusCode()) {
return $resp->getContent();
}
return '';
}
public function getParent(): string
{
return TextType::class;
}
public function getName(): string
{
return $this->getBlockPrefix();
}
public function getBlockPrefix(): string
{
return 'altcha';
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Flag\Controller;
use App\Flag\FlagAccessor;
use App\Flag\FlagEnum;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Exception\JsonException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class FlagController extends AbstractController
{
#[Route('/flag/{flagName}', name: 'flag_update', methods: ['PUT'])]
public function updateFlag(CacheItemPoolInterface $cache, Request $request, string $flagName): Response
{
try {
FlagEnum::from($flagName);
$flagValue = \json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR)[FlagAccessor::FLAG_VALUE];
} catch (\ValueError $e) {
throw new \InvalidArgumentException('invalid flag name provided');
} catch (JsonException $e) {
throw new \InvalidArgumentException('invalid json format');
}
$flag = $cache->getItem($flagName);
$flag->set($flagValue);
$cache->save($flag);
return new JsonResponse(
[\sprintf('flag %s has been %s.', $flagName, $flagValue ? 'enabled' : 'disabled')]
);
}
}

26
src/Flag/FlagAccessor.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Flag;
use Psr\Cache\CacheItemPoolInterface;
class FlagAccessor
{
public const FLAG_VALUE = 'flagValue';
public function __construct(
private readonly CacheItemPoolInterface $cache
) {
}
public function isFlagEnabled(FlagEnum $flagName, bool $fallbackValue = false): bool
{
$flagValue = $this->cache->getItem($flagName->value)->get();
if (null === $flagValue) {
return $fallbackValue;
}
return (bool) $flagValue;
}
}

8
src/Flag/FlagEnum.php Normal file
View File

@ -0,0 +1,8 @@
<?php
namespace App\Flag;
enum FlagEnum: string
{
case Altcha = 'altcha';
}

View File

@ -2,8 +2,10 @@
namespace App\Form;
use App\Altcha\Form\AltchaType;
use App\Flag\FlagAccessor;
use App\Flag\FlagEnum;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@ -11,6 +13,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class LoginType extends AbstractType
{
public function __construct(
private readonly FlagAccessor $flagAccessor,
private readonly bool $altchaEnabled
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
@ -22,12 +30,15 @@ class LoginType extends AbstractType
'translation_domain' => 'form',
'label' => 'form.label.password',
])
->add('_remember_me', CheckboxType::class, [
'required' => false,
'translation_domain' => 'form',
'label' => 'form.label.remember_me',
])
;
if ($this->flagAccessor->isFlagEnabled(FlagEnum::Altcha, $this->altchaEnabled)) {
$builder->add('altcha', AltchaType::class, [
'translation_domain' => 'form',
'label' => 'altcha.widget.title',
'required' => true,
]);
}
}
public function configureOptions(OptionsResolver $resolver): void

View File

@ -3,25 +3,22 @@
namespace App\Hydra;
use App\Hydra\Exception\InvalidChallengeException;
use Exception;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class Client
{
private HttpClientInterface $client;
private const MAX_RETRY = 3;
private const SLEEP_TIME = [
5,
500,
5000,
];
private string $hydraAdminBaseUrl;
public function __construct(HttpClientInterface $client, string $hydraAdminBaseUrl)
{
$this->client = $client;
$this->hydraAdminBaseUrl = $hydraAdminBaseUrl;
public function __construct(
private readonly HttpClientInterface $client,
private readonly string $hydraAdminBaseUrl
) {
}
public function fetchLoginRequestInfo(string $loginChallenge): ResponseInterface
@ -91,7 +88,7 @@ class Client
break;
}
if (self::MAX_RETRY === $attempt) {
throw new Exception(sprintf('Fetch consent a rencontré une erreur %s après %s tentatives', $response->getStatusCode(), self::MAX_RETRY));
throw new \Exception(sprintf('Fetch consent a rencontré une erreur %s après %s tentatives', $response->getStatusCode(), self::MAX_RETRY));
}
return $response;

View File

@ -6,4 +6,4 @@ use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class InvalidIssuerException extends BadRequestException
{
}
}

View File

@ -44,7 +44,7 @@ class HydraService extends AbstractController
// si le challenge est validé par hydra, on le stocke en session pour l'utiliser par la suite et on redirige vers une route interne protégée qui va déclencher l'identification FranceConnect
$this->session->set('challenge', $loginRequestInfo['challenge']);
return new RedirectResponse($this->baseUrl . '/connect/login-accept');
return new RedirectResponse($this->baseUrl.'/connect/login-accept');
}
public function handleConsentRequest(Request $request): RedirectResponse