feat(altcha): add altcha validation layer to login
Some checks failed
Cadoles/hydra-sql/pipeline/head There was a failure building this commit

This commit is contained in:
Gauthier Dupont 2025-03-24 17:20:17 +01:00
parent 1cb5ae6bc3
commit 488ca00a41
9 changed files with 274 additions and 9 deletions

8
.env
View File

@ -41,3 +41,11 @@ LOCK_DSN=flock
SENTRY_DSN=
###< sentry/sentry-symfony ###
REDIS_DSN=redis://redis:6379
### Altcha
ALTCHA_HOST='http://localhost:3333'
ALTCHA_BASE_URL=''
ALTCHA_DEBUG=false
ALTCHA_WORKERS=8
ALTCHA_DELAY=100
ALTCHA_MOCK_ERROR=false

View File

@ -75,3 +75,19 @@ services:
$securityPattern: '%security_pattern%'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Altcha\Form\AltchaType:
arguments:
$altchaHost: "%env(string:ALTCHA_HOST)%"
$altchaBaseUrl: "%env(string:ALTCHA_BASE_URL)%"
$altchaDebug: "%env(bool:ALTCHA_DEBUG)%"
$altchaWorkers: "%env(string:ALTCHA_WORKERS)%"
$altchaDelay: "%env(string:ALTCHA_DELAY)%"
$altchaMockError: "%env(bool:ALTCHA_MOCK_ERROR)%"
tags:
- { name: form.type, alias: altcha}
App\Altcha\AltchaValidator:
arguments:
$altchaHost: "%env(string:ALTCHA_HOST)%"
$altchaBaseUrl: "%env(string:ALTCHA_BASE_URL)%"

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\AltchaValidator;
use App\Altcha\AltchaTransformer;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
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

@ -2,12 +2,13 @@
namespace App\Form;
use App\Altcha\Form\AltchaType;
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;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
class LoginType extends AbstractType
{
@ -27,6 +28,10 @@ class LoginType extends AbstractType
'translation_domain' => 'form',
'label' => 'form.label.remember_me',
])
->add('altcha', AltchaType::class, [
'label' => false,
'required' => true,
])
;
}

View File

@ -9,19 +9,17 @@ 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

View File

@ -0,0 +1,18 @@
{% block altcha_widget %}
<style>
.altcha label {
margin-bottom: 0;
}
</style>
<altcha-widget
challengejson={{challengeJson}}
name={{form.vars.full_name}}
strings="{{translations}}"
hidelogo
hidefooter
workers= {{ workers }}
delay={{ delay }}
{{ debug ? 'debug' : ''}}
{{ mockError ? 'mockerror' : ''}}
></altcha-widget>
{% endblock %}