Skip to content

Commit

Permalink
Password: protect user password fields with recaptcha after 3 failed …
Browse files Browse the repository at this point in the history
…attempts
  • Loading branch information
glaubinix committed Mar 6, 2024
1 parent e6b10a7 commit 0cfed09
Show file tree
Hide file tree
Showing 16 changed files with 479 additions and 46 deletions.
2 changes: 2 additions & 0 deletions src/Form/ChangePasswordFormType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
namespace App\Form;

use App\Entity\User;
use App\Form\Type\InvisibleRecaptchaType;
use App\Validator\Password;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
Expand Down Expand Up @@ -46,6 +47,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
new Password(),
],
])
->add('captcha', InvisibleRecaptchaType::class)
;
}

Expand Down
52 changes: 52 additions & 0 deletions src/Form/EventSubscriber/FormBruteForceSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Form\EventSubscriber;

use App\Validator\RateLimitingRecaptcha;
use Beelab\Recaptcha2Bundle\Validator\Constraints\Recaptcha2;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Validator\ConstraintViolation;

/**
* In case we encounter a brute force error e.g. missing/invalid recaptcha, remove all other form errors to not accidentally leak any information.
*/
class FormBruteForceSubscriber implements EventSubscriberInterface
{
public function onPostSubmit(FormEvent $event): void
{
$form = $event->getForm();

if ($form->isRoot() && $form instanceof Form) {
foreach ($form->getErrors(true) as $error) {
$recaptchaMessage = (new Recaptcha2)->message;
$cause = $error->getCause();
if (
$cause instanceof ConstraintViolation && (
$cause->getCode() === RateLimitingRecaptcha::INVALID_RECAPTCHA_ERROR ||
$error->getMessage() === $recaptchaMessage
)) {
$form->clearErrors(true);
$error->getOrigin()?->addError($error);
}
}
}
}

public static function getSubscribedEvents(): array
{
return [FormEvents::POST_SUBMIT => ['onPostSubmit', -2048]];
}
}
47 changes: 47 additions & 0 deletions src/Form/EventSubscriber/FormInvalidPasswordSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Form\EventSubscriber;

use App\Security\RecaptchaHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
use Symfony\Component\Validator\ConstraintViolation;

class FormInvalidPasswordSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly RecaptchaHelper $recaptchaHelper,
) {}

public function onPostSubmit(FormEvent $event): void
{
$form = $event->getForm();

if ($form->isRoot()) {
foreach ($form->getErrors(true) as $error) {
$cause = $error->getCause();
if ($cause instanceof ConstraintViolation && $cause->getCode() === UserPassword::INVALID_PASSWORD_ERROR) {
$context = $this->recaptchaHelper->buildContext();
$this->recaptchaHelper->increaseCounter($context);
}
}
}
}

public static function getSubscribedEvents(): array
{
return [FormEvents::POST_SUBMIT => ['onPostSubmit', -1024]];
}
}
38 changes: 38 additions & 0 deletions src/Form/Extension/RecaptchaExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Form\Extension;

use App\Form\EventSubscriber\FormBruteForceSubscriber;
use App\Form\EventSubscriber\FormInvalidPasswordSubscriber;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;

class RecaptchaExtension extends AbstractTypeExtension
{
public function __construct(
private readonly FormInvalidPasswordSubscriber $formInvalidPasswordSubscriber,
private readonly FormBruteForceSubscriber $formBruteForceSubscriber,
) {}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber($this->formInvalidPasswordSubscriber);
$builder->addEventSubscriber($this->formBruteForceSubscriber);
}

public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
}
35 changes: 35 additions & 0 deletions src/Form/Type/InvisibleRecaptchaType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Form\Type;

use App\Validator\RateLimitingRecaptcha;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver;

class InvisibleRecaptchaType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
'mapped' => false,
'constraints' => new RateLimitingRecaptcha(),
]);
}

public function getParent(): string
{
return TextType::class;
}
}
4 changes: 3 additions & 1 deletion src/Form/Type/ProfileFormType.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
}
});

$builder->add('failureNotifications', null, ['required' => false, 'label' => 'Notify me of package update failures']);
$builder
->add('failureNotifications', null, ['required' => false, 'label' => 'Notify me of package update failures'])
->add('captcha', InvisibleRecaptchaType::class);
}

public function configureOptions(OptionsResolver $resolver): void
Expand Down
9 changes: 5 additions & 4 deletions src/Security/BruteForceLoginFormAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use App\Entity\User;
use App\Util\DoctrineTrait;
use App\Validator\RateLimitingRecaptcha;
use Beelab\Recaptcha2Bundle\Recaptcha\RecaptchaException;
use Beelab\Recaptcha2Bundle\Recaptcha\RecaptchaVerifier;
use Doctrine\Persistence\ManagerRegistry;
Expand Down Expand Up @@ -89,7 +90,7 @@ public function createToken(Passport $passport, string $firewallName): TokenInte

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
$this->recaptchaHelper->clearCounter($request);
$this->recaptchaHelper->clearCounter(RecaptchaContext::fromRequest($request));

