Skip to content

Commit 12a2855

Browse files
authored
Merge pull request #3 from karser/little-issues-fix
little issues fixes
2 parents 8f669df + 3d09e9f commit 12a2855

File tree

10 files changed

+277
-13
lines changed

10 files changed

+277
-13
lines changed

Form/Recaptcha3Type.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
use Symfony\Component\Form\FormView;
99
use Symfony\Component\OptionsResolver\OptionsResolver;
1010

11-
class Recaptcha3Type extends AbstractType
11+
final class Recaptcha3Type extends AbstractType
1212
{
1313
/** @var string */
1414
private $siteKey;
@@ -43,7 +43,6 @@ public function configureOptions(OptionsResolver $resolver): void
4343
{
4444
$resolver->setDefaults([
4545
'mapped' => false,
46-
'enabled' => true,
4746
'site_key' => null,
4847
'action_name' => 'homepage',
4948
]);

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,80 @@ grecaptcha.ready(function() {
148148
</script>
149149
```
150150

151+
### How to deal with functional and e2e testing:
152+
153+
Recaptcha won't allow you to test your app efficiently unless you disable it for the environment you are testing against.
154+
155+
```yaml
156+
# app/config/config.yml (or config/packages/karser_recaptcha3.yaml if using Symfony4)
157+
karser_recaptcha3:
158+
enabled: '%env(RECAPTCHA3_ENABLED)%'
159+
```
160+
161+
```bash
162+
#.env.test or a stage server environment
163+
RECAPTCHA3_ENABLED=0
164+
```
165+
166+
### How to add Cloudflare IP resolver:
167+
168+
From the [Cloudflare docs](https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers-):
169+
To provide the client (visitor) IP address for every request to the origin, Cloudflare adds the CF-Connecting-IP header.
170+
```
171+
"CF-Connecting-IP: A.B.C.D"
172+
```
173+
174+
So you can implement custom IP resolver which attempts to read the `CF-Connecting-IP` header or fallbacks with the internal IP resolver:
175+
176+
```php
177+
<?php declare(strict_types=1);
178+
179+
namespace App\Service;
180+
181+
use Karser\Recaptcha3Bundle\Services\IpResolverInterface;
182+
use Symfony\Component\HttpFoundation\RequestStack;
183+
184+
class CloudflareIpResolver implements IpResolverInterface
185+
{
186+
/** @var IpResolverInterface */
187+
private $decorated;
188+
189+
/** @var RequestStack */
190+
private $requestStack;
191+
192+
public function __construct(IpResolverInterface $decorated, RequestStack $requestStack)
193+
{
194+
$this->decorated = $decorated;
195+
$this->requestStack = $requestStack;
196+
}
197+
198+
public function resolveIp(): ?string
199+
{
200+
return $this->doResolveIp() ?? $this->decorated->resolveIp();
201+
}
202+
203+
private function doResolveIp(): ?string
204+
{
205+
$request = $this->requestStack->getCurrentRequest();
206+
if ($request === null) {
207+
return null;
208+
}
209+
return $request->server->get('HTTP_CF_CONNECTING_IP');
210+
}
211+
}
212+
```
213+
214+
Here is the service declaration. It decorates the internal resolver:
215+
```yaml
216+
#services.yaml
217+
services:
218+
App\Service\CloudflareIpResolver:
219+
decorates: 'karser_recaptcha3.ip_resolver'
220+
arguments:
221+
$decorated: '@App\Service\CloudflareIpResolver.inner'
222+
$requestStack: '@request_stack'
223+
```
224+
151225
Testing
152226
-------
153227

Resources/config/services.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ services:
1414
arguments:
1515
- '@karser_recaptcha3.google.recaptcha'
1616
- '%karser_recaptcha3.enabled%'
17-
- '@request_stack'
17+
- '@karser_recaptcha3.ip_resolver'
1818
tags:
1919
- { name: validator.constraint_validator, alias: karser_recaptcha3_validator }
2020

21+
karser_recaptcha3.ip_resolver:
22+
class: Karser\Recaptcha3Bundle\Services\IpResolver
23+
public: false
24+
arguments:
25+
- '@request_stack'
26+
2127
karser_recaptcha3.google.recaptcha:
2228
class: 'ReCaptcha\ReCaptcha'
2329
arguments:

Services/IpResolver.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Karser\Recaptcha3Bundle\Services;
4+
5+
use Symfony\Component\HttpFoundation\RequestStack;
6+
7+
final class IpResolver implements IpResolverInterface
8+
{
9+
/** @var RequestStack */
10+
private $requestStack;
11+
12+
public function __construct(RequestStack $requestStack)
13+
{
14+
$this->requestStack = $requestStack;
15+
}
16+
17+
public function resolveIp(): ?string
18+
{
19+
$request = $this->requestStack->getCurrentRequest();
20+
if ($request === null) {
21+
return null;
22+
}
23+
return $request->getClientIp();
24+
}
25+
}

Services/IpResolverInterface.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Karser\Recaptcha3Bundle\Services;
4+
5+
interface IpResolverInterface
6+
{
7+
public function resolveIp(): ?string;
8+
}

Tests/Form/Recaptcha3TypeTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Karser\Recaptcha3Bundle\Tests\Form;
4+
5+
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
6+
use Symfony\Component\Form\PreloadedExtension;
7+
use Symfony\Component\Form\Test\TypeTestCase;
8+
9+
class Recaptcha3TypeTest extends TypeTestCase
10+
{
11+
const SITEKEY = '<sitekey>';
12+
13+
protected function getExtensions()
14+
{
15+
$type = new Recaptcha3Type(self::SITEKEY, $enabled = true);
16+
17+
return [
18+
new PreloadedExtension([$type], []),
19+
];
20+
}
21+
22+
public function testDefaultOptions()
23+
{
24+
$data = '<captcha-token>';
25+
26+
$form = $this->factory->create(Recaptcha3Type::class);
27+
$form->setData($data);
28+
29+
$this->assertTrue($form->isSynchronized());
30+
$this->assertEquals($data, $form->getData());
31+
32+
$view = $form->createView();
33+
$this->assertSame(self::SITEKEY, $view->vars['site_key']);
34+
$this->assertSame('homepage', $view->vars['action_name']);
35+
$this->assertTrue($view->vars['enabled']);
36+
}
37+
}

Tests/Services/IpResolverTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Karser\Recaptcha3Bundle\Tests\Services;
4+
5+
use Karser\Recaptcha3Bundle\Services\IpResolver;
6+
use PHPUnit\Framework\TestCase;
7+
use Symfony\Component\HttpFoundation\Request;
8+
use Symfony\Component\HttpFoundation\RequestStack;
9+
10+
class IpResolverTest extends TestCase
11+
{
12+
public function testEmptyRequest()
13+
{
14+
$stack = new RequestStack();
15+
$stack->push(new Request());
16+
$resolver = new IpResolver($stack);
17+
self::assertNull($resolver->resolveIp());
18+
}
19+
20+
public function testRequest()
21+
{
22+
$stack = new RequestStack();
23+
$stack->push(new Request([], [], [], [], [], ['REMOTE_ADDR' => '0.0.0.0']));
24+
$resolver = new IpResolver($stack);
25+
self::assertSame('0.0.0.0', $resolver->resolveIp());
26+
}
27+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Karser\Recaptcha3Bundle\Tests\Validator\Constraints;
4+
5+
use Karser\Recaptcha3Bundle\Services\IpResolverInterface;
6+
use Karser\Recaptcha3Bundle\Tests\fixtures\RecaptchaMock;
7+
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
8+
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3Validator;
9+
use PHPUnit\Framework\MockObject\MockObject;
10+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
11+
12+
class Recaptcha3ValidatorTest extends ConstraintValidatorTestCase
13+
{
14+
/** @var IpResolverInterface|MockObject */
15+
private $resolver;
16+
/** @var RecaptchaMock */
17+
private $recaptcha;
18+
19+
public function setUp()
20+
{
21+
$this->resolver = $this->getMockBuilder(IpResolverInterface::class)->getMock();
22+
parent::setUp();
23+
}
24+
25+
protected function createValidator()
26+
{
27+
$this->recaptcha = new RecaptchaMock();
28+
return new Recaptcha3Validator($this->recaptcha, $enabled = true, $this->resolver);
29+
}
30+
31+
public function testNullIsValid()
32+
{
33+
$this->validator->validate(null, new Recaptcha3());
34+
$this->assertNoViolation();
35+
}
36+
37+
public function testEmptyStringIsValid()
38+
{
39+
$this->validator->validate('', new Recaptcha3());
40+
$this->assertNoViolation();
41+
}
42+
43+
public function testValidIfNotEnabled()
44+
{
45+
$validator = new Recaptcha3Validator($this->recaptcha, $enabled = false, $this->resolver);
46+
$this->recaptcha->nextSuccess = false;
47+
48+
$validator->validate('test', new Recaptcha3());
49+
$this->assertNoViolation();
50+
}
51+
52+
/**
53+
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
54+
*/
55+
public function testExpectsStringCompatibleType()
56+
{
57+
$this->validator->validate(new \stdClass(), new Recaptcha3());
58+
}
59+
60+
public function testValidCase()
61+
{
62+
$this->recaptcha->nextSuccess = true;
63+
$this->validator->validate('test', new Recaptcha3());
64+
$this->assertNoViolation();
65+
}
66+
67+
public function testInvalidCase()
68+
{
69+
$testToken = 'test-token';
70+
$this->recaptcha->nextSuccess = false;
71+
$this->validator->validate($testToken, new Recaptcha3(['message' => 'myMessage']));
72+
73+
$this->buildViolation('myMessage')
74+
->setParameter('{{ value }}', '"'.$testToken.'"')
75+
->setCode(Recaptcha3::INVALID_FORMAT_ERROR)
76+
->assertRaised();
77+
}
78+
}

Validator/Constraints/Recaptcha3.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
/**
88
* @Annotation
99
*/
10-
class Recaptcha3 extends Constraint
10+
final class Recaptcha3 extends Constraint
1111
{
12+
const INVALID_FORMAT_ERROR = '7147ffdb-0af4-4f7a-bd5e-e9dcfa6d7a2d';
13+
1214
public $message = 'Your computer or network may be sending automated queries';
1315
}

Validator/Constraints/Recaptcha3Validator.php

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,54 @@
22

33
namespace Karser\Recaptcha3Bundle\Validator\Constraints;
44

5+
use Karser\Recaptcha3Bundle\Services\IpResolverInterface;
56
use ReCaptcha\ReCaptcha;
6-
use Symfony\Component\HttpFoundation\RequestStack;
77
use Symfony\Component\Validator\Constraint;
88
use Symfony\Component\Validator\ConstraintValidator;
99
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
1010

11-
class Recaptcha3Validator extends ConstraintValidator
11+
final class Recaptcha3Validator extends ConstraintValidator
1212
{
1313
/** @var ReCaptcha */
1414
private $recaptcha;
1515

1616
/** @var bool */
1717
private $enabled;
1818

19-
/** @var RequestStack */
20-
private $requestStack;
19+
/** @var IpResolverInterface */
20+
private $ipResolver;
2121

22-
public function __construct($recaptcha, bool $enabled, RequestStack $requestStack)
22+
public function __construct($recaptcha, bool $enabled, IpResolverInterface $ipResolver)
2323
{
2424
$this->recaptcha = $recaptcha;
2525
$this->enabled = $enabled;
26-
$this->requestStack = $requestStack;
26+
$this->ipResolver = $ipResolver;
2727
}
2828

2929
public function validate($value, Constraint $constraint): void
3030
{
3131
if (!$constraint instanceof Recaptcha3) {
3232
throw new UnexpectedTypeException($constraint, Recaptcha3::class);
3333
}
34+
if (null === $value || '' === $value) {
35+
return;
36+
}
37+
if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
38+
throw new UnexpectedTypeException($value, 'string');
39+
}
3440

3541
if (!$this->enabled) {
3642
return;
3743
}
3844

39-
$request = $this->requestStack->getCurrentRequest();
40-
$ip = $request ? $request->server->get('HTTP_CF_CONNECTING_IP') ?? $request->getClientIp() : null;
45+
$ip = $this->ipResolver->resolveIp();
4146

4247
$response = $this->recaptcha->verify($value, $ip);
4348
if (!$response->isSuccess()) {
44-
$this->context->addViolation($constraint->message);
49+
$this->context->buildViolation($constraint->message)
50+
->setParameter('{{ value }}', $this->formatValue($value))
51+
->setCode(Recaptcha3::INVALID_FORMAT_ERROR)
52+
->addViolation();
4553
}
4654
}
4755
}

0 commit comments

Comments
 (0)