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..7429a05 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,141 @@ 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 between `0.0` and `1.0` where robots will get lower scores than humans. The default threshold is `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'); ``` -> 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 three parameters in the following order: -> If you try to check if the response while the reCAPTCHA wasn't resolved, you will get a `RecaptchaNotResolvedException`. +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. -## Local development and robot requests +```php +Route::post('comment', 'CommentController@store') + ->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 +182,151 @@ 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 when present. 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 +Default threshold to check against reCAPTCHA v3 challenges. Values **equal or above** will be considered as human. -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. +If you're not using reCAPTCHA v3, or you're fine with the default, leave this alone. You can still [override the default in a per-route basis](#threshold-action-and-input-name). -If you don't have them, use the [Google reCAPTCHA Admin console](https://g.co/recaptcha/admin) to create a pair. - -### 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 unit testing your application, this package [automatically fakes reCAPTCHA responses](#fake-responses) by setting. -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 or secrets in 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). +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 'App\Http\ReCaptcha\GuzzleRequestMethod', -]; -``` +use DarkGhostHunter\Captchavel\Facades\Captchavel; -You can mimic this next example were we will use Guzzle. +// Let the user login normally. +Captchavel::fakeHuman(); -#### Example implementation +$this->post('login', [ + 'email' => 'test@test.com', + 'password' => '123456', +])->assertRedirect('user.welcome'); -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. +// ... but if it's a robot, force him to use 2FA. +Captchavel::fakeRobot(); -`app\Http\ReCaptcha\GuzzleRequestMethod.php` -```php -post('login', [ + 'email' => 'test@test.com', + 'password' => '123456', +])->assertViewIs('login.2fa'); +``` -use ReCaptcha\RequestMethod; -use ReCaptcha\RequestParameters; +Alternatively, `fakeScore()` method that will fake any score you set. -class GuzzleRequestMethod implements RequestMethod -{ - // ... - - /** - * Submit the request with the specified parameters. - * - * @param RequestParameters $params Request parameters - * @return string Body of the reCAPTCHA response - */ - public function submit(RequestParameters $params) - { - return (new \GuzzleHttp\Client)->post($params->toQueryString()) - ->getBody() - ->getContents(); - } -} -``` +> Fake responses don't come with action, hostnames or APK package names. -Then, we will add the class to the `request_method` key in our configuration: +### Events -`config/captchavel.php` -```php - 'App\Http\ReCaptcha\GuzzleRequestMethod', -]; -``` +### 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..eae39dc --- /dev/null +++ b/src/CaptchavelFake.php @@ -0,0 +1,82 @@ +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 fakeScore(float $score) + { + return $this->setResponse(new ReCaptchaResponse([ + 'success' => true, + 'score' => $score, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, + ])); + } + + /** + * Makes a fake Captchavel response made by a robot with "0" score. + * + * @return $this + */ + public function fakeRobot() + { + return $this->fakeScore(0); + } + + /** + * Makes a fake Captchavel response made by a human with "1.0" score. + * + * @return $this + */ + public function fakeHuman() + { + return $this->fakeScore(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; + } + + /** + * 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/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 = $request->has('is_robot') ? + CaptchavelFacade::fakeRobot() : + CaptchavelFacade::fakeHuman(); + } + } + + /** + * 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::fakeScore(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::fakeHuman(); + + 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::fakeRobot(); + + 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..738fe2b --- /dev/null +++ b/tests/Http/Middleware/ScoreMiddlewareTest.php @@ -0,0 +1,487 @@ +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, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, + ]); + + $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, + 'action' => null, + 'hostname' => null, + 'apk_package_name' => null, + ]); + + $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()); - } - -}