diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c82b426 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f4d5389 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a7013e9 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: erayaydin diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..863488b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,52 @@ +name: Bug Report +description: Report an Issue or Bug with the Fingerprint Laravel Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 1.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.3.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 11.0.0 + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2566177 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/erayaydin/fingerprint-laravel/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/erayaydin/fingerprint-laravel/discussions/new?category=ideas + about: Share ideas for new features diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..eca8f0e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..fd4119d --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.4 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style(php): fix code style issues" diff --git a/.github/workflows/phpstan.yaml b/.github/workflows/phpstan.yaml new file mode 100644 index 0000000..d5db2f1 --- /dev/null +++ b/.github/workflows/phpstan.yaml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + - '.github/workflows/phpstan.yml' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..4aa976a --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,54 @@ +name: run-tests + +on: + push: + paths: + - '**.php' + - '.github/workflows/run-tests.yml' + - 'phpunit.xml.dist' + - 'composer.json' + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.3, 8.2] + laravel: [11.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 11.* + testbench: 9.* + carbon: ^2.63 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "nesbot/carbon:${{ matrix.os == 'windows-latest' && '^^^' || '' }}${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest --ci diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..a83e423 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,32 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: main + commit_message: "docs: update changelog" + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a6e0bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +### Composer +composer.lock +/vendor + +### PHPStan +/build + +### PHPUnit +.phpunit.cache +.phpunit.result.cache + +### Intellij +/.idea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4240d4e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing + +Contributions are **welcome**. + +The project accept contributions via Pull Requests on [GitHub](https://github.com/erayaydin/fingerprint-laravel). + +## Issues + +### Creating an Issue + +If you find a bug, problem, or maybe the documentation just doesn't make sense, please create an Issue to document the +concern. + +Please be descriptive in your Issue. The more info you provide, the more likely someone will be able to help. + +### Code Examples + +If you're experiencing an issue with the code, the most helpful thing you can do is create an example where you can +reproduce the problem. This can be an open source GitHub repo, a private repo you can share with the maintainers, or +really anything to show the issue live with code alongside of it. + +## Pull Requests + +The project uses **[PSR-12 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-extended-coding-style-guide.md)**. +The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +### Creating a Pull Request + +If you're able to fix an active Issue, feel free to create a new Pull Request addressing the problem. There are no +guarantees that the code will be merged in "as is", but chances are, if you're willing to work with the maintainers, +everyone will be able to come up with a solution everyone can be happy with. + +Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits +while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) +before submitting. + +If you want to do more than one thing, send multiple pull requests. + +Please be descriptive in your Pull Request. Whether big or small, it's important to be able to see the context of a +change throughout the history of a project. + +### Linking Issues +If the Pull Request is addressing an Issue, please link that issue by specifying the `Fixes #` syntax within +the Pull Request. + +### Tests + +Your patch won't be accepted if it does not have tests. + +You can run tests with: + +``` bash +$ composer test +``` + +Also, you can check current code coverage with: + +``` bash +$ composer test-coverage +``` + +### Documentation + +Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +### Branching + +Create a feature branch. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f356f92 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Eray Aydın + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aac5742 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Fingerprint Laravel + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/erayaydin/fingerprint-laravel.svg?style=flat-square)](https://packagist.org/packages/erayaydin/fingerprint-laravel) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/erayaydin/fingerprint-laravel/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/erayaydin/fingerprint-laravel/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/erayaydin/fingerprint-laravel/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/erayaydin/fingerprint-laravel/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/erayaydin/fingerprint-laravel.svg?style=flat-square)](https://packagist.org/packages/erayaydin/fingerprint-laravel) + +Fingerprint Laravel is a package for integrating Fingerprint Server API into your Laravel application with [PHP SDK for Fingerprint Pro Server API](https://github.com/fingerprintjs/fingerprint-pro-server-api-php-sdk). +It provides HTTP middlewares to block bots, VPNs, Tor, and more based on Fingerprint Server API event response. + +## Requirements + +- **Laravel 11** +- **PHP ^8.2** + +## Features + +- Customizable implementations with `fingerprint` config file. +- Injectable `Event` and `Fingerprint` classes. `Event` data class will auto-bind when a request with `requestId` +received. +- `Fingerprint` class provides a fluent interface to interact with the Fingerprint Server API. +- Ready to use HTTP middlewares to block bots, VPNs, Tor, and more based on Fingerprint Server API event response. + +## Installation + +You can install the package via Composer: + +```shell +$ composer require erayaydin/fingerprint-laravel +``` + +Check installation with about command. + +## Configuration + +Publish the configuration file: + +```shell +$ php artisan vendor:publish --tag=fingerprint-config +``` + +This will create a config/fingerprint.php file where you can set configurations. + +> By default, the package will use the `FINGERPRINT_PRO_SECRET_API_KEY` and `FINGERPRINT_REGION` environment variables. +> You should specify these values in your `.env` file. You can change the environment variable names in the +> configuration file after publishing it. + +### Configuration Options + +- **api_secret**: Your Fingerprint Server API key. +- **region**: The region of the Fingerprint Server API. Available options: `eu`/`europe`, `ap`/`asia`, `global`. +- **middleware** + - **bot_block**: Blocks good and/or bad bots. + - **vpn_block**: Blocks request if user is using a VPN. + - **tor_block**: Blocks tor network users. + - **min_confidence**: Minimum required confidence score. It should be in range of 0.0 to 1.0. If it's null, it will +not check the confidence score. + - **incognito_block**: Blocks users who are using incognito mode. + - **max_elapsed_time**: Maximum elapsed time between the request and the event identification. + +## Usage + +### Middlewares + +The package provides several middleware to block different types of traffics: + +- BlockBotsMiddleware (`fingerprint.bots`) +- BlockIncognitoMiddleware (`fingerprint.incognito`) +- BlockOldIdentificationMiddleware (`fingerprint.old-identification`) +- BlockTorMiddleware (`fingerprint.tor`) +- BlockVPNMiddleware (`fingerprint.vpn`) +- MinConfidenceScoreMiddleware (`fingerprint.confidence`) + +You can register these middleware in your `bootstrap/app.php` file or use the provided middleware group `fingerprint` in +routes or controllers. + +To use the middleware group in a route: + +```php +Route::middleware(['fingerprint'])->group(function () { + // Request is valid! +}); +``` + +Or you can use specific middlewares: + +```php +Route::middleware(['fingerprint.incognito', 'fingerprint.vpn'])->group(function () { + // User is not in incognito mode and not using VPN! +}); +``` + +### Event Data Access + +Use dependency injection to access the `Event` data class in your controller or middleware: + +```php +class ExampleController extends Controller +{ + public function store(Event $event) + { + ray($event->identification, $event->botD, $event->isTor, $event->isVPN); + } +} +``` + +### Fingerprint Server API Access + +Use dependency injection to access the `Fingerprint` class in your controller or middleware: + +```php +class ExampleController extends Controller +{ + public function store(Fingerprint $fingerprint) + { + ray($fingerprint->getEvent('requestId')); + } +} +``` + +### About Command Support + +The package integrates with Laravel's `AboutCommand` to provide information about the fingerprinting configuration. +This is registered automatically. + +```shell +$ php artisan about +... + Fingerprint Laravel ......................................................... + API Key ...................................................... Not Configured + Bot Block ................................................ Enabled (Bad Bots) + Incognito Block ..................................................... Enabled + Min. Confidence ......................................................... 0.8 + Old Identification ............................................... 10 seconds + Region ................................................................... eu + TOR Block ............................................. Enabled (if signaled) + VPN Block ........................................................... Enabled + Version ................................................ 1.0.0+no-version-set +``` + +## Testing + +You can run the tests with: + +```shell +composer test +``` + +## Roadmap + +- Add tests. +- Add config option to change `requestId` key. +- Add IP block middleware. +- Add auto visitor store support with `Visitor` model and migrations. +- Add `HasVisitorId` trait to use with custom Eloquent models. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6e9587c --- /dev/null +++ b/composer.json @@ -0,0 +1,83 @@ +{ + "name": "erayaydin/fingerprint-laravel", + "description": "Fingerprint Laravel Wrapper", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/erayaydin/fingerprint-laravel", + "authors": [ + { + "name": "Eray Aydın", + "email": "erayaydinn@protonmail.com", + "role": "Maintainer" + } + ], + "scripts": { + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" + ], + "lint": "@php vendor/bin/phpstan analyse --verbose --ansi", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "require": { + "php": "^8.2", + "illuminate/support": "^11.24", + "fingerprint/fingerprint-pro-server-api-sdk": "^5.0", + "illuminate/http": "^11.24", + "illuminate/routing": "^11.24" + }, + "require-dev": { + "larastan/larastan": "^2.0", + "laravel/pint": "^1.18", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.4", + "orchestra/testbench": "^9.5", + "pestphp/pest": "^3.2", + "pestphp/pest-plugin-arch": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpstan/phpstan-phpunit": "^1.4", + "spatie/laravel-ray": "^1.37" + }, + "autoload": { + "psr-4": { + "ErayAydin\\Fingerprint\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ErayAydin\\Fingerprint\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true, + "extra": { + "laravel": { + "providers": [ + "ErayAydin\\Fingerprint\\FingerprintServiceProvider" + ], + "aliases": { + "Fingerprint": "ErayAydin\\Fingerprint\\Facades\\Fingerprint" + } + } + } +} diff --git a/config/fingerprint.php b/config/fingerprint.php new file mode 100644 index 0000000..f2a9732 --- /dev/null +++ b/config/fingerprint.php @@ -0,0 +1,92 @@ + env('FINGERPRINT_PRO_SECRET_API_KEY'), + + /** + * The region of the Fingerprint Pro service. + * + * Default: eu + * + * Available options: `eu`/`europe`, `ap`/`asia`, `global` + */ + 'region' => env('FINGERPRINT_REGION', 'eu'), + + /** + * Fingerprint middleware configuration + */ + 'middleware' => [ + + /** + * Blocks good and/or bad bots. + * + * BotBlockConfiguration::BlockAll => Blocks good and bad bots. + * BotBlockConfiguration::BlockBad => Blocks only bad bots. + * BotBlockConfiguration::Allow => Disable middleware. + * + * Default: BotBlockConfiguration::BlockBad + * + * @see \ErayAydin\Fingerprint\Http\Middleware\BlockBotsMiddleware + */ + 'bot_block' => BotBlockConfiguration::BlockBad, + + /** + * Blocks request if user is using a VPN. + * + * Default: true + * + * @see \ErayAydin\Fingerprint\Http\Middleware\BlockVPNMiddleware::Class + */ + 'vpn_block' => true, + + /** + * Blocks tor network users. + * + * TorBlockConfiguration::BlockAll => Blocks request even event doesn't have a tor signal. + * TorBlockConfiguration::BlockIfSignaled => Blocks request if event has a true tor signal. + * TorBlockConfiguration::Allow => Disable middleware. + * + * Default: TorBlockConfiguration::BlockIfSignaled + * + * @see \ErayAydin\Fingerprint\Http\Middleware\BlockTorMiddleware::class + */ + 'tor_block' => TorBlockConfiguration::BlockIfSignaled, + + /** + * Minimum required confidence score. It should be in range of 0.0 to 1.0. If it's null, + * it will not check the confidence score. + * + * Default: 0.8 + * + * @see \ErayAydin\Fingerprint\Http\Middleware\MinConfidenceScoreMiddleware::class + */ + 'min_confidence' => 0.8, + + /** + * Blocks users who are using incognito mode. + * + * Default: true + * + * @see \ErayAydin\Fingerprint\Http\Middleware\BlockIncognitoMiddleware::class + */ + 'incognito_block' => true, + + /** + * Maximum elapsed time between the request and the event identification. + * + * Default: 10 seconds + * + * @see \ErayAydin\Fingerprint\Http\Middleware\BlockOldIdentificationMiddleware::class + * @see DateInterval + * @link https://php.net/manual/en/dateinterval.construct.php + */ + 'max_elapsed_time' => new DateInterval('PT10S'), + + ], +]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..20a565f --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: 5 + paths: + - src + - config + tmpDir: build/phpstan + checkOctaneCompatibility: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a2a562d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + tests + + + + + + + + ./src + + + diff --git a/src/Console/AboutCommandRegistrar.php b/src/Console/AboutCommandRegistrar.php new file mode 100644 index 0000000..705b87a --- /dev/null +++ b/src/Console/AboutCommandRegistrar.php @@ -0,0 +1,93 @@ + [ + 'API Key' => $configRepository->get('fingerprint.api_secret') + ? ConsoleHelper::success('Configured') + : ConsoleHelper::error('Not Configured'), + 'Region' => ConsoleHelper::info($configRepository->get('fingerprint.region')), + 'Bot Block' => self::getBotConfigurationDisplay($configRepository->get('fingerprint.middleware.bot_block')), + 'VPN Block' => $configRepository->get('fingerprint.middleware.vpn_block') + ? ConsoleHelper::success('Enabled') + : ConsoleHelper::warning('Disabled'), + 'TOR Block' => self::getTorConfigurationDisplay($configRepository->get('fingerprint.middleware.tor_block')), + 'Min. Confidence' => ConsoleHelper::success($configRepository->get('fingerprint.middleware.min_confidence')) ?: ConsoleHelper::warning('Disabled'), + 'Incognito Block' => $configRepository->get('fingerprint.middleware.incognito_block') + ? ConsoleHelper::success('Enabled') + : ConsoleHelper::warning('Disabled'), + 'Old Identification' => ConsoleHelper::success(self::getDateIntervalDisplay($configRepository->get('fingerprint.middleware.max_elapsed_time'))), + 'Version' => ConsoleHelper::info(InstalledVersions::getPrettyVersion('erayaydin/fingerprint-laravel')), + ]); + } + + /** + * Returns the bot configuration display text based on the given bot block configuration. + * + * @param BotBlockConfiguration $botBlock The bot block configuration. + * @return string The display text for the bot block configuration. + */ + private static function getBotConfigurationDisplay(BotBlockConfiguration $botBlock): string + { + return match ($botBlock) { + BotBlockConfiguration::BlockAll => ConsoleHelper::success('Enabled (Good and Bad Bots)'), + BotBlockConfiguration::BlockBad => ConsoleHelper::success('Enabled (Bad Bots)'), + BotBlockConfiguration::Allow => ConsoleHelper::warning('Disabled'), + }; + } + + /** + * Returns the Tor configuration display text based on the given Tor block configuration. + * + * @param TorBlockConfiguration $torBlock The Tor block configuration. + * @return string The display text for the Tor block configuration. + */ + private static function getTorConfigurationDisplay(TorBlockConfiguration $torBlock): string + { + return match ($torBlock) { + TorBlockConfiguration::BlockAll => ConsoleHelper::success('Enabled'), + TorBlockConfiguration::BlockIfSignaled => ConsoleHelper::success('Enabled (if signaled)'), + TorBlockConfiguration::Allow => ConsoleHelper::warning('Disabled'), + }; + } + + /** + * Returns the date interval display text based on the given date interval. + * + * @param DateInterval $interval The date interval. + * @return string The display text for the date interval. + * + * @throws Exception + */ + private static function getDateIntervalDisplay(DateInterval $interval): string + { + return Carbon::now()->diffAsCarbonInterval(Carbon::now()->add($interval))->forHumans(); + } +} diff --git a/src/Console/ConsoleHelper.php b/src/Console/ConsoleHelper.php new file mode 100644 index 0000000..4f9197d --- /dev/null +++ b/src/Console/ConsoleHelper.php @@ -0,0 +1,68 @@ +$text"; + } +} diff --git a/src/Enums/BotBlockConfiguration.php b/src/Enums/BotBlockConfiguration.php new file mode 100644 index 0000000..dfc02f2 --- /dev/null +++ b/src/Enums/BotBlockConfiguration.php @@ -0,0 +1,19 @@ +getProducts(); + + return new self( + self::createIdentification($products->getIdentification()->getData()), + self::getBotDResult($products->getBotd()->getData()->getBot()), + $products->getTor()?->getData()?->getResult(), + $products->getVpn()->getData()->getResult(), + ); + } + + /** + * Creates an Identification instance from a ProductsResponseIdentificationData model. + * + * @param ProductsResponseIdentificationData $model The identification data model. + * @return Identification The created Identification instance. + */ + private static function createIdentification(ProductsResponseIdentificationData $model): Identification + { + $timestamp = (int) ($model->getTimestamp() / 1000); + + return new Identification( + $model->getRequestId(), + $model->getVisitorId(), + $model->getIncognito(), + DateTimeImmutable::createFromMutable($model->getTime()), + DateTimeImmutable::createFromFormat('U', "$timestamp"), + $model->getUrl(), + $model->getIp(), + $model->getConfidence()->getScore(), + ); + } + + /** + * Gets the BotDResult from a BotdDetectionResult model. + * + * @param BotdDetectionResult $botdDetectionResult The bot detection result model. + * @return BotDResult|null The corresponding BotDResult or null if not exists in result model. + */ + private static function getBotDResult(BotdDetectionResult $botdDetectionResult): ?BotDResult + { + return match ($botdDetectionResult->getResult()) { + 'notDetected' => BotDResult::NotDetected, + 'good' => BotDResult::Good, + 'bad' => BotDResult::Bad, + default => null, + }; + } +} diff --git a/src/Exceptions/BotDetectedException.php b/src/Exceptions/BotDetectedException.php new file mode 100644 index 0000000..2fcf0c2 --- /dev/null +++ b/src/Exceptions/BotDetectedException.php @@ -0,0 +1,26 @@ +client = new FingerprintApi($httpClient, $config); + } + + /** + * Retrieves an event by request ID. + * + * @param string $requestId The request ID of the event. + * @return Event The event instance. + * + * @throws ApiException If there is a Fingerprint API error. + * @throws GuzzleException If there is an HTTP client error. + * @throws SerializationException If there is a serialization error. + */ + public function getEvent(string $requestId): Event + { + $model = Arr::first($this->client->getEvent($requestId)); + + return Event::createFromEventResponse($model); + } + + /** + * Gets the region code based on the region name. + * + * @param string $region The region name. + * @return string The region code. + * + * @throws RegionNotSupportedException If the region is not supported. + */ + private static function getRegion(string $region): string + { + return match ($region) { + 'global' => Configuration::REGION_GLOBAL, + 'eu', 'europe' => Configuration::REGION_EUROPE, + 'ap', 'asia' => Configuration::REGION_ASIA, + default => throw RegionNotSupportedException::regionNotSupported($region), + }; + } +} diff --git a/src/FingerprintServiceProvider.php b/src/FingerprintServiceProvider.php new file mode 100644 index 0000000..c4b7476 --- /dev/null +++ b/src/FingerprintServiceProvider.php @@ -0,0 +1,143 @@ + BlockBotsMiddleware::class, + 'vpn' => BlockVPNMiddleware::class, + 'tor' => BlockTorMiddleware::class, + 'confidence' => MinConfidenceScoreMiddleware::class, + 'incognito' => BlockIncognitoMiddleware::class, + 'old-identification' => BlockOldIdentificationMiddleware::class, + ]; + + /** + * Register services and configurations. + * + * @throws InvalidConfiguration If the API key is not specified. + */ + public function register(): void + { + $this->mergeConfigFrom( + __DIR__.'/../config/fingerprint.php', + 'fingerprint' + ); + + $this->app->singleton(Fingerprint::class, function (Container $app) { + /** @var Repository $config */ + $config = $app->make(Repository::class); + + /** @var ClientInterface $httpClient */ + $httpClient = $app->make(Client::class); + + $apiKey = $config->get('fingerprint.api_secret'); + + if (! $apiKey) { + throw InvalidConfiguration::apiKeyNotSpecified(); + } + + return new Fingerprint( + $apiKey, + $config->get('fingerprint.region'), + $httpClient, + ); + }); + $this->app->alias(Fingerprint::class, 'fingerprint'); + } + + /** + * Boot services and middleware. + * + * @throws BindingResolutionException If there is an error resolving bindings. + */ + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/fingerprint.php' => $this->app->configPath('fingerprint.php'), + ], 'fingerprint-config'); + } + + $this->app->singleton(Event::class, function ($app) { + /** @var Fingerprint $fingerprint */ + $fingerprint = $app->make(Fingerprint::class); + + $request = $app->make('request'); + $requestId = $request->input('requestId', null); + + if (! $requestId) { + return null; + } + + return $fingerprint->getEvent($requestId); + }); + + /** @var Router $router */ + $router = $this->app->make(Router::class); + + $this->registerMiddlewareAliases($router); + $this->registerMiddlewareGroup($router); + + /** @var Repository $configRepository */ + $configRepository = $this->app->make(Repository::class); + + if (! $this->app->runningUnitTests()) { + AboutCommandRegistrar::register($configRepository); + } + } + + /** + * Register middleware aliases. + * + * @param Router $router The router instance. + */ + private function registerMiddlewareAliases(Router $router): void + { + foreach (self::MIDDLEWARES as $key => $middleware) { + $router->aliasMiddleware("fingerprint.$key", $middleware); + } + } + + /** + * Register middleware aliases. + * + * @param Router $router The router instance. + */ + private function registerMiddlewareGroup(Router $router): void + { + $router->middlewareGroup('fingerprint', [ + BlockBotsMiddleware::class, + BlockVPNMiddleware::class, + BlockTorMiddleware::class, + MinConfidenceScoreMiddleware::class, + BlockIncognitoMiddleware::class, + BlockOldIdentificationMiddleware::class, + ]); + } +} diff --git a/src/Http/Middleware/BlockBotsMiddleware.php b/src/Http/Middleware/BlockBotsMiddleware.php new file mode 100644 index 0000000..15fb842 --- /dev/null +++ b/src/Http/Middleware/BlockBotsMiddleware.php @@ -0,0 +1,84 @@ +botBlock = $config->get('fingerprint.middleware.bot_block'); + } + + /** + * Handle an incoming request. + * + * @param Request $request The incoming HTTP request. + * @param Closure $next The next middleware in the pipeline. + * + * @throws BotDetectedException + */ + public function __invoke(Request $request, Closure $next): mixed + { + $botDResult = $this->event->botD; + + if ($this->isBadBotDetected($botDResult)) { + throw BotDetectedException::badBotDetected(); + } + + if ($this->isAnyBotDetected($botDResult)) { + throw BotDetectedException::botDetected(); + } + + return $next($request); + } + + /** + * Check if a bad bot is detected. + * + * @param BotDResult $result The result of the bot detection. + */ + private function isBadBotDetected(BotDResult $result): bool + { + if ($this->botBlock != BotBlockConfiguration::BlockBad) { + return false; + } + + return $result == BotDResult::Bad; + } + + /** + * Check if any bot is detected. + * + * @param BotDResult $result The result of the bot detection. + */ + private function isAnyBotDetected(BotDResult $result): bool + { + if ($this->botBlock != BotBlockConfiguration::BlockAll) { + return false; + } + + return $result == BotDResult::Bad || $result == BotDResult::Good; + } +} diff --git a/src/Http/Middleware/BlockIncognitoMiddleware.php b/src/Http/Middleware/BlockIncognitoMiddleware.php new file mode 100644 index 0000000..48ace1d --- /dev/null +++ b/src/Http/Middleware/BlockIncognitoMiddleware.php @@ -0,0 +1,50 @@ +blockIncognito = $config->get('fingerprint.middleware.incognito_block'); + } + + /** + * Handle an incoming request. + * + * @param Request $request The incoming HTTP request. + * @param Closure $next The next middleware in the pipeline. + * + * @throws IncognitoModeException + */ + public function __invoke(Request $request, Closure $next): mixed + { + $incognito = $this->event->identification->incognito; + + if ($this->blockIncognito && $incognito) { + throw IncognitoModeException::incognitoModeDetected(); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/BlockOldIdentificationMiddleware.php b/src/Http/Middleware/BlockOldIdentificationMiddleware.php new file mode 100644 index 0000000..f31285e --- /dev/null +++ b/src/Http/Middleware/BlockOldIdentificationMiddleware.php @@ -0,0 +1,52 @@ +maxElapsedTime = $config->get('fingerprint.middleware.max_elapsed_time'); + } + + /** + * Handle an incoming request. + * + * @param Request $request The incoming HTTP request. + * @param Closure $next The next middleware in the pipeline. + * + * @throws OldIdentificationException + */ + public function __invoke(Request $request, Closure $next): mixed + { + $time = Carbon::createFromImmutable($this->event->identification->time); + + if (Carbon::now()->sub($this->maxElapsedTime)->greaterThan($time)) { + throw OldIdentificationException::oldIdentification(); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/BlockTorMiddleware.php b/src/Http/Middleware/BlockTorMiddleware.php new file mode 100644 index 0000000..15c2da8 --- /dev/null +++ b/src/Http/Middleware/BlockTorMiddleware.php @@ -0,0 +1,55 @@ +torBlock = $config->get('fingerprint.middleware.tor_block'); + } + + /** + * Handle an incoming request. + * + * @param Request $request The incoming HTTP request. + * @param Closure $next The next middleware in the pipeline. + * + * @throws TorDetectionException + */ + public function __invoke(Request $request, Closure $next): mixed + { + $tor = $this->event->isTor; + + if ($this->torBlock === TorBlockConfiguration::BlockAll && $tor !== false) { + throw TorDetectionException::torDetectionRequired(); + } + + if ($this->torBlock === TorBlockConfiguration::BlockIfSignaled && $tor === true) { + throw TorDetectionException::torDetected(); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/BlockVPNMiddleware.php b/src/Http/Middleware/BlockVPNMiddleware.php new file mode 100644 index 0000000..18f59d8 --- /dev/null +++ b/src/Http/Middleware/BlockVPNMiddleware.php @@ -0,0 +1,46 @@ +blockVPN = $config->get('fingerprint.middleware.vpn_block'); + } + + /** + * Handle an incoming request. + * + * @param Request $request The incoming HTTP request. + * @param Closure $next The next middleware in the pipeline. + */ + public function __invoke(Request $request, Closure $next): mixed + { + if ($this->blockVPN && $this->event->isVPN) { + throw VPNDetectedException::vpnDetected(); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/MinConfidenceScoreMiddleware.php b/src/Http/Middleware/MinConfidenceScoreMiddleware.php new file mode 100644 index 0000000..a5bc27b --- /dev/null +++ b/src/Http/Middleware/MinConfidenceScoreMiddleware.php @@ -0,0 +1,49 @@ +minConfidenceScore = $config->get('fingerprint.middleware.min_confidence'); + } + + /** + * Handle an incoming request. + * + * @param Request $request The incoming HTTP request. + * @param Closure $next The next middleware in the pipeline. + */ + public function __invoke(Request $request, Closure $next): mixed + { + $confidenceScore = $this->event->identification->confidence; + + if ($this->minConfidenceScore !== null && $confidenceScore < $this->minConfidenceScore) { + throw MinConfidenceScoreException::minConfidenceScoreNotReached($confidenceScore, $this->minConfidenceScore); + } + + return $next($request); + } +} diff --git a/src/Identification.php b/src/Identification.php new file mode 100644 index 0000000..12c3329 --- /dev/null +++ b/src/Identification.php @@ -0,0 +1,36 @@ +expect(['dd', 'dump', 'ray', 'dump_if', 'dump_if_empty', 'dump_if_not_empty']) + ->each->not->toBeUsed(); diff --git a/tests/Features/AboutCommandRegistrarTest.php b/tests/Features/AboutCommandRegistrarTest.php new file mode 100644 index 0000000..59e7d41 --- /dev/null +++ b/tests/Features/AboutCommandRegistrarTest.php @@ -0,0 +1,22 @@ +getConfigRepository(); + + AboutCommandRegistrar::register($configRepository); + + $this->artisan('about') + ->expectsOutputToContain('Fingerprint Laravel') + ->expectsOutputToContain('API Key') + ->expectsOutputToContain('Region') + ->expectsOutputToContain('Bot Block') + ->expectsOutputToContain('VPN Block') + ->expectsOutputToContain('TOR Block') + ->expectsOutputToContain('Min. Confidence') + ->expectsOutputToContain('Incognito Block') + ->expectsOutputToContain('Old Identification') + ->expectsOutputToContain('Version') + ->assertExitCode(0); +}); diff --git a/tests/Features/FacadeTest.php b/tests/Features/FacadeTest.php new file mode 100644 index 0000000..c690057 --- /dev/null +++ b/tests/Features/FacadeTest.php @@ -0,0 +1,16 @@ +app->instance('fingerprint', $fingerprintService); + + expect(Fingerprint::getFacadeRoot())->toBe($fingerprintService); +}); diff --git a/tests/Features/FingerprintServiceProviderTest.php b/tests/Features/FingerprintServiceProviderTest.php new file mode 100644 index 0000000..d0afc46 --- /dev/null +++ b/tests/Features/FingerprintServiceProviderTest.php @@ -0,0 +1,26 @@ +config = $this->app->make(Repository::class); +}); + +it('registers the Fingerprint service', function () { + $this->config->set('fingerprint.api_secret', 'test_api_key'); + $this->config->set('fingerprint.region', 'global'); + + $fingerprint = $this->app->make(Fingerprint::class); + + expect($fingerprint)->toBeInstanceOf(Fingerprint::class); +}); + +it('throws InvalidConfiguration if API key is not specified', function () { + $this->config->set('fingerprint.api_secret', null); + + $this->expectException(InvalidConfiguration::class); + + $this->app->make(Fingerprint::class); +}); diff --git a/tests/Fixtures/response.json b/tests/Fixtures/response.json new file mode 100644 index 0000000..ef1b99b --- /dev/null +++ b/tests/Fixtures/response.json @@ -0,0 +1,318 @@ +{ + "products": { + "identification": { + "data": { + "visitorId": "Ibk1527CUFmcnjLwIs4A9", + "requestId": "1708102555327.NLOjmg", + "incognito": true, + "linkedId": "somelinkedId", + "tag": {}, + "time": "2019-05-21T16:40:13Z", + "timestamp": 1582299576512, + "url": "https://www.example.com/login?hope{this{works[!", + "ip": "61.127.217.15", + "ipLocation": { + "accuracyRadius": 10, + "latitude": 49.982, + "longitude": 36.2566, + "postalCode": "61202", + "timezone": "Europe/Dusseldorf", + "city": { + "name": "Dusseldorf" + }, + "country": { + "code": "DE", + "name": "Germany" + }, + "continent": { + "code": "EU", + "name": "Europe" + }, + "subdivisions": [ + { + "isoCode": "63", + "name": "North Rhine-Westphalia" + } + ] + }, + "browserDetails": { + "browserName": "Chrome", + "browserMajorVersion": "74", + "browserFullVersion": "74.0.3729", + "os": "Windows", + "osVersion": "7", + "device": "Other", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ...." + }, + "confidence": { + "score": 0.97 + }, + "visitorFound": false, + "firstSeenAt": { + "global": "2022-03-16T11:26:45.362Z", + "subscription": "2022-03-16T11:31:01.101Z" + }, + "lastSeenAt": { + "global": null, + "subscription": null + } + } + }, + "botd": { + "data": { + "bot": { + "result": "notDetected" + }, + "url": "https://www.example.com/login?hope{this{works}[!", + "ip": "61.127.217.15", + "time": "2019-05-21T16:40:13Z", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 YaBrowser/24.1.0.0 Safari/537.36", + "requestId": "1708102555327.NLOjmg" + } + }, + "rootApps": { + "data": { + "result": false + } + }, + "emulator": { + "data": { + "result": false + } + }, + "ipInfo": { + "data": { + "v4": { + "address": "94.142.239.124", + "geolocation": { + "accuracyRadius": 20, + "latitude": 50.05, + "longitude": 14.4, + "postalCode": "150 00", + "timezone": "Europe/Prague", + "city": { + "name": "Prague" + }, + "country": { + "code": "CZ", + "name": "Czechia" + }, + "continent": { + "code": "EU", + "name": "Europe" + }, + "subdivisions": [ + { + "isoCode": "10", + "name": "Hlavni mesto Praha" + } + ] + }, + "asn": { + "asn": "7922", + "name": "COMCAST-7922", + "network": "73.136.0.0/13" + }, + "datacenter": { + "result": true, + "name": "DediPath" + } + }, + "v6": { + "address": "2001:db8:3333:4444:5555:6666:7777:8888", + "geolocation": { + "accuracyRadius": 5, + "latitude": 49.982, + "longitude": 36.2566, + "postalCode": "10112", + "timezone": "Europe/Berlin", + "city": { + "name": "Berlin" + }, + "country": { + "code": "DE", + "name": "Germany" + }, + "continent": { + "code": "EU", + "name": "Europe" + }, + "subdivisions": [ + { + "isoCode": "BE", + "name": "Land Berlin" + } + ] + }, + "asn": { + "asn": "6805", + "name": "Telefonica Germany", + "network": "2a02:3100::/24" + }, + "datacenter": { + "result": false, + "name": "" + } + } + } + }, + "ipBlocklist": { + "data": { + "result": false, + "details": { + "emailSpam": false, + "attackSource": false + } + } + }, + "tor": { + "data": { + "result": false + } + }, + "vpn": { + "data": { + "result": false, + "originTimezone": "Europe/Berlin", + "originCountry": "unknown", + "methods": { + "timezoneMismatch": false, + "publicVPN": false, + "auxiliaryMobile": false, + "osMismatch": false + } + } + }, + "proxy": { + "data": { + "result": false + } + }, + "incognito": { + "data": { + "result": false + } + }, + "tampering": { + "data": { + "result": false, + "anomalyScore": 0.1955 + } + }, + "clonedApp": { + "data": { + "result": false + } + }, + "factoryReset": { + "data": { + "time": "1970-01-01T00:00:00Z", + "timestamp": 0 + } + }, + "jailbroken": { + "data": { + "result": false + } + }, + "frida": { + "data": { + "result": false + } + }, + "privacySettings": { + "data": { + "result": false + } + }, + "virtualMachine": { + "data": { + "result": false + } + }, + "rawDeviceAttributes": { + "data": { + "architecture": { + "value": 127 + }, + "audio": { + "value": 35.73832903057337 + }, + "canvas": { + "value": { + "Winding": true, + "Geometry": "4dce9d6017c3e0c052a77252f29f2b1c", + "Text": "dd2474a56ff78c1de3e7a07070ba3b7d" + } + }, + "colorDepth": { + "value": 30 + }, + "colorGamut": { + "value": "p3" + }, + "contrast": { + "value": 0 + }, + "cookiesEnabled": { + "value": true + }, + "cpuClass": {}, + "fonts": { + "value": [ + "Arial Unicode MS", + "Gill Sans", + "Helvetica Neue", + "Menlo" + ] + } + } + }, + "highActivity": { + "data": { + "result": false + } + }, + "locationSpoofing": { + "data": { + "result": false + } + }, + "remoteControl": { + "data": { + "result": false + } + }, + "velocity": { + "data": { + "distinctIp": { + "intervals": { + "5m": 1, + "1h": 1, + "24h": 1 + } + }, + "distinctLinkedId": {}, + "distinctCountry": { + "intervals": { + "5m": 1, + "1h": 2, + "24h": 2 + } + }, + "events": { + "intervals": { + "5m": 1, + "1h": 5, + "24h": 5 + } + } + } + }, + "developerTools": { + "data": { + "result": false + } + } + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..91a1408 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..d55ba86 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,160 @@ +shouldReceive('getIdentification')->andReturn($this->getIdentificationMock($requestId, $visitorId, $incognito, $url, $ip, $confidence, $time)); + $productsResponse->shouldReceive('getBotd')->andReturn($this->getBotDResponseMock($botDResult)); + $productsResponse->shouldReceive('getTor')->andReturn($this->getTorResponseMock($isTor)); + $productsResponse->shouldReceive('getVpn')->andReturn($this->getVpnResponseMock($isVPN)); + + $eventResponse = Mockery::mock(EventResponse::class); + $eventResponse->shouldReceive('getProducts')->andReturn($productsResponse); + + return $eventResponse; + } + + public function getConfigRepository(?DateInterval $elapsedTime = new DateInterval('P1D')): Repository + { + $configRepository = Mockery::mock(Repository::class); + $configRepository->shouldReceive('get') + ->with('fingerprint.api_secret') + ->andReturn('secret'); + $configRepository->shouldReceive('get') + ->with('fingerprint.region') + ->andReturn('eu'); + $configRepository->shouldReceive('get') + ->with('fingerprint.middleware.bot_block') + ->andReturn(BotBlockConfiguration::BlockBad); + $configRepository->shouldReceive('get') + ->with('fingerprint.middleware.vpn_block') + ->andReturn(true); + $configRepository->shouldReceive('get') + ->with('fingerprint.middleware.tor_block') + ->andReturn(TorBlockConfiguration::BlockIfSignaled); + $configRepository->shouldReceive('get') + ->with('fingerprint.middleware.min_confidence') + ->andReturn(0.8); + $configRepository->shouldReceive('get') + ->with('fingerprint.middleware.incognito_block') + ->andReturn(true); + $configRepository->shouldReceive('get') + ->with('fingerprint.middleware.max_elapsed_time') + ->andReturn($elapsedTime); + + return $configRepository; + } + + protected function getPackageProviders($app): array + { + return [ + FingerprintServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app): void + { + config()->set('database.default', 'testing'); + } + + private function getIdentificationMock( + string $requestId, + string $visitorId, + bool $incognito, + string $url, + string $ip, + float $confidence, + DateTime $time, + ): ProductsResponseIdentification { + $confidenceMock = Mockery::mock(Confidence::class); + $confidenceMock->shouldReceive('getScore')->andReturn($confidence); + + $identificationData = Mockery::mock(ProductsResponseIdentificationData::class); + + $identificationData->shouldReceive('getTimestamp')->andReturn(1000000000000); + $identificationData->shouldReceive('getRequestId')->andReturn($requestId); + $identificationData->shouldReceive('getVisitorId')->andReturn($visitorId); + $identificationData->shouldReceive('getIncognito')->andReturn($incognito); + $identificationData->shouldReceive('getTime')->andReturn($time); + $identificationData->shouldReceive('getUrl')->andReturn($url); + $identificationData->shouldReceive('getIp')->andReturn($ip); + $identificationData->shouldReceive('getConfidence')->andReturn($confidenceMock); + + $identification = Mockery::mock(ProductsResponseIdentification::class); + + $identification->shouldReceive('getData')->andReturn($identificationData); + + return $identification; + } + + private function getBotDResponseMock(string $botDResult): ProductsResponseBotd + { + $botDDetectionResult = Mockery::mock(BotdDetectionResult::class); + $botDDetectionResult->shouldReceive('getResult')->andReturn($botDResult); + + $botDResultMock = Mockery::mock(BotdResult::class); + $botDResultMock->shouldReceive('getBot')->andReturn($botDDetectionResult); + + $botDDetection = Mockery::mock(ProductsResponseBotd::class); + $botDDetection->shouldReceive('getData')->andReturn($botDResultMock); + + return $botDDetection; + } + + private function getTorResponseMock(bool $isTor): SignalResponseTor + { + $torResult = Mockery::mock(TorResult::class); + $torResult->shouldReceive('getResult')->andReturn($isTor); + + $torResponse = Mockery::mock(SignalResponseTor::class); + $torResponse->shouldReceive('getData')->andReturn($torResult); + + return $torResponse; + } + + private function getVpnResponseMock(bool $isVPN): SignalResponseVpn + { + $vpnResult = Mockery::mock(VpnResult::class); + $vpnResult->shouldReceive('getResult')->andReturn($isVPN); + + $vpnResponse = Mockery::mock(SignalResponseVpn::class); + $vpnResponse->shouldReceive('getData')->andReturn($vpnResult); + + return $vpnResponse; + } +} diff --git a/tests/Unit/BlockBotsMiddlewareTest.php b/tests/Unit/BlockBotsMiddlewareTest.php new file mode 100644 index 0000000..fece24c --- /dev/null +++ b/tests/Unit/BlockBotsMiddlewareTest.php @@ -0,0 +1,39 @@ +event = Mockery::mock(Event::class); + $this->config = Mockery::mock(Repository::class); + $this->request = Mockery::mock(Request::class); + $this->next = fn ($request) => 'done'; +}); + +it('allows request when no bot is detected', function () { + $this->event->botD = BotDResult::NotDetected; + $this->config->shouldReceive('get')->with('fingerprint.middleware.bot_block')->andReturn(BotBlockConfiguration::BlockAll); + + $response = (new BlockBotsMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when a bad bot is detected', function () { + $this->event->botD = BotDResult::Bad; + $this->config->shouldReceive('get')->with('fingerprint.middleware.bot_block')->andReturn(BotBlockConfiguration::BlockBad); + + (new BlockBotsMiddleware($this->event, $this->config))($this->request, $this->next); +})->throws(BotDetectedException::class); + +it('blocks request when any bot is detected', function () { + $this->event->botD = BotDResult::Good; + $this->config->shouldReceive('get')->with('fingerprint.middleware.bot_block')->andReturn(BotBlockConfiguration::BlockAll); + + (new BlockBotsMiddleware($this->event, $this->config))($this->request, $this->next); +})->throws(BotDetectedException::class); diff --git a/tests/Unit/BlockIncognitoMiddlewareTest.php b/tests/Unit/BlockIncognitoMiddlewareTest.php new file mode 100644 index 0000000..831847d --- /dev/null +++ b/tests/Unit/BlockIncognitoMiddlewareTest.php @@ -0,0 +1,38 @@ +config = Mockery::mock(Repository::class); + $this->request = Mockery::mock(Request::class); + $this->next = fn ($request) => 'done'; +}); + +it('allows request when not in incognito mode', function () { + $event = Event::createFromEventResponse($this->getEventResponseMock()); + $this->config->shouldReceive('get')->with('fingerprint.middleware.incognito_block')->andReturn(true); + + $response = (new BlockIncognitoMiddleware($event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when in incognito mode', function () { + $event = Event::createFromEventResponse($this->getEventResponseMock(incognito: true)); + $this->config->shouldReceive('get')->with('fingerprint.middleware.incognito_block')->andReturn(true); + + (new BlockIncognitoMiddleware($event, $this->config))($this->request, $this->next); +})->throws(IncognitoModeException::class); + +it('allows request when incognito blocking is disabled', function () { + $event = Event::createFromEventResponse($this->getEventResponseMock(incognito: true)); + $this->config->shouldReceive('get')->with('fingerprint.middleware.incognito_block')->andReturn(false); + + $response = (new BlockIncognitoMiddleware($event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); diff --git a/tests/Unit/BlockOldIdentificationMiddlewareTest.php b/tests/Unit/BlockOldIdentificationMiddlewareTest.php new file mode 100644 index 0000000..51f4457 --- /dev/null +++ b/tests/Unit/BlockOldIdentificationMiddlewareTest.php @@ -0,0 +1,30 @@ +config = Mockery::mock(Repository::class); + $this->request = Mockery::mock(Request::class); + $this->next = fn ($request) => 'done'; +}); + +it('allows request when identification is recent', function () { + $event = Event::createFromEventResponse($this->getEventResponseMock(time: Carbon::now()->subMinutes(5))); + $this->config->shouldReceive('get')->with('fingerprint.middleware.max_elapsed_time')->andReturn(new DateInterval('PT10M')); + + $response = (new BlockOldIdentificationMiddleware($event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when identification is old', function () { + $event = Event::createFromEventResponse($this->getEventResponseMock(time: Carbon::now()->subMinutes(15))); + $this->config->shouldReceive('get')->with('fingerprint.middleware.max_elapsed_time')->andReturn(new DateInterval('PT10M')); + + (new BlockOldIdentificationMiddleware($event, $this->config))($this->request, $this->next); +})->throws(OldIdentificationException::class); diff --git a/tests/Unit/BlockTorMiddlewareTest.php b/tests/Unit/BlockTorMiddlewareTest.php new file mode 100644 index 0000000..b85e322 --- /dev/null +++ b/tests/Unit/BlockTorMiddlewareTest.php @@ -0,0 +1,47 @@ +event = Mockery::mock(Event::class); + $this->config = Mockery::mock(Repository::class); + $this->request = Mockery::mock(Request::class); + $this->next = fn ($request) => 'done'; +}); + +it('allows request when tor is not detected', function () { + $this->event->isTor = false; + $this->config->shouldReceive('get')->with('fingerprint.middleware.tor_block')->andReturn(TorBlockConfiguration::BlockAll); + + $response = (new BlockTorMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when Tor is detected and configuration is BlockAll', function () { + $this->event->isTor = true; + $this->config->shouldReceive('get')->with('fingerprint.middleware.tor_block')->andReturn(TorBlockConfiguration::BlockAll); + + (new BlockTorMiddleware($this->event, $this->config))($this->request, $this->next); +})->throws(TorDetectionException::class); + +it('allows request when Tor is not detected and configuration is BlockIfSignaled', function () { + $this->event->isTor = null; + $this->config->shouldReceive('get')->with('fingerprint.middleware.tor_block')->andReturn(TorBlockConfiguration::BlockIfSignaled); + + $response = (new BlockTorMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when Tor is detected and configuration is BlockIfSignaled', function () { + $this->event->isTor = true; + $this->config->shouldReceive('get')->with('fingerprint.middleware.tor_block')->andReturn(TorBlockConfiguration::BlockIfSignaled); + + (new BlockTorMiddleware($this->event, $this->config))($this->request, $this->next); +})->throws(TorDetectionException::class); diff --git a/tests/Unit/BlockVPNMiddlewareTest.php b/tests/Unit/BlockVPNMiddlewareTest.php new file mode 100644 index 0000000..98f6622 --- /dev/null +++ b/tests/Unit/BlockVPNMiddlewareTest.php @@ -0,0 +1,39 @@ +event = Mockery::mock(Event::class); + $this->config = Mockery::mock(Repository::class); + $this->request = Mockery::mock(Request::class); + $this->next = fn ($request) => 'done'; +}); + +it('allows request when VPN is not detected', function () { + $this->event->isVPN = false; + $this->config->shouldReceive('get')->with('fingerprint.middleware.vpn_block')->andReturn(true); + + $response = (new BlockVPNMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when VPN is detected and blocking is enabled', function () { + $this->event->isVPN = true; + $this->config->shouldReceive('get')->with('fingerprint.middleware.vpn_block')->andReturn(true); + + (new BlockVPNMiddleware($this->event, $this->config))($this->request, $this->next); +})->throws(VPNDetectedException::class); + +it('allows request when VPN is detected but blocking is disabled', function () { + $this->event->isVPN = true; + $this->config->shouldReceive('get')->with('fingerprint.middleware.vpn_block')->andReturn(false); + + $response = (new BlockVPNMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); diff --git a/tests/Unit/BotDetectedExceptionTest.php b/tests/Unit/BotDetectedExceptionTest.php new file mode 100644 index 0000000..0ce6259 --- /dev/null +++ b/tests/Unit/BotDetectedExceptionTest.php @@ -0,0 +1,17 @@ +toBeInstanceOf(BotDetectedException::class) + ->and($exception->getMessage())->toBe('Bot detected.'); +}); + +it('throws an exception when bad bot detected', function () { + $exception = BotDetectedException::badBotDetected(); + + expect($exception)->toBeInstanceOf(BotDetectedException::class) + ->and($exception->getMessage())->toBe('Bad bot detected.'); +}); diff --git a/tests/Unit/ConsoleHelperTest.php b/tests/Unit/ConsoleHelperTest.php new file mode 100644 index 0000000..e3d2038 --- /dev/null +++ b/tests/Unit/ConsoleHelperTest.php @@ -0,0 +1,33 @@ +toBe('Success'); +}); + +it('formats error text', function () { + $text = 'Error'; + $formattedText = ConsoleHelper::error($text); + expect($formattedText)->toBe('Error'); +}); + +it('formats warning text', function () { + $text = 'Warning'; + $formattedText = ConsoleHelper::warning($text); + expect($formattedText)->toBe('Warning'); +}); + +it('formats info text', function () { + $text = 'Info'; + $formattedText = ConsoleHelper::info($text); + expect($formattedText)->toBe('Info'); +}); + +it('formats italic text with custom color', function () { + $text = 'Italic'; + $formattedText = ConsoleHelper::colorText('cyan', $text, 'italic'); + expect($formattedText)->toBe('Italic'); +}); diff --git a/tests/Unit/EventTest.php b/tests/Unit/EventTest.php new file mode 100644 index 0000000..c19e039 --- /dev/null +++ b/tests/Unit/EventTest.php @@ -0,0 +1,52 @@ +getEventResponseMock(incognito: true, botDResult: 'bad', isTor: true) + ); + + expect($event->identification->requestId)->toBe('request-id') + ->and($event->identification->visitorId)->toBe('visitor-id') + ->and($event->identification->incognito)->toBeTrue() + ->and($event->identification->url)->toBe('https://example.com') + ->and($event->identification->ip)->toBe('127.0.0.1') + ->and($event->identification->confidence)->toBe(0.9) + ->and($event->botD)->toBe(BotDResult::Bad) + ->and($event->isTor)->toBeTrue() + ->and($event->isVPN)->toBeFalse(); +}); + +it('creates an Event instance with good bot', function () { + $event = Event::createFromEventResponse( + $this->getEventResponseMock(incognito: true, botDResult: 'good', isTor: true) + ); + + expect($event->identification->requestId)->toBe('request-id') + ->and($event->identification->visitorId)->toBe('visitor-id') + ->and($event->identification->incognito)->toBeTrue() + ->and($event->identification->url)->toBe('https://example.com') + ->and($event->identification->ip)->toBe('127.0.0.1') + ->and($event->identification->confidence)->toBe(0.9) + ->and($event->botD)->toBe(BotDResult::Good) + ->and($event->isTor)->toBeTrue() + ->and($event->isVPN)->toBeFalse(); +}); + +it('creates an Event instance with not detected bot', function () { + $event = Event::createFromEventResponse( + $this->getEventResponseMock(incognito: true, isTor: true) + ); + + expect($event->identification->requestId)->toBe('request-id') + ->and($event->identification->visitorId)->toBe('visitor-id') + ->and($event->identification->incognito)->toBeTrue() + ->and($event->identification->url)->toBe('https://example.com') + ->and($event->identification->ip)->toBe('127.0.0.1') + ->and($event->identification->confidence)->toBe(0.9) + ->and($event->botD)->toBe(BotDResult::NotDetected) + ->and($event->isTor)->toBeTrue() + ->and($event->isVPN)->toBeFalse(); +}); diff --git a/tests/Unit/FingerprintTest.php b/tests/Unit/FingerprintTest.php new file mode 100644 index 0000000..e503c33 --- /dev/null +++ b/tests/Unit/FingerprintTest.php @@ -0,0 +1,60 @@ +apiKey = 'api-key'; + $this->region = 'global'; + $this->responseJson = File::get(__DIR__.'/../Fixtures/response.json'); + $this->httpClient = new Client; +}); + +it('creates a Fingerprint instance with default HTTP client', function () { + $fingerprint = new Fingerprint($this->apiKey, $this->region); + expect($fingerprint->client)->toBeInstanceOf(FingerprintApi::class); +}); + +it('creates a Fingerprint instance with provided HTTP client', function () { + $fingerprint = new Fingerprint($this->apiKey, $this->region, $this->httpClient); + expect($fingerprint->client)->toBeInstanceOf(FingerprintApi::class); +}); + +it('retrieves an event by request ID', function () { + $handlerStack = HandlerStack::create(new MockHandler([ + new Response(200, [], $this->responseJson), + ])); + $httpClient = new Client([ + 'handler' => $handlerStack, + ]); + $fingerprint = new Fingerprint($this->apiKey, $this->region, $httpClient); + + $event = $fingerprint->getEvent('request-id'); + expect($event)->toBeInstanceOf(Event::class); +}); + +it('throws RegionNotSupportedException for unsupported region', function () { + $unsupportedRegion = 'unsupported_region'; + $this->expectException(RegionNotSupportedException::class); + new Fingerprint($this->apiKey, $unsupportedRegion); +}); + +it('throws ApiException when Fingerprint API error occurs', function () { + $handlerStack = HandlerStack::create(new MockHandler([ + new Response(500, [], null), + ])); + $httpClient = new Client([ + 'handler' => $handlerStack, + ]); + $fingerprint = new Fingerprint($this->apiKey, $this->region, $httpClient); + $this->expectException(ApiException::class); + $fingerprint->getEvent('request-id'); +}); diff --git a/tests/Unit/IncognitoModeExceptionTest.php b/tests/Unit/IncognitoModeExceptionTest.php new file mode 100644 index 0000000..d24b209 --- /dev/null +++ b/tests/Unit/IncognitoModeExceptionTest.php @@ -0,0 +1,10 @@ +toBeInstanceOf(IncognitoModeException::class) + ->and($exception->getMessage())->toBe('Incognito mode detected.'); +}); diff --git a/tests/Unit/InvalidConfigurationExceptionTest.php b/tests/Unit/InvalidConfigurationExceptionTest.php new file mode 100644 index 0000000..20d59cc --- /dev/null +++ b/tests/Unit/InvalidConfigurationExceptionTest.php @@ -0,0 +1,10 @@ +toBeInstanceOf(InvalidConfiguration::class) + ->and($exception->getMessage())->toBe('Fingerprint API key is not specified.'); +}); diff --git a/tests/Unit/MinConfidenceScoreExceptionTest.php b/tests/Unit/MinConfidenceScoreExceptionTest.php new file mode 100644 index 0000000..b9fffe5 --- /dev/null +++ b/tests/Unit/MinConfidenceScoreExceptionTest.php @@ -0,0 +1,12 @@ +toBeInstanceOf(MinConfidenceScoreException::class) + ->and($exception->getMessage())->toBe("Confidence score $confidenceScore is lower than required $minConfidenceScore score"); +}); diff --git a/tests/Unit/MinConfidenceScoreMiddlewareTest.php b/tests/Unit/MinConfidenceScoreMiddlewareTest.php new file mode 100644 index 0000000..a6a956a --- /dev/null +++ b/tests/Unit/MinConfidenceScoreMiddlewareTest.php @@ -0,0 +1,39 @@ +event = Event::createFromEventResponse($this->getEventResponseMock()); + $this->config = Mockery::mock(Repository::class); + $this->request = Mockery::mock(Request::class); + $this->next = fn ($request) => 'done'; +}); + +it('allows request when confidence score meets the minimum requirement', function () { + $this->event->identification->confidence = 0.8; + $this->config->shouldReceive('get')->with('fingerprint.middleware.min_confidence')->andReturn(0.7); + + $response = (new MinConfidenceScoreMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); + +it('blocks request when confidence score is below the minimum requirement', function () { + $this->event->identification->confidence = 0.5; + $this->config->shouldReceive('get')->with('fingerprint.middleware.min_confidence')->andReturn(0.7); + + (new MinConfidenceScoreMiddleware($this->event, $this->config))($this->request, $this->next); +})->throws(MinConfidenceScoreException::class); + +it('allows request when no minimum confidence score is set', function () { + $this->event->identification->confidence = 0.5; + $this->config->shouldReceive('get')->with('fingerprint.middleware.min_confidence')->andReturn(null); + + $response = (new MinConfidenceScoreMiddleware($this->event, $this->config))($this->request, $this->next); + + expect($response)->toBe('done'); +}); diff --git a/tests/Unit/OldIdentificationExceptionTest.php b/tests/Unit/OldIdentificationExceptionTest.php new file mode 100644 index 0000000..6420bab --- /dev/null +++ b/tests/Unit/OldIdentificationExceptionTest.php @@ -0,0 +1,10 @@ +toBeInstanceOf(OldIdentificationException::class) + ->and($exception->getMessage())->toBe('The identification is too old.'); +}); diff --git a/tests/Unit/RegionNotSupportedExceptionTest.php b/tests/Unit/RegionNotSupportedExceptionTest.php new file mode 100644 index 0000000..489d210 --- /dev/null +++ b/tests/Unit/RegionNotSupportedExceptionTest.php @@ -0,0 +1,11 @@ +toBeInstanceOf(RegionNotSupportedException::class) + ->and($exception->getMessage())->toBe("The region '$region' is not supported. Available regions are 'global', 'eu', 'ap'."); +}); diff --git a/tests/Unit/TorDetectionExceptionTest.php b/tests/Unit/TorDetectionExceptionTest.php new file mode 100644 index 0000000..e0747f5 --- /dev/null +++ b/tests/Unit/TorDetectionExceptionTest.php @@ -0,0 +1,17 @@ +toBeInstanceOf(TorDetectionException::class) + ->and($exception->getMessage())->toBe('Tor network detected.'); +}); + +it('throws an exception when Tor detection is required', function () { + $exception = TorDetectionException::torDetectionRequired(); + + expect($exception)->toBeInstanceOf(TorDetectionException::class) + ->and($exception->getMessage())->toBe('Tor detection required.'); +}); diff --git a/tests/Unit/VPNDetectedExceptionTest.php b/tests/Unit/VPNDetectedExceptionTest.php new file mode 100644 index 0000000..4941803 --- /dev/null +++ b/tests/Unit/VPNDetectedExceptionTest.php @@ -0,0 +1,10 @@ +toBeInstanceOf(VPNDetectedException::class) + ->and($exception->getMessage())->toBe('VPN network detected.'); +});