From 15e92108012096bfbb0079d7df57006ba9756f5b Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Fri, 29 May 2020 00:28:57 -0400 Subject: [PATCH 1/5] Version 3.0 --- .github/workflows/php.yml | 4 +- README.md | 495 +++++++----------- composer.json | 39 +- config/captchavel.php | 71 ++- phpunit.xml.dist | 4 - resources/views/script.blade.php | 60 --- src/Captchavel.php | 166 ++++++ src/CaptchavelFake.php | 79 +++ src/CaptchavelServiceProvider.php | 119 +---- src/Events/ReCaptchaResponseReceived.php | 35 ++ src/Exceptions/CaptchavelException.php | 7 - src/Exceptions/FailedRecaptchaException.php | 21 - .../InvalidCaptchavelMiddlewareMethod.php | 15 - src/Exceptions/InvalidRecaptchaException.php | 32 -- .../RecaptchaNotResolvedException.php | 15 - src/Facades/Captchavel.php | 35 ++ src/Facades/ReCaptcha.php | 29 - .../Middleware/BaseReCaptchaMiddleware.php | 176 +++++++ src/Http/Middleware/CheckRecaptcha.php | 139 ----- src/Http/Middleware/InjectRecaptchaScript.php | 96 ---- src/Http/Middleware/TransparentRecaptcha.php | 49 -- src/Http/Middleware/VerifyReCaptchaV2.php | 44 ++ src/Http/Middleware/VerifyReCaptchaV3.php | 86 +++ src/Http/ReCaptchaResponse.php | 132 +++++ src/ReCaptcha.php | 128 ----- src/helpers.php | 24 +- tests/CaptchavelFakeTest.php | 61 +++ tests/CaptchavelTest.php | 193 +++++++ tests/ExtendsRequestMethodTest.php | 60 --- tests/HelperTest.php | 48 ++ .../Middleware/ChallengeMiddlewareTest.php | 349 ++++++++++++ tests/Http/Middleware/ScoreMiddlewareTest.php | 481 +++++++++++++++++ .../Middleware/UsesRoutesWithMiddleware.php | 58 ++ tests/Http/ReCaptchaResponseTest.php | 23 + tests/Middleware/CheckRecaptchaTest.php | 167 ------ .../Middleware/InjectRecaptchaScriptTest.php | 112 ---- tests/Middleware/ThrottleRecaptchaTest.php | 167 ------ tests/Middleware/TransparentRecaptchaTest.php | 66 --- tests/RecaptchaResponseHolderTest.php | 123 ----- tests/RegistersPackage.php | 19 + tests/ServiceProviderTest.php | 178 ------- 41 files changed, 2263 insertions(+), 1942 deletions(-) delete mode 100644 resources/views/script.blade.php create mode 100644 src/Captchavel.php create mode 100644 src/CaptchavelFake.php create mode 100644 src/Events/ReCaptchaResponseReceived.php delete mode 100644 src/Exceptions/CaptchavelException.php delete mode 100644 src/Exceptions/FailedRecaptchaException.php delete mode 100644 src/Exceptions/InvalidCaptchavelMiddlewareMethod.php delete mode 100644 src/Exceptions/InvalidRecaptchaException.php delete mode 100644 src/Exceptions/RecaptchaNotResolvedException.php create mode 100644 src/Facades/Captchavel.php delete mode 100644 src/Facades/ReCaptcha.php create mode 100644 src/Http/Middleware/BaseReCaptchaMiddleware.php delete mode 100644 src/Http/Middleware/CheckRecaptcha.php delete mode 100644 src/Http/Middleware/InjectRecaptchaScript.php delete mode 100644 src/Http/Middleware/TransparentRecaptcha.php create mode 100644 src/Http/Middleware/VerifyReCaptchaV2.php create mode 100644 src/Http/Middleware/VerifyReCaptchaV3.php create mode 100644 src/Http/ReCaptchaResponse.php delete mode 100644 src/ReCaptcha.php create mode 100644 tests/CaptchavelFakeTest.php create mode 100644 tests/CaptchavelTest.php delete mode 100644 tests/ExtendsRequestMethodTest.php create mode 100644 tests/HelperTest.php create mode 100644 tests/Http/Middleware/ChallengeMiddlewareTest.php create mode 100644 tests/Http/Middleware/ScoreMiddlewareTest.php create mode 100644 tests/Http/Middleware/UsesRoutesWithMiddleware.php create mode 100644 tests/Http/ReCaptchaResponseTest.php delete mode 100644 tests/Middleware/CheckRecaptchaTest.php delete mode 100644 tests/Middleware/InjectRecaptchaScriptTest.php delete mode 100644 tests/Middleware/ThrottleRecaptchaTest.php delete mode 100644 tests/Middleware/TransparentRecaptchaTest.php delete mode 100644 tests/RecaptchaResponseHolderTest.php create mode 100644 tests/RegistersPackage.php delete mode 100644 tests/ServiceProviderTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 373ece8..a6da838 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -12,13 +12,11 @@ jobs: fail-fast: true matrix: php: [7.4, 7.3, 7.2.15] - laravel: [7.*, 6.*] + laravel: [7.*] dependency-version: [prefer-lowest, prefer-stable] include: - laravel: 7.* testbench: 5.* - - laravel: 6.* - testbench: ^4.1 name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} diff --git a/README.md b/README.md index 5282457..90edd4b 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,27 @@ # Captchavel -Easily integrate Google reCAPTCHA v3 into your Laravel application. +Integrate reCAPTCHA into your Laravel app better than the Big G itself! -> This is totally compatible with reCAPTCHA v2, so you can use both. Check [this GitHub comment](https://github.com/google/recaptcha/issues/279#issuecomment-445529732) about the caveats. +It uses your Laravel HTTP Client and **HTTP/2**, making your app **fast**. You only need a couple of lines to integrate. + +## Table of Contents + +* [Requirements](#requirements) +* [Installation](#installation) +* [Set Up](#set-up) +* [Usage](#usage) + - [Checkbox, Invisible and Android challenges](#checkbox-invisible-and-android-challenges) + - [Score driven interaction](#score-driven-interaction) +* [Frontend integration](#frontend-integration) +* [Advanced configuration](#advanced-configuration) +* [Testing with Captchavel](#testing-with-captchavel) +* [Security](#security) +* [License](#license) ## Requirements -* Laravel 6 or Laravel 7 +* Laravel 7.x ## Installation @@ -22,213 +36,143 @@ You can install the package via composer: composer require darkghosthunter/captchavel ``` -## Usage +## Set up -The first thing you need is to add the `RECAPTCHA_V3_KEY` and `RECAPTCHA_V3_SECRET` environment variables in your `.env` file with your reCAPTCHA Site Key and Secret Key, respectively. If you don't have them, you should go to your [Google reCAPTCHA Admin console](https://g.co/recaptcha/admin) and create them for your application. +Add the reCAPTCHA keys for your site to the environment file of your project. You can add each of them for reCAPTCHA v2 **checkbox**, **invisible**, **Android**, and **v3** (score). -```dotenv -RECAPTCHA_V3_KEY=JmXJEeOqjHkr9LXEzgjuKsAhV84RH--DvRJo5mXl -RECAPTCHA_V3_SECRET=JmXJEeOqjHkr9WjDR4rjuON1MGxqCxdOA4zDTH0w -``` +If you don't have one, generate it in your [reCAPTCHA Admin panel](https://www.google.com/recaptcha/admin/). -Captchavel by default works on `auto` mode, allowing you minimal configuration in the backend and frontend. Let's start with the latter. - -### Frontend - -Just add the `data-recaptcha="true"` attribute to the forms where you want to have the reCAPTCHA check. A JavaScript will be injected in all your responses that will detect these forms an add a reCAPTCHA token to them so they can be checked in the backend. +```dotenv +RECAPTCHA_V2_CHECKBOX_SECRET=6t5geA1UAAAAAN... +RECAPTCHA_V2_CHECKBOX_KEY=6t5geA1UAAAAAN... -```blade -
- @csrf - - - -
-``` +RECAPTCHA_V2_INVISIBLE_SECRET=6t5geA2UAAAAAN... +RECAPTCHA_V2_INVISIBLE_KEY=6t5geA2UAAAAAN... -The Google reCAPTCHA script from Google will be automatically injected on all responses for better analytics. +RECAPTCHA_V2_ANDROID_SECRET=6t5geA3UAAAAAN... +RECAPTCHA_V2_ANDROID_KEY=6t5geA3UAAAAAN... -> Alternatively, you may want to use the [`manual` mode](#manual) if you want control on how to deal with the frontend reCAPTCHA script, or use a [personalized one](#editing-the-script-view). +RECAPTCHA_V3_SECRET=6t5geA4UAAAAAN... +RECAPTCHA_V3_KEY=6t5geA4UAAAAAN... +``` -#### Form submission prevented +This allows you to check different reCAPTCHA mechanisms using the same application, in different environments. -Form submission is disabled by default until the token from reCAPTCHA is retrieved. If you want to disable this behaviour, append `data-recaptcha-dont-prevent` to the form: +> Captchavel already comes with v2 keys for local development. For v3, you will need to create your own set of credentials. -```blade - -
- -
-``` +## Usage -#### Token resolved helper +After you integrate reCAPTCHA into your frontend or Android app, set the Captchavel middleware in the routes you want: -When the reCAPTCHA token is being retrieved for the form, the form will have the property `recaptcha_unresolved` set to `true`. You can use this property for your other scripts to conditionally allow submission or whatever. +* `recaptcha.v2` for Checkbox, Invisible and Android challenges. +* `recaptcha.v3` for Score driven interaction. -```javascript -if (form.recaptcha_unresolved) { - alert('Wait until reCAPTCHA sends the token!'); -} else { - form.submit(); -} -``` +### Checkbox, Invisible and Android challenges -### Backend +Add the `recaptcha.v2` middleware to your `POST` routes. The middleware will catch the `g-recaptcha-response` input and check if it's valid. -After that, you should add the `recaptcha` middleware inside your controllers that will receive input and you want to *protect* with the reCAPTCHA check. +* `recaptcha.v2:checkbox` for explicitly rendered checkbox challenges. +* `recaptcha.v2:invisible` for invisible challenges. +* `recaptcha.v2:android` for Android app challenges. -You can use the `isHuman()` and `isRobot()` methods in the Request instance to check if the request was made by a human or a robot, respectively. +When the validation fails, the user will be redirected back to the form route, or a JSON response will be returned with the validation errors. ```php middleware('recaptcha.v2:checkbox'); +``` -namespace App\Http\Controllers; +> You can change the input name from `g-recaptcha-response` to a custom using a second parameter, like `recaptcha.v2:checkbox,_recaptcha`. -use Illuminate\Http\Request; +### Score driven interaction -class CustomController extends Controller -{ - /** - * Create a new CustomController instance - * - * @return void - */ - public function __construct() - { - $this->middleware('recaptcha')->only('form'); - } - - /** - * Receive the HTTP POST Request - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function form(Request $request) - { - $request->validate([ - 'username' => 'required|string|exists:users,username' - ]); - - if ($request->isRobot()) { - return response()->view('web.user.pending_approval'); - } - - return response()->view('web.user.success'); - - } -} -``` +The reCAPTCHA v3 middleware works differently from v2. This is a score-driven challenge where robots will get lower scores than humans, with a default threshold of `0.5`. -Since it's a middleware, you can alternatively set it inside your route declaration: +Simply add the `recaptcha.v3` middleware to your route: ```php -uses('CustomController@form')->middleware('recaptcha'); +Route::post('comment', 'CommentController@store') + ->middleware('recaptcha.v3:0.8'); ``` -> The `recaptcha` middleware only works on `POST/PUT/PATCH/DELETE` methods, so don't worry if you use it in a `GET` method. You will receive a nice `InvalidMethodException` so you can use it correctly. - -### Accessing the reCAPTCHA response - -You can access the reCAPTCHA response in four ways: - -* using [dependency injection](https://laravel.com/docs/container#automatic-injection), -* using the `ReCaptcha` facade anywhere in your code, -* the `recaptcha()` helper, -* and resolving it from the Service Container with `app('recaptha')`. - -These methods will return the reCAPTCHA Response from the servers, with useful helpers so you don't have to dig in the raw response: +Once the challenge has been received, you will have access to two methods from the Request instance: `isHuman()` and `isRobot()`, which return `true` or `false`: ```php -middleware('recaptcha')->only('form'); - } + $request->validate([ + 'body' => 'required|string|max:255' + ]); - /** - * Receive the HTTP POST Request - * - * @param \Illuminate\Http\Request $request - * @param \DarkGhostHunter\Captchavel\ReCaptcha $reCaptcha - * @return \Illuminate\Http\Response - * @throws \DarkGhostHunter\Captchavel\Exceptions\RecaptchaNotResolvedException - */ - public function form(Request $request, ReCaptcha $reCaptcha) - { - $request->validate([ - 'username' => 'required|string|exists:users,username' - ]); - - if ($reCaptcha->isRobot()) { - return response()->view('web.user.you-are-a-robot'); - } - - return response()->view('web.user.success'); - } + $comment = $post->comment()->make($request->only('body')); + + // Flag the comment as "moderated" if it was a written by robot. + $comment->moderated = $request->isRobot(); + + $comment->save(); - // ... + return view('post.comment.show', ['comment' => $comment]); } ``` -The class has handy methods you can use to check the status of the reCAPTCHA information: +#### Threshold, action and input name -* `isResolved()`: Returns if the reCAPTCHA check was made in the current Request -* `isHuman()`: Detects if the Request has been made by a Human (equal or above threshold). -* `isRobot()`: Detects if the Request has been made by a Robot (below threshold). -* `since()`: Returns the time the reCAPTCHA challenge was resolved as a Carbon timestamp. +The middleware accepts two parameters in the following order: -> If you try to check if the response while the reCAPTCHA wasn't resolved, you will get a `RecaptchaNotResolvedException`. +* Threshold: Values **above or equal** are considered human. +* Action: The action name to optionally check against. +* Input: The name of the reCAPTCHA input to verify. -## Local development and robot requests +```php +middleware('recaptcha.v3:0.7,login,custom-recaptcha-input'); +``` -When developing, this package registers a transparent middleware that allows you to work on your application without contacting reCAPTCHA servers ever. Instead, it will always generate a successful "dummy" response with a `1.0` score. +> When checking the action name, ensure your Frontend action matches -You can override the score to an absolute `0.0` in two ways: +#### Faking robot and human scores -* appending the `is_robot` key to the Request query, +You can easily fake a reCAPTCHA v3 response in your local development by setting `CAPTCHAVEL_FAKE` to `true`. -```http request -POST http://myapp.com/login?is_robot +```dotenv +CAPTCHAVEL_FAKE=true ``` -* or adding a checkbox with the name `is_robot` checked. +Then, you can fake a low-score response by adding an `is_robot` a checkbox, respectively. -```html -
- - - - - - +```blade + + + + + +
``` -If you want to connect to the reCAPTCHA servers on `local` environment, you can set the `CAPTCHAVEL_LOCAL=true` in your `.env` file. +## Frontend integration + +[Check the official reCAPTCHA documentation](https://developers.google.com/recaptcha/intro) to integrate the reCAPTCHA script in your frontend, or inside your Android application. + +You can use the `captchavel()` helper to output the site key depending on the challenge version you want to render: `checkbox`, `invisible`, `android` or `score` (v3). + +```blade +
+ + + + +
+
+``` -> The transparent middleware also registers itself on testing environment, so you can test your application using requests made by a robot and made by a human just adding an empty `_recaptcha` input. Sweet! +> You can also retrieve the key using `android` for Android apps. -## Configuration +## Advanced configuration -For finner configuration, publish the configuration file for Captchavel: +Captchavel is intended to work out-of-the-box, but you can publish the configuration file for fine-tuning and additional reCAPTCHA verification. ```bash php artisan vendor:publish --provider="DarkGhostHunter\Captchavel\CaptchavelServiceProvider" @@ -240,215 +184,148 @@ You will get a config file with this array: env('CAPTCHAVEL_MODE', 'auto'), - 'enable_local' => env('CAPTCHAVEL_LOCAL', false), - 'key' => env('RECAPTCHA_V3_KEY'), - 'secret' => env('RECAPTCHA_V3_SECRET'), + 'enable' => env('CAPTCHAVEL_ENABLE', false), + 'fake' => env('CAPTCHAVEL_FAKE', false), + 'hostname' => env('RECAPTCHA_HOSTNAME'), + 'apk_package_name' => env('RECAPTCHA_APK_PACKAGE_NAME'), 'threshold' => 0.5, - 'request_method' => null, + 'credentials' => [ + // ... + ] ]; -``` - -### Mode +``` -Captchavel works painlessly once installed. You can modify the behaviour with just changing the `CAPTCHAVEL_MODE` to `auto` or `manual`, since the config file just picks up the environment file values. +### Enable Switch ```dotenv -CAPTCHAVEL_MODE=auto +CAPTCHAVEL_ENABLE=true ``` -#### `auto` - -The `auto` option leverages the frontend work from you. Just add the `data-recaptcha="true"` attribute to the forms where you want to check for reCAPTCHA. - -```blade -
- @csrf - - - -
-``` +The main switch to enable or disable Captchavel middleware. This can be handy to enable on some local environments to check real interaction using the included localhost test keys. -Captchavel will inject the Google reCAPTCHA v3 as a deferred script before `` tag, in every response (except JSON, AJAX or anything non-HTML), so it can have more analytics about how users interact with your site. +When switched off, the `g-recaptcha-response` won't be validated in the Request input, so you can safely disregard any frontend script or reCAPTCHA tokens or boxes. -To override the script that gets injected, take a look in the [editing the script view](#editing-the-script-view) section. +### Fake responses -#### `manual` +```dotenv +CAPTCHAVEL_FAKE=true +``` -This will disable the global middleware that injects the Google reCAPTCHA script in your frontend. You should check out the [Google reCAPTCHA documentation](https://developers.google.com/recaptcha/docs/v3) on how to implement it yourself. +Setting this to true will allow your application to [fake v3-score responses from reCAPTCHA servers](#faking-robot-and-human-scores). -Since the frontend won't have nothing injected, this mode it gives you freedom to: +> This is automatically set to `true` when [running unit tests](#testing-with-captchavel). -* manually include the `recaptcha-inject` middleware only in the routes you want, -* or include the `recaptcha::script` blade template in your layouts you want. +### Hostname and APK Package Name -> The manual mode is very handy if your responses have a lot of data and want better performance, because the middleware won't look into the responses. +```dotenv +RECAPTCHA_HOSTNAME=myapp.com +RECAPTCHA_APK_PACKAGE_NAME=my.package.name +``` -### Enable on Local Environment +If you are not verifying the Hostname or APK Package Name in your [reCAPTCHA Admin Panel](https://www.google.com/recaptcha/admin/), you will have to issue the strings in the environment file. -By default, this package is transparent on `local` and `testing` environments, so you can develop without requiring to use reCAPTCHA anywhere. +When the reCAPTCHA response from the servers is retrieved, it will be checked against these values. In case of mismatch, a validation exception will be thrown. -For troubleshooting, you can forcefully enable Captchavel setting `enable_local` to `true`, or better, using your environment `.env` file and setting `CAPTCHAVEL_LOCAL` to `true`. +### Threshold ```php - env('CAPTCHAVEL_LOCAL', false), + 'threshold' => 0.4 ]; ``` -### Key and Secret - -These parameters are self-explanatory. One is the reCAPTCHA Site Key, which is shown publicly in your views, and the Secret, which is used to recover the user interaction information privately inside your application. +Default threshold to check against reCAPTCHA v3 challenges. Values **equal or above** will be considered as human. -If you don't have them, use the [Google reCAPTCHA Admin console](https://g.co/recaptcha/admin) to create a pair. +If you're not using reCAPTCHA v3, or you're fine with the default, leave this alone. -### Threshold - -Google reCAPTCHA v3 returns a *score* for interactions. Lower scores means the Request has been probably made by a robot, while high scores mean a more human-like interaction. - -By default, this package uses a score of 0.5, which is considered *sane* in most of cases, but you can override it using the`CAPTCHAVEL_THRESHOLD` key with float values between 0.1 and 1.0. - -```dotenv -CAPTCHAVEL_THRESHOLD=0.7 -``` - -Aside from that, you can also override the score using a parameter within the `recaptha` middleware, which will take precedence over the default score (set or not). For example, you can set it lower for comments, but higher for product reviews. +### Credentials ```php - [ + // ... + ] +]; +``` -Route::post('{product}/comments') - ->uses('Product/CommentController@create') - ->middleware('recaptcha:0.3'); +Here is the full array of [reCAPTCHA credentials](#set-up) to use depending on the version. Do not change the array unless you know what you're doing. -Route::post('{product}/review') - ->uses('Product/ReviewController@create') - ->middleware('recaptcha:0.8'); -``` +## Testing with Captchavel -### Request Method +When testing your application, you may want to mock the reCAPTCHA responses from the servers. There is no need to the whole client, you can use the `fake()` method of the `Captchavel` facade. -The Google reCAPTCHA library underneath allows to make the request to the reCAPTCHA servers using a custom "Request Method". The `request_method` key accepts the Class you want to instance. +> When mocking requests, there is no need to add any reCAPTCHA token to your tests. -The default `null` value is enough for any normal application, but you're free to, for example, create your own logic or use the classes included in the [ReCaptcha package](https://github.com/google/recaptcha/tree/master/src/ReCaptcha/RequestMethod) (that this package requires). +Since you will have access to the Response to check if it was made by a robot or a human, simply use the `asHuman()` and `asRobot()` methods to score `1.0` or `0.0`, respectively. ```php 'App\Http\ReCaptcha\GuzzleRequestMethod', -]; -``` +// Let the user login normally. +Captchavel::fake()->asHuman(); -You can mimic this next example were we will use Guzzle. +$this->post('login', [ + 'email' => 'test@test.com', + 'password' => '123456', +])->assertRedirect('user.welcome'); -#### Example implementation +// Let the user login using 2FA. +Captchavel::fake()->asRobot(); -First, we will create our `GuzzleRequestMethod` with the `submit()` method as required. This method will return the reCAPTCHA response from the external server using the Guzzle Client. - -`app\Http\ReCaptcha\GuzzleRequestMethod.php` -```php -post($params->toQueryString()) - ->getBody() - ->getContents(); - } -} +$this->post('login', [ + 'email' => 'test@test.com', + 'password' => '123456', +])->assertViewIs('login.2fa'); ``` -Then, we will add the class to the `request_method` key in our configuration: +Alternatively, `shouldScore()` method that will fake the score for anything you set. -`config/captchavel.php` -```php - 'App\Http\ReCaptcha\GuzzleRequestMethod', -]; -``` +When a reCAPTCHA challenge is resolved, whatever result is received, the `ReCaptchaResponseReceived` event fires with the HTTP Request instance and the reCAPTCHA response. + +### Using your own reCAPTCHA middleware -Finally, we will tell the Service Container to give our `GuzzleRequestMethod` to the underneath `ReCaptcha` class when Captchavel tries to instance it, using the Service Container [Contextual Binding](https://laravel.com/docs/container#contextual-binding). +You may want to create your own reCAPTCHA middleware. Instead of doing one from scratch, you can extend the `BaseReCaptchaMiddleware`. -`app\Providers\AppServiceProvider.php` ```php app->when(ReCaptcha::class) - ->needs(RequestMethod::class) - ->give(function () { - return new GuzzleRequestMethod; - }); - } -} -``` + $this->validateRequest($request, $input); -We're leaving the Contextual Binding to you, since your *requester* may need some logic that a simple `app()->make(MyRequester::class)` may not be sufficient. + $response = $this->retrieve($request, $input, 2, 'checkbox'); -### Editing the Script view - -You can edit the script Blade view under by just creating a Blade template in `resources/vendor/captchavel/script.blade.php`. - -This blade view contains the reCAPTCHA script of the package. The view receives the `$key` variable witch is just the reCAPTCHA v3 Site Key. - -There you can edit how the script is downloaded from Google, and how it checks for forms to link with the backend, if the default script isn't enough for you. - -### AJAX Requests + if ($response->isInvalid()) { + throw $this->validationException($input, 'Complete the reCAPTCHA challenge'); + } -Depending on the application, AJAX Requests won't include the reCAPTCHA token. This may be for various reasons: + return $next($request); + } +} +``` -* Using virtual DOM frameworks like Vue and React. -* Creating a form after the page loaded with JavaScript. -* An AJAX Requests being done entirely in JavaScript. +## Security -In any of these scenarios, you may want disable the injection script and [use the reCAPATCHA v3 scripts directly](https://developers.google.com/recaptcha/docs/v3) or your [custom script](#editing-the-script-view). +If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker. ## License diff --git a/composer.json b/composer.json index 0a6df2b..6f9bd17 100644 --- a/composer.json +++ b/composer.json @@ -1,57 +1,44 @@ { "name": "darkghosthunter/captchavel", - "description": "Easily integrate Google Recaptcha v3 into your Laravel application", + "description": "Integrate reCAPTCHA into your Laravel application better than the Big G itself!", "keywords": [ "darkghosthunter", - "captchavel", - "laravel", - "recaptcha", - "google" + "recaptchavel", + "recaptcha" ], - "minimum-stability": "dev", - "prefer-stable": true, "homepage": "https://github.com/darkghosthunter/captchavel", "license": "MIT", "type": "library", "authors": [ { - "name": "Italo Baeza Cabrera", + "name": "Italo Israel Baeza Cabrera", "email": "darkghosthunter@gmail.com", "role": "Developer" } ], "require": { - "php": "^7.2.15", - "ext-json" : "*", - "google/recaptcha": "^1.2", - "illuminate/config": "^6.0||^7.0", - "illuminate/container": "^6.0||^7.0", - "illuminate/http": "^6.0||^7.0", - "illuminate/routing": "^6.0||^7.0", - "illuminate/support": "^6.0||^7.0", - "illuminate/validation": "^6.0||^7.0", - "illuminate/view": "^6.0||^7.0" + "php": "^7.2", + "ext-json": "*", + "illuminate/support": "^7.0", + "guzzlehttp/guzzle": "^6.3.1||^7.0" }, "require-dev": { - "orchestra/testbench": "^4.1||^5.0" + "orchestra/testbench": "^5.0" }, "autoload": { - "files": [ - "src/helpers.php" - ], "psr-4": { "DarkGhostHunter\\Captchavel\\": "src" - } + }, + "files": ["src/helpers.php"] }, "autoload-dev": { "psr-4": { - "DarkGhostHunter\\Captchavel\\Tests\\": "tests" + "Tests\\": "tests" } }, "scripts": { "test": "vendor/bin/phpunit", "test-coverage": "vendor/bin/phpunit --coverage-html coverage" - }, "config": { "sort-packages": true @@ -62,7 +49,7 @@ "DarkGhostHunter\\Captchavel\\CaptchavelServiceProvider" ], "aliases": { - "ReCaptcha": "DarkGhostHunter\\Captchavel\\ReCaptcha" + "Captchavel": "DarkGhostHunter\\Captchavel\\Facades\\Captchavel" } } } diff --git a/config/captchavel.php b/config/captchavel.php index 8662644..d1ed7c7 100644 --- a/config/captchavel.php +++ b/config/captchavel.php @@ -1,55 +1,57 @@ env('CAPTCHAVEL_MODE', 'auto'), + 'enable' => env('CAPTCHAVEL_ENABLE', false), /* |-------------------------------------------------------------------------- - | Enable on Local Environment + | Fake on local development |-------------------------------------------------------------------------- | - | Having reCAPTCHA on local environment is usually not a good idea unless - | you want to make some manual-human tests. For these moments, you can - | enable reCAPTCHA setting this to true until you are sure it works. + | Sometimes you may want to fake success or failed responses from reCAPTCHA + | servers in local development. To do this, simply enable the environment + | variable and then issue as a checkbox parameter is_robot to any form. | */ - 'enable_local' => env('CAPTCHAVEL_LOCAL', false), + 'fake' => env('CAPTCHAVEL_FAKE', false), /* |-------------------------------------------------------------------------- - | Site Key and Secret + | Constraints |-------------------------------------------------------------------------- | - | Google reCAPTCHA issues two keys: a Site Key to show in your responses, - | and a Secret you should hold privately, since this Secret checks the - | reCAPTCHA behaviour. Check the reCAPTCHA Admin panel to make them. + | These default constraints allows further verification of the incoming + | response from reCAPTCHA servers. Hostname and APK Package Name are + | required if these are not verified in your reCAPTCHA admin panel. | */ - 'key' => env('RECAPTCHA_V3_KEY'), - 'secret' => env('RECAPTCHA_V3_SECRET'), + 'hostname' => env('RECAPTCHA_HOSTNAME'), + 'apk_package_name' => env('RECAPTCHA_APK_PACKAGE_NAME'), /* |-------------------------------------------------------------------------- | Threshold |-------------------------------------------------------------------------- | - | The response from reCAPTCHA contains a score of interactivity. You can - | set the default threshold number to differentiate between humans and - | robots, so you can make actions depending on who made the Request. + | For reCAPTCHA v3, which is an score-driven interaction, this default + | threshold is the slicing point between bots and humans. If a score + | is below this threshold, it means the request was made by a bot. | */ @@ -57,14 +59,33 @@ /* |-------------------------------------------------------------------------- - | Request Method + | Credenctials |-------------------------------------------------------------------------- | - | The underlying Google reCAPTCHA library for PHP admits a custom Request - | Method for your application. That means, you can delegate an specific - | class to handle how to send and to receive the reCaptcha response. + | The following is the array of credentials for each version and variant + | of the reCAPTCHA services. You shouldn't need to edit this unless you + | know what you're doing. On reCAPTCHA v2, it comes with testing keys. | */ - 'request_method' => null, -]; \ No newline at end of file + 'credentials' => [ + 'v2' => [ + 'checkbox' => [ + 'secret' => env('RECAPTCHA_V2_CHECKBOX_SECRET', Captchavel::TEST_V2_SECRET), + 'key' => env('RECAPTCHA_V2_CHECKBOX_KEY', Captchavel::TEST_V2_KEY), + ], + 'invisible' => [ + 'secret' => env('RECAPTCHA_V2_INVISIBLE_SECRET', Captchavel::TEST_V2_SECRET), + 'key' => env('RECAPTCHA_V2_INVISIBLE_KEY', Captchavel::TEST_V2_KEY), + ], + 'android' => [ + 'secret' => env('RECAPTCHA_V2_ANDROID_SECRET', Captchavel::TEST_V2_SECRET), + 'key' => env('RECAPTCHA_V2_ANDROID_KEY', Captchavel::TEST_V2_KEY), + ], + ], + 'v3' => [ + 'secret' => env('RECAPTCHA_V3_SECRET'), + 'key' => env('RECAPTCHA_V3_KEY'), + ], + ], +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index aba488b..12ba420 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,10 +19,6 @@ src/ - - - - diff --git a/resources/views/script.blade.php b/resources/views/script.blade.php deleted file mode 100644 index 741b30a..0000000 --- a/resources/views/script.blade.php +++ /dev/null @@ -1,60 +0,0 @@ - - diff --git a/src/Captchavel.php b/src/Captchavel.php new file mode 100644 index 0000000..94298bd --- /dev/null +++ b/src/Captchavel.php @@ -0,0 +1,166 @@ +httpFactory = $httpFactory; + $this->config = $config; + } + + /** + * Returns the Captchavel Response, if any. + * + * @return null|\DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + */ + public function getResponse() + { + return $this->response; + } + + /** + * Check if the a response was resolved from reCAPTCHA servers. + * + * @return bool + */ + public function isNotResolved() + { + return $this->response === null; + } + + /** + * Sets the correct credentials to use to retrieve the challenge results. + * + * @param int $version + * @param string|null $variant + * @return $this + */ + public function useCredentials(int $version, string $variant = null) + { + if ($version === 2) { + if (! in_array($variant, static::V2_VARIANTS, true)) { + throw new LogicException("The reCAPTCHA v2 variant must be [checkbox], [invisible] or [android]."); + } + $this->secret = $this->config->get("captchavel.credentials.v2.{$variant}.secret"); + } elseif ($version === 3) { + $this->secret = $this->config->get('captchavel.credentials.v3.secret'); + } + + if (! $this->secret) { + $name = 'v' . $version . ($variant ? '-' . $variant : ''); + throw new LogicException("The reCAPTCHA secret for [{$name}] doesn't exists."); + } + + return $this; + } + + /** + * Retrieves the Response Challenge. + * + * @param string $challenge + * @param string $ip + * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + */ + public function retrieve(string $challenge, string $ip) + { + $response = $this->httpFactory->asForm() + ->withOptions(['version' => 2.0]) + ->post(static::RECAPTCHA_ENDPOINT, [ + 'secret' => $this->secret, + 'response' => $challenge, + 'remoteip' => $ip, + ]); + + return $this->parse($response); + } + + /** + * Parses the Response + * + * @param \Illuminate\Http\Client\Response $response + * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + */ + protected function parse(Response $response) + { + return $this->response = new ReCaptchaResponse($response->json()); + } +} diff --git a/src/CaptchavelFake.php b/src/CaptchavelFake.php new file mode 100644 index 0000000..21ba567 --- /dev/null +++ b/src/CaptchavelFake.php @@ -0,0 +1,79 @@ +response = $response; + + return $this; + } + + /** + * Sets the correct credentials to use to retrieve the challenge results. + * + * @param int $version + * @param string|null $variant + * @return $this + */ + public function useCredentials(int $version, string $variant = null) + { + return $this; + } + + /** + * Retrieves the Response Challenge. + * + * @param string $challenge + * @param string $ip + * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + */ + public function retrieve(?string $challenge, string $ip) + { + return $this->response; + } + + /** + * Makes the fake Captchavel response with a fake score. + * + * @param float $score + * @return $this + */ + public function shouldScore(float $score) + { + return $this->setResponse(new ReCaptchaResponse([ + 'success' => true, + 'score' => $score, + ])); + } + + /** + * Makes a fake Captchavel response made by a robot with "0" score. + * + * @return $this + */ + public function asRobot() + { + return $this->shouldScore(0); + } + + /** + * Makes a fake Captchavel response made by a human with "1.0" score. + * + * @return $this + */ + public function asHuman() + { + return $this->shouldScore(1); + } +} diff --git a/src/CaptchavelServiceProvider.php b/src/CaptchavelServiceProvider.php index 10b1bac..17e673c 100644 --- a/src/CaptchavelServiceProvider.php +++ b/src/CaptchavelServiceProvider.php @@ -2,14 +2,11 @@ namespace DarkGhostHunter\Captchavel; -use Illuminate\Http\Request; use Illuminate\Routing\Router; -use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\ServiceProvider; -use ReCaptcha\ReCaptcha as ReCaptchaFactory; -use DarkGhostHunter\Captchavel\Http\Middleware\CheckRecaptcha; -use DarkGhostHunter\Captchavel\Http\Middleware\TransparentRecaptcha; -use DarkGhostHunter\Captchavel\Http\Middleware\InjectRecaptchaScript; +use Illuminate\Contracts\Config\Repository; +use DarkGhostHunter\Captchavel\Http\Middleware\VerifyReCaptchaV2; +use DarkGhostHunter\Captchavel\Http\Middleware\VerifyReCaptchaV3; class CaptchavelServiceProvider extends ServiceProvider { @@ -20,117 +17,31 @@ class CaptchavelServiceProvider extends ServiceProvider */ public function register() { - // Automatically apply the package configuration - $this->mergeConfigFrom(__DIR__ . '/../config/captchavel.php', 'captchavel'); + $this->mergeConfigFrom(__DIR__.'/../config/captchavel.php', 'captchavel'); - // When the application tries to resolve the ReCaptcha instance, we will pass the Site Key. - $this->app->when(ReCaptchaFactory::class) - ->needs('$secret') - ->give(function ($app) { - return $app->make('config')->get('captchavel.secret'); - }); - - $this->app->singleton(ReCaptcha::class); - $this->app->alias(ReCaptcha::class, 'recaptcha'); + $this->app->singleton(Captchavel::class); } /** * Bootstrap the application services. * - * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @param \Illuminate\Routing\Router $router + * @param \Illuminate\Contracts\Config\Repository $config + * @return void */ - public function boot() + public function boot(Router $router, Repository $config) { - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'captchavel'); - if ($this->app->runningInConsole()) { $this->publishes([ - __DIR__ . '/../config/captchavel.php' => config_path('captchavel.php'), + __DIR__.'/../config/config.php' => config_path('captchavel.php'), ], 'config'); - // Publishing the views. - $this->publishes([ - __DIR__ . '/../resources/views' => resource_path('views/vendor/captchavel'), - ], 'views'); - } - - $this->bootMiddleware(); - $this->extendRequestMacro(); - } - - /** - * Registers the Middleware - * - * @return void - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - protected function bootMiddleware() - { - /** @var \Illuminate\Routing\Router $router */ - $router = $this->app->make('router'); - - // We will check if we should enable the Middleware of this package based on the environment - // and package config. If we shouldn't, we will register a transparent middleware in the - // application to avoid the errors when the "recaptcha" is used but not registered. - if ($this->shouldEnableMiddleware()) { - $this->addMiddleware($router); - } else { - $this->addTransparentMiddleware($router); - } - } - - /** - * Returns if the application should enable ReCaptcha middleware - * - * @return bool - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - protected function shouldEnableMiddleware() - { - return $this->app->environment('production') - || ($this->app->environment('local') && $this->app->make('config')->get('captchavel.enable_local')); - } - - /** - * Registers real middleware for the package - * - * @param \Illuminate\Routing\Router $router - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - protected function addMiddleware(Router $router) - { - $router->aliasMiddleware('recaptcha', CheckRecaptcha::class); - $router->aliasMiddleware('recaptcha-inject', InjectRecaptchaScript::class); - - if ($this->app->make('config')->get('captchavel.mode') === 'auto') { - $this->app->make(Kernel::class)->pushMiddleware(InjectRecaptchaScript::class); + if ($this->app->runningUnitTests()) { + $config->set('captchavel.fake', true); + } } - } - /** - * Registers a Dummy (Transparent) Middleware - * - * @param \Illuminate\Routing\Router $router - */ - protected function addTransparentMiddleware(Router $router) - { - $router->aliasMiddleware('recaptcha', TransparentRecaptcha::class); - } - - /** - * Extend the Request with a couple of macros - * - * @return void - */ - protected function extendRequestMacro() - { - Request::macro('isHuman', function () { - return recaptcha()->isHuman(); - }); - - Request::macro('isRobot', function () { - return recaptcha()->isRobot(); - }); + $router->aliasMiddleware('recaptcha.v2', VerifyReCaptchaV2::class); + $router->aliasMiddleware('recaptcha.v3', VerifyReCaptchaV3::class); } - } diff --git a/src/Events/ReCaptchaResponseReceived.php b/src/Events/ReCaptchaResponseReceived.php new file mode 100644 index 0000000..b64c440 --- /dev/null +++ b/src/Events/ReCaptchaResponseReceived.php @@ -0,0 +1,35 @@ +request = $request; + $this->response = $response; + } +} diff --git a/src/Exceptions/CaptchavelException.php b/src/Exceptions/CaptchavelException.php deleted file mode 100644 index 274f665..0000000 --- a/src/Exceptions/CaptchavelException.php +++ /dev/null @@ -1,7 +0,0 @@ -message = 'The Google reCAPTCHA library returned the following errors:' . - implode("\n- ", $errorCodes); - - parent::__construct(); - } -} \ No newline at end of file diff --git a/src/Exceptions/InvalidCaptchavelMiddlewareMethod.php b/src/Exceptions/InvalidCaptchavelMiddlewareMethod.php deleted file mode 100644 index 63e1603..0000000 --- a/src/Exceptions/InvalidCaptchavelMiddlewareMethod.php +++ /dev/null @@ -1,15 +0,0 @@ -message = 'The reCAPTCHA token received is invalid'; - } - - parent::__construct($this->message, $code, $previous); - } -} \ No newline at end of file diff --git a/src/Exceptions/RecaptchaNotResolvedException.php b/src/Exceptions/RecaptchaNotResolvedException.php deleted file mode 100644 index a4d8195..0000000 --- a/src/Exceptions/RecaptchaNotResolvedException.php +++ /dev/null @@ -1,15 +0,0 @@ -make(CaptchavelFake::class)); + + return $fake; + } +} diff --git a/src/Facades/ReCaptcha.php b/src/Facades/ReCaptcha.php deleted file mode 100644 index 9a275c4..0000000 --- a/src/Facades/ReCaptcha.php +++ /dev/null @@ -1,29 +0,0 @@ -captchavel = $captchavel; + $this->config = $config; + } + + /** + * Determines if the reCAPTCHA verification should be enabled. + * + * @return bool + */ + protected function isEnabled() + { + return $this->config->get('captchavel.enable'); + } + + /** + * Check if the reCAPTCHA response can be faked on-demand. + * + * @return bool + */ + protected function isFake() + { + return $this->config->get('captchavel.fake'); + } + + /** + * Check if the reCAPTCHA response must be real. + * + * @return bool + */ + protected function isReal() + { + return ! $this->isFake(); + } + + /** + * Validate if this Request has the reCAPTCHA challenge string. + * + * @param \Illuminate\Http\Request $request + * @param string $name + * @return void + * @throws \Illuminate\Validation\ValidationException + */ + protected function validateRequest($request, string $name) + { + if (! is_string($request->get($name))) { + throw $this->validationException($name, 'The reCAPTCHA challenge is missing or has not been completed.'); + } + } + + /** + * Retrieves the Captchavel response from reCAPTCHA servers. + * + * @param \Illuminate\Http\Request $request + * @param string $input + * @param int $version + * @param string|null $variant + * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse + */ + protected function retrieve(Request $request, string $input, int $version, string $variant = null) + { + return $this->captchavel + ->useCredentials($version, $variant) + ->retrieve($request->input($input), $request->ip()); + } + + /** + * Fakes a v3 reCAPTCHA response. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function fakeResponseScore($request) + { + // We will first check if the Captchavel instance was not already faked. If it has been, + // we will hands out the control to the developer. Otherwise, we will manually create a + // fake Captchavel instance and look for the "is_robot" parameter to set a fake score. + if ($this->captchavel instanceof Captchavel && $this->captchavel->isNotResolved()) { + $this->captchavel = CaptchavelFacade::fake(); + + $request->has('is_robot') ? $this->captchavel->asRobot() : $this->captchavel->asHuman(); + } + + + } + + /** + * Validate the Hostname and APK name from the response. + * + * @param \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse $response + * @param string $input + * @param string|null $action + * @throws \Illuminate\Validation\ValidationException + */ + protected function validateResponse(ReCaptchaResponse $response, $input = Captchavel::INPUT, ?string $action = null) + { + if ($response->differentHostname($this->config->get('captchavel.hostname'))) { + throw $this->validationException('hostname', + "The hostname [{$response->hostname}] of the response is invalid."); + } + + if ($response->differentApk($this->config->get('captchavel.apk_package_name'))) { + throw $this->validationException('apk_package_name', + "The apk_package_name [{$response->apk_package_name}] of the response is invalid."); + } + + if ($response->differentAction($action)) { + throw $this->validationException('action', + "The action [{$response->action}] of the response is invalid."); + } + + if ($response->isInvalid()) { + throw $this->validationException($input, + "The reCAPTCHA challenge is invalid or was not completed."); + } + } + + /** + * Creates a new Validation Exception instance. + * + * @param string $input + * @param string $message + * @return \Illuminate\Validation\ValidationException + */ + protected function validationException($input, $message) + { + return ValidationException::withMessages([$input => trans($message)])->redirectTo(back()->getTargetUrl()); + } + + /** + * Dispatch an event with the request and the Captchavel Response + * + * @param \Illuminate\Http\Request $request + * @param \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse $response + */ + protected function dispatch(Request $request, ReCaptchaResponse $response) + { + event(new ReCaptchaResponseReceived($request, $response)); + } +} diff --git a/src/Http/Middleware/CheckRecaptcha.php b/src/Http/Middleware/CheckRecaptcha.php deleted file mode 100644 index dadaa12..0000000 --- a/src/Http/Middleware/CheckRecaptcha.php +++ /dev/null @@ -1,139 +0,0 @@ -validator = $validator; - $this->config = $config->get('captchavel'); - $this->reCaptchaFactory = $reCaptchaFactory; - $this->reCaptcha = $reCaptcha; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @param float $threshold - * @return mixed - * @throws \Throwable - */ - public function handle($request, Closure $next, float $threshold = null) - { - if ($request->getRealMethod() === 'POST') { - $this->hasValidRequest($request); - $this->hasValidReCaptcha($request, $threshold ?? $this->config['threshold']); - } - - return $next($request); - } - - /** - * Return if the Request has a valid reCAPTCHA token - * - * @param \Illuminate\Http\Request $request - * @return bool - * @throws \Throwable - */ - protected function hasValidRequest(Request $request) - { - $isValid = !$this->validator->make($request->only('_recaptcha'), [ - '_recaptcha' => 'required|string', - ])->fails(); - - return throw_unless($isValid, InvalidRecaptchaException::class, $request->only('_recaptcha')); - } - - /** - * Checks if the reCAPTCHA Response is valid - * - * @param \Illuminate\Http\Request $request - * @param float $threshold - * @return mixed - * @throws \Throwable - */ - protected function hasValidReCaptcha(Request $request, float $threshold) - { - $response = $this->resolve($request, $threshold)->response(); - - return throw_unless($response->isSuccess(), FailedRecaptchaException::class, $response->getErrorCodes()); - } - - /** - * Resolves a reCAPTCHA Request into a reCAPTCHA Response - * - * @param \Illuminate\Http\Request $request - * @param float $threshold - * @return \DarkGhostHunter\Captchavel\ReCaptcha - */ - protected function resolve(Request $request, float $threshold) - { - return $this->reCaptcha->setResponse( - $this->reCaptchaFactory - ->setExpectedAction($this->sanitizeAction($request->getRequestUri())) - ->verify($request->input('_recaptcha'), $request->getClientIp()) - )->setThreshold($threshold); - } - - /** - * Sanitizes the Action string to be sent to Google reCAPTCHA servers - * - * @param string $action - * @return string|string[]|null - */ - protected function sanitizeAction(string $action) - { - return preg_replace('/[^A-z\/\_]/s', '', $action); - } -} diff --git a/src/Http/Middleware/InjectRecaptchaScript.php b/src/Http/Middleware/InjectRecaptchaScript.php deleted file mode 100644 index 3d91ec9..0000000 --- a/src/Http/Middleware/InjectRecaptchaScript.php +++ /dev/null @@ -1,96 +0,0 @@ -key = $config->get('captchavel.key'); - $this->view = $view; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed - * @throws \Throwable - * @throws \\Symfony\Component\HttpKernel\Exception\HttpException - */ - public function handle($request, Closure $next) - { - $response = $next($request); - - if ($this->isHtml($request, $response)) { - return $this->injectScript($response); - } - - return $response; - } - - /** - * Detect if the Request accepts HTML and is not an AJAX/PJAX Request - * - * @param \Illuminate\Http\Request $request - * @param \Illuminate\Http\Response | \Illuminate\Http\JsonResponse $response - * @return bool - */ - protected function isHtml(Request $request, $response) - { - return $response instanceof Response - && $request->acceptsHtml() - && ! $request->ajax() - && ! $request->pjax() - && ! $response->exception; - } - - /** - * Injects the front-end Scripts - * - * @param \Illuminate\Http\Response $response - * @return \Illuminate\Http\Response - */ - protected function injectScript(Response $response) - { - // To inject the script automatically, we will do it before the ending - // head tag. If it's not found, the response may not be valid HTML, - // so we will bail out returning the original untouched content. - if (! $endHeadPosition = stripos($content = $response->content(), '')) { - return $response; - }; - - $script = $this->view->make('captchavel::script', ['key' => $this->key])->render(); - - return $response->setContent( - substr_replace($content, $script, $endHeadPosition, 0) - ); - } -} \ No newline at end of file diff --git a/src/Http/Middleware/TransparentRecaptcha.php b/src/Http/Middleware/TransparentRecaptcha.php deleted file mode 100644 index fbdd525..0000000 --- a/src/Http/Middleware/TransparentRecaptcha.php +++ /dev/null @@ -1,49 +0,0 @@ -validator->make($request->only('_recaptcha'), [ - '_recaptcha' => 'nullable', - ])->fails(); - - return throw_unless($isValid, InvalidRecaptchaException::class, $request->only('_recaptcha')); - } - /** - * Resolves a reCAPTCHA Request into a reCAPTCHA Response - * - * @param \Illuminate\Http\Request $request - * @param float $threshold - * @return \DarkGhostHunter\Captchavel\ReCaptcha - */ - protected function resolve(Request $request, float $threshold) - { - return $this->reCaptcha->setResponse( - new Response( - true, - [], - null, - now()->toIso8601ZuluString(), - null, - (int)$request->has('is_robot'), - $this->sanitizeAction($request->getRequestUri())) - ); - } -} diff --git a/src/Http/Middleware/VerifyReCaptchaV2.php b/src/Http/Middleware/VerifyReCaptchaV2.php new file mode 100644 index 0000000..6990908 --- /dev/null +++ b/src/Http/Middleware/VerifyReCaptchaV2.php @@ -0,0 +1,44 @@ +isEnabled() && $this->isReal()) { + $this->validateRequest($request, $input); + $this->processChallenge($request, $variant, $input); + } + + return $next($request); + } + + /** + * Process a real challenge and response from reCAPTCHA servers. + * + * @param \Illuminate\Http\Request $request + * @param string $variant + * @param string $input + * @throws \Illuminate\Validation\ValidationException + */ + protected function processChallenge($request, $variant, $input) + { + $this->dispatch($request, $response = $this->retrieve($request, $input, 2, $variant)); + + $this->validateResponse($response, $input); + } +} diff --git a/src/Http/Middleware/VerifyReCaptchaV3.php b/src/Http/Middleware/VerifyReCaptchaV3.php new file mode 100644 index 0000000..ad660a1 --- /dev/null +++ b/src/Http/Middleware/VerifyReCaptchaV3.php @@ -0,0 +1,86 @@ +isEnabled()) { + if ($this->isReal()) { + $this->validateRequest($request, $input); + } else { + $this->fakeResponseScore($request); + + // We will disable the action name since it will be verified if we don't null it. + $action = null; + } + + $this->processChallenge($request, $threshold, $action, $input); + } + + return $next($request); + } + + /** + * Process the response from reCAPTCHA servers. + * + * @param \Illuminate\Http\Request $request + * @param null|string $threshold + * @param null|string $action + * @param string $input + * @throws \Illuminate\Validation\ValidationException + */ + protected function processChallenge($request, $threshold, $action, $input) + { + $response = $this->retrieve($request, $input, 3); + + $response->setThreshold($this->normalizeThreshold($threshold)); + + $this->dispatch($request, $response); + + $this->validateResponse($response, $input, $this->normalizeAction($action)); + + // After we get the response, we will register the instance as a shared ("singleton"). + // Obviously we will set the threshold set by the developer or just use the default. + // The Response should not be available until the middleware runs, so this is ok. + app()->instance(ReCaptchaResponse::class, $response); + } + + /** + * Normalize the threshold string. + * + * @param string|null $threshold + * @return array|float|mixed + */ + protected function normalizeThreshold($threshold) + { + return $threshold === 'null' ? $this->config->get('captchavel.threshold') : (float)$threshold; + } + + /** + * Normalizes the action name, or returns null. + * + * @param null|string $action + * @return null|string + */ + protected function normalizeAction($action) + { + return strtolower($action) === 'null' ? null : $action; + } +} diff --git a/src/Http/ReCaptchaResponse.php b/src/Http/ReCaptchaResponse.php new file mode 100644 index 0000000..6661154 --- /dev/null +++ b/src/Http/ReCaptchaResponse.php @@ -0,0 +1,132 @@ +threshold = $threshold; + + return $this; + } + + /** + * Returns if the response was made by a Human. + * + * @throws \LogicException + * @return bool + */ + public function isHuman() + { + if ($this->score === null) { + throw new LogicException('This is not a reCAPTCHA v3 response, or the score is absent.'); + } + + return $this->threshold >= $this->score; + } + + /** + * Returns if the response was made by a Robot. + * + * @return bool + */ + public function isRobot() + { + return ! $this->isHuman(); + } + + /** + * Returns if the challenge is valid. + * + * @return bool + */ + public function isValid() + { + return $this->success && empty($this->error_codes); + } + + /** + * Returns if the challenge is invalid. + * + * @return bool + */ + public function isInvalid() + { + return ! $this->isValid(); + } + + /** + * Check if the hostname is different to the one issued. + * + * @param string|null $string + * @return bool + */ + public function differentHostname(?string $string) + { + return $string && $this->hostname !== $string; + } + + /** + * Check if the APK name is different to the one issued. + * + * @param string|null $string + * @return bool + */ + public function differentApk(?string $string) + { + return $string && $this->apk_package_name !== $string; + } + + /** + * Check if the action name is different to the one issued. + * + * @param null|string $action + * @return bool + */ + public function differentAction(?string $action) + { + return $action && $this->action !== $action; + } + + /** + * Dynamically return an attribute as a property. + * + * @param $name + * @return null|mixed + */ + public function __get($name) + { + // Minor fix for getting the error codes + return parent::__get($name === 'error_codes' ? 'error-codes' : $name); + } +} diff --git a/src/ReCaptcha.php b/src/ReCaptcha.php deleted file mode 100644 index 14a3cf7..0000000 --- a/src/ReCaptcha.php +++ /dev/null @@ -1,128 +0,0 @@ -response = $response; - - return $this; - } - - /** - * Returns if the reCAPTCHA has been resolved by the servers - * - * @return bool - */ - public function isResolved() - { - return $this->response !== null; - } - - /** - * Returns the threshold - * - * @return float - */ - public function getThreshold() - { - return $this->threshold; - } - - /** - * Sets the Threshold - * - * @param float $threshold - * @return \DarkGhostHunter\Captchavel\ReCaptcha - */ - public function setThreshold(float $threshold) - { - $this->threshold = $threshold; - - return $this; - } - - /** - * Return if the Response was made by a Human - * - * @return bool - * @throws \DarkGhostHunter\Captchavel\Exceptions\RecaptchaNotResolvedException - */ - public function isHuman() - { - if (!$this->response) { - throw new RecaptchaNotResolvedException(); - } - - return $this->response->getScore() >= $this->threshold; - } - - /** - * Return if the Response was made by a Robot - * - * @return bool - * @throws \DarkGhostHunter\Captchavel\Exceptions\RecaptchaNotResolvedException - */ - public function isRobot() - { - return !$this->isHuman(); - } - - /** - * Return the underlying reCAPTCHA response - * - * @return \ReCaptcha\Response - */ - public function response() - { - return $this->response; - } - - /** - * Return the reCAPTCHA Response timestamp as a Carbon instance - * - * @return \Illuminate\Support\Carbon - * @throws \DarkGhostHunter\Captchavel\Exceptions\RecaptchaNotResolvedException - */ - public function since() - { - if (!$this->response) { - throw new RecaptchaNotResolvedException(); - } - - return $this->since ?? $this->since = Carbon::parse($this->response->getChallengeTs()); - } -} diff --git a/src/helpers.php b/src/helpers.php index cd34df1..3f6c4c5 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,13 +1,23 @@ afterApplicationCreated(function () { + $this->createsRoutes(); + }); + + parent::setUp(); + } + + public function test_using_fake_on_unit_test() + { + $this->assertTrue(config('captchavel.fake')); + } + + public function test_makes_fake_score() + { + Captchavel::fake()->shouldScore(0.3); + + Route::post('test', function (ReCaptchaResponse $response) { + return [$response->score, $response->isRobot(), $response->isHuman()]; + })->middleware('recaptcha.v3:0.6'); + + $this->post('test')->assertOk()->assertExactJson([0.3, true, false]); + } + + public function test_makes_human_score_one() + { + Captchavel::fake()->asHuman(); + + Route::post('test', function (ReCaptchaResponse $response) { + return [$response->score, $response->isRobot(), $response->isHuman()]; + })->middleware('recaptcha.v3:0.6'); + + $this->post('test')->assertOk()->assertExactJson([1.0, false, true]); + } + + public function test_makes_robot_score_zero() + { + Captchavel::fake()->asRobot(); + + Route::post('test', function (ReCaptchaResponse $response) { + return [$response->score, $response->isRobot(), $response->isHuman()]; + })->middleware('recaptcha.v3:0.6'); + + $this->post('test')->assertOk()->assertExactJson([0.0, true, false]); + } +} diff --git a/tests/CaptchavelTest.php b/tests/CaptchavelTest.php new file mode 100644 index 0000000..5aa9d31 --- /dev/null +++ b/tests/CaptchavelTest.php @@ -0,0 +1,193 @@ +mock(Factory::class); + + $mock->shouldReceive('asForm')->withNoArgs()->times(3)->andReturnSelf(); + $mock->shouldReceive('withOptions')->with(['version' => 2.0])->times(3)->andReturnSelf(); + $mock->shouldReceive('post') + ->with(Captchavel::RECAPTCHA_ENDPOINT, [ + 'secret' => Captchavel::TEST_V2_SECRET, + 'response' => 'token', + 'remoteip' => '127.0.0.1', + ]) + ->times(3) + ->andReturn(new Response(new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ])))); + + $instance = app(Captchavel::class); + + $this->assertNull($instance->getResponse()); + + $checkbox = $instance->useCredentials(2, 'checkbox')->retrieve('token', '127.0.0.1'); + $this->assertSame($checkbox, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $checkbox); + $this->assertTrue($checkbox->success); + $this->assertSame(0.5, $checkbox->score); + $this->assertSame('bar', $checkbox->foo); + + $invisible = $instance->useCredentials(2, 'invisible')->retrieve('token', '127.0.0.1'); + $this->assertSame($invisible, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $invisible); + $this->assertTrue($invisible->success); + $this->assertSame(0.5, $invisible->score); + $this->assertSame('bar', $invisible->foo); + + $android = $instance->useCredentials(2, 'android')->retrieve('token', '127.0.0.1'); + $this->assertSame($android, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $android); + $this->assertTrue($android->success); + $this->assertSame(0.5, $android->score); + $this->assertSame('bar', $android->foo); + } + + public function test_uses_v2_custom_credentials() + { + config(['captchavel.credentials.v2' => [ + 'checkbox' => ['secret' => 'secret-checkbox'], + 'invisible' => ['secret' => 'secret-invisible'], + 'android' => ['secret' => 'secret-android'], + ]]); + + $mock = $this->mock(Factory::class); + + $mock->shouldReceive('asForm')->withNoArgs()->times(3)->andReturnSelf(); + $mock->shouldReceive('withOptions')->with(['version' => 2.0])->times(3)->andReturnSelf(); + + $mock->shouldReceive('post') + ->with(Captchavel::RECAPTCHA_ENDPOINT, [ + 'secret' => 'secret-checkbox', + 'response' => 'token', + 'remoteip' => '127.0.0.1', + ]) + ->once() + ->andReturn(new Response(new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ])))); + + $mock->shouldReceive('post') + ->with(Captchavel::RECAPTCHA_ENDPOINT, [ + 'secret' => 'secret-invisible', + 'response' => 'token', + 'remoteip' => '127.0.0.1', + ]) + ->once() + ->andReturn(new Response(new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ])))); + + $mock->shouldReceive('post') + ->with(Captchavel::RECAPTCHA_ENDPOINT, [ + 'secret' => 'secret-android', + 'response' => 'token', + 'remoteip' => '127.0.0.1', + ]) + ->once() + ->andReturn(new Response(new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ])))); + + $instance = app(Captchavel::class); + + $this->assertNull($instance->getResponse()); + + $checkbox = $instance->useCredentials(2, 'checkbox')->retrieve('token', '127.0.0.1'); + $this->assertSame($checkbox, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $checkbox); + $this->assertTrue($checkbox->success); + $this->assertSame(0.5, $checkbox->score); + $this->assertSame('bar', $checkbox->foo); + + $invisible = $instance->useCredentials(2, 'invisible')->retrieve('token', '127.0.0.1'); + $this->assertSame($invisible, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $invisible); + $this->assertTrue($invisible->success); + $this->assertSame(0.5, $invisible->score); + $this->assertSame('bar', $invisible->foo); + + $android = $instance->useCredentials(2, 'android')->retrieve('token', '127.0.0.1'); + $this->assertSame($android, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $android); + $this->assertTrue($android->success); + $this->assertSame(0.5, $android->score); + $this->assertSame('bar', $android->foo); + } + + public function test_exception_if_no_v3_secret_issued() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The reCAPTCHA secret for [v3] doesn\'t exists.'); + + $instance = app(Captchavel::class); + + $instance->useCredentials(3)->retrieve('token', '127.0.0.1'); + } + + public function test_exception_when_invalid_credentials_issued() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The reCAPTCHA v2 variant must be [checkbox], [invisible] or [android].'); + + $instance = app(Captchavel::class); + + $instance->useCredentials(2, 'invalid')->retrieve('token', '127.0.0.1'); + } + + public function test_receives_v3_secret() + { + config(['captchavel.credentials.v3.secret' => 'secret']); + + $mock = $this->mock(Factory::class); + + $mock->shouldReceive('asForm')->withNoArgs()->once()->andReturnSelf(); + $mock->shouldReceive('withOptions')->with(['version' => 2.0])->once()->andReturnSelf(); + $mock->shouldReceive('post') + ->with(Captchavel::RECAPTCHA_ENDPOINT, [ + 'secret' => 'secret', + 'response' => 'token', + 'remoteip' => '127.0.0.1', + ]) + ->once() + ->andReturn(new Response(new GuzzleResponse(200, ['Content-type' => 'application/json'], json_encode([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar', + ])))); + + $instance = app(Captchavel::class); + + $this->assertNull($instance->getResponse()); + + $score = $instance->useCredentials(3)->retrieve('token', '127.0.0.1'); + + $this->assertSame($score, $instance->getResponse()); + $this->assertInstanceOf(ReCaptchaResponse::class, $score); + $this->assertTrue($score->success); + $this->assertSame(0.5, $score->score); + $this->assertSame('bar', $score->foo); + } +} diff --git a/tests/ExtendsRequestMethodTest.php b/tests/ExtendsRequestMethodTest.php deleted file mode 100644 index cdd2268..0000000 --- a/tests/ExtendsRequestMethodTest.php +++ /dev/null @@ -1,60 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - public function testExtendsRequestMethod() - { - $requester = \Mockery::mock(RequestMethod::class); - $requester->shouldReceive('submit') - ->with(RequestParameters::class) - ->andReturn(json_encode([ - 'success' => $success = true, - 'score' => $score = 0.8, - 'action' => $action = 'test-action', - 'challenge_ts' => $challenge_ts = Carbon::now()->toIso8601ZuluString(), - ])); - - config()->set('captchavel.request_method', $requester); - config()->set('captchavel.secret', Str::random()); - - $this->app->when(ReCaptcha::class) - ->needs(RequestMethod::class) - ->give(function () use ($requester) { - return $requester; - }); - - $recaptcha = $this->app->make(ReCaptcha::class); - - $response = $recaptcha->verify('anytoken'); - - $this->assertInstanceOf(Response::class, $response); - $this->assertTrue($response->isSuccess()); - $this->assertEquals($response->getScore(), $score); - $this->assertEquals($response->getAction(), $action); - $this->assertEquals($response->getChallengeTs(), $challenge_ts); - } - -} diff --git a/tests/HelperTest.php b/tests/HelperTest.php new file mode 100644 index 0000000..437eec1 --- /dev/null +++ b/tests/HelperTest.php @@ -0,0 +1,48 @@ +expectException(LogicException::class); + $this->expectExceptionMessage('The reCAPTCHA site key for [3] doesn\'t exists.'); + + captchavel(3); + } + + public function test_retrieves_test_keys_by_default() + { + $this->assertSame(Captchavel::TEST_V2_KEY, captchavel('checkbox')); + $this->assertSame(Captchavel::TEST_V2_KEY, captchavel('invisible')); + $this->assertSame(Captchavel::TEST_V2_KEY, captchavel('android')); + } + + public function test_retrieves_secrets() + { + config(['captchavel.credentials.v2' => [ + 'checkbox' => ['key' => 'key-checkbox'], + 'invisible' => ['key' => 'key-invisible'], + 'android' => ['key' => 'key-android'], + ]]); + + config(['captchavel.credentials.v3' => [ + 'key' => 'key-score' + ]]); + + $this->assertSame('key-checkbox', captchavel('checkbox')); + $this->assertSame('key-invisible', captchavel('invisible')); + $this->assertSame('key-android', captchavel('android')); + $this->assertSame('key-score', captchavel('score')); + $this->assertSame('key-score', captchavel('v3')); + $this->assertSame('key-score', captchavel('3')); + $this->assertSame('key-score', captchavel(3)); + } +} diff --git a/tests/Http/Middleware/ChallengeMiddlewareTest.php b/tests/Http/Middleware/ChallengeMiddlewareTest.php new file mode 100644 index 0000000..4b6d62b --- /dev/null +++ b/tests/Http/Middleware/ChallengeMiddlewareTest.php @@ -0,0 +1,349 @@ +afterApplicationCreated(function () { + $this->createsRoutes(); + config(['captchavel.fake' => false]); + }); + + parent::setUp(); + } + + public function test_exception_if_no_challenge_specified() + { + Route::post('test', function () { + return 'ok'; + })->middleware('recaptcha.v2'); + + $this->post('test')->assertStatus(500); + + $this->postJson('test')->assertJson(['message' => 'Server Error']); + } + + public function test_bypass_if_not_enabled() + { + config(['captchavel.enable' => false]); + + $event = Event::fake(); + + $this->mock(Captchavel::class)->shouldNotReceive('useCredentials', 'retrieve'); + + $this->post('v2/checkbox')->assertOk(); + $this->post('v2/invisible')->assertOk(); + $this->post('v2/android')->assertOk(); + + $event->assertNotDispatched(ReCaptchaResponseReceived::class); + } + + public function test_fakes_success() + { + config(['captchavel.fake' => true]); + + $this->post('v2/checkbox')->assertOk(); + $this->post('v2/checkbox/input_bar')->assertOk(); + $this->post('v2/invisible')->assertOk(); + $this->post('v2/invisible/input_bar')->assertOk(); + $this->post('v2/android')->assertOk(); + $this->post('v2/android/input_bar')->assertOk(); + } + + public function test_validates_if_real() + { + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->once()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(3) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar' + ])); + + $this->post('v2/checkbox', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + $this->post('v2/invisible', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + $this->post('v2/android', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 3); + } + + public function test_uses_custom_input() + { + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->once()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(3) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar' + ])); + + $this->post('v2/checkbox/input_bar', [ + 'bar' => 'token' + ])->assertOk(); + $this->post('v2/invisible/input_bar', [ + 'bar' => 'token' + ])->assertOk(); + $this->post('v2/android/input_bar', [ + 'bar' => 'token' + ])->assertOk(); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 3); + } + + public function test_exception_when_token_absent() + { + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldNotReceive('useCredentials', 'retrieve'); + + $this->post('v2/checkbox')->assertRedirect('/'); + $this->postJson('v2/checkbox')->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/invisible')->assertRedirect('/'); + $this->postJson('v2/invisible')->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/android')->assertRedirect('/'); + $this->postJson('v2/android')->assertJsonValidationErrors(Captchavel::INPUT); + + $this->post('v2/checkbox/input_bar')->assertRedirect('/'); + $this->postJson('v2/checkbox/input_bar')->assertJsonValidationErrors('bar'); + $this->post('v2/invisible/input_bar')->assertRedirect('/'); + $this->postJson('v2/invisible/input_bar')->assertJsonValidationErrors('bar'); + $this->post('v2/android/input_bar')->assertRedirect('/'); + $this->postJson('v2/android/input_bar')->assertJsonValidationErrors('bar'); + + $event->assertNotDispatched(ReCaptchaResponseReceived::class); + } + + public function test_exception_when_response_invalid() + { + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->twice()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->twice()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->twice()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(6) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => false, + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors(Captchavel::INPUT); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 6); + } + + public function test_no_error_if_not_hostname_issued() + { + config(['captchavel.hostname' => null]); + + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->once()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(3) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'hostname' => 'foo' + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 3); + } + + public function test_no_error_if_not_hostname_same() + { + config(['captchavel.hostname' => 'foo']); + + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->once()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(3) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'hostname' => 'foo' + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 3); + } + + public function test_exception_if_hostname_not_equal() + { + config(['captchavel.hostname' => 'bar']); + + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->twice()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->twice()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->twice()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(6) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'hostname' => 'foo' + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('hostname'); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('hostname'); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('hostname'); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 6); + } + + public function test_no_error_if_no_apk_issued() + { + config(['captchavel.apk_package_name' => null]); + + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->once()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(3) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => 'foo' + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 3); + } + + public function test_no_error_if_no_apk_same() + { + config(['captchavel.apk_package_name' => 'foo']); + + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->once()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->once()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(3) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => 'foo' + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertOk(); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertOk(); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 3); + } + + public function test_exception_if_apk_not_equal() + { + config(['captchavel.apk_package_name' => 'bar']); + + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->twice()->with(2, 'checkbox')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->twice()->with(2, 'invisible')->andReturnSelf(); + $mock->shouldReceive('useCredentials')->twice()->with(2, 'android')->andReturnSelf(); + + $mock->shouldReceive('retrieve') + ->times(6) + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => 'foo' + ])); + + $this->post('v2/checkbox', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/checkbox', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('apk_package_name'); + $this->post('v2/invisible', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/invisible', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('apk_package_name'); + $this->post('v2/android', [Captchavel::INPUT => 'token'])->assertRedirect('/'); + $this->postJson('v2/android', [Captchavel::INPUT => 'token'])->assertJsonValidationErrors('apk_package_name'); + + $event->assertDispatchedTimes(ReCaptchaResponseReceived::class, 6); + } +} diff --git a/tests/Http/Middleware/ScoreMiddlewareTest.php b/tests/Http/Middleware/ScoreMiddlewareTest.php new file mode 100644 index 0000000..c9ffe3b --- /dev/null +++ b/tests/Http/Middleware/ScoreMiddlewareTest.php @@ -0,0 +1,481 @@ +afterApplicationCreated(function () { + $this->createsRoutes(); + config(['captchavel.fake' => false]); + }); + + parent::setUp(); + } + + public function test_bypass_if_not_enabled() + { + config(['captchavel.enable' => false]); + + $event = Event::fake(); + + $this->mock(Captchavel::class)->shouldNotReceive('useCredentials', 'retrieve'); + + $this->post('v3/default')->assertOk(); + + $event->assertNotDispatched(ReCaptchaResponseReceived::class); + } + + public function test_validates_if_real() + { + $mock = $this->mock(Captchavel::class); + + $event = Event::fake(); + + $mock->shouldReceive('useCredentials') + ->with(3, null) + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar' + ])); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ]) + ->assertOk() + ->assertExactJson([ + 'success' => true, + 'score' => 0.5, + 'foo' => 'bar' + ]); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->foo === 'bar' && $event->request instanceof Request; + }); + } + + public function test_fakes_human_response_automatically() + { + config(['captchavel.fake' => true]); + + $event = Event::fake(); + + $this->post('v3/default') + ->assertOk() + ->assertExactJson([ + 'success' => true, + 'score' => 1, + ]); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === true && $event->request instanceof Request; + }); + } + + public function test_fakes_robot_response_if_input_is_robot_present() + { + config(['captchavel.fake' => true]); + + $event = Event::fake(); + + $this->post('v3/default', ['is_robot' => 'on']) + ->assertOk() + ->assertExactJson([ + 'success' => true, + 'score' => 0, + ]); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === true && $event->request instanceof Request; + }); + } + + public function test_uses_custom_threshold() + { + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials')->andReturnSelf(); + $mock->shouldReceive('retrieve')->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'score' => 0.7, + 'foo' => 'bar' + ])); + + $event = Event::fake(); + + Route::post('test', function (ReCaptchaResponse $response) { + return [$response->isHuman(), $response->isRobot(), $response->score]; + })->middleware('recaptcha.v3:0.7'); + + $this->post('test', [ + Captchavel::INPUT => 'token' + ]) + ->assertOk() + ->assertExactJson([ + true, false, 0.7 + ]); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === true && $event->request instanceof Request; + }); + } + + public function test_uses_custom_input() + { + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'score' => 0.7, + 'foo' => 'bar' + ])); + + $event = Event::fake(); + + Route::post('test', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:null,null,foo'); + + $this->post('test', [ + 'foo' => 'token' + ]) + ->assertOk() + ->assertExactJson([ + 'success' => true, + 'score' => 0.7, + 'foo' => 'bar', + ]); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === true && $event->request instanceof Request; + }); + } + + public function test_exception_when_token_absent() + { + $event = Event::fake(); + + $this->post('v3/default', [ + 'foo' => 'bar' + ])->assertRedirect('/'); + + $this->postJson('v3/default', [ + 'foo' => 'bar' + ])->assertJsonValidationErrors(Captchavel::INPUT); + + $event->assertNotDispatched(ReCaptchaResponseReceived::class); + } + + public function test_exception_when_response_invalid() + { + $event = Event::fake(); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => false, + ])); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertRedirect('/'); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === false && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + 'foo' => 'bar' + ])->assertJsonValidationErrors(Captchavel::INPUT); + } + + public function test_no_error_if_not_hostname_issued() + { + config(['captchavel.hostname' => null]); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'hostname' => 'foo', + ])); + + $event = Event::fake(); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === true && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + } + + public function test_no_error_if_hostname_same() + { + config(['captchavel.hostname' => 'bar']); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'hostname' => 'bar', + ])); + + $event = Event::fake(); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->success === true && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + } + + public function test_exception_if_hostname_not_equal() + { + config(['captchavel.hostname' => 'bar']); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'hostname' => 'foo', + ])); + + $event = Event::fake(); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertRedirect('/'); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->hostname === 'foo' && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertJsonValidationErrors('hostname'); + } + + public function test_no_error_if_no_apk_issued() + { + config(['captchavel.apk_package_name' => null]); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => 'foo', + ])); + + $event = Event::fake(); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->apk_package_name === 'foo' && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + } + + public function test_no_error_if_apk_same() + { + config(['captchavel.apk_package_name' => 'foo']); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => 'foo', + ])); + + $event = Event::fake(); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->apk_package_name === 'foo' && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + } + + public function test_exception_if_apk_not_equal() + { + config(['captchavel.apk_package_name' => 'bar']); + + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => null, + ])); + + $event = Event::fake(); + + $this->post('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertRedirect('/'); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->apk_package_name === null && $event->request instanceof Request; + }); + + $this->postJson('v3/default', [ + Captchavel::INPUT => 'token' + ])->assertJsonValidationErrors('apk_package_name'); + } + + public function test_no_error_if_no_action() + { + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => null, + 'action' => 'foo', + ])); + + $event = Event::fake(); + + Route::post('test', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:null,null'); + + $this->post('test', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->action === 'foo' && $event->request instanceof Request; + }); + } + + public function test_no_error_if_action_same() + { + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => null, + 'action' => 'foo', + ])); + + $event = Event::fake(); + + Route::post('test', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:null,foo'); + + $this->post('test', [ + Captchavel::INPUT => 'token' + ])->assertOk(); + + $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { + return $event->response->action === 'foo' && $event->request instanceof Request; + }); + } + + public function test_exception_if_action_not_equal() + { + $mock = $this->mock(Captchavel::class); + + $mock->shouldReceive('useCredentials') + ->andReturnSelf(); + $mock->shouldReceive('retrieve') + ->with('token', '127.0.0.1') + ->andReturn(new ReCaptchaResponse([ + 'success' => true, + 'apk_package_name' => null, + 'action' => 'foo', + ])); + + Route::post('test', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:null,bar'); + + $this->post('test', [ + Captchavel::INPUT => 'token' + ])->assertRedirect('/'); + + $this->postJson('test', [ + Captchavel::INPUT => 'token' + ])->assertJsonValidationErrors('action'); + } +} diff --git a/tests/Http/Middleware/UsesRoutesWithMiddleware.php b/tests/Http/Middleware/UsesRoutesWithMiddleware.php new file mode 100644 index 0000000..befffe3 --- /dev/null +++ b/tests/Http/Middleware/UsesRoutesWithMiddleware.php @@ -0,0 +1,58 @@ + true]); + + Route::post('v3/default', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3'); + + Route::post('v3/threshold_1', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:1.0'); + + Route::post('v3/threshold_0', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:0'); + + Route::post('v3/action_foo', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:null,foo'); + + Route::post('v3/input_bar', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v3:null,null,bar'); + + Route::post('v2/checkbox', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v2:checkbox'); + + Route::post('v2/checkbox/input_bar', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v2:checkbox,bar'); + + Route::post('v2/invisible', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v2:invisible'); + + Route::post('v2/invisible/input_bar', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v2:invisible,bar'); + + Route::post('v2/android', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v2:android'); + + Route::post('v2/android/input_bar', function (ReCaptchaResponse $response) { + return $response; + })->middleware('recaptcha.v2:android,bar'); + } +} diff --git a/tests/Http/ReCaptchaResponseTest.php b/tests/Http/ReCaptchaResponseTest.php new file mode 100644 index 0000000..5b99c23 --- /dev/null +++ b/tests/Http/ReCaptchaResponseTest.php @@ -0,0 +1,23 @@ +expectException(LogicException::class); + $this->expectExceptionMessage('This is not a reCAPTCHA v3 response, or the score is absent.'); + + (new ReCaptchaResponse([ + 'success' => true, + ]))->isHuman(); + } +} diff --git a/tests/Middleware/CheckRecaptchaTest.php b/tests/Middleware/CheckRecaptchaTest.php deleted file mode 100644 index 1766cfd..0000000 --- a/tests/Middleware/CheckRecaptchaTest.php +++ /dev/null @@ -1,167 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - /** - * Define environment setup. - * - * @param \Illuminate\Foundation\Application $app - * @return void - */ - protected function getEnvironmentSetUp($app) - { - $app['env'] = 'local'; - - $app->make('config')->set('captchavel.enable_local', true); - $app->make('config')->set('captchavel.secret', 'test-secret'); - $app->make('config')->set('captchavel.key', 'test-key'); - - $app->make('router')->get('test-get-with-middleware', function () { return 'true'; })->middleware('recaptcha'); - $app->make('router')->get('test-get', function () { return 'true'; }); - $app->make('router')->post('test-post', function () { return 'true'; })->middleware('recaptcha'); - } - - public function testSameRecaptchaInstance() - { - $mockRequester = \Mockery::mock(RequestMethod::class); - $mockRequester->shouldReceive('submit')->andReturn(json_encode([ - 'success' => true, - 'score' => 0.8, - 'action' => '/testpost', - 'challenge_ts' => Carbon::now()->toIso8601ZuluString(), - ])); - - $this->app->when(ReCaptchaFactory::class) - ->needs(RequestMethod::class) - ->give(function () use ($mockRequester) { - return $mockRequester; - }); - - $this->post('test-post', [ - '_recaptcha' => Str::random(356) - ])->assertOk(); - - $this->assertEquals(app(ReCaptcha::class), app('recaptcha')); - $this->assertEquals(app(ReCaptcha::class), recaptcha()); - $this->assertNotEquals(app(ReCaptcha::class), new ReCaptcha()); - $this->assertNotEquals(app('recaptcha'), new ReCaptcha()); - $this->assertNotEquals(recaptcha(), new ReCaptcha()); - } - - public function testRequestWithCaptchaValidates() - { - $mockRequester = \Mockery::mock(RequestMethod::class); - $mockRequester->shouldReceive('submit')->andReturn(json_encode([ - 'success' => true, - 'score' => 0.8, - 'action' => '/testpost', - 'challenge_ts' => Carbon::now()->toIso8601ZuluString(), - ])); - - $this->app->when(ReCaptchaFactory::class) - ->needs(RequestMethod::class) - ->give(function ($app) use ($mockRequester) { - return $mockRequester; - }); - - $this->post('test-post', [ - '_recaptcha' => Str::random(356) - ])->assertOk(); - } - - public function testFailsOnNonPostMethod() - { - $this->app->make('router')->get('get-route', function () { return 'true'; })->middleware('recaptcha'); - $this->app->make('router')->match(['head'], 'head-route', function () { return 'true'; })->middleware('recaptcha'); - - $response = $this->get('get-route'); - - $response->assertStatus(200); - - $response = $this->call('head', 'head-route'); - - $response->assertStatus(200); - } - - public function testFailsInvalidToken() - { - $response = $this->post('test-post', [ '_recaptcha' => ['not.string']]); - - $response->assertStatus(500); - $this->assertInstanceOf(InvalidRecaptchaException::class, $response->exception); - } - - public function testFailsInvalidRecaptcha() - { - $mockRequester = \Mockery::mock(RequestMethod::class); - $mockRequester->shouldReceive('submit')->andReturn(json_encode([ - 'success' => false, - 'score' => 0.8, - 'action' => '/testpost', - 'challenge_ts' => Carbon::now()->toIso8601ZuluString(), - ])); - - $this->app->when(ReCaptchaFactory::class) - ->needs(RequestMethod::class) - ->give(function ($app) use ($mockRequester) { - return $mockRequester; - }); - - $response = $this->post('test-post', [ '_recaptcha' => Str::random(356)]); - - $response->assertStatus(500); - $this->assertInstanceOf(FailedRecaptchaException::class, $response->exception); - } - - public function testMiddlewareAcceptsParameter() - { - $mockReCaptchaFactory = \Mockery::mock(ReCaptchaFactory::class); - $mockReCaptchaFactory->shouldReceive('setExpectedAction') - ->once() - ->andReturnSelf(); - $mockReCaptchaFactory->shouldReceive('verify') - ->once() - ->andReturn(new Response(true, [], null, null, null, 1.0, null)); - - $this->app->when(CheckRecaptcha::class) - ->needs(ReCaptchaFactory::class) - ->give(function () use ($mockReCaptchaFactory) { - return $mockReCaptchaFactory; - }); - - $this->app->make('router') - ->post('test-post', function () { - return 'reaches'; - }) - ->middleware('recaptcha:0.9'); - - $this->post('test-post', [ '_recaptcha' => Str::random(356) ]) - ->assertOk(); - } -} diff --git a/tests/Middleware/InjectRecaptchaScriptTest.php b/tests/Middleware/InjectRecaptchaScriptTest.php deleted file mode 100644 index 575530a..0000000 --- a/tests/Middleware/InjectRecaptchaScriptTest.php +++ /dev/null @@ -1,112 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - /** - * Define environment setup. - * - * @param \Illuminate\Foundation\Application $app - * @return void - */ - protected function getEnvironmentSetUp($app) - { - $app['env'] = 'local'; - - $app->make('config')->set('captchavel.enable_local', true); - $app->make('config')->set('captchavel.secret', 'test-secret'); - $app->make('config')->set('captchavel.key', 'test-key'); - - $this->afterApplicationCreated(function () { - - $router = $this->app->make('router'); - - $router->get('test-get', function () { - return response()->make(/** @lang HTML */ << - - - - - - Document - - -
-
- - -EOT - ); - }); - - $router->get('invalid-html', function () { - return response()->make('Hellow'); - }); - - $router->get('json', function () { - return response()->json(''); - }); - - }); - } - - public function testInjectsScriptAutomatically() - { - $response = $this->get('test-get') - ->assertSee('Start Captchavel Script'); - - $this->assertStringContainsString('api.js?render=test-key&onload=captchavelCallback', $response->getContent()); - } - - public function testDoesntInjectsOnInvalidHtml() - { - $response = $this->get('invalid-html') - ->assertDontSee('Start Captchavel Script'); - - $this->assertStringNotContainsString('api.js?render=test-key&onload=captchavelCallback', $response->getContent()); - } - - public function testDoesntInjectsOnJson() - { - $response = $this->get('json') - ->assertDontSee('Start Captchavel Script'); - - $this->assertStringNotContainsString('api.js?render=test-key&onload=captchavelCallback', $response->getContent()); - } - - public function testDoesntInjectsOnAjax() - { - $response = $this->get('test-get', [ - 'X-Requested-With' => 'XMLHttpRequest' - ]) - ->assertDontSee('Start Captchavel Script'); - - $this->assertStringNotContainsString('api.js?render=test-key&onload=captchavelCallback', $response->getContent()); - } - - public function testDoesntInjectsOnException() - { - $response = $this->get('route-doesnt-exists-will-trigger-exception') - ->assertDontSee('Start Captchavel Script'); - - $this->assertStringNotContainsString('api.js?render=test-key&onload=captchavelCallback', $response->getContent()); - } - -} diff --git a/tests/Middleware/ThrottleRecaptchaTest.php b/tests/Middleware/ThrottleRecaptchaTest.php deleted file mode 100644 index 6c34f88..0000000 --- a/tests/Middleware/ThrottleRecaptchaTest.php +++ /dev/null @@ -1,167 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - /** - * Define environment setup. - * - * @param \Illuminate\Foundation\Application $app - * @return void - */ - protected function getEnvironmentSetUp($app) - { - $app['env'] = 'local'; - - $app->make('config')->set('captchavel.enable_local', true); - $app->make('config')->set('captchavel.secret', 'test-secret'); - $app->make('config')->set('captchavel.key', 'test-key'); - - $app->make('router')->get('test-get-with-middleware', function () { return 'true'; })->middleware('recaptcha'); - $app->make('router')->get('test-get', function () { return 'true'; }); - $app->make('router')->post('test-post', function () { return 'true'; })->middleware('recaptcha'); - } - - public function testSameRecaptchaInstance() - { - $mockRequester = \Mockery::mock(RequestMethod::class); - $mockRequester->shouldReceive('submit')->andReturn(json_encode([ - 'success' => true, - 'score' => 0.8, - 'action' => '/testpost', - 'challenge_ts' => Carbon::now()->toIso8601ZuluString(), - ])); - - $this->app->when(ReCaptchaFactory::class) - ->needs(RequestMethod::class) - ->give(function () use ($mockRequester) { - return $mockRequester; - }); - - $this->post('test-post', [ - '_recaptcha' => Str::random(356) - ])->assertOk(); - - $this->assertEquals(app(ReCaptcha::class), app('recaptcha')); - $this->assertEquals(app(ReCaptcha::class), recaptcha()); - $this->assertNotEquals(app(ReCaptcha::class), new ReCaptcha()); - $this->assertNotEquals(app('recaptcha'), new ReCaptcha()); - $this->assertNotEquals(recaptcha(), new ReCaptcha()); - } - - public function testRequestWithCaptchaValidates() - { - $mockRequester = \Mockery::mock(RequestMethod::class); - $mockRequester->shouldReceive('submit')->andReturn(json_encode([ - 'success' => true, - 'score' => 0.8, - 'action' => '/testpost', - 'challenge_ts' => Carbon::now()->toIso8601ZuluString(), - ])); - - $this->app->when(ReCaptchaFactory::class) - ->needs(RequestMethod::class) - ->give(function ($app) use ($mockRequester) { - return $mockRequester; - }); - - $this->post('test-post', [ - '_recaptcha' => Str::random(356) - ])->assertOk(); - } - - public function testFailsOnNonPostMethod() - { - $this->app->make('router')->get('get-route', function () { return 'true'; })->middleware('recaptcha'); - $this->app->make('router')->match(['head'], 'head-route', function () { return 'true'; })->middleware('recaptcha'); - - $response = $this->get('get-route'); - - $response->assertStatus(200); - - $response = $this->call('head', 'head-route'); - - $response->assertStatus(200); - } - - public function testFailsInvalidToken() - { - $response = $this->post('test-post', [ '_recaptcha' => ['not.string']]); - - $response->assertStatus(500); - $this->assertInstanceOf(InvalidRecaptchaException::class, $response->exception); - } - - public function testFailsInvalidRecaptcha() - { - $mockRequester = \Mockery::mock(RequestMethod::class); - $mockRequester->shouldReceive('submit')->andReturn(json_encode([ - 'success' => false, - 'score' => 0.8, - 'action' => '/testpost', - 'challenge_ts' => Carbon::now()->toIso8601ZuluString(), - ])); - - $this->app->when(ReCaptchaFactory::class) - ->needs(RequestMethod::class) - ->give(function ($app) use ($mockRequester) { - return $mockRequester; - }); - - $response = $this->post('test-post', [ '_recaptcha' => Str::random(356)]); - - $response->assertStatus(500); - $this->assertInstanceOf(FailedRecaptchaException::class, $response->exception); - } - - public function testMiddlewareAcceptsParameter() - { - $mockReCaptchaFactory = \Mockery::mock(ReCaptchaFactory::class); - $mockReCaptchaFactory->shouldReceive('setExpectedAction') - ->once() - ->andReturnSelf(); - $mockReCaptchaFactory->shouldReceive('verify') - ->once() - ->andReturn(new Response(true, [], null, null, null, 1.0, null)); - - $this->app->when(CheckRecaptcha::class) - ->needs(ReCaptchaFactory::class) - ->give(function () use ($mockReCaptchaFactory) { - return $mockReCaptchaFactory; - }); - - $this->app->make('router') - ->post('test-post', function () { - return 'reaches'; - }) - ->middleware('recaptcha:0.9'); - - $this->post('test-post', [ '_recaptcha' => Str::random(356) ]) - ->assertOk(); - } -} diff --git a/tests/Middleware/TransparentRecaptchaTest.php b/tests/Middleware/TransparentRecaptchaTest.php deleted file mode 100644 index 1756f8b..0000000 --- a/tests/Middleware/TransparentRecaptchaTest.php +++ /dev/null @@ -1,66 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - /** - * Define environment setup. - * - * @param \Illuminate\Foundation\Application $app - * @return void - */ - protected function getEnvironmentSetUp($app) - { - $app->make('config')->set('captchavel.secret', 'test-secret'); - $app->make('config')->set('captchavel.key', 'test-key'); - - $app->make('router')->post('test-post', function () { - return 'true'; - })->middleware('recaptcha'); - } - - public function testTransparentMiddlewareReturnsHuman() - { - $this->post('test-post', [ - '_recaptcha' => null - ])->assertSeeText('true'); - - $this->assertTrue(recaptcha()->isHuman()); - } - - public function testTransparentMiddlewareReturnsRobotOnQuery() - { - $this->post('test-post?is_robot=null', [ - '_recaptcha' => null - ])->assertSeeText('true'); - - $this->assertFalse(recaptcha()->isRobot()); - } - - public function testTransparentMiddlewareReturnsRobotOnInput() - { - $this->post('test-post?is_robot=null', [ - '_recaptcha' => null, - 'is_robot' => 'true' - ])->assertSeeText('true'); - - $this->assertFalse(recaptcha()->isRobot()); - } - -} diff --git a/tests/RecaptchaResponseHolderTest.php b/tests/RecaptchaResponseHolderTest.php deleted file mode 100644 index 3ee7c1c..0000000 --- a/tests/RecaptchaResponseHolderTest.php +++ /dev/null @@ -1,123 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - public function testReCaptchaResponse() - { - $response = \Mockery::mock(Response::class); - - $holder = new ReCaptcha(); - - $holder->setResponse($response); - - $this->assertInstanceOf(Response::class, $holder->response()); - } - - public function testThreshold() - { - $holder = new ReCaptcha(); - - $holder->setThreshold(0.4); - - $this->assertIsFloat($holder->getThreshold()); - } - - public function testSince() - { - $response = \Mockery::mock(Response::class); - $response->shouldReceive('getChallengeTs') - ->once() - ->andReturn(Carbon::now()->toIso8601ZuluString()); - - $holder = new ReCaptcha(); - - $holder->setResponse($response); - - $this->assertInstanceOf(Carbon::class, $holder->since()); - } - - public function testThresholdOverScore() - { - $response = \Mockery::mock(Response::class); - $response->shouldReceive('getScore') - ->twice() - ->andReturn(0.8); - - $holder = new ReCaptcha(); - - $holder->setResponse($response); - $holder->setThreshold(0.5); - - $this->assertTrue($holder->isHuman()); - $this->assertFalse($holder->isRobot()); - } - - public function testThresholdUnderScore() - { - $response = \Mockery::mock(Response::class); - $response->shouldReceive('getScore') - ->twice() - ->andReturn(0.2); - - $holder = new ReCaptcha(); - - $holder->setResponse($response); - $holder->setThreshold(0.5); - - $this->assertTrue($holder->isRobot()); - $this->assertFalse($holder->isHuman()); - } - - public function testIsResolved() - { - $holder = new ReCaptcha(); - - $this->assertFalse($holder->isResolved()); - - $holder->setResponse(\Mockery::mock(Response::class)); - - $this->assertTrue($holder->isResolved()); - } - - public function testExceptionOnHumanCheck() - { - $this->expectException(RecaptchaNotResolvedException::class); - - (new ReCaptcha())->isHuman(); - } - - public function testExceptionOnRobotCheck() - { - $this->expectException(RecaptchaNotResolvedException::class); - - (new ReCaptcha())->isRobot(); - } - - public function testExceptionOnSinceCheck() - { - $this->expectException(RecaptchaNotResolvedException::class); - - (new ReCaptcha())->since(); - } -} diff --git a/tests/RegistersPackage.php b/tests/RegistersPackage.php new file mode 100644 index 0000000..c69a80c --- /dev/null +++ b/tests/RegistersPackage.php @@ -0,0 +1,19 @@ + 'DarkGhostHunter\Captchavel\Facades\Captchavel' + ]; + } + + protected function getPackageProviders($app) + { + return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; + } + +} diff --git a/tests/ServiceProviderTest.php b/tests/ServiceProviderTest.php deleted file mode 100644 index ce22dbd..0000000 --- a/tests/ServiceProviderTest.php +++ /dev/null @@ -1,178 +0,0 @@ - 'DarkGhostHunter\Captchavel\Facades\ReCaptcha' - ]; - } - - protected function getPackageProviders($app) - { - return ['DarkGhostHunter\Captchavel\CaptchavelServiceProvider']; - } - - public function testRegistersPackage() - { - $instance = $this->app->make('recaptcha'); - - $this->assertInstanceOf(ReCaptcha::class, $instance); - } - - public function testRecaptchaFacade() - { - $this->assertInstanceOf(ReCaptcha::class, \ReCaptcha::getFacadeRoot()); - } - - public function testReceivesConfig() - { - $this->assertEquals( - include __DIR__.'/../config/captchavel.php', - $this->app->make('config')->get('captchavel') - ); - } - - public function testResolvesRecaptchaResponse() - { - config()->set('captchavel.secret', Str::random()); - - $instance = app()->make(ReCaptchaFactory::class); - - $this->assertInstanceOf(ReCaptchaFactory::class, $instance); - } - - public function testDoesntResolveRecaptchaWithoutSecret() - { - $this->expectException(\RuntimeException::class); - - $instance = app()->make(ReCaptchaFactory::class); - - $this->assertInstanceOf(ReCaptchaFactory::class, $instance); - } - - public function testRegisterMiddleware() - { - $this->app['env'] = 'production'; - - /** @var CaptchavelServiceProvider $provider */ - $provider = app()->make(CaptchavelServiceProvider::class, ['app' => $this->app]); - - $provider->boot(); - - $middleware = $this->app->make('router')->getMiddleware(); - - $this->assertArrayHasKey('recaptcha', $middleware); - $this->assertEquals($middleware['recaptcha'], CheckRecaptcha::class); - $this->assertEquals($middleware['recaptcha-inject'], InjectRecaptchaScript::class); - } - - public function testDoesntRegisterMiddlewareOnTesting() - { - $this->assertFalse($this->app->make(Kernel::class)->hasMiddleware(CheckRecaptcha::class)); - $middleware = $this->app->make('router')->getMiddleware(); - - $this->assertEquals(TransparentRecaptcha::class, $middleware['recaptcha']); - } - - public function testRegisterTransparentMiddlewareOnNotProduction() - { - $this->app['env'] = 'local'; - - /** @var CaptchavelServiceProvider $provider */ - $provider = $this->app->make(CaptchavelServiceProvider::class, ['app' => $this->app]); - - $provider->boot(); - - $middleware = $this->app->make('router')->getMiddleware(); - - $this->assertEquals(TransparentRecaptcha::class, $middleware['recaptcha']); - } - - public function testRegisterInjectMiddlewareOnAuto() - { - $this->app['env'] = 'production'; - - /** @var CaptchavelServiceProvider $provider */ - $provider = $this->app->make(CaptchavelServiceProvider::class, ['app' => $this->app]); - - $provider->boot(); - - $this->assertTrue( - $this->app->make(Kernel::class)->hasMiddleware(InjectRecaptchaScript::class) - ); - } - - public function testDoesntRegisterInjectMiddlewareOnNonAuto() - { - $this->app['env'] = 'production'; - $this->app['config']->set('captchavel.mode', 'manual'); - - /** @var CaptchavelServiceProvider $provider */ - $provider = $this->app->make(CaptchavelServiceProvider::class, ['app' => $this->app]); - - $provider->boot(); - - $this->assertFalse( - $this->app->make(Kernel::class)->hasMiddleware(InjectRecaptchaScript::class) - ); - } - - public function testRegisterMiddlewareOnLocalTrue() - { - $this->app['env'] = 'local'; - $this->app['config']->set('captchavel.enable_local', true); - - /** @var CaptchavelServiceProvider $provider */ - $provider = $this->app->make(CaptchavelServiceProvider::class, ['app' => $this->app]); - - $provider->boot(); - - /** @var \Illuminate\Routing\Router $router */ - $router = $this->app->make('router'); - - $this->assertEquals(CheckRecaptcha::class, $router->getMiddleware()['recaptcha']); - } - - public function testPublishesConfigFile() - { - $this->artisan('vendor:publish', [ - '--provider' => CaptchavelServiceProvider::class - ]); - - $this->assertFileExists(config_path('captchavel.php')); - $this->assertFileIsReadable(config_path('captchavel.php')); - $this->assertFileEquals(config_path('captchavel.php'), __DIR__ . '/../config/captchavel.php'); - $this->assertTrue(unlink(config_path('captchavel.php'))); - } - - public function testRegistersMacros() - { - \DarkGhostHunter\Captchavel\Facades\ReCaptcha::shouldReceive('isHuman') - ->once() - ->andReturnTrue(); - - \DarkGhostHunter\Captchavel\Facades\ReCaptcha::shouldReceive('isRobot') - ->once() - ->andReturnFalse(); - - $this->assertTrue(Request::isHuman()); - $this->assertFalse(Request::isRobot()); - } - -} From 42ce15f8e13f827e1c693634a56065b0dbe4eb0b Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Fri, 29 May 2020 00:45:11 -0400 Subject: [PATCH 2/5] Fixed typos. --- README.md | 18 +++++++++--------- .../Middleware/BaseReCaptchaMiddleware.php | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 90edd4b..e2269d3 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,13 @@ Route::post('login', 'LoginController@login') ### Score driven interaction -The reCAPTCHA v3 middleware works differently from v2. This is a score-driven challenge where robots will get lower scores than humans, with a default threshold of `0.5`. +The reCAPTCHA v3 middleware works differently from v2. This is a score-driven challenge between `0.0` and `1.0` where robots will get lower scores than humans. The default threshold is `0.5`. Simply add the `recaptcha.v3` middleware to your route: ```php Route::post('comment', 'CommentController@store') - ->middleware('recaptcha.v3:0.8'); + ->middleware('recaptcha.v3'); ``` Once the challenge has been received, you will have access to two methods from the Request instance: `isHuman()` and `isRobot()`, which return `true` or `false`: @@ -118,11 +118,11 @@ public function store(Request $request, Post $post) #### Threshold, action and input name -The middleware accepts two parameters in the following order: +The middleware accepts three parameters in the following order: -* Threshold: Values **above or equal** are considered human. -* Action: The action name to optionally check against. -* Input: The name of the reCAPTCHA input to verify. +1. Threshold: Values **above or equal** are considered human. +2. Action: The action name to optionally check against. +3. Input: The name of the reCAPTCHA input to verify. ```php When mocking requests, there is no need to add any reCAPTCHA token to your tests. +> When mocking requests, there is no need to add any reCAPTCHA token or secrets in your tests. Since you will have access to the Response to check if it was made by a robot or a human, simply use the `asHuman()` and `asRobot()` methods to score `1.0` or `0.0`, respectively. diff --git a/src/Http/Middleware/BaseReCaptchaMiddleware.php b/src/Http/Middleware/BaseReCaptchaMiddleware.php index 2c58f74..7ade300 100644 --- a/src/Http/Middleware/BaseReCaptchaMiddleware.php +++ b/src/Http/Middleware/BaseReCaptchaMiddleware.php @@ -111,13 +111,13 @@ protected function fakeResponseScore($request) // We will first check if the Captchavel instance was not already faked. If it has been, // we will hands out the control to the developer. Otherwise, we will manually create a // fake Captchavel instance and look for the "is_robot" parameter to set a fake score. - if ($this->captchavel instanceof Captchavel && $this->captchavel->isNotResolved()) { + if ($this->captchavel instanceof Captchavel) { $this->captchavel = CaptchavelFacade::fake(); + } + if ($this->captchavel->isNotResolved()) { $request->has('is_robot') ? $this->captchavel->asRobot() : $this->captchavel->asHuman(); } - - } /** From 4e0caa3c1e1bf2e0e22c86850979c3852c1fccad Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Fri, 29 May 2020 00:46:43 -0400 Subject: [PATCH 3/5] Fixed PHP starting string. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index e2269d3..e117779 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,6 @@ Add the `recaptcha.v2` middleware to your `POST` routes. The middleware will cat When the validation fails, the user will be redirected back to the form route, or a JSON response will be returned with the validation errors. ```php -middleware('recaptcha.v2:checkbox'); ``` @@ -125,7 +124,6 @@ The middleware accepts three parameters in the following order: 3. Input: The name of the reCAPTCHA input to verify. ```php -middleware('recaptcha.v3:0.7,login,custom-recaptcha-input'); ``` @@ -260,6 +258,7 @@ Since you will have access to the Response to check if it was made by a robot or ```php Date: Fri, 29 May 2020 00:58:32 -0400 Subject: [PATCH 4/5] Fixed not faking responses. --- src/Http/Middleware/BaseReCaptchaMiddleware.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Http/Middleware/BaseReCaptchaMiddleware.php b/src/Http/Middleware/BaseReCaptchaMiddleware.php index 7ade300..2de6028 100644 --- a/src/Http/Middleware/BaseReCaptchaMiddleware.php +++ b/src/Http/Middleware/BaseReCaptchaMiddleware.php @@ -111,11 +111,8 @@ protected function fakeResponseScore($request) // We will first check if the Captchavel instance was not already faked. If it has been, // we will hands out the control to the developer. Otherwise, we will manually create a // fake Captchavel instance and look for the "is_robot" parameter to set a fake score. - if ($this->captchavel instanceof Captchavel) { + if ($this->captchavel instanceof Captchavel && $this->captchavel->isNotResolved()) { $this->captchavel = CaptchavelFacade::fake(); - } - - if ($this->captchavel->isNotResolved()) { $request->has('is_robot') ? $this->captchavel->asRobot() : $this->captchavel->asHuman(); } } From b54fee71952c18eba3253f2582141a49cd6d3ba9 Mon Sep 17 00:00:00 2001 From: DarkGhostHunter Date: Fri, 29 May 2020 01:31:19 -0400 Subject: [PATCH 5/5] Reworked faking responses. --- README.md | 14 ++++--- src/CaptchavelFake.php | 13 ++++--- src/Facades/Captchavel.php | 37 ++++++++++++++++++- .../Middleware/BaseReCaptchaMiddleware.php | 5 ++- tests/CaptchavelFakeTest.php | 6 +-- tests/Http/Middleware/ScoreMiddlewareTest.php | 6 +++ 6 files changed, 64 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e117779..7429a05 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,11 @@ Here is the full array of [reCAPTCHA credentials](#set-up) to use depending on t ## Testing with Captchavel -When testing your application, you may want to mock the reCAPTCHA responses from the servers. There is no need to the whole client, you can use the `fake()` method of the `Captchavel` facade. +When unit testing your application, this package [automatically fakes reCAPTCHA responses](#fake-responses) by setting. > When mocking requests, there is no need to add any reCAPTCHA token or secrets in your tests. -Since you will have access to the Response to check if it was made by a robot or a human, simply use the `asHuman()` and `asRobot()` methods to score `1.0` or `0.0`, respectively. +When using reCAPTCHA v3 (score), you can fake a response made by a human or robot by simply using the `fakeHuman()` and `fakeRobot()` methods, which will score `1.0` or `0.0` respectively. ```php asHuman(); +Captchavel::fakeHuman(); $this->post('login', [ 'email' => 'test@test.com', 'password' => '123456', ])->assertRedirect('user.welcome'); -// Let the user login using 2FA. -Captchavel::fake()->asRobot(); +// ... but if it's a robot, force him to use 2FA. +Captchavel::fakeRobot(); $this->post('login', [ 'email' => 'test@test.com', @@ -278,7 +278,9 @@ $this->post('login', [ ])->assertViewIs('login.2fa'); ``` -Alternatively, `shouldScore()` method that will fake the score for anything you set. +Alternatively, `fakeScore()` method that will fake any score you set. + +> Fake responses don't come with action, hostnames or APK package names. ### Events diff --git a/src/CaptchavelFake.php b/src/CaptchavelFake.php index 21ba567..eae39dc 100644 --- a/src/CaptchavelFake.php +++ b/src/CaptchavelFake.php @@ -49,11 +49,14 @@ public function retrieve(?string $challenge, string $ip) * @param float $score * @return $this */ - public function shouldScore(float $score) + public function fakeScore(float $score) { return $this->setResponse(new ReCaptchaResponse([ 'success' => true, 'score' => $score, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, ])); } @@ -62,9 +65,9 @@ public function shouldScore(float $score) * * @return $this */ - public function asRobot() + public function fakeRobot() { - return $this->shouldScore(0); + return $this->fakeScore(0); } /** @@ -72,8 +75,8 @@ public function asRobot() * * @return $this */ - public function asHuman() + public function fakeHuman() { - return $this->shouldScore(1); + return $this->fakeScore(1); } } diff --git a/src/Facades/Captchavel.php b/src/Facades/Captchavel.php index a29e033..4a71a81 100644 --- a/src/Facades/Captchavel.php +++ b/src/Facades/Captchavel.php @@ -26,10 +26,45 @@ protected static function getFacadeAccessor() * * @return \DarkGhostHunter\Captchavel\CaptchavelFake */ - public static function fake() + protected static function fake() { + if (static::$resolvedInstance instanceof CaptchavelFake) { + return static::$resolvedInstance; + } + static::swap($fake = static::$app->make(CaptchavelFake::class)); return $fake; } + + /** + * Makes the fake Captchavel response with a fake score. + * + * @param float $score + * @return \DarkGhostHunter\Captchavel\CaptchavelFake + */ + public static function fakeScore(float $score) + { + return static::fake()->fakeScore($score); + } + + /** + * Makes a fake Captchavel response made by a robot with "0" score. + * + * @return \DarkGhostHunter\Captchavel\CaptchavelFake + */ + public static function fakeRobot() + { + return static::fake()->fakeRobot(); + } + + /** + * Makes a fake Captchavel response made by a human with "1.0" score. + * + * @return \DarkGhostHunter\Captchavel\CaptchavelFake + */ + public static function fakeHuman() + { + return static::fake()->fakeHuman(); + } } diff --git a/src/Http/Middleware/BaseReCaptchaMiddleware.php b/src/Http/Middleware/BaseReCaptchaMiddleware.php index 2de6028..d4e04d4 100644 --- a/src/Http/Middleware/BaseReCaptchaMiddleware.php +++ b/src/Http/Middleware/BaseReCaptchaMiddleware.php @@ -112,8 +112,9 @@ protected function fakeResponseScore($request) // we will hands out the control to the developer. Otherwise, we will manually create a // fake Captchavel instance and look for the "is_robot" parameter to set a fake score. if ($this->captchavel instanceof Captchavel && $this->captchavel->isNotResolved()) { - $this->captchavel = CaptchavelFacade::fake(); - $request->has('is_robot') ? $this->captchavel->asRobot() : $this->captchavel->asHuman(); + $this->captchavel = $request->has('is_robot') ? + CaptchavelFacade::fakeRobot() : + CaptchavelFacade::fakeHuman(); } } diff --git a/tests/CaptchavelFakeTest.php b/tests/CaptchavelFakeTest.php index 11bbe90..229e58c 100644 --- a/tests/CaptchavelFakeTest.php +++ b/tests/CaptchavelFakeTest.php @@ -28,7 +28,7 @@ public function test_using_fake_on_unit_test() public function test_makes_fake_score() { - Captchavel::fake()->shouldScore(0.3); + Captchavel::fakeScore(0.3); Route::post('test', function (ReCaptchaResponse $response) { return [$response->score, $response->isRobot(), $response->isHuman()]; @@ -39,7 +39,7 @@ public function test_makes_fake_score() public function test_makes_human_score_one() { - Captchavel::fake()->asHuman(); + Captchavel::fakeHuman(); Route::post('test', function (ReCaptchaResponse $response) { return [$response->score, $response->isRobot(), $response->isHuman()]; @@ -50,7 +50,7 @@ public function test_makes_human_score_one() public function test_makes_robot_score_zero() { - Captchavel::fake()->asRobot(); + Captchavel::fakeRobot(); Route::post('test', function (ReCaptchaResponse $response) { return [$response->score, $response->isRobot(), $response->isHuman()]; diff --git a/tests/Http/Middleware/ScoreMiddlewareTest.php b/tests/Http/Middleware/ScoreMiddlewareTest.php index c9ffe3b..738fe2b 100644 --- a/tests/Http/Middleware/ScoreMiddlewareTest.php +++ b/tests/Http/Middleware/ScoreMiddlewareTest.php @@ -82,6 +82,9 @@ public function test_fakes_human_response_automatically() ->assertExactJson([ 'success' => true, 'score' => 1, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, ]); $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) { @@ -100,6 +103,9 @@ public function test_fakes_robot_response_if_input_is_robot_present() ->assertExactJson([ 'success' => true, 'score' => 0, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, ]); $event->assertDispatched(ReCaptchaResponseReceived::class, function ($event) {