feat(altcha): add altcha validation layer to login
Some checks are pending
Cadoles/hydra-sql/pipeline/pr-develop Build started...
Some checks are pending
Cadoles/hydra-sql/pipeline/pr-develop Build started...
This commit is contained in:
48
src/Altcha/AltchaTransformer.php
Normal file
48
src/Altcha/AltchaTransformer.php
Normal 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);
|
||||
}
|
||||
}
|
52
src/Altcha/AltchaValidator.php
Normal file
52
src/Altcha/AltchaValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
39
src/Altcha/Form/AltchaModel.php
Normal file
39
src/Altcha/Form/AltchaModel.php
Normal 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;
|
||||
}
|
81
src/Altcha/Form/AltchaType.php
Normal file
81
src/Altcha/Form/AltchaType.php
Normal 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';
|
||||
}
|
||||
}
|
37
src/Flag/Controller/FlagController.php
Normal file
37
src/Flag/Controller/FlagController.php
Normal 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
26
src/Flag/FlagAccessor.php
Normal 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
8
src/Flag/FlagEnum.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Flag;
|
||||
|
||||
enum FlagEnum: string
|
||||
{
|
||||
case Altcha = 'altcha';
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -6,4 +6,4 @@ use Symfony\Component\HttpFoundation\Exception\BadRequestException;
|
||||
|
||||
class InvalidIssuerException extends BadRequestException
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user