Skip to content

Commit e825699

Browse files
committed
Additional validation
Add additional validation options for expected: - hostname - apk_package_name - action - score threshold - challenge_ts timeout
1 parent 66a7a2a commit e825699

File tree

2 files changed

+286
-12
lines changed

2 files changed

+286
-12
lines changed

src/ReCaptcha/ReCaptcha.php

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,54 @@ class ReCaptcha
3737
*/
3838
const VERSION = 'php_1.2';
3939

40+
/**
41+
* Invalid JSON received
42+
* @const string
43+
*/
44+
const E_INVALID_JSON = 'invalid-json';
45+
46+
/**
47+
* Not a success, but no error codes received!
48+
* @const string
49+
*/
50+
const E_UNKNOWN_ERROR = 'unknown-error';
51+
52+
/**
53+
* ReCAPTCHA response not provided
54+
* @const string
55+
*/
56+
const E_MISSING_INPUT_RESPONSE = 'missing-input-response';
57+
58+
/**
59+
* Expected hostname did not match
60+
* @const string
61+
*/
62+
const E_HOSTNAME_MISMATCH = 'hostname-mismatch';
63+
64+
/**
65+
* Expected APK package name did not match
66+
* @const string
67+
*/
68+
const E_APK_PACKAGE_NAME_MISMATCH = 'apk_package_name-mismatch';
69+
70+
/**
71+
* Expected action did not match
72+
* @const string
73+
*/
74+
const E_ACTION_MISMATCH = 'action-mismatch';
75+
76+
/**
77+
* Score threshold not met
78+
* @const string
79+
*/
80+
const E_SCORE_THRESHOLD_NOT_MET = 'score-threshold-not-met';
81+
82+
/**
83+
* Challenge timeout
84+
* @const string
85+
*/
86+
const E_CHALLENGE_TIMEOUT = 'challenge-timeout';
87+
4088
/**
4189
* Shared secret for the site.
4290
* @var string
@@ -52,7 +100,7 @@ class ReCaptcha
52100
/**
53101
* Create a configured instance to use the reCAPTCHA service.
54102
*
55-
* @param string $secret shared secret between site and reCAPTCHA server.
103+
* @param string $secret The shared key between your site and reCAPTCHA.
56104
* @param RequestMethod $requestMethod method used to send the request. Defaults to POST.
57105
* @throws \RuntimeException if $secret is invalid
58106
*/
@@ -79,20 +127,135 @@ public function __construct($secret, RequestMethod $requestMethod = null)
79127
* Calls the reCAPTCHA siteverify API to verify whether the user passes
80128
* CAPTCHA test.
81129
*
82-
* @param string $response The value of 'g-recaptcha-response' in the submitted form.
130+
* @param string $response The user response token provided by reCAPTCHA, verifying the user on your site.
83131
* @param string $remoteIp The end user's IP address.
84132
* @return Response Response from the service.
85133
*/
86134
public function verify($response, $remoteIp = null)
87135
{
88136
// Discard empty solution submissions
89137
if (empty($response)) {
90-
$recaptchaResponse = new Response(false, array('missing-input-response'));
138+
$recaptchaResponse = new Response(false, array(self::E_MISSING_INPUT_RESPONSE));
91139
return $recaptchaResponse;
92140
}
93141

94142
$params = new RequestParameters($this->secret, $response, $remoteIp, self::VERSION);
95143
$rawResponse = $this->requestMethod->submit($params);
96144
return Response::fromJson($rawResponse);
97145
}
146+
147+
/**
148+
* Provide a hostname to match against in verifyAndValidate()
149+
* This should be without a protocol or trailing slash, e.g. www.google.com
150+
*
151+
* @param string $hostname Expected hostname
152+
* @return ReCaptcha Current instance for fluent interface
153+
*/
154+
public function setExpectedHostname($hostname)
155+
{
156+
$this->hostname = $hostname;
157+
return $this;
158+
}
159+
160+
/**
161+
* Provide an APK package name to match against in verifyAndValidate()
162+
*
163+
* @param string $apkPackageName Expected APK package name
164+
* @return ReCaptcha Current instance for fluent interface
165+
*/
166+
public function setExpectedApkPackageName($apkPackageName)
167+
{
168+
$this->apkPackageName = $apkPackageName;
169+
return $this;
170+
}
171+
172+
/**
173+
* Provide an action to match against in verifyAndValidate()
174+
* This should be set per page.
175+
*
176+
* @param string $action Expected action
177+
* @return ReCaptcha Current instance for fluent interface
178+
*/
179+
public function setExpectedAction($action)
180+
{
181+
$this->action = $action;
182+
return $this;
183+
}
184+
185+
/**
186+
* Provide a threshold to meet or exceed in verifyAndValidate()
187+
* Threshold should be a float between 0 and 1 which will be tested as response >= threshold.
188+
*
189+
* @param float $threshold Expected threshold
190+
* @return ReCaptcha Current instance for fluent interface
191+
*/
192+
public function setScoreThreshold($threshold)
193+
{
194+
$this->threshold = floatval($threshold);
195+
return $this;
196+
}
197+
198+
/**
199+
* Provide a timeout in seconds to test against the challenge timestamp in verifyAndValidate()
200+
*
201+
* @param int $timeoutSeconds Expected hostname
202+
* @return ReCaptcha Current instance for fluent interface
203+
*/
204+
public function setChallengeTimeout($timeoutSeconds)
205+
{
206+
$this->timeoutSeconds = $timeoutSeconds;
207+
return $this;
208+
}
209+
210+
/**
211+
* Calls the reCAPTCHA siteverify API to verify whether the user passes
212+
* CAPTCHA test and additionally runs any specified additional checks
213+
*
214+
* @param string $response The user response token provided by reCAPTCHA, verifying the user on your site.
215+
* @param string $remoteIp The end user's IP address.
216+
* @return Response Response from the service.
217+
*/
218+
public function verifyAndValidate($response, $remoteIp = null)
219+
{
220+
$initialResponse = $this->verify($response, $remoteIp);
221+
$validationErrors = array();
222+
223+
if (isset($this->hostname) && strcasecmp($this->hostname, $initialResponse->getHostname()) !== 0) {
224+
$validationErrors[] = self::E_HOSTNAME_MISMATCH;
225+
}
226+
227+
if (isset($this->apkPackageName) && strcasecmp($this->apkPackageName, $initialResponse->getApkPackageName()) !== 0) {
228+
$validationErrors[] = self::E_APK_PACKAGE_NAME_MISMATCH;
229+
}
230+
231+
if (isset($this->action) && strcasecmp($this->action, $initialResponse->getAction()) !== 0) {
232+
$validationErrors[] = self::E_ACTION_MISMATCH;
233+
}
234+
235+
if (isset($this->threshold) && $this->threshold > $initialResponse->getScore()) {
236+
$validationErrors[] = self::E_SCORE_THRESHOLD_NOT_MET;
237+
}
238+
239+
if (isset($this->timeoutSeconds)) {
240+
$challengeTs = strtotime($initialResponse->getChallengeTs());
241+
242+
if ($challengeTs > 0 && time() - $challengeTs > $this->timeoutSeconds) {
243+
$validationErrors[] = self::E_CHALLENGE_TIMEOUT;
244+
}
245+
}
246+
247+
if (empty($validationErrors)) {
248+
return $initialResponse;
249+
}
250+
251+
return new Response(
252+
false,
253+
array_merge($initialResponse->getErrorCodes(), $validationErrors),
254+
$initialResponse->getHostname(),
255+
$initialResponse->getChallengeTs(),
256+
$initialResponse->getApkPackageName(),
257+
$initialResponse->getScore(),
258+
$initialResponse->getAction()
259+
);
260+
}
98261
}