if ($token->getUser() instanceof User) {
$token->getUser()->setLastLogin(new \DateTimeImmutable());
Expand All @@ -106,7 +107,7 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token,

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
$this->recaptchaHelper->increaseCounter($request);
$this->recaptchaHelper->increaseCounter(RecaptchaContext::fromRequest($request));

$request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception);

Expand Down Expand Up @@ -145,15 +146,15 @@ private function getCredentials(Request $request): array
*/
private function validateRecaptcha(array $credentials): void
{
if ($this->recaptchaHelper->requiresRecaptcha($credentials['ip'] ?? '', $credentials['username'])) {
if ($this->recaptchaHelper->requiresRecaptcha(new RecaptchaContext($credentials['ip'] ?? '', $credentials['username'], $credentials['recaptcha']))) {
if (!$credentials['recaptcha']) {
throw new CustomUserMessageAuthenticationException('We detected too many failed login attempts. Please log in again with ReCaptcha.');
}

try {
$this->recaptchaVerifier->verify($credentials['recaptcha']);
} catch (RecaptchaException $e) {
throw new CustomUserMessageAuthenticationException('Invalid ReCaptcha');
throw new CustomUserMessageAuthenticationException(RateLimitingRecaptcha::INVALID_RECAPTCHA_MESSAGE);
}
}
}
Expand Down
47 changes: 47 additions & 0 deletions src/Security/RecaptchaContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare(strict_types=1);

/*
* This file is part of Packagist.
*
* (c) Jordi Boggiano <[email protected]>
* Nils Adermann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;

class RecaptchaContext
{
private const LOGIN_BASE_KEY_IP = 'bf:login:ip:';
private const LOGIN_BASE_KEY_USER = 'bf:login:user:';

public function __construct(
public readonly string $ip,
public readonly string $username,
public readonly ?string $recaptcha,
) {}

/**
* @return string[]
*/
public function getRedisKeys(bool $forClear = false): array
{
return array_filter([
! $forClear && $this->ip ? self::LOGIN_BASE_KEY_IP . $this->ip : null,
$this->username ? self::LOGIN_BASE_KEY_USER . strtolower($this->username) : null,
]);
}

public static function fromRequest(Request $request): self
{
return new self(
$request->getClientIp() ?: '',
(string) $request->request->get('_username'),
(string) $request->request->get('g-recaptcha-response'),
);
}
}
56 changes: 32 additions & 24 deletions src/Security/RecaptchaHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,41 @@

namespace App\Security;

use App\Entity\User;
use Predis\Client;
use Predis\Profile\RedisProfile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class RecaptchaHelper
{
private const LOGIN_BASE_KEY_IP = 'bf:login:ip:';
private const LOGIN_BASE_KEY_USER = 'bf:login:user:';

private Client $redisCache;
private bool $recaptchaEnabled;

public function __construct(Client $redisCache, bool $recaptchaEnabled)
public function __construct(
private readonly Client $redisCache,
private readonly bool $recaptchaEnabled,
private readonly RequestStack $requestStack,
private readonly TokenStorageInterface $tokenStorage,
private readonly AuthenticationUtils $authenticationUtils,
){}

public function buildContext(): RecaptchaContext
{
$this->redisCache = $redisCache;
$this->recaptchaEnabled = $recaptchaEnabled;
return new RecaptchaContext(
$this->requestStack->getMainRequest()?->getClientIp() ?: '',
$this->getCurrentUsername(),
(string) $this->requestStack->getMainRequest()?->request->get('g-recaptcha-response'),
);
}

public function requiresRecaptcha(string $ip, string $username): bool
public function requiresRecaptcha(RecaptchaContext $context): bool
{
if (!$this->recaptchaEnabled) {
return false;
}

$keys = [self::LOGIN_BASE_KEY_IP . $ip];
if ($username) {
$keys[] = self::LOGIN_BASE_KEY_USER . strtolower($username);
$keys = $context->getRedisKeys();
if (!$keys) {
return false;
}

$result = $this->redisCache->mget($keys);
Expand All @@ -51,32 +59,32 @@ public function requiresRecaptcha(string $ip, string $username): bool
return false;
}

public function increaseCounter(Request $request): void
public function increaseCounter(RecaptchaContext $context): void
{
if (!$this->recaptchaEnabled) {
return;
}

$ipKey = self::LOGIN_BASE_KEY_IP . $request->getClientIp();
$userKey = $this->getUserKey($request);
/** @phpstan-ignore-next-line */
$this->redisCache->incrFailedLoginCounter($ipKey, $userKey);
$this->redisCache->incrFailedLoginCounter(...$context->getRedisKeys());
}

public function clearCounter(Request $request): void
public function clearCounter(RecaptchaContext $context): void
{
if (!$this->recaptchaEnabled) {
return;
}

$userKey = $this->getUserKey($request);
$this->redisCache->del([$userKey]);
$this->redisCache->del($context->getRedisKeys(true));
}

private function getUserKey(Request $request): string
private function getCurrentUsername(): string
{
$username = (string) $request->request->get('_username');
$user = $this->tokenStorage->getToken()?->getUser();
if ($user instanceof User) {
return $user->getUsername();
}

return self::LOGIN_BASE_KEY_USER . strtolower($username);
return $this->authenticationUtils->getLastUsername();
}
}
Loading

0 comments on commit 0cfed09

Please sign in to comment.