├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── php.yml ├── resources └── lang │ └── en │ └── validation.php ├── src ├── Http │ ├── Middleware │ │ ├── NormalizeInput.php │ │ ├── VerificationHelpers.php │ │ ├── VerifyReCaptchaV3.php │ │ └── VerifyReCaptchaV2.php │ ├── CheckScore.php │ ├── ValidatesResponse.php │ └── ReCaptchaResponse.php ├── helpers.php ├── Blade │ └── Directives │ │ └── Challenged.php ├── RequestMacro.php ├── Facades │ └── Captchavel.php ├── CaptchavelServiceProvider.php ├── CaptchavelFake.php ├── Captchavel.php └── ReCaptcha.php ├── LICENSE.md ├── composer.json ├── config └── captchavel.php └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Help me support this package 2 | 3 | ko_fi: DarkGhostHunter 4 | custom: ['https://paypal.me/darkghosthunter'] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /resources/lang/en/validation.php: -------------------------------------------------------------------------------- 1 | 'The reCAPTCHA challenge does not matches this action.', 5 | 'missing' => 'The reCAPTCHA challenge was not completed or is missing.', 6 | 'error' => 'Error resolving the reCAPTCHA challenge: :errors.', 7 | ]; 8 | -------------------------------------------------------------------------------- /src/Http/Middleware/NormalizeInput.php: -------------------------------------------------------------------------------- 1 | get(config()->get('recaptcha.remember.key', '_recaptcha')); 19 | 20 | return $timestamp !== null && (!$timestamp || now()->getTimestamp() < $timestamp); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/RequestMacro.php: -------------------------------------------------------------------------------- 1 | isHuman(); 22 | } 23 | 24 | /** 25 | * Check if the reCAPTCHA response is below threshold score. 26 | * 27 | * @return bool 28 | */ 29 | public static function isRobot(): bool 30 | { 31 | return !static::isHuman(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Italo Baeza Cabrera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Http/CheckScore.php: -------------------------------------------------------------------------------- 1 | threshold = $threshold; 25 | 26 | return $this; 27 | } 28 | 29 | /** 30 | * Check if the request was made by a human. 31 | * 32 | * @return bool If the response is V2, this always returns false. 33 | */ 34 | public function isHuman(): bool 35 | { 36 | return $this->get('score', 1.0) >= $this->threshold; 37 | } 38 | 39 | /** 40 | * Check if the request was made by a robot. 41 | * 42 | * @return bool If the response is V2, this always returns false. 43 | */ 44 | public function isRobot(): bool 45 | { 46 | return ! $this->isHuman(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerificationHelpers.php: -------------------------------------------------------------------------------- 1 | guard($guard)->check()) { 32 | return false; 33 | } 34 | } 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * Checks if the user is authenticated on the given guards. 41 | * 42 | * @param array $guards 43 | * @return bool 44 | */ 45 | protected function isAuth(array $guards): bool 46 | { 47 | return ! $this->isGuest($guards); 48 | } 49 | 50 | /** 51 | * Validate if this Request has the reCAPTCHA challenge string. 52 | * 53 | * @param \Illuminate\Http\Request $request 54 | * @param string $input 55 | * @return void 56 | * @throws \Illuminate\Validation\ValidationException 57 | */ 58 | protected function ensureChallengeIsPresent(Request $request, string $input): void 59 | { 60 | if ($request->isNotFilled($input)) { 61 | throw ValidationException::withMessages([ 62 | $input => trans('captchavel::validation.missing') 63 | ])->redirectTo(back()->getTargetUrl()); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | php: [8.0, 8.1] 15 | laravel: [8.*] 16 | dependency-version: [prefer-lowest, prefer-stable] 17 | include: 18 | - laravel: 8.* 19 | testbench: ^6.22 20 | 21 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: mbstring, intl 32 | coverage: xdebug 33 | 34 | - name: Cache dependencies 35 | uses: actions/cache@v2 36 | with: 37 | path: ~/.composer/cache/files 38 | key: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 39 | restore-keys: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- 40 | 41 | - name: Install dependencies 42 | run: | 43 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-progress --no-update 44 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-progress --no-suggest 45 | 46 | - name: Run Tests 47 | run: composer run-script test 48 | 49 | - name: Upload Coverage to Coveralls 50 | env: 51 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | COVERALLS_SERVICE_NAME: github 53 | run: | 54 | rm -rf composer.* vendor/ 55 | composer require php-coveralls/php-coveralls 56 | vendor/bin/php-coveralls 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "darkghosthunter/captchavel", 3 | "description": "Integrate reCAPTCHA into your Laravel application better than the Big G itself!", 4 | "keywords": [ 5 | "darkghosthunter", 6 | "recaptchavel", 7 | "recaptcha" 8 | ], 9 | "homepage": "https://github.com/darkghosthunter/captchavel", 10 | "license": "MIT", 11 | "type": "library", 12 | "authors": [ 13 | { 14 | "name": "Italo Israel Baeza Cabrera", 15 | "email": "darkghosthunter@gmail.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "abandoned": "laragear/recaptcha", 20 | "require": { 21 | "php": "^8.0", 22 | "ext-json": "*", 23 | "illuminate/support": "^8.0", 24 | "illuminate/http": "^8.0", 25 | "illuminate/routing": "^8.0", 26 | "illuminate/container": "^8.0", 27 | "illuminate/events": "^8.0", 28 | "guzzlehttp/guzzle": "^7.4.0" 29 | }, 30 | "require-dev": { 31 | "orchestra/testbench": "^6.22.0", 32 | "phpunit/phpunit": "^9.5.10" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "DarkGhostHunter\\Captchavel\\": "src" 37 | }, 38 | "files": ["src/helpers.php"] 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Tests\\": "tests" 43 | } 44 | }, 45 | "scripts": { 46 | "test": "vendor/bin/phpunit", 47 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 48 | }, 49 | "config": { 50 | "sort-packages": true 51 | }, 52 | "extra": { 53 | "laravel": { 54 | "providers": [ 55 | "DarkGhostHunter\\Captchavel\\CaptchavelServiceProvider" 56 | ], 57 | "aliases": { 58 | "Captchavel": "DarkGhostHunter\\Captchavel\\Facades\\Captchavel" 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Facades/Captchavel.php: -------------------------------------------------------------------------------- 1 | make(CaptchavelFake::class)); 39 | 40 | return $instance; 41 | } 42 | 43 | /** 44 | * Makes the fake Captchavel response with a fake score. 45 | * 46 | * @param float $score 47 | * 48 | * @return void 49 | */ 50 | public static function fakeScore(float $score): void 51 | { 52 | static::fake()->fakeScore($score); 53 | } 54 | 55 | /** 56 | * Makes a fake Captchavel response made by a robot with "0" score. 57 | * 58 | * @return void 59 | */ 60 | public static function fakeRobot(): void 61 | { 62 | static::fake()->fakeRobot(); 63 | } 64 | 65 | /** 66 | * Makes a fake Captchavel response made by a human with "1.0" score. 67 | * 68 | * @return void 69 | */ 70 | public static function fakeHuman(): void 71 | { 72 | static::fake()->fakeHuman(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/CaptchavelServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/captchavel.php', 'captchavel'); 27 | $this->loadTranslationsFrom(__DIR__. '/../resources/lang', 'captchavel'); 28 | 29 | $this->app->singleton(Captchavel::class, static function ($app): Captchavel { 30 | return new Captchavel($app[Factory::class], $app['config']); 31 | }); 32 | } 33 | 34 | /** 35 | * Bootstrap the application services. 36 | * 37 | * @param \Illuminate\Routing\Router $router 38 | * @param \Illuminate\Contracts\Config\Repository $config 39 | * @return void 40 | */ 41 | public function boot(Router $router, Repository $config) 42 | { 43 | if ($this->app->runningInConsole()) { 44 | $this->publishes([__DIR__.'/../config/captchavel.php' => config_path('captchavel.php')], 'config'); 45 | 46 | if ($this->app->runningUnitTests()) { 47 | $config->set('captchavel.fake', true); 48 | } 49 | } 50 | 51 | $router->aliasMiddleware(VerifyReCaptchaV2::SIGNATURE, VerifyReCaptchaV2::class); 52 | $router->aliasMiddleware(VerifyReCaptchaV3::SIGNATURE, VerifyReCaptchaV3::class); 53 | 54 | Request::macro('isRobot', [RequestMacro::class, 'isRobot']); 55 | Request::macro('isHuman', [RequestMacro::class, 'isHuman']); 56 | 57 | $this->app->resolving('blade.compiler', static function (BladeCompiler $blade): void { 58 | $blade->if('challenged', [Blade\Directives\Challenged::class, 'directive']); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Http/ValidatesResponse.php: -------------------------------------------------------------------------------- 1 | attributes, 'success') !== true) { 29 | throw $this->validationException([ 30 | $this->input => trans('captchavel::validation.error', [ 31 | 'errors' => implode(', ', Arr::wrap($this->attributes['errors'] ?? [])) 32 | ]) 33 | ]); 34 | } 35 | 36 | foreach ($this->expectations() as $key => $value) { 37 | $expectation = $this->attributes[$key] ?? null; 38 | 39 | if ($expectation !== '' && $expectation !== $value) { 40 | $errors[$key] = trans('captchavel::validation.match'); 41 | } 42 | } 43 | 44 | if (!empty($errors)) { 45 | throw $this->validationException([$this->input => $errors]); 46 | } 47 | } 48 | 49 | /** 50 | * Creates a new validation exceptions with messages. 51 | * 52 | * @param array $messages 53 | * @return \Illuminate\Validation\ValidationException 54 | */ 55 | protected function validationException(array $messages): ValidationException 56 | { 57 | return ValidationException::withMessages($messages)->redirectTo(back()->getTargetUrl()); 58 | } 59 | 60 | /** 61 | * Retrieve the expectations for the current response. 62 | * 63 | * @return array 64 | * @internal 65 | */ 66 | protected function expectations(): array 67 | { 68 | return array_filter( 69 | Arr::only(config('captchavel'), ['hostname', 'apk_package_name']) + 70 | ['action' => $this->expectedAction] 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/CaptchavelFake.php: -------------------------------------------------------------------------------- 1 | 'application/json'], 45 | json_encode([ 46 | 'success' => true, 47 | 'action' => null, 48 | 'hostname' => null, 49 | 'apk_package_name' => null, 50 | 'challenge_ts' => now()->toAtomString(), 51 | 'score' => $this->score ?? 1.0, 52 | ], JSON_THROW_ON_ERROR) 53 | ) 54 | ) 55 | ), 56 | $input, 57 | ); 58 | } 59 | 60 | /** 61 | * Adds a fake score to return as a reCAPTCHA response. 62 | * 63 | * @param float $score 64 | * 65 | * @return void 66 | */ 67 | public function fakeScore(float $score): void 68 | { 69 | $this->score = $score; 70 | } 71 | 72 | /** 73 | * Makes a fake Captchavel response made by a robot with "0" score. 74 | * 75 | * @return void 76 | */ 77 | public function fakeRobot(): void 78 | { 79 | $this->fakeScore(0); 80 | } 81 | 82 | /** 83 | * Makes a fake Captchavel response made by a human with "1.0" score. 84 | * 85 | * @return void 86 | */ 87 | public function fakeHuman(): void 88 | { 89 | $this->fakeScore(1.0); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /config/captchavel.php: -------------------------------------------------------------------------------- 1 | env('CAPTCHAVEL_ENABLE', false), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Fake on local development 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Sometimes you may want to fake success or failed responses from reCAPTCHA 26 | | servers in local development. To do this, simply enable the environment 27 | | variable and then issue as a checkbox parameter is_robot to any form. 28 | | 29 | | For v2 middleware, faking means bypassing checks. 30 | | 31 | */ 32 | 33 | 'fake' => env('CAPTCHAVEL_FAKE', false), 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Constraints 38 | |-------------------------------------------------------------------------- 39 | | 40 | | These default constraints allows further verification of the incoming 41 | | response from reCAPTCHA servers. Hostname and APK Package Name are 42 | | required if these are not verified in your reCAPTCHA admin panel. 43 | | 44 | */ 45 | 46 | 'hostname' => env('RECAPTCHA_HOSTNAME'), 47 | 'apk_package_name' => env('RECAPTCHA_APK_PACKAGE_NAME'), 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Threshold 52 | |-------------------------------------------------------------------------- 53 | | 54 | | For reCAPTCHA v3, which is a score-driven interaction, this default 55 | | threshold is the slicing point between bots and humans. If a score 56 | | is below this threshold it means the request was made by a bot. 57 | | 58 | */ 59 | 60 | 'threshold' => 0.5, 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Remember V2 Challenge 65 | |-------------------------------------------------------------------------- 66 | | 67 | | Asking again and again for validation may become cumbersome when a form 68 | | is expected to fail. You can globally remember successful challenges 69 | | for the user for a given number of minutes to avoid asking again. 70 | | 71 | | To remember the challenge until the session dies, set "minutes" to zero. 72 | */ 73 | 74 | 'remember' => [ 75 | 'enabled' => false, 76 | 'key' => '_recaptcha', 77 | 'minutes' => 10, 78 | ], 79 | 80 | /* 81 | |-------------------------------------------------------------------------- 82 | | Credentials 83 | |-------------------------------------------------------------------------- 84 | | 85 | | The following is the array of credentials for each version and variant 86 | | of the reCAPTCHA services. You shouldn't need to edit this unless you 87 | | know what you're doing. On reCAPTCHA v2, it comes with testing keys. 88 | | 89 | */ 90 | 91 | 'credentials' => [ 92 | Captchavel::CHECKBOX => [ 93 | 'secret' => env('RECAPTCHA_CHECKBOX_SECRET', Captchavel::TEST_V2_SECRET), 94 | 'key' => env('RECAPTCHA_CHECKBOX_KEY', Captchavel::TEST_V2_KEY), 95 | ], 96 | Captchavel::INVISIBLE => [ 97 | 'secret' => env('RECAPTCHA_INVISIBLE_SECRET', Captchavel::TEST_V2_SECRET), 98 | 'key' => env('RECAPTCHA_INVISIBLE_KEY', Captchavel::TEST_V2_KEY), 99 | ], 100 | Captchavel::ANDROID => [ 101 | 'secret' => env('RECAPTCHA_ANDROID_SECRET', Captchavel::TEST_V2_SECRET), 102 | 'key' => env('RECAPTCHA_ANDROID_KEY', Captchavel::TEST_V2_KEY), 103 | ], 104 | Captchavel::SCORE => [ 105 | 'secret' => env('RECAPTCHA_SCORE_SECRET'), 106 | 'key' => env('RECAPTCHA_SCORE_KEY'), 107 | ], 108 | ], 109 | ]; 110 | -------------------------------------------------------------------------------- /src/Captchavel.php: -------------------------------------------------------------------------------- 1 | enabled = $this->config->get('captchavel.enable'); 73 | $this->fake = $this->config->get('captchavel.fake'); 74 | } 75 | 76 | /** 77 | * Check if Captchavel is enabled. 78 | * 79 | * @return bool 80 | */ 81 | public function isEnabled(): bool 82 | { 83 | return $this->enabled; 84 | } 85 | 86 | /** 87 | * Check if Captchavel is disabled. 88 | * 89 | * @return bool 90 | */ 91 | public function isDisabled(): bool 92 | { 93 | return !$this->isEnabled(); 94 | } 95 | 96 | /** 97 | * Check if the reCAPTCHA response should be faked on-demand. 98 | * 99 | * @return bool 100 | */ 101 | public function shouldFake(): bool 102 | { 103 | return $this->fake; 104 | } 105 | 106 | /** 107 | * Returns the reCAPTCHA response. 108 | * 109 | * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse 110 | */ 111 | public function response(): ReCaptchaResponse 112 | { 113 | return app(ReCaptchaResponse::class); 114 | } 115 | 116 | /** 117 | * Resolves a reCAPTCHA challenge. 118 | * 119 | * @param string|null $challenge 120 | * @param string $ip 121 | * @param string $version 122 | * @param string $input 123 | * @param string|null $action 124 | * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse 125 | */ 126 | public function getChallenge( 127 | ?string $challenge, 128 | string $ip, 129 | string $version, 130 | string $input, 131 | string $action = null, 132 | ): ReCaptchaResponse 133 | { 134 | return new ReCaptchaResponse($this->request($challenge, $ip, $version), $input, $action); 135 | } 136 | 137 | /** 138 | * Creates a Pending Request or a Promise. 139 | * 140 | * @param string $challenge 141 | * @param string $ip 142 | * @param string $version 143 | * @return \GuzzleHttp\Promise\PromiseInterface<\Illuminate\Http\Client\Response> 144 | */ 145 | protected function request(string $challenge, string $ip, string $version): PromiseInterface 146 | { 147 | return $this->http 148 | ->asForm() 149 | ->async() 150 | ->withOptions(['version' => 2.0]) 151 | ->post(static::RECAPTCHA_ENDPOINT, [ 152 | 'secret' => $this->secret($version), 153 | 'response' => $challenge, 154 | 'remoteip' => $ip, 155 | ]); 156 | } 157 | 158 | /** 159 | * Sets the correct credentials to use to retrieve the challenge results. 160 | * 161 | * @param string $version 162 | * @return string 163 | */ 164 | protected function secret(string $version): string 165 | { 166 | return $this->config->get("captchavel.credentials.$version.secret") 167 | ?? throw new LogicException("The reCAPTCHA secret for [$version] doesn't exists or is not set."); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerifyReCaptchaV3.php: -------------------------------------------------------------------------------- 1 | captchavel = CaptchavelFacade::getFacadeRoot(); 58 | 59 | $input = $this->normalizeInput($input); 60 | 61 | // Ensure responses are always faked as humans, unless disabled and real. 62 | if ($this->isAuth($guards) || ($this->captchavel->isDisabled() || $this->captchavel->shouldFake())) { 63 | $this->fakeResponseScore($request); 64 | } else { 65 | $this->ensureChallengeIsPresent($request, $input); 66 | } 67 | 68 | $this->process($this->response($request, $input, $action), $threshold); 69 | 70 | return $next($request); 71 | } 72 | 73 | /** 74 | * Fakes a score reCAPTCHA response. 75 | * 76 | * @param \Illuminate\Http\Request $request 77 | * @return void 78 | */ 79 | protected function fakeResponseScore(Request $request): void 80 | { 81 | // Swap the implementation to the Captchavel Fake. 82 | $this->captchavel = CaptchavelFacade::fake(); 83 | 84 | // If we're faking scores, allow the user to fake it through the input. 85 | if ($this->captchavel->shouldFake()) { 86 | $this->captchavel->score ??= (float) $request->missing('is_robot'); 87 | } 88 | } 89 | 90 | /** 91 | * Retrieves the response, still being a promise pending resolution. 92 | * 93 | * @param \Illuminate\Http\Request $request 94 | * @param string $input 95 | * @param string|null $action 96 | * @return \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse 97 | */ 98 | protected function response(Request $request, string $input, ?string $action): ReCaptchaResponse 99 | { 100 | return $this->captchavel->getChallenge( 101 | $request->input($input), $request->ip(), Captchavel::SCORE, $input, $this->normalizeAction($action) 102 | ); 103 | } 104 | 105 | /** 106 | * Process the response from reCAPTCHA servers. 107 | * 108 | * @param \DarkGhostHunter\Captchavel\Http\ReCaptchaResponse $response 109 | * @param null|string $threshold 110 | * @return void 111 | */ 112 | protected function process(ReCaptchaResponse $response, ?string $threshold): void 113 | { 114 | $response->setThreshold($this->normalizeThreshold($threshold)); 115 | 116 | Container::getInstance()->instance(ReCaptchaResponse::class, $response); 117 | } 118 | 119 | /** 120 | * Normalize the threshold string, or returns the default. 121 | * 122 | * @param string|null $threshold 123 | * @return float 124 | */ 125 | protected function normalizeThreshold(?string $threshold): float 126 | { 127 | return strtolower($threshold) === 'null' ? config('captchavel.threshold') : (float) $threshold; 128 | } 129 | 130 | /** 131 | * Normalizes the action name, or returns null. 132 | * 133 | * @param null|string $action 134 | * 135 | * @return null|string 136 | */ 137 | protected function normalizeAction(?string $action): ?string 138 | { 139 | return strtolower($action) === 'null' ? null : $action; 140 | } 141 | 142 | /** 143 | * Handle tasks after the response has been sent to the browser. 144 | * 145 | * @return void 146 | */ 147 | public function terminate(): void 148 | { 149 | if (app()->has(ReCaptchaResponse::class)) { 150 | app(ReCaptchaResponse::class)->terminate(); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerifyReCaptchaV2.php: -------------------------------------------------------------------------------- 1 | config = config(); 73 | $this->captchavel = CaptchavelFacade::getFacadeRoot(); 74 | 75 | if ($this->shouldCheckReCaptcha($remember, $guards)) { 76 | $this->ensureChallengeIsPresent($request, $input = $this->normalizeInput($input)); 77 | 78 | app()->instance( 79 | ReCaptchaResponse::class, 80 | $this->captchavel->getChallenge($request->input($input), $request->ip(), $version, $input)->wait() 81 | ); 82 | 83 | if ($this->shouldCheckRemember($remember)) { 84 | $this->storeRememberInSession($remember); 85 | } 86 | } 87 | 88 | return $next($request); 89 | } 90 | 91 | /** 92 | * Check if the reCAPTCHA should be checked for this request. 93 | * 94 | * @param string $remember 95 | * @param array $guards 96 | * @return bool 97 | */ 98 | protected function shouldCheckReCaptcha(string $remember, array $guards): bool 99 | { 100 | if ($this->captchavel->isDisabled()) { 101 | return false; 102 | } 103 | 104 | if ($this->captchavel->shouldFake()) { 105 | return false; 106 | } 107 | 108 | if ($this->shouldCheckRemember($remember) && $this->hasRemember()) { 109 | return false; 110 | } 111 | 112 | return $this->isGuest($guards); 113 | } 114 | 115 | /** 116 | * Check if the "remember" should be checked. 117 | * 118 | * @param string $remember 119 | * @return bool 120 | */ 121 | protected function shouldCheckRemember(string $remember): bool 122 | { 123 | if ($remember === 'null') { 124 | $remember = $this->config->get('captchavel.remember.enabled', false); 125 | } 126 | 127 | if ($remember === 'false') { 128 | return false; 129 | } 130 | 131 | return $remember !== false; 132 | } 133 | 134 | /** 135 | * Check if the request "remember" should be checked. 136 | * 137 | * @return bool 138 | */ 139 | protected function hasRemember(): bool 140 | { 141 | $timestamp = session($key = $this->config->get('recaptcha.remember.key', '_recaptcha')); 142 | 143 | if (is_numeric($timestamp)) { 144 | if (!$timestamp || now()->timestamp < $timestamp) { 145 | return true; 146 | } 147 | 148 | // Dispose of the session key if we have the opportunity when invalid. 149 | session()->forget($key); 150 | } 151 | 152 | return false; 153 | } 154 | 155 | /** 156 | * Stores the recaptcha remember expiration time in the session. 157 | * 158 | * @param string|int $offset 159 | * @return void 160 | */ 161 | protected function storeRememberInSession(string|int $offset): void 162 | { 163 | if (! is_numeric($offset)) { 164 | $offset = $this->config->get('captchavel.remember.minutes', 10); 165 | } 166 | 167 | $offset = (int) $offset; 168 | 169 | // If the offset is over zero, we will set it as offset minutes. 170 | if ($offset) { 171 | $offset = now()->addMinutes($offset)->getTimestamp(); 172 | } 173 | 174 | session()->put($this->config->get('captchavel.remember.key', '_recaptcha'), $offset); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Http/ReCaptchaResponse.php: -------------------------------------------------------------------------------- 1 | promise = $this->promise->then(function (Response $response): void { 53 | $this->attributes = $response->json(); 54 | $this->validate(); 55 | }); 56 | } 57 | 58 | /** 59 | * Checks if the response has been resolved. 60 | * 61 | * @return bool 62 | */ 63 | public function isResolved(): bool 64 | { 65 | return $this->promise->getState() === PromiseInterface::FULFILLED; 66 | } 67 | 68 | /** 69 | * Checks if the response has yet to be resolved. 70 | * 71 | * @return bool 72 | */ 73 | public function isPending(): bool 74 | { 75 | return ! $this->isResolved(); 76 | } 77 | 78 | /** 79 | * Returns the timestamp of the challenge as a Carbon instance. 80 | * 81 | * @return \Illuminate\Support\Carbon 82 | */ 83 | public function carbon(): Carbon 84 | { 85 | return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->get('challenge_ts')); 86 | } 87 | 88 | /** 89 | * Waits for this reCAPTCHA to be resolved. 90 | * 91 | * @return $this 92 | */ 93 | public function wait(): static 94 | { 95 | $this->promise->wait(); 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * Terminates the reCAPTCHA response if still pending. 102 | * 103 | * @return void 104 | */ 105 | public function terminate(): void 106 | { 107 | $this->promise->cancel(); 108 | } 109 | 110 | /** 111 | * Returns the raw attributes of the response, bypassing the promise resolving. 112 | * 113 | * @return array 114 | */ 115 | public function getAttributes(): array 116 | { 117 | return $this->attributes; 118 | } 119 | 120 | /** 121 | * Get an attribute from the instance. 122 | * 123 | * @param string $key 124 | * @param mixed $default 125 | * @return mixed 126 | */ 127 | public function get(string $key, mixed $default = null): mixed 128 | { 129 | $this->wait(); 130 | 131 | return $this->attributes[$key] ?? value($default); 132 | } 133 | 134 | /** 135 | * Convert the instance to an array. 136 | * 137 | * @return array 138 | */ 139 | public function toArray(): array 140 | { 141 | $this->wait(); 142 | 143 | return $this->getAttributes(); 144 | } 145 | 146 | /** 147 | * Convert the object into something JSON serializable. 148 | * 149 | * @return array 150 | */ 151 | public function jsonSerialize(): array 152 | { 153 | return $this->toArray(); 154 | } 155 | 156 | /** 157 | * Convert the instance to JSON. 158 | * 159 | * @param int $options 160 | * @return string 161 | * @throws \JsonException 162 | */ 163 | public function toJson($options = 0): string 164 | { 165 | return json_encode($this->jsonSerialize(), JSON_THROW_ON_ERROR | $options); 166 | } 167 | 168 | /** 169 | * Dynamically retrieve the value of an attribute. 170 | * 171 | * @param string $key 172 | * @return mixed 173 | */ 174 | public function __get(string $key): mixed 175 | { 176 | return $this->get($key); 177 | } 178 | 179 | /** 180 | * Dynamically set the value of an attribute. 181 | * 182 | * @param string $key 183 | * @param mixed $value 184 | * @return void 185 | */ 186 | public function __set(string $key, mixed $value): void 187 | { 188 | $this->wait(); 189 | 190 | $this->attributes[$key] = $value; 191 | } 192 | 193 | /** 194 | * Dynamically check if an attribute is set. 195 | * 196 | * @param string $key 197 | * @return bool 198 | */ 199 | public function __isset(string $key): bool 200 | { 201 | $this->wait(); 202 | 203 | return array_key_exists($key, $this->attributes); 204 | } 205 | 206 | /** 207 | * Dynamically unset an attribute. 208 | * 209 | * @param string $key 210 | * @return void 211 | */ 212 | public function __unset(string $key): void 213 | { 214 | $this->wait(); 215 | 216 | unset($this->attributes[$key]); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/ReCaptcha.php: -------------------------------------------------------------------------------- 1 | threshold($threshold ?? config('captchavel.threshold', 0.5)); 81 | } 82 | 83 | /** 84 | * Sets the input for the reCAPTCHA challenge on this route. 85 | * 86 | * @param string $name 87 | * @return $this 88 | */ 89 | public function input(string $name): static 90 | { 91 | $this->input = $name; 92 | 93 | return $this; 94 | } 95 | 96 | /** 97 | * Bypass the check on users authenticated in the given guards. 98 | * 99 | * @param string ...$guards 100 | * @return $this 101 | */ 102 | public function except(string ...$guards): static 103 | { 104 | return $this->forGuests(...$guards); 105 | } 106 | 107 | /** 108 | * Show the challenge on non-authenticated users. 109 | * 110 | * @param string ...$guards 111 | * @return $this 112 | */ 113 | public function forGuests(string ...$guards): static 114 | { 115 | $this->guards = $guards ?: ['null']; 116 | 117 | return $this; 118 | } 119 | 120 | /** 121 | * Checking for a "remember" on this route. 122 | * 123 | * @param int|null $minutes 124 | * @return static 125 | */ 126 | public function remember(int $minutes = null): static 127 | { 128 | $this->ensureVersionIsCorrect(true); 129 | 130 | $this->remember = (string) ($minutes ?? config('recaptcha.remember.minutes', 10)); 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * Checking for a "remember" on this route and stores the key forever. 137 | * 138 | * @return $this 139 | */ 140 | public function rememberForever(): static 141 | { 142 | return $this->remember(0); 143 | } 144 | 145 | /** 146 | * Bypass checking for a "remember" on this route. 147 | * 148 | * @return static 149 | */ 150 | public function dontRemember(): static 151 | { 152 | $this->ensureVersionIsCorrect(true); 153 | 154 | $this->remember = 'false'; 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Sets the threshold for the score-driven challenge. 161 | * 162 | * @param float $threshold 163 | * @return $this 164 | */ 165 | public function threshold(float $threshold): static 166 | { 167 | $this->ensureVersionIsCorrect(false); 168 | 169 | $this->threshold = number_format(max(0, min(1, $threshold)), 1); 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Sets the action for the 176 | * 177 | * @param string $action 178 | * @return $this 179 | */ 180 | public function action(string $action): static 181 | { 182 | $this->ensureVersionIsCorrect(false); 183 | 184 | $this->action = $action; 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * Throws an exception if this middleware version should be score or not. 191 | * 192 | * @param bool $score 193 | * @return void 194 | */ 195 | protected function ensureVersionIsCorrect(bool $score): void 196 | { 197 | if ($score ? $this->version === Captchavel::SCORE : $this->version !== Captchavel::SCORE) { 198 | $function = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)[1]['function']; 199 | 200 | throw new LogicException("You cannot set [$function] for a [$this->version] middleware."); 201 | } 202 | } 203 | 204 | /** 205 | * Transforms the middleware helper into a string. 206 | * 207 | * @return string 208 | */ 209 | public function toString(): string 210 | { 211 | return $this->__toString(); 212 | } 213 | 214 | /** 215 | * Returns the string representation of the instance. 216 | * 217 | * @return string 218 | */ 219 | public function __toString(): string 220 | { 221 | $declaration = $this->getBaseParameters() 222 | ->reverse() 223 | ->unless($this->guards, static function (Collection $parameters): Collection { 224 | return $parameters->skipUntil(static function (string $parameter): bool { 225 | return $parameter !== 'null'; 226 | }); 227 | }) 228 | ->reverse() 229 | ->implode(','); 230 | 231 | return Str::replaceFirst(',', ':', $declaration); 232 | } 233 | 234 | /** 235 | * Returns the parameters as a collection. 236 | * 237 | * @return \Illuminate\Support\Collection 238 | */ 239 | protected function getBaseParameters(): Collection 240 | { 241 | return Collection::make( 242 | $this->version === Captchavel::SCORE 243 | ? [VerifyReCaptchaV3::SIGNATURE, $this->threshold, $this->action] 244 | : [VerifyReCaptchaV2::SIGNATURE, $this->version, $this->remember] 245 | )->push($this->input, ...$this->guards); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Package superseded by [Laragear/ReCaptcha](https://github.com/Laragear/ReCaptcha) 2 | 3 | --- 4 | 5 | # Captchavel 6 | 7 | Integrate reCAPTCHA into your Laravel app better than the Big G itself! 8 | 9 | It uses your Laravel HTTP Client **async HTTP/2**, making your app **fast**. You only need a couple of lines to integrate. 10 | 11 | ## Requirements 12 | 13 | * Laravel 8.x, or later 14 | * PHP 8.0 or later 15 | 16 | > If you need support for old versions, consider sponsoring or donating. 17 | 18 | ## Installation 19 | 20 | You can install the package via Composer: 21 | 22 | ```bash 23 | composer require darkghosthunter/captchavel 24 | ``` 25 | 26 | ## Set up 27 | 28 | 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 **score**. 29 | 30 | If you don't have one, generate it in your [reCAPTCHA Admin panel](https://www.google.com/recaptcha/admin/). 31 | 32 | ```dotenv 33 | RECAPTCHA_CHECKBOX_SECRET=6t5geA1UAAAAAN... 34 | RECAPTCHA_CHECKBOX_KEY=6t5geA1UAAAAAN... 35 | 36 | RECAPTCHA_INVISIBLE_SECRET=6t5geA2UAAAAAN... 37 | RECAPTCHA_INVISIBLE_KEY=6t5geA2UAAAAAN... 38 | 39 | RECAPTCHA_ANDROID_SECRET=6t5geA3UAAAAAN... 40 | RECAPTCHA_ANDROID_KEY=6t5geA3UAAAAAN... 41 | 42 | RECAPTCHA_SCORE_SECRET=6t5geA4UAAAAAN... 43 | RECAPTCHA_SCORE_KEY=6t5geA4UAAAAAN... 44 | ``` 45 | 46 | This allows you to check different reCAPTCHA mechanisms using the same application, in different environments. 47 | 48 | > Captchavel already comes with v2 keys for local development. For v3, you will need to create your own set of credentials. 49 | 50 | ## Usage 51 | 52 | Usage differs based on if you're using checkbox, invisible, or Android challenges, or the v3 score-driven challenge. 53 | 54 | ### Checkbox, invisible and Android challenges 55 | 56 | After you integrate reCAPTCHA into your frontend or Android app, set the Captchavel middleware in the `POST` routes where a form with reCAPTCHA is submitted. The middleware will catch the `g-recaptcha-response` input (you can change it later) and check if it's valid. 57 | 58 | To declare the middleware, use the `ReCaptcha` helper to ease your development pain: 59 | 60 | * `ReCaptcha::checkbox()` for explicitly rendered checkbox challenges. 61 | * `ReCaptcha::invisible()` for invisible challenges. 62 | * `ReCaptcha::android()` for Android app challenges. 63 | 64 | ```php 65 | use App\Http\Controllers\Auth\LoginController; 66 | use DarkGhostHunter\Captchavel\ReCaptcha; 67 | 68 | Route::post('login', [LoginController::class, 'login']) 69 | ->middleware(ReCaptcha::checkbox()); 70 | ``` 71 | 72 | > [Laravel 8.69 or below](https://github.com/laravel/framework/releases/tag/v8.70.0) need to cast the object as a string. 73 | 74 | #### Remembering challenges 75 | 76 | To avoid a form asking for challenges over and over again, you can "remember" the challenge for a given set of minutes. This can be [enabled globally](#remember), but you may prefer to do it in a per-route basis. 77 | 78 | Simple use the `remember()` method to use the config defaults. It accepts the number of minutes to override the [global parameter](#remember). Alternatively, `rememberForever()` will remember the challenge forever. 79 | 80 | ```php 81 | use App\Http\Controllers\Auth\LoginController; 82 | use DarkGhostHunter\Captchavel\ReCaptcha; 83 | 84 | Route::post('login', [LoginController::class, 'login']) 85 | ->middleware(ReCaptcha::invisible()->remember()); 86 | 87 | Route::post('message', [ChatController::class, 'login']) 88 | ->middleware(ReCaptcha::checkbox()->rememberForever()); 89 | ``` 90 | 91 | You should use this in conjunction with the `@unlesschallenged` directive in your Blade templates to render a challenge when the user has not successfully done one before. 92 | 93 | ```blade 94 | @unlesschallenged 95 |