tests/ReCaptcha/ReCaptchaTest.php

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,24 +56,135 @@ public function testVerifyReturnsErrorOnMissingResponse()
5656
$rc = new ReCaptcha('secret');
5757
$response = $rc->verify('');
5858
$this->assertFalse($response->isSuccess());
59-
$this->assertEquals(array('missing-input-response'), $response->getErrorCodes());
59+
$this->assertEquals(array(Recaptcha::E_MISSING_INPUT_RESPONSE), $response->getErrorCodes());
6060
}
6161

62-
public function testVerifyReturnsResponse()
62+
private function getMockRequestMethod($responseJson)
6363
{
6464
$method = $this->getMockBuilder(\ReCaptcha\RequestMethod::class)
6565
->disableOriginalConstructor()
6666
->setMethods(array('submit'))
6767
->getMock();
68-
$method->expects($this->once())
69-
->method('submit')
70-
->with($this->callback(function ($params) {
71-
return true;
72-
}))
73-
->will($this->returnValue('{"success": true}'));
74-
;
68+
$method->expects($this->any())
69+
->method('submit')
70+
->with($this->callback(function ($params) {
71+
return true;
72+
}))
73+
->will($this->returnValue($responseJson));
74+
return $method;
75+
}
76+
77+
public function testVerifyReturnsResponse()
78+
{
79+
$method = $this->getMockRequestMethod('{"success": true}');
7580
$rc = new ReCaptcha('secret', $method);
7681
$response = $rc->verify('response');
7782
$this->assertTrue($response->isSuccess());
7883
}
84+
85+
public function testVerifyAndValidateReturnsInitialResponseWithoutAdditionalChecks()
86+
{
87+
$method = $this->getMockRequestMethod('{"success": true}');
88+
$rc = new ReCaptcha('secret', $method);
89+
$initialResponse = $rc->verify('response');
90+
$this->assertEquals($initialResponse, $rc->verifyAndValidate('response'));
91+
}
92+
93+
public function testVerifyAndValidateHostnameMatch()
94+
{
95+
$method = $this->getMockRequestMethod('{"success": true, "hostname": "host.name"}');
96+
$rc = new ReCaptcha('secret', $method);
97+
$response = $rc->setExpectedHostname('host.name')->verifyAndValidate('response');
98+
$this->assertTrue($response->isSuccess());
99+
}
100+
101+
public function testVerifyAndValidateHostnameMisMatch()
102+
{
103+
$method = $this->getMockRequestMethod('{"success": true, "hostname": "host.NOTname"}');
104+
$rc = new ReCaptcha('secret', $method);
105+
$response = $rc->setExpectedHostname('host.name')->verifyAndValidate('response');
106+
$this->assertFalse($response->isSuccess());
107+
$this->assertEquals(array(ReCaptcha::E_HOSTNAME_MISMATCH), $response->getErrorCodes());
108+
}
109+
110+
public function testVerifyAndValidateApkPackageNameMatch()
111+
{
112+
$method = $this->getMockRequestMethod('{"success": true, "apk_package_name": "apk.name"}');
113+
$rc = new ReCaptcha('secret', $method);
114+
$response = $rc->setExpectedApkPackageName('apk.name')->verifyAndValidate('response');
115+
$this->assertTrue($response->isSuccess());
116+
}
117+
118+
public function testVerifyAndValidateApkPackageNameMisMatch()
119+
{
120+
$method = $this->getMockRequestMethod('{"success": true, "apk_package_name": "apk.NOTname"}');
121+
$rc = new ReCaptcha('secret', $method);
122+
$response = $rc->setExpectedApkPackageName('apk.name')->verifyAndValidate('response');
123+
$this->assertFalse($response->isSuccess());
124+
$this->assertEquals(array(ReCaptcha::E_APK_PACKAGE_NAME_MISMATCH), $response->getErrorCodes());
125+
}
126+
127+
public function testVerifyAndValidateActionMatch()
128+
{
129+
$method = $this->getMockRequestMethod('{"success": true, "action": "action/name"}');
130+
$rc = new ReCaptcha('secret', $method);
131+
$response = $rc->setExpectedAction('action/name')->verifyAndValidate('response');
132+
$this->assertTrue($response->isSuccess());
133+
}
134+
135+
public function testVerifyAndValidateActionMisMatch()
136+
{
137+
$method = $this->getMockRequestMethod('{"success": true, "action": "action/NOTname"}');
138+
$rc = new ReCaptcha('secret', $method);
139+
$response = $rc->setExpectedAction('action/name')->verifyAndValidate('response');
140+
$this->assertFalse($response->isSuccess());
141+
$this->assertEquals(array(ReCaptcha::E_ACTION_MISMATCH), $response->getErrorCodes());
142+
}
143+
144+
public function testVerifyAndValidateAboveThreshold()
145+
{
146+
$method = $this->getMockRequestMethod('{"success": true, "score": "0.9"}');
147+
$rc = new ReCaptcha('secret', $method);
148+
$response = $rc->setScoreThreshold('0.5')->verifyAndValidate('response');
149+
$this->assertTrue($response->isSuccess());
150+
}
151+
152+
public function testVerifyAndValidateBelowThreshold()
153+
{
154+
$method = $this->getMockRequestMethod('{"success": true, "score": "0.1"}');
155+
$rc = new ReCaptcha('secret', $method);
156+
$response = $rc->setScoreThreshold('0.5')->verifyAndValidate('response');
157+
$this->assertFalse($response->isSuccess());
158+
$this->assertEquals(array(ReCaptcha::E_SCORE_THRESHOLD_NOT_MET), $response->getErrorCodes());
159+
}
160+
161+
public function testVerifyAndValidateWithinTimeout()
162+
{
163+
// Responses come back like 2018-07-31T13:48:41Z
164+
$challengeTs = date('Y-M-d\TH:i:s\Z', time());
165+
$method = $this->getMockRequestMethod('{"success": true, "challenge_ts": "'.$challengeTs.'"}');
166+
$rc = new ReCaptcha('secret', $method);
167+
$response = $rc->setChallengeTimeout('1000')->verifyAndValidate('response');
168+
$this->assertTrue($response->isSuccess());
169+
}
170+
171+
public function testVerifyAndValidateOverTimeout()
172+
{
173+
// Responses come back like 2018-07-31T13:48:41Z
174+
$challengeTs = date('Y-M-d\TH:i:s\Z', time() - 600);
175+
$method = $this->getMockRequestMethod('{"success": true, "challenge_ts": "'.$challengeTs.'"}');
176+
$rc = new ReCaptcha('secret', $method);
177+
$response = $rc->setChallengeTimeout('60')->verifyAndValidate('response');
178+
$this->assertFalse($response->isSuccess());
179+
$this->assertEquals(array(ReCaptcha::E_CHALLENGE_TIMEOUT), $response->getErrorCodes());
180+
}
181+
182+
public function testVerifyAndValidateMergesErrors()
183+
{
184+
$method = $this->getMockRequestMethod('{"success": false, "error-codes": ["initial-error"], "score": "0.1"}');
185+
$rc = new ReCaptcha('secret', $method);
186+
$response = $rc->setScoreThreshold('0.5')->verifyAndValidate('response');
187+
$this->assertFalse($response->isSuccess());
188+
$this->assertEquals(array('initial-error', ReCaptcha::E_SCORE_THRESHOLD_NOT_MET), $response->getErrorCodes());
189+
}
79190
}

0 commit comments

Comments
 (0)