├── .env.example ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── config.yml ├── SECURITY.md ├── assets │ └── logo.png ├── dependabot.yml └── workflows │ └── dependabot-auto-merge.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── devices.php ├── database ├── factories │ └── DeviceFactory.php ├── migrations │ ├── 2024_09_23_091755_create_sessions_table.php │ ├── 2024_09_23_092114_create_devices_table.php │ ├── 2024_09_23_214915_create_user_devices_table.php │ ├── 2024_09_26_162000_create_2fa_configuration_table.php │ ├── 2024_10_16_115600_add_fingerprint_to_device_table.php │ ├── 2024_10_21_224500_add_metadata_to_sessions.php │ ├── 2024_10_31_144134_create_device_events_table.php │ └── 2024_10_31_145007_add_risk_fields_to_device_table.php └── seeders │ ├── DataFactory.php │ ├── DeviceEnrichmentSeeder.php │ ├── DeviceTrackerSeeder.php │ └── DevicesSeeder.php ├── docs ├── 2fa.md ├── README.md ├── api-reference.md ├── api │ ├── 2fa.md │ ├── devices.md │ └── sessions.md ├── caching.md ├── configuration.md ├── custom-ids.md ├── database-schema.md ├── device-management.md ├── events.md ├── extending.md ├── fingerprinting.md ├── installation.md ├── localization.md ├── location-tracking.md ├── monitoring-performance.md ├── notifications.md ├── quick-start.md ├── security.md ├── session-management.md ├── system-overview.md └── testing.md ├── helpers.php ├── phpcs.xml ├── phpstan.neon ├── phpunit.xml ├── pint.json ├── resources └── views │ ├── clientjs-tracking-script.blade.php │ └── fingerprintjs-tracking-script.blade.php ├── routes └── devices.php ├── src ├── Cache │ ├── AbstractCache.php │ ├── DeviceCache.php │ ├── LocationCache.php │ ├── SessionCache.php │ └── UserAgentCache.php ├── Console │ └── Commands │ │ ├── CacheInvalidateCommand.php │ │ ├── CacheWarmCommand.php │ │ ├── CleanupDevicesCommand.php │ │ ├── CleanupSessionsCommand.php │ │ ├── DeviceInspectCommand.php │ │ └── DeviceStatusCommand.php ├── Contracts │ ├── Cacheable.php │ ├── CodeGenerator.php │ ├── RequestAware.php │ └── StorableId.php ├── DTO │ ├── Device.php │ └── Metadata.php ├── DeviceManager.php ├── DeviceTrackerServiceProvider.php ├── Enums │ ├── DeviceStatus.php │ ├── DeviceTransport.php │ ├── SessionIpChangeBehaviour.php │ ├── SessionStatus.php │ ├── SessionTransport.php │ └── Traits │ │ └── CanTransport.php ├── EventSubscriber.php ├── Events │ ├── DeviceAttachedEvent.php │ ├── DeviceCreatedEvent.php │ ├── DeviceDeletedEvent.php │ ├── DeviceFingerprintedEvent.php │ ├── DeviceHijackedEvent.php │ ├── DeviceTrackedEvent.php │ ├── DeviceUpdatedEvent.php │ ├── DeviceVerifiedEvent.php │ ├── Google2FAFailed.php │ ├── Google2FASuccess.php │ ├── SessionBlockedEvent.php │ ├── SessionFinishedEvent.php │ ├── SessionLocationChangedEvent.php │ ├── SessionLockedEvent.php │ ├── SessionStartedEvent.php │ ├── SessionUnblockedEvent.php │ └── SessionUnlockedEvent.php ├── Exception │ ├── DeviceNotFoundException.php │ ├── FingerprintDuplicatedException.php │ ├── FingerprintNotFoundException.php │ ├── InvalidDeviceDetectedException.php │ ├── SessionNotFoundException.php │ ├── TwoFactorAuthenticationNotEnabled.php │ └── UnknownDeviceDetectedException.php ├── Facades │ ├── DeviceManager.php │ └── SessionManager.php ├── Factories │ ├── AbstractStorableIdFactory.php │ ├── DeviceIdFactory.php │ ├── EventIdFactory.php │ └── SessionIdFactory.php ├── Generators │ ├── Google2FACodeGenerator.php │ └── RandomCodeGenerator.php ├── Http │ ├── Controllers │ │ ├── DeviceController.php │ │ ├── SessionController.php │ │ └── TwoFactorController.php │ ├── Middleware │ │ ├── DeviceChecker.php │ │ ├── DeviceTracker.php │ │ └── SessionTracker.php │ └── Resources │ │ ├── DeviceResource.php │ │ └── SessionResource.php ├── Models │ ├── Device.php │ ├── Google2FA.php │ ├── Relations │ │ ├── BelongsToManyDevices.php │ │ └── HasManySessions.php │ └── Session.php ├── Modules │ ├── Detection │ │ ├── Contracts │ │ │ ├── DeviceDetector.php │ │ │ └── RequestTypeDetector.php │ │ ├── DTO │ │ │ ├── Browser.php │ │ │ ├── DeviceType.php │ │ │ ├── Platform.php │ │ │ └── Version.php │ │ ├── Device │ │ │ └── UserAgentDeviceDetector.php │ │ └── Request │ │ │ ├── AbstractRequestDetector.php │ │ │ ├── AjaxRequestDetector.php │ │ │ ├── ApiRequestDetector.php │ │ │ ├── AuthenticationRequestDetector.php │ │ │ ├── DetectorRegistry.php │ │ │ ├── LivewireRequestDetector.php │ │ │ ├── PageViewDetector.php │ │ │ └── RedirectResponseDetector.php │ ├── Fingerprinting │ │ ├── Http │ │ │ └── Middleware │ │ │ │ └── FingerprintTracker.php │ │ └── Injector │ │ │ ├── AbstractInjector.php │ │ │ ├── ClientJSInjector.php │ │ │ ├── Contracts │ │ │ └── Injector.php │ │ │ ├── Enums │ │ │ └── Library.php │ │ │ ├── Factories │ │ │ └── InjectorFactory.php │ │ │ └── FingerprintJSInjector.php │ ├── Location │ │ ├── AbstractLocationProvider.php │ │ ├── Contracts │ │ │ └── LocationProvider.php │ │ ├── DTO │ │ │ └── Location.php │ │ ├── Exception │ │ │ └── LocationLookupFailedException.php │ │ ├── FallbackLocationProvider.php │ │ ├── IpinfoLocationProvider.php │ │ └── MaxmindLocationProvider.php │ └── Tracking │ │ ├── Cache │ │ └── EventTypeCache.php │ │ ├── Enums │ │ └── EventType.php │ │ ├── Http │ │ └── Middleware │ │ │ └── EventTracker.php │ │ └── Models │ │ ├── Event.php │ │ └── Relations │ │ └── HasManyEvents.php ├── SessionManager.php ├── Traits │ ├── Has2FA.php │ ├── HasDevices.php │ └── PropertyProxy.php └── ValueObject │ ├── AbstractStorableId.php │ ├── DeviceId.php │ ├── EventId.php │ └── SessionId.php ├── testbench.yaml └── tests ├── Feature ├── Enums │ ├── DeviceTransportTest.php │ └── SessionTransportTest.php └── Http │ └── Middleware │ ├── DeviceCheckerTest.php │ └── DeviceTrackerTest.php ├── FeatureTestCase.php ├── Pest.php └── TestCase.php /.env.example: -------------------------------------------------------------------------------- 1 | # Package Configuration 2 | DEVICES_FINGERPRINTING_ENABLED=true 3 | DEVICES_GOOGLE_2FA_ENABLED=true 4 | DEVICES_EVENT_TRACKING_ENABLED=true 5 | DEVICES_TRACK_GUEST_SESSIONS=false 6 | DEVICES_REGENERATE_DEVICES=false 7 | DEVICES_ALLOW_UNKNOWN_DEVICES=false 8 | DEVICES_ALLOW_BOT_DEVICES=false 9 | DEVICES_ALLOW_DEVICE_MULTI_SESSION=false 10 | 11 | # Authentication Routes 12 | DEVICES_LOGIN_ROUTE_NAME=login 13 | DEVICES_LOGOUT_ROUTE_NAME=logout 14 | DEVICES_2FA_ROUTE_NAME=2fa 15 | 16 | # Security Settings 17 | DEVICES_INACTIVITY_SECONDS=1200 18 | DEVICES_INACTIVITY_SESSION_BEHAVIOUR=terminate 19 | DEVICES_EVENT_RETENTION_PERIOD=30 20 | DEVICES_ORPHAN_RETENTION_PERIOD=1 21 | 22 | # Transport Settings 23 | DEVICES_DEVICE_ID_TRANSPORT=cookie 24 | DEVICES_SESSION_ID_TRANSPORT=cookie 25 | DEVICES_DEVICE_ID_PARAMETER=laravel_device_id 26 | DEVICES_SESSION_ID_PARAMETER=laravel_session_id 27 | 28 | # Fingerprinting Configuration 29 | DEVICES_FINGERPRINT_CLIENT_LIBRARY=fingerprintjs 30 | DEVICES_CLIENT_FINGERPRINT_TRANSPORT=cookie 31 | DEVICES_CLIENT_FINGERPRINT_KEY=csf 32 | DEVICES_FINGERPRINT_ID_COOKIE_NAME=laravel_device_fingerprint 33 | 34 | # Cache Configuration 35 | DEVICES_CACHE_STORE=file 36 | DEVICES_CACHE_TTL_SESSION=3600 37 | DEVICES_CACHE_TTL_DEVICE=3600 38 | DEVICES_CACHE_TTL_LOCATION=2592000 39 | DEVICES_CACHE_TTL_UA=2592000 40 | 41 | # Google 2FA Settings 42 | DEVICES_GOOGLE_2FA_WINDOW=1 43 | DEVICES_GOOGLE_2FA_COMPANY="${APP_NAME}" 44 | DEVICES_GOOGLE_2FA_QR_FORMAT=svg 45 | 46 | # Authentication Configuration 47 | DEVICES_AUTH_GUARD=web 48 | DEVICES_AUTH_MIDDLEWARE=auth 49 | DEVICES_AUTHENTICATABLE_CLASS="App\\Models\\User" 50 | DEVICES_AUTHENTICATABLE_TABLE=users 51 | 52 | # Route Configuration 53 | DEVICES_LOAD_ROUTES=true 54 | DEVICES_USE_REDIRECTS=true 55 | 56 | # Session Configuration 57 | DEVICES_START_NEW_SESSION_ON_LOGIN=false 58 | 59 | # Development Pools 60 | DEVICES_DEVELOPMENT_IP_POOL="138.100.56.25,2.153.101.169,104.26.14.39,104.26.3.12" 61 | DEVICES_IGNORE_RESTART='[{"method":"GET","route":"polling/*"},{"method":"POST","route":"chat/*"}]' 62 | 63 | # Cache Configuration for specific types 64 | DEVICES_CACHE_ENABLED_TYPES="device,location,session,ua,event_type" 65 | 66 | # Location Providers Configuration 67 | # Uncomment and configure the provider you want to use 68 | 69 | # IPInfo Configuration 70 | IPINFO_API_KEY=your_api_key_here 71 | 72 | # MaxMind Configuration 73 | # MAXMIND_LICENSE_KEY=your_license_key_here 74 | # MAXMIND_USER_ID=your_user_id_here 75 | # MAXMIND_DATABASE_PATH=/path/to/GeoLite2-City.mmdb -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Your contributions are highly appreciated, and they will be duly recognized. 4 | 5 | Before you proceed to create an issue or a pull request, please take a moment to familiarize yourself with our contribution guide. 6 | 7 | ## Etiquette 8 | 9 | This project thrives on the spirit of open source collaboration. Our maintainers dedicate their precious time to create and uphold the source code, and they share it with the hope that it will benefit fellow developers. Let's ensure they don't bear the brunt of abuse or anger for their hard work. 10 | 11 | When raising issues or submitting pull requests, let's maintain a considerate and respectful tone. Our goal is to exemplify that developers are a courteous and collaborative community. 12 | 13 | The maintainers have the responsibility to evaluate the quality and compatibility of all contributions with the project. Every developer brings unique skills, strengths, and perspectives to the table. Please respect their decisions, even if your submission isn't integrated. 14 | 15 | ## Relevance 16 | 17 | Before proposing or submitting new features, consider whether they are genuinely beneficial to the broader user base. Open source projects serve a diverse group of developers with varying needs. It's important to assess whether your feature is likely to be widely useful. 18 | 19 | ## Procedure 20 | 21 | ### Preliminary Steps Before Filing an Issue 22 | 23 | - Try to replicate the problem to ensure it's not an isolated occurrence. 24 | - Verify if your feature suggestion has already been addressed within the project. 25 | - Review the pull requests to make sure a solution for the bug isn't already underway. 26 | - Check the pull requests to confirm that the feature isn't already under development. 27 | 28 | ### Preparing Your Pull Request 29 | 30 | - Examine the codebase to prevent duplication of your proposed feature. 31 | - Check the pull requests to verify that another contributor hasn't already submitted the same feature or fix. 32 | 33 | ## Opening a Pull Request 34 | 35 | To maintain coding consistency, we adhere to the PSR-12 coding standard and use PHPStan for static code analysis. You can utilize the following command: 36 | 37 | ```bash 38 | composer all-check 39 | ``` 40 | This command encompasses: 41 | 42 | - PSR-12 Coding Standard checks employing PHP_CodeSniffer. 43 | - PHPStan analysis at level 8. 44 | - Execution of all tests from the `./tests/*` directory using PestPHP. 45 | 46 | We recommend running `composer all-check` before committing and creating a pull request. 47 | 48 | When working on a pull request, it is advisable to create a new branch that originates from the main branch. This branch can serve as the target branch when you submit your pull request to the original repository. 49 | 50 | For a high-quality pull request, please ensure that you: 51 | 52 | - Include tests as part of your patch. We cannot accept submissions lacking tests. 53 | - Document changes in behavior, keeping the README.md and other pertinent documentation up-to-date. 54 | - Respect our release cycle. We follow SemVer v2.0.0, and we cannot afford to randomly break public APIs. 55 | - Stick to one pull request per feature. Multiple changes should be presented through separate pull requests. 56 | - Provide a cohesive history. Each individual commit within your pull request should serve a meaningful purpose. If you have made several intermediary commits during development, please consolidate them before submission. 57 | 58 | Happy coding! 🚀 59 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Help me support this package 2 | 3 | ko_fi: diegoninja 4 | custom: ['https://paypal.me/diegorin'] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Request a new feature 4 | url: https://github.com/diego-ninja/cosmic/issues/new?labels=enhancement 5 | about: Share ideas for new features / functions 6 | - name: Report a bug 7 | url: https://github.com/diego-ninja/cosmic/issues/new?labels=bug 8 | about: Report a reproducable bug 9 | - name: Documentation 10 | url: https://github.com/diego-ninja/cosmic/issues/new?labels=documentation 11 | about: Improvements or additions to documentation 12 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Package Security Policy 2 | 3 | ## Reporting Security Issues 4 | 5 | If you discover any security-related issues within our package, we take these matters seriously and encourage you to report them to us promptly. Your assistance in disclosing potential security vulnerabilities is highly appreciated. 6 | 7 | To report a security issue, please email us at [cosmic@diego.ninja](mailto:cosmic@diego.ninja). We request that you do not use public issue trackers or other public communication channels to report security concerns related to this package. This helps us maintain the confidentiality and integrity of the issue while we investigate and address it. 8 | 9 | ## Responsible Disclosure 10 | 11 | We follow a responsible disclosure policy, and we kindly ask you to: 12 | 13 | 1. **Provide Sufficient Details**: When reporting a security issue, please include as much information as possible so that we can reproduce and understand the problem. This may include steps to reproduce, the affected component, and any proof-of-concept code if available. 14 | 15 | 2. **Allow Time for Resolution**: We will acknowledge the receipt of your report promptly and work diligently to assess and resolve the issue. We appreciate your patience and understanding during this process. 16 | 17 | 3. **Keep Information Confidential**: Please do not disclose or share the details of the security issue with others until we have addressed and resolved it. This helps protect our users and the security of our package. 18 | 19 | 4. **Do Not Impact Other Users**: Please refrain from taking any actions that may negatively impact the availability or integrity of our package or the data of other users. 20 | 21 | If you are unsure whether a specific issue qualifies, please report it, and we will assess its validity. 22 | 23 | Thank you for your cooperation in helping us maintain the security of our package and protecting our users. We value your contributions to our security efforts, and we deeply appreciate your valuable contributions. 24 | -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diego-ninja/laravel-devices/9cc55370022021d1f89d9ba7a77d085c623384e8/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | interval: "weekly" 10 | labels: 11 | - "dependencies" 12 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@v2.4.0 17 | with: 18 | github-token: "${{ secrets.GITHUB_TOKEN }}" 19 | 20 | - name: Auto-merge Dependabot PRs for semver-minor updates 21 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} 22 | run: gh pr merge --auto --merge "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | 27 | - name: Auto-merge Dependabot PRs for semver-patch updates 28 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 29 | run: gh pr merge --auto --merge "$PR_URL" 30 | env: 31 | PR_URL: ${{github.event.pull_request.html_url}} 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | vendor 4 | .idea 5 | /.phpunit.cache 6 | /.phpunit.result.cache 7 | tester.php.dev-environment/ 8 | .dev-environment/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Diego Rin Martín 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Laravel Devices Logo 3 |

4 | 5 | [![Laravel Package](https://img.shields.io/badge/Laravel%2010+%20Package-red?logo=laravel&logoColor=white)](https://www.laravel.com) 6 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/diego-ninja/laravel-devices.svg?style=flat&color=blue)](https://packagist.org/packages/diego-ninja/laravel-devices) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/diego-ninja/laravel-devices.svg?style=flat&color=blue)](https://packagist.org/packages/diego-ninja/laravel-devices) 8 | ![PHP Version](https://img.shields.io/packagist/php-v/diego-ninja/cosmic.svg?style=flat&color=blue) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 10 | ![GitHub last commit](https://img.shields.io/github/last-commit/diego-ninja/laravel-devices?color=blue) 11 | [![Hits-of-Code](https://hitsofcode.com/github/diego-ninja/laravel-devices?branch=main&label=Hits-of-Code)](https://hitsofcode.com/github/diego-ninja/laravel-devices/view?branch=main&label=Hits-of-Code&color=blue) 12 | [![wakatime](https://wakatime.com/badge/user/bd65f055-c9f3-4f73-92aa-3c9810f70cc3/project/94491bff-6b6c-4b9d-a5fd-5568319d3071.svg)](https://wakatime.com/badge/user/bd65f055-c9f3-4f73-92aa-3c9810f70cc3/project/94491bff-6b6c-4b9d-a5fd-5568319d3071) 13 | 14 | Laravel Devices is a comprehensive package for managing user devices and sessions in Laravel applications. It provides robust device tracking, session management, and security features including device fingerprinting and two-factor authentication support. 15 | 16 | This is a work in progress, and maybe or maybe not be ready for production use. Help is needed to improve the project and write documentation so if you are interested in contributing, please read the [contributing guide](./docs/contributing.md). 17 | 18 | ## ❤️ Features 19 | 20 | * Authenticated User Devices 21 | * Session Management 22 | * Session blocking 23 | * Session locking (Google 2FA support for session locking) 24 | * Session location tracking 25 | * Device verifying 26 | * Custom id format for sessions and devices 27 | * Application events 28 | * Ready to use middleware, routes, controllers, dtos, value objects and resources 29 | * Ready to use Google 2FA integration 30 | * Cache support for devices, sessions, locations and user agents 31 | * [FingerprintJS](https://github.com/fingerprintjs/fingerprintjs) and [ClientJS](https://github.com/jackspirou/clientjs) integrations for device fingerprinting 32 | 33 | ## 🗓️ Planned features 34 | 35 | * Device hijacking detection 36 | * Livewire integrations for [Laravel Jetstream](https://jetstream.laravel.com/) and [Laravel Breeze](https://laravel.com/docs/11.x/starter-kits#laravel-breeze) 37 | * [Laravel Pulse](https://laravel.com/docs/11.x/pulse) integration 38 | 39 | 40 | ## 📚 Documentation 41 | 42 | Please refer to the [documentation](./docs/README.md) for more information on the features and how to use this package. 43 | 44 | 45 | ## 🙏 Credits 46 | 47 | This project is developed and maintained by 🥷 [Diego Rin](https://diego.ninja) in his free time. 48 | 49 | Special thanks to: 50 | 51 | - [Laravel Framework](https://laravel.com/) for providing the most exciting and well-crafted PHP framework. 52 | - [Hamed Mehryar](https://github.com/hamedmehryar) for developing the [inital code](https://github.com/hamedmehryar/laravel-session-tracker) that serves Laravel Devices as starting point. 53 | - All the contributors and testers who have helped to improve this project through their contributions. 54 | 55 | If you find this project useful, please consider giving it a ⭐ on GitHub! 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diego-ninja/laravel-devices", 3 | "description": "This package provides session tracking functionalities, multi-session management and user device management features for laravel applications.", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Diego Rin Martín", 9 | "email": "yosoy@diego.ninja" 10 | }, 11 | { 12 | "name": "Davide Pizzato", 13 | "email": "davide.pizzato@kimiagroup.com" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.2", 18 | "ext-json": "*", 19 | "ext-pdo": "*", 20 | "bacon/bacon-qr-code": "^3.0", 21 | "geoip2/geoip2": "^3.0", 22 | "laravel/framework": "^10 || ^11 || ^12", 23 | "matomo/device-detector": "^6.4", 24 | "nesbot/carbon": "^2 || ^3", 25 | "pragmarx/google2fa": "^8.0", 26 | "ramsey/uuid": "^4.7", 27 | "zero-to-prod/data-model": "^81.7" 28 | }, 29 | "require-dev": { 30 | "ext-redis": "*", 31 | "barryvdh/laravel-ide-helper": "^3.1", 32 | "fakerphp/faker": "^1.24", 33 | "larastan/larastan": "^3.0", 34 | "laravel/octane": "^2.5", 35 | "laravel/pint": "^1.18", 36 | "mockery/mockery": "^1.4.4", 37 | "orchestra/testbench": "^9.9", 38 | "pestphp/pest": "^2 || ^3", 39 | "phpstan/phpstan": "^2", 40 | "phpstan/phpstan-deprecation-rules": "^2", 41 | "swoole/ide-helper": "~5.0.0" 42 | }, 43 | "minimum-stability": "stable", 44 | "autoload": { 45 | "psr-4": { 46 | "Ninja\\DeviceTracker\\": "src/", 47 | "Ninja\\DeviceTracker\\Database\\Seeders\\": "database/seeders", 48 | "Ninja\\DeviceTracker\\Database\\Factories\\": "database/factories" 49 | }, 50 | "files": [ 51 | "helpers.php" 52 | ] 53 | }, 54 | "autoload-dev": { 55 | "psr-4": { 56 | "Ninja\\DeviceTracker\\Tests\\": "tests/" 57 | } 58 | }, 59 | "config": { 60 | "allow-plugins": { 61 | "pestphp/pest-plugin": true 62 | } 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "aliases": { 67 | "DeviceManager": "Ninja\\DeviceTracker\\Facades\\DeviceManager", 68 | "SessionManager": "Ninja\\DeviceTracker\\Facades\\SessionManager" 69 | }, 70 | "providers": [ 71 | "Ninja\\DeviceTracker\\DeviceTrackerServiceProvider" 72 | ] 73 | } 74 | }, 75 | "$schema": "https://getcomposer.org/schema.json" 76 | } 77 | -------------------------------------------------------------------------------- /database/factories/DeviceFactory.php: -------------------------------------------------------------------------------- 1 | DeviceIdFactory::from($this->faker->uuid), 17 | 'ip' => $this->faker->ipv4(), 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /database/migrations/2024_09_23_091755_create_sessions_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 14 | $table->string('uuid')->unique(); 15 | $table->integer('user_id'); 16 | $table->string('device_uuid'); 17 | $table->string('ip')->nullable(); 18 | $table->json('location')->nullable(); 19 | $table->string('status')->default(SessionStatus::Active->value); 20 | $table->timestamp('started_at')->nullable(); 21 | $table->timestamp('finished_at')->nullable(); 22 | $table->timestamp('last_activity_at')->nullable(); 23 | $table->timestamp('blocked_at')->nullable(); 24 | $table->timestamp('unlocked_at')->nullable(); 25 | $table->integer('blocked_by')->nullable(); 26 | }); 27 | } 28 | 29 | public function down(): void 30 | { 31 | Schema::drop('device_sessions'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2024_09_23_092114_create_devices_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 14 | $table->string('uuid')->unique(); 15 | $table->string('status')->default(DeviceStatus::Unverified->value); 16 | $table->string('browser')->nullable(); 17 | $table->string('browser_family')->nullable(); 18 | $table->string('browser_version')->nullable(); 19 | $table->string('browser_engine')->nullable(); 20 | $table->string('platform')->nullable(); 21 | $table->string('platform_family')->nullable(); 22 | $table->string('platform_version')->nullable(); 23 | $table->string('device_type')->nullable(); 24 | $table->string('device_family')->nullable(); 25 | $table->string('device_model')->nullable(); 26 | $table->string('grade')->nullable(); 27 | $table->string('source')->nullable(); 28 | $table->string('ip'); 29 | $table->json('metadata')->nullable(); 30 | $table->timestamps(); 31 | $table->timestamp('verified_at')->nullable(); 32 | $table->timestamp('hijacked_at')->nullable(); 33 | }); 34 | } 35 | 36 | public function down(): void 37 | { 38 | Schema::drop('devices'); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /database/migrations/2024_09_23_214915_create_user_devices_table.php: -------------------------------------------------------------------------------- 1 | table(), function (Blueprint $table) { 13 | $table->id(); 14 | $table->bigInteger($this->field())->unsigned(); 15 | $table->string('device_uuid'); 16 | $table->string('status')->default(DeviceStatus::Unverified->value); 17 | $table->timestamp('verified_at')->nullable(); 18 | $table->timestamp('last_activity_at')->nullable(); 19 | $table->timestamps(); 20 | $table->foreign($this->field()) 21 | ->references('id') 22 | ->on(config('devices.authenticatable_table')) 23 | ->onDelete('cascade'); 24 | $table->foreign('device_uuid') 25 | ->references('uuid') 26 | ->on('devices') 27 | ->onDelete('cascade'); 28 | }); 29 | } 30 | 31 | public function down(): void 32 | { 33 | Schema::drop($this->table()); 34 | } 35 | 36 | private function table(): string 37 | { 38 | return sprintf('%s_devices', str(config('devices.authenticatable_table'))->singular()); 39 | } 40 | 41 | private function field(): string 42 | { 43 | return sprintf('%s_id', str(config('devices.authenticatable_table'))->singular()); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /database/migrations/2024_09_26_162000_create_2fa_configuration_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | $table->integer('user_id'); 14 | $table->boolean('enabled')->nullable(); 15 | $table->text('secret')->nullable(); 16 | $table->timestamp('last_success_at')->nullable(); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | public function down(): void 22 | { 23 | Schema::drop('google_2fa'); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /database/migrations/2024_10_16_115600_add_fingerprint_to_device_table.php: -------------------------------------------------------------------------------- 1 | string('fingerprint') 12 | ->unique() 13 | ->nullable() 14 | ->after('uuid'); 15 | }); 16 | } 17 | 18 | public function down(): void 19 | { 20 | Schema::table('devices', function (Blueprint $table) { 21 | $table->dropColumn('fingerprint'); 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /database/migrations/2024_10_21_224500_add_metadata_to_sessions.php: -------------------------------------------------------------------------------- 1 | json('metadata') 12 | ->nullable() 13 | ->after('status'); 14 | }); 15 | } 16 | 17 | public function down(): void 18 | { 19 | Schema::table('device_sessions', function (Blueprint $table) { 20 | $table->dropColumn('metadata'); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /database/migrations/2024_10_31_144134_create_device_events_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('uuid')->unique(); 14 | $table->string('device_uuid'); 15 | $table->string('session_uuid')->nullable(); 16 | $table->string('type'); 17 | $table->string('ip_address')->nullable(); 18 | $table->json('metadata')->nullable(); 19 | $table->timestamp('occurred_at')->useCurrent(); 20 | $table->foreign('device_uuid') 21 | ->references('uuid') 22 | ->on('devices') 23 | ->onDelete('cascade'); 24 | 25 | $table->foreign('session_uuid') 26 | ->references('uuid') 27 | ->on('device_sessions') 28 | ->onDelete('cascade'); 29 | 30 | $table->index('device_uuid'); 31 | $table->index('session_uuid'); 32 | $table->index('type'); 33 | $table->index('occurred_at'); 34 | 35 | $table->index(['device_uuid', 'occurred_at']); 36 | $table->index(['session_uuid', 'occurred_at']); 37 | }); 38 | } 39 | 40 | public function down(): void 41 | { 42 | Schema::dropIfExists('device_events'); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /database/migrations/2024_10_31_145007_add_risk_fields_to_device_table.php: -------------------------------------------------------------------------------- 1 | unsignedTinyInteger('risk_score')->default(0); 13 | $table->json('risk_level')->nullable(); 14 | $table->timestamp('risk_assessed_at')->nullable(); 15 | 16 | $table->index('risk_score'); 17 | $table->index('risk_assessed_at'); 18 | 19 | $table->index(['risk_score', 'status']); 20 | }); 21 | } 22 | 23 | public function down(): void 24 | { 25 | Schema::table('devices', function (Blueprint $table) { 26 | $table->dropColumn('risk_score'); 27 | $table->dropColumn('risk_level'); 28 | $table->dropColumn('risk_assessed_at'); 29 | }); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/seeders/DevicesSeeder.php: -------------------------------------------------------------------------------- 1 | call([ 12 | DeviceTrackerSeeder::class, 13 | DeviceEnrichmentSeeder::class, 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Laravel Devices Documentation 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/diego-ninja/laravel-devices.svg?style=flat&color=blue)](https://packagist.org/packages/diego-ninja/laravel-devices) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/diego-ninja/laravel-devices.svg?style=flat&color=blue)](https://packagist.org/packages/diego-ninja/laravel-devices) 5 | ![PHP Version](https://img.shields.io/packagist/php-v/diego-ninja/cosmic.svg?style=flat&color=blue) 6 | ![Static Badge](https://img.shields.io/badge/laravel-10-blue) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | ![GitHub last commit](https://img.shields.io/github/last-commit/diego-ninja/laravel-devices?color=blue) 9 | [![Hits-of-Code](https://hitsofcode.com/github/diego-ninja/laravel-devices?branch=main&label=Hits-of-Code)](https://hitsofcode.com/github/diego-ninja/laravel-devices/view?branch=main&label=Hits-of-Code&color=blue) 10 | [![wakatime](https://wakatime.com/badge/user/bd65f055-c9f3-4f73-92aa-3c9810f70cc3/project/94491bff-6b6c-4b9d-a5fd-5568319d3071.svg)](https://wakatime.com/badge/user/bd65f055-c9f3-4f73-92aa-3c9810f70cc3/project/94491bff-6b6c-4b9d-a5fd-5568319d3071) 11 | 12 | ## Documentation 13 | This documentation has been generated almost in its entirety using 🦠 [Claude 3.5 Sonnet](https://claude.ai/) based on source code analysis. Some sections may be incomplete, outdated or may contain documentation for planned or not-released features. For the most accurate information, please refer to the source code or open an issue on the package repository. 14 | 15 | If you find any issues or have suggestions for improvements, please open an issue or a pull request on the package repository. 16 | 17 | ### Getting Started 18 | * [Installation](installation.md) 19 | * [Quick Start](quick-start.md) 20 | * [Configuration](configuration.md) 21 | 22 | ### Core Concepts 23 | * [Overview](system-overview.md) 24 | * [Database Schema](database-schema.md) 25 | * [Device Management](device-management.md) 26 | * [Session Management](session-management.md) 27 | 28 | ### Features 29 | * [Device Fingerprinting](fingerprinting.md) 30 | * [Two-Factor Authentication](2fa.md) 31 | * [Location Tracking](location-tracking.md) 32 | * [Caching System](caching.md) 33 | 34 | ### Developer Reference 35 | * [API Reference](api-reference.md) 36 | * [Devices API](api/devices.md) 37 | * [Sessions API](api/sessions.md) 38 | * [2FA API](api/2fa.md) 39 | * [Events](events.md) 40 | * [Custom ID Implementation](custom-ids.md) 41 | * [Extending the Package](extending.md) 42 | 43 | ## Requirements 44 | - PHP 8.2 or higher 45 | - Laravel 10.x or 11.x 46 | - Redis extension (recommended for caching) 47 | 48 | ## Quick Installation 49 | ```bash 50 | composer require diego-ninja/laravel-devices 51 | ``` 52 | 53 | ## Basic Usage 54 | ```php 55 | // Track current device 56 | $device = DeviceManager::track(); 57 | 58 | // Start a new session 59 | $session = SessionManager::start(); 60 | 61 | // Check device status 62 | if ($device->verified()) { 63 | // Process request 64 | } 65 | ``` 66 | 67 | ## Credits 68 | This package is developed and maintained by [Diego Rin](https://diego.ninja). 69 | 70 | ## License 71 | The MIT License (MIT). Please see [License File](../LICENSE) for more information. -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Overview 4 | 5 | Laravel Devices provides a comprehensive REST API divided into three main sections: 6 | 7 | 1. [Device Management API](api/devices.md) - Manage and track devices 8 | 2. [Session Management API](api/sessions.md) - Control user sessions 9 | 3. [Two-Factor Authentication API](api/2fa.md) - Handle 2FA operations 10 | 11 | ## Authentication 12 | 13 | All API endpoints require authentication. The package uses your configured Laravel auth guard. 14 | 15 | ```bash 16 | # Example with Bearer token 17 | curl -X GET /api/devices \ 18 | -H "Authorization: Bearer your-token" \ 19 | -H "Accept: application/json" 20 | ``` 21 | 22 | ## Common Error Responses 23 | 24 | ### Unauthorized (401) 25 | ```json 26 | { 27 | "message": "Unauthenticated." 28 | } 29 | ``` 30 | 31 | ### Forbidden (403) 32 | ```json 33 | { 34 | "message": "This action is unauthorized." 35 | } 36 | ``` 37 | 38 | ### Session Locked (423) 39 | ```json 40 | { 41 | "message": "Session locked" 42 | } 43 | ``` 44 | 45 | ### Rate Limited (429) 46 | ```json 47 | { 48 | "message": "Too Many Attempts.", 49 | "retry_after": 60 50 | } 51 | ``` 52 | 53 | ## Best Practices 54 | 55 | 1. Always include appropriate headers: 56 | - `Accept: application/json` 57 | - Valid authentication token 58 | - `Content-Type: application/json` for POST/PATCH requests 59 | 60 | 2. Handle rate limiting: 61 | - Check for 429 status codes 62 | - Respect the `retry_after` header 63 | 64 | 3. Implement proper error handling: 65 | - Handle all possible status codes 66 | - Validate responses 67 | - Implement retry logic where appropriate 68 | 69 | 4. Use appropriate HTTP methods: 70 | - GET for retrieving data 71 | - POST for creating 72 | - PATCH for updates 73 | - DELETE for removal 74 | 75 | ## Next Steps 76 | 77 | - Read detailed [Device API Documentation](api/devices.md) 78 | - Explore [Session API Documentation](api/sessions.md) 79 | - Check [2FA API Documentation](api/2fa.md) 80 | - Review [Events System](events.md) -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation Guide 2 | 3 | ## Requirements 4 | 5 | Before installing Laravel Devices, ensure your environment meets the following requirements: 6 | 7 | - PHP 8.2 or higher 8 | - Laravel 10.x or 11.x 9 | - Redis extension (recommended for caching) 10 | - Composer 11 | 12 | ## Step-by-Step Installation 13 | 14 | ### 1. Install via Composer 15 | 16 | ```bash 17 | composer require diego-ninja/laravel-devices 18 | ``` 19 | 20 | ### 2. Publish Configuration and Migrations 21 | 22 | ```bash 23 | php artisan vendor:publish --provider="Ninja\DeviceTracker\DeviceTrackerServiceProvider" 24 | ``` 25 | 26 | This command will publish: 27 | - Configuration file: `config/devices.php` 28 | - Database migrations in `database/migrations/`: 29 | - Device table migration 30 | - Sessions table migration 31 | - Google 2FA configuration table migration 32 | - User devices pivot table migration 33 | - Blade templates for fingerprint library injection 34 | 35 | ### 3. Run Migrations 36 | 37 | ```bash 38 | php artisan migrate 39 | ``` 40 | 41 | ### 4. Configure User Model 42 | 43 | Add the necessary traits to your User model: 44 | 45 | ```php 46 | use Ninja\DeviceTracker\Traits\HasDevices; 47 | use Ninja\DeviceTracker\Traits\Has2FA; // Optional, only if using 2FA 48 | 49 | class User extends Authenticatable 50 | { 51 | use HasDevices; 52 | use Has2FA; // Optional 53 | 54 | // ... rest of your model 55 | } 56 | ``` 57 | 58 | ### 5. Register Middleware 59 | 60 | Add the required middleware in your `boot/app.php`: 61 | 62 | ```php 63 | protected $middleware = [ 64 | // ... other middleware 65 | \Ninja\DeviceTracker\Http\Middleware\DeviceTracker::class, 66 | \Ninja\DeviceTracker\Modules\Fingerprinting\Http\Middleware\FingerprintTracker::class, 67 | ]; 68 | 69 | protected $routeMiddleware = [ 70 | // ... other route middleware 71 | 'session-tracker' => \Ninja\DeviceTracker\Http\Middleware\SessionTracker::class, 72 | ]; 73 | ``` 74 | 75 | ### 6. Configure Service Provider (Optional) 76 | 77 | If you're using Laravel < 10, add the service provider to `config/app.php`: 78 | 79 | ```php 80 | 'providers' => [ 81 | // ... other providers 82 | Ninja\DeviceTracker\DeviceTrackerServiceProvider::class, 83 | ], 84 | ``` 85 | 86 | For Laravel 10+ using package discovery, this step is not necessary. 87 | 88 | ### 7. Configure Cache Driver (Recommended) 89 | 90 | For optimal performance, configure a fast cache driver in your `.env` file: 91 | 92 | ```env 93 | CACHE_DRIVER=redis 94 | REDIS_CLIENT=predis 95 | ``` 96 | 97 | ## Post-Installation Steps 98 | 99 | ### 1. Configure Google 2FA (Optional) 100 | 101 | If you plan to use Google 2FA: 102 | 103 | 1. Make sure your User model uses the `Has2FA` trait 104 | 2. Enable 2FA in the configuration: 105 | 106 | ```php 107 | // config/devices.php 108 | return [ 109 | 'google_2fa_enabled' => true, 110 | 'google_2fa_company' => env('APP_NAME', 'Your Company'), 111 | ]; 112 | ``` 113 | 114 | ### 2. Configure Device Fingerprinting (Optional) 115 | 116 | Choose your preferred fingerprinting library: 117 | 118 | ```php 119 | // config/devices.php 120 | return [ 121 | 'fingerprinting_enabled' => true, 122 | 'client_fingerprint_transport' => 'header', // or 'cookie' 123 | 'client_fingerprint_key' => 'X-Device-Fingerprint', 124 | ]; 125 | ``` 126 | 127 | ### 3. Configure Session Handling 128 | 129 | Define your preferred session handling behavior: 130 | 131 | ```php 132 | // config/devices.php 133 | return [ 134 | 'allow_device_multi_session' => true, 135 | 'start_new_session_on_login' => false, 136 | 'inactivity_seconds' => 1200, // 20 minutes 137 | 'inactivity_session_behaviour' => 'terminate', // or 'ignore' 138 | ]; 139 | ``` 140 | 141 | ## Verify Installation 142 | 143 | To verify your installation is working correctly: 144 | 145 | 1. Check migrations are applied: 146 | ```bash 147 | php artisan migrate:status 148 | ``` 149 | 150 | 2. Test device tracking is working: 151 | ```php 152 | use Ninja\DeviceTracker\Facades\DeviceManager; 153 | 154 | // In a route or controller: 155 | if (DeviceManager::tracked()) { 156 | $device = DeviceManager::current(); 157 | return "Device tracked successfully: " . $device->uuid; 158 | } 159 | ``` 160 | 161 | ## Troubleshooting 162 | 163 | ### Common Issues 164 | 165 | 1. **Class not found errors** 166 | - Run `composer dump-autoload` 167 | - Ensure provider is registered 168 | 169 | 2. **Migration errors** 170 | - Check database permissions 171 | - Ensure migrations are published 172 | - Clear cache: `php artisan config:clear` 173 | 174 | 3. **Middleware not working** 175 | - Verify middleware registration in Kernel.php 176 | - Check middleware order 177 | - Clear route cache: `php artisan route:clear` 178 | 179 | ### Debug Mode 180 | 181 | Enable debug mode in your configuration for more detailed error messages: 182 | 183 | ```php 184 | // config/devices.php 185 | return [ 186 | 'debug' => true, 187 | // ... other config 188 | ]; 189 | ``` 190 | 191 | ## Next Steps 192 | 193 | Once installed, you should: 194 | 195 | 1. Review the [Configuration Guide](configuration.md) for detailed setup options 196 | 2. Follow the [Quick Start Guide](quick-start.md) for basic usage 197 | 3. Set up [Device Fingerprinting](fingerprinting.md) if needed 198 | 4. Configure [Two-Factor Authentication](2fa.md) if required 199 | 200 | For more information on specific features: 201 | - [Device Management](device-management.md) 202 | - [Session Management](session-management.md) 203 | - [API Reference](api-reference.md) -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # 🚀 Quickstart 2 | 3 | ## Service provider 4 | Add the service provider to `bootstrap/providers.php` under `providers`: 5 | ```php 6 | return [ 7 | ... 8 | Ninja\DeviceTracker\DeviceTrackerServiceProvider::class, 9 | ] 10 | ``` 11 | 12 | ## Config file 13 | Update [config file](configuration.md) to fit your needs: 14 | ```php 15 | config/devices.php 16 | ``` 17 | 18 | ## Run migrations 19 | Migrate your [database](database-schema.md) to create the necessary tables: 20 | ```bash 21 | php artisan migrate 22 | ``` 23 | 24 | ## Configure model 25 | Add the HasDevices trait to your user model: 26 | ```php 27 | use Ninja\DeviceTracker\Traits\HasDevices; 28 | 29 | class User extends Model { 30 | use HasDevices; 31 | ... 32 | } 33 | ``` 34 | Add the Has2FA trait to your user model if you want to use the Google 2FA provided integration: 35 | ```php 36 | use Ninja\DeviceTracker\Traits\Has2FA; 37 | 38 | class User extends Model { 39 | use Has2FA; 40 | ... 41 | } 42 | ``` 43 | 44 | ## Add middlewares: 45 | 46 | Add the DeviceTrack middleware in your bootstrap/app.php file. This middleware will track the user device, it will check the presence of a cookie with a device uuid and will create a new device if it doesn't exist. 47 | 48 | Optionally, you can add the FingerprintTracker middleware to try to fingerprint the device. This middleware uses javascript injection to work, so, it only works on html responses. Thi middleware needs a current device to work, so it should be placed after the DeviceTracker middleware. 49 | 50 | ```php 51 | protected $middleware = [ 52 | 'Ninja\DeviceTracker\Http\Middleware\DeviceTracker', 53 | ... 54 | 'Ninja\DeviceTracker\Modules\Fingerprinting\Http\Middleware\FingerprintTracker', 55 | ]; 56 | ``` 57 | 58 | In your routes.php file you should add 'session-tracker' middleware for routes which you want to keep track of. This middleware will check if the user has a valid session and device. If not, it will redirect to the login page or return a 401 json response depending on your configuration. 59 | 60 | ```php 61 | Route::group(['middleware'=>'session-tracker'], function(){ 62 | Route::get('your-route', 'YourController@yourAction'); 63 | }); 64 | ``` 65 | 66 | ## Next Steps 67 | 68 | Once installed, you should: 69 | 70 | 1. Review the [Configuration Guide](configuration.md) for detailed setup options 71 | 2. Set up [Device Fingerprinting](fingerprinting.md) if needed 72 | 3. Configure [Two-Factor Authentication](2fa.md) if required 73 | 74 | For more information on specific features: 75 | - [Device Management](device-management.md) 76 | - [Session Management](session-management.md) 77 | - [API Reference](api-reference.md) 78 | -------------------------------------------------------------------------------- /helpers.php: -------------------------------------------------------------------------------- 1 | hasUser() ? guard()->user() : null; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - src/ 4 | - helpers.php 5 | excludePaths: 6 | - src/Traits/ 7 | bootstrapFiles: 8 | - vendor/autoload.php 9 | level: 8 10 | scanDirectories: 11 | - src 12 | universalObjectCratesClasses: 13 | - Illuminate\Database\Eloquent\Model 14 | - Illuminate\Support\Collection 15 | ignoreErrors: 16 | - '#Call to an undefined method Illuminate\\Contracts\\Auth\\Authenticatable::#' 17 | - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable::#' 18 | treatPhpDocTypesAsCertain: false 19 | 20 | includes: 21 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 22 | - vendor/larastan/larastan/extension.neon 23 | - vendor/nesbot/carbon/extension.neon 24 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests/Feature 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel" 3 | } -------------------------------------------------------------------------------- /resources/views/clientjs-tracking-script.blade.php: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /resources/views/fingerprintjs-tracking-script.blade.php: -------------------------------------------------------------------------------- 1 | 10 | 28 | -------------------------------------------------------------------------------- /routes/devices.php: -------------------------------------------------------------------------------- 1 | 'device::', 7 | 'prefix' => 'api/devices', 8 | 'middleware' => Config::get('devices.auth_middleware'), 9 | ], function (): void { 10 | Route::get('/', 'Ninja\DeviceTracker\Http\Controllers\DeviceController@list')->name('list'); 11 | Route::get('/{id}', 'Ninja\DeviceTracker\Http\Controllers\DeviceController@show')->name('show'); 12 | Route::patch('/{id}/verify', 'Ninja\DeviceTracker\Http\Controllers\DeviceController@verify')->name('verify'); 13 | Route::patch('/{id}/hijack', 'Ninja\DeviceTracker\Http\Controllers\DeviceController@hijack')->name('hijack'); 14 | Route::patch('/{id}/forget', 'Ninja\DeviceTracker\Http\Controllers\DeviceController@forget')->name('forget'); 15 | Route::post('/signout', 'Ninja\DeviceTracker\Http\Controllers\DeviceController@signout')->name('signout'); 16 | }); 17 | 18 | Route::group([ 19 | 'as' => 'session::', 20 | 'prefix' => 'api/sessions', 21 | 'middleware' => Config::get('devices.auth_middleware'), 22 | ], function (): void { 23 | Route::get('/', 'Ninja\DeviceTracker\Http\Controllers\SessionController@list')->name('list'); 24 | Route::get('/active', 'Ninja\DeviceTracker\Http\Controllers\SessionController@active')->name('active'); 25 | Route::get('/{id}', 'Ninja\DeviceTracker\Http\Controllers\SessionController@show')->name('show'); 26 | Route::patch('/{id}/renew', 'Ninja\DeviceTracker\Http\Controllers\SessionController@renew')->name('renew'); 27 | Route::delete('/{id}/end', 'Ninja\DeviceTracker\Http\Controllers\SessionController@end')->name('end'); 28 | Route::patch('/{id}/block', 'Ninja\DeviceTracker\Http\Controllers\SessionController@block')->name('block'); 29 | Route::patch('/{id}/unblock', 'Ninja\DeviceTracker\Http\Controllers\SessionController@unlbock')->name('unblock'); 30 | Route::post('/signout', 'Ninja\DeviceTracker\Http\Controllers\SessionController@signout')->name('signout'); 31 | }); 32 | 33 | Route::group([ 34 | 'as' => '2fa::', 35 | 'prefix' => 'api/2fa', 36 | 'middleware' => Config::get('devices.auth_middleware'), 37 | ], function (): void { 38 | Route::get('/code', "Ninja\DeviceTracker\Http\Controllers\TwoFactorController@code") 39 | ->withoutMiddleware(['session-tracker']) 40 | ->name('code'); 41 | Route::post('/verify', "Ninja\DeviceTracker\Http\Controllers\TwoFactorController@verify") 42 | ->withoutMiddleware(['session-tracker']) 43 | ->name('verify'); 44 | Route::patch('/disable', "Ninja\DeviceTracker\Http\Controllers\TwoFactorController@disable") 45 | ->withoutMiddleware(['session-tracker']) 46 | ->name('disable'); 47 | Route::patch('/enable', "Ninja\DeviceTracker\Http\Controllers\TwoFactorController@enable") 48 | ->withoutMiddleware(['session-tracker']) 49 | ->name('enable'); 50 | }); 51 | -------------------------------------------------------------------------------- /src/Cache/AbstractCache.php: -------------------------------------------------------------------------------- 1 | enabled()) { 26 | return; 27 | } 28 | 29 | /** @var string $store */ 30 | $store = config('devices.cache_store'); 31 | $this->cache = Cache::store($store); 32 | } 33 | 34 | public static function instance(): self 35 | { 36 | if (! isset(self::$instances[static::class])) { 37 | self::$instances[static::class] = new static; 38 | } 39 | 40 | return self::$instances[static::class]; 41 | } 42 | 43 | /** 44 | * @throws InvalidArgumentException 45 | */ 46 | public static function get(string $key): ?Device 47 | { 48 | return self::instance()->getItem($key); 49 | } 50 | 51 | public static function put(Cacheable $item): void 52 | { 53 | self::instance()->putItem($item); 54 | } 55 | 56 | public static function remember(string $key, Closure $callback): mixed 57 | { 58 | if (! self::instance()->enabled()) { 59 | return $callback(); 60 | } 61 | 62 | return self::instance()->cache?->remember($key, self::instance()->ttl(), $callback); 63 | } 64 | 65 | public static function key(string $key): string 66 | { 67 | return sprintf('%s:%s', static::KEY_PREFIX, hash('xxh128', $key)); 68 | } 69 | 70 | public static function forget(Cacheable $item): void 71 | { 72 | self::instance()->forgetItem($item); 73 | } 74 | 75 | public static function flush(): void 76 | { 77 | if (! self::instance()->enabled()) { 78 | return; 79 | } 80 | 81 | if (! is_null(self::instance()->cache) && method_exists(self::instance()->cache, 'flush')) { 82 | self::instance()->cache->flush(); 83 | } 84 | } 85 | 86 | /** 87 | * @throws InvalidArgumentException 88 | */ 89 | protected function getItem(string $key): ?Device 90 | { 91 | if (! $this->enabled()) { 92 | return null; 93 | } 94 | 95 | return $this->cache?->get($key); 96 | } 97 | 98 | protected function putItem(Cacheable $item): void 99 | { 100 | if (! $this->enabled()) { 101 | return; 102 | } 103 | 104 | $this->cache?->put($item->key(), $item, $item->ttl() ?? $this->ttl()); 105 | } 106 | 107 | protected function forgetItem(Cacheable $item): void 108 | { 109 | if (! $this->enabled()) { 110 | return; 111 | } 112 | 113 | $this->cache?->forget($item->key()); 114 | } 115 | 116 | protected function ttl(): int 117 | { 118 | return Config::get('devices.cache_ttl')[static::KEY_PREFIX]; 119 | } 120 | 121 | abstract protected function enabled(): bool; 122 | } 123 | -------------------------------------------------------------------------------- /src/Cache/DeviceCache.php: -------------------------------------------------------------------------------- 1 | enabled()) { 30 | return; 31 | } 32 | 33 | if (! $item instanceof Device) { 34 | throw new InvalidArgumentException('Item must be an instance of Device'); 35 | } 36 | 37 | $this->cache?->forget($item->key()); 38 | $item->users()->each(fn (Authenticatable $user) => $this->cache?->forget(sprintf('user:devices:%s', $user->getAuthIdentifier()))); 39 | } 40 | 41 | /** 42 | * @return Collection|null 43 | */ 44 | public static function userDevices(Authenticatable $user): ?Collection 45 | { 46 | $uses = in_array(HasDevices::class, class_uses($user), true); 47 | if (! $uses) { 48 | throw new InvalidArgumentException('User must use HasDevices trait'); 49 | } 50 | 51 | if (! self::instance()->enabled()) { 52 | return $user->devices; 53 | } 54 | 55 | /** @var Collection $devices */ 56 | $devices = self::remember(sprintf('user:devices:%s', $user->getAuthIdentifier()), function () use ($user) { 57 | return $user->devices; 58 | }); 59 | 60 | return $devices; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Cache/LocationCache.php: -------------------------------------------------------------------------------- 1 | enabled()) { 24 | return; 25 | } 26 | 27 | if (! $item instanceof Session) { 28 | throw new InvalidArgumentException('Item must be an instance of Session'); 29 | } 30 | 31 | $this->cache?->forget($item->key()); 32 | $this->cache?->forget(sprintf('user:sessions:%s', $item->device->id)); 33 | } 34 | 35 | /** 36 | * @return Collection|null 37 | */ 38 | public static function userSessions(Authenticatable $user): ?Collection 39 | { 40 | if (! self::instance()->enabled()) { 41 | return $user->sessions()->with('device')->get(); 42 | } 43 | 44 | return self::remember('user:sessions:'.$user->getAuthIdentifier(), function () use ($user) { 45 | return $user->sessions()->with('device')->get(); 46 | }); 47 | } 48 | 49 | /** 50 | * @return Collection|null 51 | */ 52 | public static function activeSessions(Authenticatable $user): ?Collection 53 | { 54 | if (! self::instance()->enabled()) { 55 | return $user->sessions()->with('device')->active(); 56 | } 57 | 58 | return self::remember('user:sessions:active:'.$user->getAuthIdentifier(), function () use ($user) { 59 | return $user->sessions()->with('device')->active(); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Cache/UserAgentCache.php: -------------------------------------------------------------------------------- 1 | info('Invalidating device cache...'); 23 | Device::all()->each(fn ($device) => DeviceCache::forget($device)); 24 | 25 | $this->info('Invalidating session cache...'); 26 | Session::all()->each(fn ($session) => SessionCache::forget($session)); 27 | 28 | $this->info('Invalidating location cache...'); 29 | LocationCache::flush(); 30 | 31 | $this->info('Invalidating user agent cache...'); 32 | UserAgentCache::flush(); 33 | 34 | $this->info('Cache invalidation completed.'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/Commands/CacheWarmCommand.php: -------------------------------------------------------------------------------- 1 | info('Warming up device cache...'); 20 | Device::all()->each(fn ($device) => DeviceCache::put($device)); 21 | 22 | $this->info('Warming up session cache...'); 23 | Session::all()->each(fn ($session) => SessionCache::put($session)); 24 | 25 | $this->info('Cache warmup completed.'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Console/Commands/CleanupDevicesCommand.php: -------------------------------------------------------------------------------- 1 | option('force') !== null) { 18 | $deletedHijacked = Device::where('status', DeviceStatus::Hijacked)->delete(); 19 | $this->info(sprintf('Deleted %d hijacked devices.', $deletedHijacked)); 20 | } 21 | 22 | $count = Device::orphans()->count(); 23 | Device::orphans()->each(fn ($device) => $device->delete()); 24 | 25 | $this->info(sprintf('Deleted %d orphaned devices.', $count)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Console/Commands/CleanupSessionsCommand.php: -------------------------------------------------------------------------------- 1 | option('days'); 20 | $cutoffDate = Carbon::now()->subDays($days); 21 | 22 | // Delete old finished sessions 23 | $deletedFinished = Session::where('status', SessionStatus::Finished) 24 | ->where('finished_at', '<', $cutoffDate) 25 | ->delete(); 26 | 27 | $this->info("Deleted {$deletedFinished} old finished sessions."); 28 | 29 | // Handle inactive sessions based on config 30 | $inactivitySeconds = config('devices.inactivity_seconds', 1200); 31 | if ($inactivitySeconds > 0) { 32 | $cutoffTime = Carbon::now()->subSeconds($inactivitySeconds); 33 | 34 | /** @var Collection $inactiveSessions */ 35 | $inactiveSessions = Session::where('status', SessionStatus::Active) 36 | ->where('last_activity_at', '<', $cutoffTime) 37 | ->get(); 38 | 39 | foreach ($inactiveSessions as $session) { 40 | if (config('devices.inactivity_session_behaviour') === 'terminate') { 41 | $session->end(); 42 | } else { 43 | $session->status = SessionStatus::Inactive; 44 | $session->save(); 45 | } 46 | } 47 | 48 | $this->info("Processed {$inactiveSessions->count()} inactive sessions."); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Console/Commands/DeviceInspectCommand.php: -------------------------------------------------------------------------------- 1 | argument('uuid'); 17 | if (! is_string($uuid)) { 18 | $this->error('Invalid UUID provided'); 19 | 20 | return; 21 | } 22 | 23 | $device = Device::byUuid($uuid); 24 | 25 | if ($device === null) { 26 | $this->error(sprintf('Device with UUID %s not found', $uuid)); 27 | 28 | return; 29 | } 30 | 31 | $this->info('Device Information:'); 32 | $this->table( 33 | ['Property', 'Value'], 34 | [ 35 | ['UUID', $device->uuid], 36 | ['Status', $device->status->value], 37 | ['Browser', $device->browser], 38 | ['Platform', $device->platform], 39 | ['Device Type', $device->device_type], 40 | ['IP', $device->ip], 41 | ['Created', $device->created_at], 42 | ['Last Updated', $device->updated_at], 43 | ['Active Sessions', $device->sessions()->active()->count()], 44 | ['Total Sessions', $device->sessions->count()], 45 | ['Associated Users', $device->users->count()], 46 | ] 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Console/Commands/DeviceStatusCommand.php: -------------------------------------------------------------------------------- 1 | info('Device Tracker Status:'); 20 | $this->table( 21 | ['Metric', 'Count'], 22 | [ 23 | ['Total Devices', Device::count()], 24 | ['Verified Devices', Device::where('status', DeviceStatus::Verified)::count()], 25 | ['Unverified Devices', Device::where('status', DeviceStatus::Unverified)::count()], 26 | ['Hijacked Devices', Device::where('status', DeviceStatus::Hijacked)::count()], 27 | ['Active Sessions', Session::where('status', SessionStatus::Active)::count()], 28 | ['Locked Sessions', Session::where('status', SessionStatus::Locked)::count()], 29 | ['Blocked Sessions', Session::where('status', SessionStatus::Blocked)::count()], 30 | ['Finished Sessions', Session::where('status', SessionStatus::Finished)::count()], 31 | ] 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Contracts/Cacheable.php: -------------------------------------------------------------------------------- 1 | browser->unknown() || $this->platform->unknown(); 33 | } 34 | 35 | public function bot(): bool 36 | { 37 | return $this->bot ?? false; 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function array(): array 44 | { 45 | return [ 46 | 'browser' => $this->browser->array(), 47 | 'platform' => $this->platform->array(), 48 | 'device' => $this->device->array(), 49 | 'grade' => $this->grade, 50 | 'source' => $this->source, 51 | 'label' => (string) $this, 52 | 'bot' => $this->bot(), 53 | ]; 54 | } 55 | 56 | public function valid(): bool 57 | { 58 | $validUnknown = $this->unknown() && config('devices.allow_unknown_devices') === true; 59 | $validBot = $this->bot() && config('devices.allow_bot_devices') === true; 60 | $validDevice = ! $this->unknown() && ! $this->bot(); 61 | 62 | return $validUnknown || $validBot || $validDevice; 63 | 64 | } 65 | 66 | public function __toString(): string 67 | { 68 | return sprintf('%s at %s on %s', $this->browser, $this->device, $this->platform); 69 | } 70 | 71 | /** 72 | * @return array 73 | */ 74 | public function jsonSerialize(): array 75 | { 76 | return $this->array(); 77 | } 78 | 79 | public function json(): string|false 80 | { 81 | return json_encode($this->array()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DTO/Metadata.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private array $data; 15 | 16 | /** 17 | * @param array $data 18 | */ 19 | public function __construct(array $data = []) 20 | { 21 | $this->data = $data; 22 | } 23 | 24 | /** 25 | * @param array $arguments 26 | */ 27 | public function __call(string $name, array $arguments): mixed 28 | { 29 | $property = $this->underscorize(substr($name, 3)); 30 | 31 | if (str_starts_with($name, 'get')) { 32 | return $this->get($property); 33 | } 34 | 35 | if (str_starts_with($name, 'set')) { 36 | $this->set($property, $arguments[0]); 37 | 38 | return $this; 39 | } 40 | 41 | if (isset($this->data[$name])) { 42 | return $this->data[$name]; 43 | } 44 | 45 | return null; 46 | } 47 | 48 | public function has(string $key): bool 49 | { 50 | return Arr::has($this->data, $key); 51 | } 52 | 53 | public function get(string $key, mixed $default = null): mixed 54 | { 55 | return Arr::get($this->data, $key, $default); 56 | } 57 | 58 | public function set(string $key, mixed $value): self 59 | { 60 | Arr::set($this->data, $key, $value); 61 | 62 | return $this; 63 | } 64 | 65 | public function forget(string $key): self 66 | { 67 | Arr::forget($this->data, $key); 68 | 69 | return $this; 70 | } 71 | 72 | public function push(string $key, mixed $value): self 73 | { 74 | $array = $this->get($key, []); 75 | if (! is_array($array)) { 76 | throw new InvalidArgumentException(sprintf('Key %s is not an array', $key)); 77 | } 78 | 79 | $array[] = $value; 80 | 81 | return $this->set($key, $array); 82 | } 83 | 84 | public function increment(string $key, int $amount = 1): self 85 | { 86 | $value = (int) $this->get($key, 0); 87 | 88 | return $this->set($key, $value + $amount); 89 | } 90 | 91 | public function decrement(string $key, int $amount = 1): self 92 | { 93 | return $this->increment($key, -$amount); 94 | } 95 | 96 | /** 97 | * @param array|self $data 98 | */ 99 | public function merge(array|self $data): self 100 | { 101 | if ($data instanceof self) { 102 | $data = $data->array(); 103 | } 104 | 105 | $this->data = array_merge_recursive($this->data, $data); 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * @param array $keys 112 | * @return array 113 | */ 114 | public function only(array $keys): array 115 | { 116 | return Arr::only($this->data, $keys); 117 | } 118 | 119 | /** 120 | * @param array $keys 121 | * @return array 122 | */ 123 | public function except(array $keys): array 124 | { 125 | return Arr::except($this->data, $keys); 126 | } 127 | 128 | public function filter(callable $callback): self 129 | { 130 | $this->data = array_filter($this->data, $callback, ARRAY_FILTER_USE_BOTH); 131 | 132 | return $this; 133 | } 134 | 135 | public function transform(callable $callback): self 136 | { 137 | $this->data = array_map($callback, $this->data); 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * @return array 144 | */ 145 | public function array(): array 146 | { 147 | return $this->data; 148 | } 149 | 150 | public function json(): string|false 151 | { 152 | return json_encode($this->data); 153 | } 154 | 155 | /** 156 | * @return array 157 | */ 158 | public function jsonSerialize(): array 159 | { 160 | return $this->array(); 161 | } 162 | 163 | /** 164 | * @param array $data 165 | */ 166 | public static function from(array $data): self 167 | { 168 | return new self($data); 169 | } 170 | 171 | public function empty(): bool 172 | { 173 | return count($this->data) === 0; 174 | } 175 | 176 | public function count(): int 177 | { 178 | return count($this->data); 179 | } 180 | 181 | /** 182 | * @return array 183 | */ 184 | public function keys(): array 185 | { 186 | return array_keys($this->data); 187 | } 188 | 189 | /** 190 | * @return array 191 | */ 192 | public function values(): array 193 | { 194 | return array_values($this->data); 195 | } 196 | 197 | private function underscorize(string $str): string 198 | { 199 | return str($str)->lower()->snake(); 200 | } 201 | 202 | public function offsetExists(string $offset): bool 203 | { 204 | return $this->has($offset); 205 | } 206 | 207 | public function offsetGet(string $offset): mixed 208 | { 209 | return $this->get($offset); 210 | } 211 | 212 | public function offsetSet(string $offset, mixed $value): void 213 | { 214 | $this->set($offset, $value); 215 | } 216 | 217 | public function offsetUnset(string $offset): void 218 | { 219 | $this->forget($offset); 220 | } 221 | 222 | public function __get(string $name): mixed 223 | { 224 | return $this->get($name); 225 | } 226 | 227 | public function __set(string $name, mixed $value): void 228 | { 229 | $this->set($name, $value); 230 | } 231 | 232 | public function __isset(string $name): bool 233 | { 234 | return $this->has($name); 235 | } 236 | 237 | public function __unset(string $name): void 238 | { 239 | $this->forget($name); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/DeviceManager.php: -------------------------------------------------------------------------------- 1 | app = $app; 32 | } 33 | 34 | public function isUserDevice(StorableId $deviceUuid): bool 35 | { 36 | return user()?->hasDevice($deviceUuid); 37 | } 38 | 39 | public function attach(?StorableId $deviceUuid = null): bool 40 | { 41 | $deviceUuid = $deviceUuid ?? device_uuid(); 42 | 43 | if ($deviceUuid === null) { 44 | return false; 45 | } 46 | 47 | if (user() === null) { 48 | return false; 49 | } 50 | 51 | if (! Device::exists($deviceUuid)) { 52 | return false; 53 | } 54 | 55 | if (user()->hasDevice($deviceUuid)) { 56 | return true; 57 | } 58 | 59 | user()->devices()->attach($deviceUuid); 60 | 61 | $device = Device::byUuid($deviceUuid); 62 | if ($device === null) { 63 | return false; 64 | } 65 | 66 | event(new DeviceAttachedEvent($device, user())); 67 | 68 | return true; 69 | } 70 | 71 | /** 72 | * @return Collection 73 | */ 74 | public function userDevices(): Collection 75 | { 76 | return user()?->devices; 77 | } 78 | 79 | public function tracked(): bool 80 | { 81 | return device_uuid() !== null && Device::exists(device_uuid()); 82 | } 83 | 84 | public function fingerprinted(): bool 85 | { 86 | return fingerprint() !== null && Device::byFingerprint(fingerprint()) !== null; 87 | } 88 | 89 | /** 90 | * @throws DeviceNotFoundException 91 | */ 92 | public function track(): StorableId 93 | { 94 | if (device_uuid() !== null) { 95 | if (config('devices.regenerate_devices') === true) { 96 | event(new DeviceTrackedEvent(device_uuid())); 97 | DeviceTransport::propagate(device_uuid()); 98 | 99 | return device_uuid(); 100 | } else { 101 | throw new DeviceNotFoundException('Tracked device not found in database'); 102 | } 103 | } else { 104 | $deviceUuid = DeviceIdFactory::generate(); 105 | DeviceTransport::propagate($deviceUuid); 106 | event(new DeviceTrackedEvent($deviceUuid)); 107 | 108 | return $deviceUuid; 109 | } 110 | } 111 | 112 | public function shouldRegenerate(): bool 113 | { 114 | try { 115 | return 116 | device_uuid() !== null && 117 | ! Device::exists(device_uuid()) && 118 | config('devices.regenerate_devices') === true; 119 | } catch (Throwable) { 120 | return false; 121 | } 122 | } 123 | 124 | public function detect(): ?DeviceDTO 125 | { 126 | return app(DeviceDetector::class)->detect(request()); 127 | } 128 | 129 | public function isWhitelisted(DeviceDTO|string|null $device): bool 130 | { 131 | if ($device === null) { 132 | return false; 133 | } 134 | 135 | $userAgent = is_string($device) ? $device : $device->source; 136 | 137 | return in_array($userAgent, config('devices.user_agent_whitelist', [])); 138 | } 139 | 140 | /** 141 | * @throws UnknownDeviceDetectedException 142 | */ 143 | public function create(?StorableId $deviceUuid = null): ?Device 144 | { 145 | $payload = $this->detect(); 146 | if (! $payload) { 147 | return null; 148 | } 149 | 150 | $ua = request()->header('User-Agent'); 151 | 152 | if ($payload->valid() || $this->isWhitelisted($ua)) { 153 | $deviceUuid = $deviceUuid ?? device_uuid(); 154 | if ($deviceUuid !== null) { 155 | return Device::register( 156 | deviceUuid: $deviceUuid, 157 | data: $payload 158 | ); 159 | } 160 | 161 | return null; 162 | } 163 | 164 | if (is_string($ua)) { 165 | throw UnknownDeviceDetectedException::withUA($ua); 166 | } 167 | 168 | throw UnknownDeviceDetectedException::withUA('Unknown'); 169 | } 170 | 171 | public function current(): ?Device 172 | { 173 | if (config('devices.fingerprinting_enabled') === true && fingerprint() !== null) { 174 | return Device::byFingerprint(fingerprint()); 175 | } 176 | 177 | $device_uuid = device_uuid(); 178 | if ($device_uuid !== null) { 179 | return Device::byUuid($device_uuid, false); 180 | } 181 | 182 | return null; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Enums/DeviceStatus.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public static function values(): array 17 | { 18 | return [ 19 | self::Unverified, 20 | self::PartiallyVerified, 21 | self::Verified, 22 | self::Hijacked, 23 | self::Inactive, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Enums/DeviceTransport.php: -------------------------------------------------------------------------------- 1 | value]); 23 | if (empty($hierarchy)) { 24 | $hierarchy = [self::Cookie->value]; 25 | } 26 | 27 | return self::currentFromHierarchy($hierarchy, self::Cookie); 28 | } 29 | 30 | public static function responseTransport(): self 31 | { 32 | $responseTransportString = config('devices.device_id_response_transport', self::Cookie->value); 33 | return self::tryFrom($responseTransportString) ?? self::Cookie; 34 | } 35 | 36 | public static function getIdFromHierarchy(): ?StorableId 37 | { 38 | $hierarchy = config('devices.device_id_transport_hierarchy', [self::Cookie->value]); 39 | if (empty($hierarchy)) { 40 | $hierarchy = [self::Cookie->value]; 41 | } 42 | 43 | return self::storableIdFromHierarchy($hierarchy); 44 | } 45 | 46 | private static function parameter(): string 47 | { 48 | return config('devices.device_id_parameter'); 49 | } 50 | 51 | private static function alternativeParameter(): ?string 52 | { 53 | return config('devices.device_id_alternative_parameter'); 54 | } 55 | 56 | /** 57 | * @return class-string 58 | */ 59 | private static function storableIdFactory(): string 60 | { 61 | return DeviceIdFactory::class; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Enums/SessionIpChangeBehaviour.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public static function values(): array 17 | { 18 | return [ 19 | self::Active, 20 | self::Inactive, 21 | self::Finished, 22 | self::Blocked, 23 | self::Locked, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Enums/SessionTransport.php: -------------------------------------------------------------------------------- 1 | value]); 23 | if (empty($hierarchy)) { 24 | $hierarchy = [self::Cookie->value]; 25 | } 26 | 27 | return self::currentFromHierarchy($hierarchy, self::Cookie); 28 | } 29 | 30 | public static function responseTransport(): self 31 | { 32 | $responseTransportString = config('devices.session_id_response_transport', self::Cookie->value); 33 | return self::tryFrom($responseTransportString) ?? self::Cookie; 34 | } 35 | 36 | public static function getIdFromHierarchy(): ?StorableId 37 | { 38 | $hierarchy = config('devices.session_id_transport_hierarchy', [self::Cookie->value]); 39 | if (empty($hierarchy)) { 40 | $hierarchy = [self::Cookie->value]; 41 | } 42 | 43 | return self::storableIdFromHierarchy($hierarchy); 44 | } 45 | 46 | private static function parameter(): string 47 | { 48 | return config('devices.session_id_parameter'); 49 | } 50 | 51 | private static function alternativeParameter(): ?string 52 | { 53 | return config('devices.session_id_alternative_parameter'); 54 | } 55 | 56 | /** 57 | * @return class-string 58 | */ 59 | private static function storableIdFactory(): string 60 | { 61 | return SessionIdFactory::class; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/EventSubscriber.php: -------------------------------------------------------------------------------- 1 | user); 39 | SessionTransport::propagate($session?->uuid); 40 | } catch (DeviceNotFoundException $e) { 41 | Log::error('Login failed due to device error', [ 42 | 'error' => $e->getMessage(), 43 | 'user_id' => $event->user->getAuthIdentifier(), 44 | ]); 45 | 46 | throw $e; 47 | } 48 | } 49 | 50 | public function onLogout(Logout $event): void 51 | { 52 | Session::current()?->end( 53 | user: $event->user, 54 | ); 55 | } 56 | 57 | public function onGoogle2FASuccess(Google2FASuccess $event): void 58 | { 59 | $user = $event->user; 60 | $user->session()->device->verify(); 61 | $user->session()->unlock(); 62 | } 63 | 64 | public function onDeviceTracked(DeviceTrackedEvent $event): void 65 | { 66 | if (config('devices.track_guest_sessions') === false) { 67 | return; 68 | } 69 | 70 | if (! Device::exists($event->deviceUuid)) { 71 | return; 72 | } 73 | 74 | if (user() !== null) { 75 | DeviceManager::attach($event->deviceUuid); 76 | } 77 | } 78 | 79 | public function subscribe(Dispatcher $events): void 80 | { 81 | $events->listen('Illuminate\Auth\Events\Login', [self::class, 'onLogin']); 82 | $events->listen('Illuminate\Auth\Events\Logout', [self::class, 'onLogout']); 83 | $events->listen('Ninja\DeviceTracker\Events\Google2FASuccess', [self::class, 'onGoogle2FASuccess']); 84 | $events->listen('Ninja\DeviceTracker\Events\DeviceTrackedEvent', [self::class, 'onDeviceTracked']); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Events/DeviceAttachedEvent.php: -------------------------------------------------------------------------------- 1 | uuid)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exception/FingerprintNotFoundException.php: -------------------------------------------------------------------------------- 1 | getAuthIdentifier())); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Exception/UnknownDeviceDetectedException.php: -------------------------------------------------------------------------------- 1 | userDevices() 15 | * @method static bool attach(?StorableId $deviceUuid = null) 16 | * @method static Device create(?StorableId $deviceId = null) 17 | * @method static StorableId track() 18 | * @method static bool tracked() 19 | * @method static bool fingerprinted() 20 | * @method static bool shouldRegenerate() 21 | * @method static DeviceDto|null detect() 22 | * @method static bool isWhitelisted(string|null $userAgent) 23 | */ 24 | final class DeviceManager extends Facade 25 | { 26 | protected static function getFacadeAccessor(): string 27 | { 28 | return 'device_manager'; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Facades/SessionManager.php: -------------------------------------------------------------------------------- 1 | , AbstractStorableIdFactory> */ 11 | protected static array $instances = []; 12 | 13 | private function __construct() {} 14 | 15 | public static function instance(): self 16 | { 17 | $class = static::class; 18 | if (! isset(self::$instances[$class])) { 19 | self::$instances[$class] = new static; 20 | } 21 | 22 | return self::$instances[$class]; 23 | } 24 | 25 | public static function generate(): StorableId 26 | { 27 | $idClass = self::instance()->getIdClass(); 28 | 29 | return $idClass::build(); 30 | } 31 | 32 | public static function from(string $id): ?StorableId 33 | { 34 | $idClass = self::instance()->getIdClass(); 35 | 36 | return $idClass::from($id); 37 | } 38 | 39 | abstract protected function getIdClass(): string; 40 | } 41 | -------------------------------------------------------------------------------- /src/Factories/DeviceIdFactory.php: -------------------------------------------------------------------------------- 1 | google2FA->generateSecretKey(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Generators/RandomCodeGenerator.php: -------------------------------------------------------------------------------- 1 | json(['message' => 'User not found'], 404); 24 | } 25 | 26 | $devices = DeviceCache::userDevices($user); 27 | 28 | return response()->json(DeviceResource::collection($devices)); 29 | } 30 | 31 | public function show(Request $request, string $id): JsonResponse 32 | { 33 | $device = $this->getUserDevice($request, $id); 34 | 35 | if ($device !== null) { 36 | return response()->json(DeviceResource::make($device)); 37 | } 38 | 39 | return response()->json(['message' => 'Device not found'], 404); 40 | } 41 | 42 | public function verify(Request $request, string $id): JsonResponse 43 | { 44 | $device = $this->getUserDevice($request, $id); 45 | 46 | if ($device !== null) { 47 | $device->verify(); 48 | 49 | return response()->json(['message' => 'Device verified successfully']); 50 | } 51 | 52 | return response()->json(['message' => 'Device not found'], 404); 53 | } 54 | 55 | public function hijack(Request $request, string $id): JsonResponse 56 | { 57 | $device = $this->getUserDevice($request, $id); 58 | 59 | if ($device !== null) { 60 | $device->hijack(); 61 | 62 | return response()->json(['message' => sprintf('Device %s flagged as hijacked', $device->uuid)]); 63 | } 64 | 65 | return response()->json(['message' => 'Device not found'], 404); 66 | } 67 | 68 | public function forget(Request $request, string $id): JsonResponse 69 | { 70 | $device = $this->getUserDevice($request, $id); 71 | 72 | if ($device !== null) { 73 | $device->forget(); 74 | 75 | return response()->json(['message' => 'Device forgotten successfully. All active sessions were ended.']); 76 | } 77 | 78 | return response()->json(['message' => 'Device not found'], 404); 79 | } 80 | 81 | public function signout(Request $request, string $id): JsonResponse 82 | { 83 | $device = $this->getUserDevice($request, $id); 84 | if ($device === null) { 85 | return response()->json(['message' => 'Device not found'], 404); 86 | } 87 | 88 | $device 89 | ->sessions() 90 | ->active() 91 | ->each(fn (Session $session) => $session->end()); 92 | 93 | return response()->json(['message' => 'All active sessions for device finished successfully.']); 94 | } 95 | 96 | private function getUserDevice(Request $request, string $id): ?Device 97 | { 98 | $user = user(); 99 | 100 | return DeviceCache::remember(DeviceCache::key($id), function () use ($user, $id) { 101 | return $user?->devices()->where('uuid', DeviceIdFactory::from($id))->first(); 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Http/Controllers/SessionController.php: -------------------------------------------------------------------------------- 1 | getUserSessions(); 21 | 22 | return response()->json(SessionResource::collection($sessions)); 23 | } 24 | 25 | public function active(): JsonResponse 26 | { 27 | $sessions = $this->getUserActiveSessions(); 28 | 29 | return response()->json(SessionResource::collection($sessions)); 30 | } 31 | 32 | public function show(string $id): JsonResponse 33 | { 34 | $session = $this->findUserSession($id); 35 | 36 | if ($session !== null) { 37 | return response()->json(SessionResource::make($session)); 38 | } 39 | 40 | return response()->json(['message' => 'Session not found'], 404); 41 | } 42 | 43 | public function end(string $id): JsonResponse 44 | { 45 | $session = $this->findUserSession($id); 46 | 47 | if ($session !== null) { 48 | $session->end(); 49 | 50 | return response()->json(['message' => 'Session ended successfully']); 51 | } 52 | 53 | return response()->json(['message' => 'Session not found'], 404); 54 | } 55 | 56 | public function block(string $id): JsonResponse 57 | { 58 | $session = $this->findUserSession($id); 59 | 60 | if ($session !== null) { 61 | $session->block(); 62 | 63 | return response()->json(['message' => 'Session blocked successfully']); 64 | } 65 | 66 | return response()->json(['message' => 'Session not found'], 404); 67 | } 68 | 69 | public function unblock(string $id): JsonResponse 70 | { 71 | $session = $this->findUserSession($id); 72 | 73 | if ($session !== null) { 74 | $session->unblock(); 75 | 76 | return response()->json(['message' => 'Session unblocked successfully']); 77 | } 78 | 79 | return response()->json(['message' => 'Session not found'], 404); 80 | } 81 | 82 | public function renew(string $id): JsonResponse 83 | { 84 | $user = user(); 85 | $session = $this->findUserSession($id); 86 | 87 | if ($session !== null) { 88 | $session->renew($user); 89 | 90 | return response()->json(['message' => 'Session renewed successfully']); 91 | } 92 | 93 | return response()->json(['message' => 'Session not found'], 404); 94 | } 95 | 96 | public function signout(): JsonResponse 97 | { 98 | $user = user(); 99 | $user?->signout(true); 100 | 101 | return response()->json(['message' => 'Signout successful']); 102 | } 103 | 104 | /** 105 | * @return Collection|null 106 | */ 107 | private function getUserSessions(): ?Collection 108 | { 109 | $user = user(); 110 | if ($user === null) { 111 | return null; 112 | } 113 | 114 | return SessionCache::userSessions($user); 115 | } 116 | 117 | /** 118 | * @return Collection|null 119 | */ 120 | private function getUserActiveSessions(): ?Collection 121 | { 122 | $user = user(); 123 | if ($user === null) { 124 | return null; 125 | } 126 | 127 | return SessionCache::activeSessions($user); 128 | } 129 | 130 | private function findUserSession(string $id): ?Session 131 | { 132 | $user = user(); 133 | if ($user === null) { 134 | return null; 135 | } 136 | 137 | $sessions = SessionCache::userSessions($user); 138 | 139 | return $sessions?->where('uuid', SessionIdFactory::from($id))->first(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Http/Controllers/TwoFactorController.php: -------------------------------------------------------------------------------- 1 | user = user(); 27 | } 28 | 29 | public function code(): JsonResponse 30 | { 31 | if (! $this->user?->google2faEnabled()) { 32 | return response()->json(['message' => 'Two factor authentication is not enabled for current user'], 400); 33 | } 34 | 35 | return response()->json([ 36 | 'code' => $this->user->google2faQrCode(Config::get('devices.google_2fa_qr_format')), 37 | ]); 38 | } 39 | 40 | /** 41 | * @throws IncompatibleWithGoogleAuthenticatorException 42 | * @throws InvalidCharactersException 43 | * @throws SecretKeyTooShortException 44 | */ 45 | public function verify(Request $request): JsonResponse 46 | { 47 | if (! $this->user?->google2faEnabled()) { 48 | return response()->json(['message' => 'Two factor authentication is not enabled for current user'], 400); 49 | } 50 | 51 | $code = $request->input('code'); 52 | if ($code === null) { 53 | return response()->json(['message' => 'Authenticator code is required'], 400); 54 | } 55 | 56 | $valid = app(Google2FA::class) 57 | ->verifyKeyNewer( 58 | secret: $this->user->google2fa->secret(), 59 | key: $code, 60 | oldTimestamp: $this->user->google2fa->last_sucess_at->timestamp ?? 0 61 | ); 62 | 63 | if ($valid !== false) { 64 | $this->user->google2fa->success(); 65 | event(new Google2FASuccess($this->user)); 66 | 67 | return response()->json(['message' => 'Two factor authentication successful']); 68 | } else { 69 | event(new Google2FAFailed($this->user)); 70 | 71 | return response()->json(['message' => 'Two factor authentication failed'], 400); 72 | } 73 | } 74 | 75 | public function disable(): JsonResponse 76 | { 77 | if (! $this->user?->google2faEnabled()) { 78 | return response()->json(['message' => 'Two factor authentication is not enabled for current user'], 400); 79 | } 80 | 81 | $this->user->google2fa->disable(); 82 | 83 | return response()->json(['message' => 'Two factor authentication disabled for current user']); 84 | } 85 | 86 | /** 87 | * @throws IncompatibleWithGoogleAuthenticatorException 88 | * @throws SecretKeyTooShortException 89 | * @throws InvalidCharactersException 90 | */ 91 | public function enable(): JsonResponse 92 | { 93 | if ($this->user?->google2faEnabled()) { 94 | return response()->json(['message' => 'Two factor authentication already for current user']); 95 | } 96 | 97 | $this->user?->enable2fa( 98 | secret: app(Google2FA::class)->generateSecretKey() 99 | ); 100 | 101 | return response()->json(['message' => 'Two factor authentication enabled for current user']); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Http/Middleware/DeviceChecker.php: -------------------------------------------------------------------------------- 1 | shouldThrow()) { 20 | $errorCode = config('devices.middlewares.device-checker.http_error_code', 403); 21 | if (! array_key_exists($errorCode, Response::$statusTexts)) { 22 | $errorCode = 403; 23 | } 24 | abort($errorCode, 'Device not found.'); 25 | } else { 26 | throw new DeviceNotFoundException; 27 | } 28 | } 29 | 30 | return $next($request); 31 | } 32 | 33 | private function shouldThrow(): bool 34 | { 35 | return Config::get('devices.middlewares.device-checker.exception_on_unavailable_devices', false); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Http/Middleware/DeviceTracker.php: -------------------------------------------------------------------------------- 1 | checkCustomDeviceTransportHierarchy($hierarchyParameterString); 32 | $this->checkCustomDeviceResponseTransport($responseTransport); 33 | 34 | /** @var Device|null $detectedDevice */ 35 | $detectedDevice = DeviceManager::detect(); 36 | if (! $detectedDevice || ! DeviceManager::isWhitelisted($detectedDevice->source)) { 37 | if (! $detectedDevice || $detectedDevice->unknown()) { 38 | $this->isDeviceAllowed( 39 | userAgent: $detectedDevice->source ?? null, 40 | ); 41 | } elseif ($detectedDevice->bot()) { 42 | $this->isDeviceAllowed( 43 | unknown: false, 44 | userAgent: $detectedDevice->source, 45 | ); 46 | } 47 | } 48 | 49 | if (DeviceManager::shouldRegenerate()) { 50 | DeviceManager::create(); 51 | DeviceManager::attach(); 52 | 53 | return $next(DeviceTransport::propagate(device_uuid())); 54 | } 55 | 56 | if (! DeviceManager::tracked()) { 57 | try { 58 | if (config('devices.track_guest_sessions') === true) { 59 | DeviceManager::track(); 60 | DeviceManager::create(); 61 | } else { 62 | DeviceTransport::propagate(DeviceIdFactory::generate()); 63 | } 64 | } catch (DeviceNotFoundException|FingerprintNotFoundException|UnknownDeviceDetectedException $e) { 65 | Log::info($e->getMessage()); 66 | 67 | $this->isDeviceAllowed(userAgent: $detectedDevice?->source); 68 | 69 | return $next($request); 70 | } 71 | } 72 | 73 | $deviceUuid = device_uuid(); 74 | if ($deviceUuid === null) { 75 | $this->isDeviceAllowed(userAgent: $detectedDevice?->source); 76 | 77 | return $next($request); 78 | } 79 | 80 | return DeviceTransport::set($next(DeviceTransport::propagate($deviceUuid)), $deviceUuid); 81 | } 82 | 83 | private function checkCustomDeviceTransportHierarchy(?string $hierarchyParameterString = null): void 84 | { 85 | if (! empty($hierarchyParameterString)) { 86 | $hierarchy = array_filter( 87 | explode('|', $hierarchyParameterString), 88 | fn (string $value) => DeviceTransport::tryFrom($value) !== null, 89 | ); 90 | if (! empty($hierarchy)) { 91 | Config::set('devices.device_id_transport_hierarchy', $hierarchy); 92 | } 93 | } 94 | } 95 | 96 | private function checkCustomDeviceResponseTransport(?string $parameterString = null): void 97 | { 98 | if ( 99 | ! empty($parameterString) 100 | && DeviceTransport::tryFrom($parameterString) !== null 101 | && $parameterString !== DeviceTransport::Request->value 102 | ) { 103 | Config::set('devices.device_id_response_transport', $parameterString); 104 | } 105 | } 106 | 107 | /** 108 | * @throws UnknownDeviceDetectedException 109 | * @throws InvalidDeviceDetectedException 110 | */ 111 | private function isDeviceAllowed(bool $unknown = true, ?string $userAgent = null): void 112 | { 113 | if (isset($userAgent) && DeviceManager::isWhitelisted($userAgent)) { 114 | return; 115 | } 116 | if (Config::get('devices.allow_'.($unknown ? 'unknown' : 'bot').'_devices', false) === false) { 117 | if (! $this->shouldThrow()) { 118 | $errorCode = config('devices.middlewares.device-tracker.http_error_code', 403); 119 | if (! array_key_exists($errorCode, Response::$statusTexts)) { 120 | $errorCode = 403; 121 | } 122 | abort($errorCode, sprintf( 123 | '%s device detected: user-agent %s', 124 | $unknown ? 'Unknown' : 'Bot', 125 | $userAgent 126 | )); 127 | } else { 128 | if ($unknown === true) { 129 | throw UnknownDeviceDetectedException::withUA($userAgent); 130 | } 131 | throw InvalidDeviceDetectedException::withUA($userAgent); 132 | } 133 | } 134 | } 135 | 136 | private function shouldThrow(): bool 137 | { 138 | return Config::get('devices.middlewares.device-tracker.exception_on_invalid_devices', false); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Http/Resources/DeviceResource.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function toArray(Request $request): array 23 | { 24 | return [ 25 | 'uuid' => (string) $this->resource->uuid, 26 | 'fingerprint' => $this->whenNotNull($this->resource->fingerprint), 27 | 'status' => $this->resource->status, 28 | 'verified_at' => $this->when($this->resource->status === DeviceStatus::Verified, $this->resource->verified_at), 29 | 'browser' => $this->browser($this->resource), 30 | 'platform' => $this->platform($this->resource), 31 | 'device' => $this->device($this->resource), 32 | 'is_current' => $this->resource->isCurrent(), 33 | 'source' => $this->resource->source, 34 | 'ip_address' => $this->resource->ip, 35 | 'grade' => $this->when($this->resource->grade !== null, $this->resource->grade), 36 | 'metadata' => $this->resource->metadata, 37 | 'sessions' => SessionResource::collection($this->whenLoaded('sessions')), 38 | ]; 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | private function browser(Device $device): array 45 | { 46 | return Browser::from([ 47 | 'name' => $device->browser, 48 | 'version' => $device->browser_version, 49 | 'family' => $device->browser_family, 50 | 'engine' => $device->browser_engine, 51 | ])->array(); 52 | } 53 | 54 | /** 55 | * @return array 56 | */ 57 | private function platform(Device $device): array 58 | { 59 | return Platform::from([ 60 | 'name' => $device->platform, 61 | 'version' => $device->platform_version, 62 | 'family' => $device->platform_family, 63 | ])->array(); 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | private function device(Device $device): array 70 | { 71 | return [ 72 | 'family' => $device->device_family, 73 | 'model' => $device->device_model, 74 | 'type' => $device->device_type, 75 | ]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Http/Resources/SessionResource.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function toArray(Request $request): array 20 | { 21 | return [ 22 | 'uuid' => (string) $this->resource->uuid, 23 | 'ip' => $this->resource->ip, 24 | 'location' => $this->resource->location->array(), 25 | 'status' => $this->resource->status->value, 26 | 'last_activity_at' => $this->resource->last_activity_at, 27 | 'started_at' => $this->resource->started_at, 28 | 'finished_at' => $this->resource->finished_at, 29 | 'device' => new DeviceResource($this->whenLoaded('device')), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Models/Google2FA.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * @property int $id unsigned int 17 | * @property int $user_id unsigned int 18 | * @property bool $enabled boolean 19 | * @property string $secret string 20 | * @property ?Carbon $last_success_at datetime 21 | * @property Carbon $created_at datetime 22 | * @property Carbon $updated_at datetime 23 | * @property-read User $user 24 | */ 25 | class Google2FA extends Model 26 | { 27 | protected $table = 'google_2fa'; 28 | 29 | /** 30 | * @return HasOne 31 | */ 32 | public function user(): HasOne 33 | { 34 | return $this->hasOne(User::class, 'id', 'user_id'); 35 | } 36 | 37 | public function enable(string $secret): bool 38 | { 39 | $this->secret = $secret; 40 | $this->enabled = true; 41 | $this->last_success_at = null; 42 | 43 | return $this->save(); 44 | } 45 | 46 | public function disable(): bool 47 | { 48 | $this->enabled = false; 49 | $this->last_success_at = null; 50 | 51 | return $this->save(); 52 | } 53 | 54 | public function success(): void 55 | { 56 | $this->last_success_at = Carbon::now(); 57 | $this->save(); 58 | } 59 | 60 | public function secret(): ?string 61 | { 62 | return $this->secret; 63 | } 64 | 65 | public function lastSuccess(): ?Carbon 66 | { 67 | return $this->last_success_at; 68 | } 69 | 70 | public function enabled(): bool 71 | { 72 | if (config('devices.google_2fa_enabled') === false) { 73 | return false; 74 | } 75 | 76 | return $this->enabled && $this->secret !== null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Models/Relations/BelongsToManyDevices.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class BelongsToManyDevices extends BelongsToMany 13 | { 14 | public function current(): ?Device 15 | { 16 | /** @var Device|null $device */ 17 | $device = $this->get()->where('uuid', device_uuid())->first(); 18 | 19 | return $device; 20 | } 21 | 22 | /** 23 | * @return array 24 | */ 25 | public function uuids(): array 26 | { 27 | return $this->get()->pluck('uuid')->toArray(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Models/Relations/HasManySessions.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * @phpstan-param Device|User $parent 16 | */ 17 | final class HasManySessions extends HasMany 18 | { 19 | public function first(): ?Session 20 | { 21 | /** @var Session|null $session */ 22 | $session = $this 23 | ->with('device') 24 | ->orderBy('started_at') 25 | ->get() 26 | ->first(); 27 | 28 | return $session; 29 | } 30 | 31 | public function last(): ?Session 32 | { 33 | /** @var Session|null $session */ 34 | $session = $this 35 | ->with('device') 36 | ->orderByDesc('started_at') 37 | ->get() 38 | ->first(); 39 | 40 | return $session; 41 | } 42 | 43 | public function current(): ?Session 44 | { 45 | /** @var Session|null $session */ 46 | $session = $this 47 | ->with('device') 48 | ->where('uuid', session_uuid()) 49 | ->get() 50 | ->first(); 51 | 52 | return $session; 53 | } 54 | 55 | public function recent(): ?Session 56 | { 57 | /** @var Session|null $session */ 58 | $session = $this 59 | ->with('device') 60 | ->where('status', SessionStatus::Active->value) 61 | ->orderByDesc('last_activity_at') 62 | ->get() 63 | ->first(); 64 | 65 | return $session; 66 | } 67 | 68 | /** 69 | * @return Collection 70 | */ 71 | public function active(bool $exceptCurrent = false): Collection 72 | { 73 | $query = $this 74 | ->with('device') 75 | ->where('finished_at', null) 76 | ->where('status', SessionStatus::Active); 77 | 78 | if ($exceptCurrent) { 79 | if (session_uuid() !== null) { 80 | $query->where('id', '!=', session_uuid()); 81 | } 82 | } 83 | 84 | return $query->get(); 85 | } 86 | 87 | /** 88 | * @return Collection 89 | */ 90 | public function finished(): Collection 91 | { 92 | return $this 93 | ->with('device') 94 | ->whereNotNull('finished_at') 95 | ->where('status', SessionStatus::Finished) 96 | ->orderBy('finished_at', 'desc') 97 | ->get(); 98 | } 99 | 100 | public function signout(bool $logoutCurrentSession = false): void 101 | { 102 | if ($logoutCurrentSession) { 103 | $this->current()?->end(); 104 | } 105 | 106 | $this->get()->each(function (Session $session) { 107 | $session->end(); 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Modules/Detection/Contracts/DeviceDetector.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function array(): array 28 | { 29 | return [ 30 | 'name' => $this->name, 31 | 'version' => $this->version?->array(), 32 | 'family' => $this->family, 33 | 'engine' => $this->engine, 34 | 'type' => $this->type, 35 | 'label' => (string) $this, 36 | ]; 37 | } 38 | 39 | public function unknown(): bool 40 | { 41 | return 42 | in_array($this->name, [Device::UNKNOWN, '', null], true) && 43 | in_array($this->family, [Device::UNKNOWN, '', null], true) && 44 | in_array($this->engine, [Device::UNKNOWN, '', null], true); 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function jsonSerialize(): array 51 | { 52 | return $this->array(); 53 | } 54 | 55 | public function __toString(): string 56 | { 57 | return sprintf('%s', $this->name); 58 | } 59 | 60 | public function json(): string|false 61 | { 62 | return json_encode($this->array()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Modules/Detection/DTO/DeviceType.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function array(): array 24 | { 25 | return [ 26 | 'family' => $this->family, 27 | 'model' => $this->model, 28 | 'type' => $this->type, 29 | 'label' => (string) $this, 30 | ]; 31 | } 32 | 33 | public function unknown(): bool 34 | { 35 | return 36 | in_array($this->family, [Device::UNKNOWN, '', null], true) && 37 | in_array($this->model, [Device::UNKNOWN, '', null], true) && 38 | in_array($this->type, [Device::UNKNOWN, '', null], true); 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public function jsonSerialize(): array 45 | { 46 | return $this->array(); 47 | } 48 | 49 | public function __toString(): string 50 | { 51 | return sprintf('%s %s (%s)', $this->family, $this->model, $this->type); 52 | } 53 | 54 | public function json(): string|false 55 | { 56 | return json_encode($this->array()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Modules/Detection/DTO/Platform.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function array(): array 24 | { 25 | return [ 26 | 'name' => $this->name, 27 | 'version' => $this->version?->array(), 28 | 'family' => $this->family, 29 | 'label' => (string) $this, 30 | ]; 31 | } 32 | 33 | public function unknown(): bool 34 | { 35 | return 36 | in_array($this->name, [Device::UNKNOWN, '', null], true) && 37 | in_array($this->family, [Device::UNKNOWN, '', null], true); 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function jsonSerialize(): array 44 | { 45 | return $this->array(); 46 | } 47 | 48 | public function __toString(): string 49 | { 50 | return sprintf('%s', $this->name); 51 | } 52 | 53 | public function json(): string|false 54 | { 55 | return json_encode($this->array()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Modules/Detection/DTO/Version.php: -------------------------------------------------------------------------------- 1 | |string|object $context 21 | */ 22 | public static function from(array|string|object $context): self 23 | { 24 | if (is_array($context)) { 25 | return new self( 26 | major: $context['major'] ?? '0', 27 | minor: $context['minor'] ?? '0', 28 | patch: $context['patch'] ?? '0', 29 | ); 30 | } 31 | 32 | if (is_string($context)) { 33 | return self::fromString($context); 34 | } 35 | 36 | return new self( 37 | major: property_exists($context, 'major') ? $context->major : '0', 38 | minor: property_exists($context, 'minor') ? $context->minor : '0', 39 | patch: property_exists($context, 'patch') ? $context->patch : '0', 40 | ); 41 | } 42 | 43 | public static function fromString(string $version): self 44 | { 45 | $parts = explode('.', $version); 46 | 47 | return new self( 48 | major: $parts[0] ?? '0', 49 | minor: $parts[1] ?? '0', 50 | patch: $parts[2] ?? '0', 51 | ); 52 | } 53 | 54 | /** 55 | * @return array 56 | */ 57 | public function array(): array 58 | { 59 | return [ 60 | 'major' => $this->major, 61 | 'minor' => $this->minor, 62 | 'patch' => $this->patch, 63 | 'label' => (string) $this, 64 | ]; 65 | } 66 | 67 | public function equals(Version $version): bool 68 | { 69 | return $this->major === $version->major 70 | && $this->minor === $version->minor 71 | && $this->patch === $version->patch; 72 | } 73 | 74 | /** 75 | * @return array 76 | */ 77 | public function jsonSerialize(): array 78 | { 79 | return $this->array(); 80 | } 81 | 82 | public function __toString(): string 83 | { 84 | return sprintf('%s.%s.%s', $this->major, $this->minor, $this->patch); 85 | } 86 | 87 | public function json(): string|false 88 | { 89 | return json_encode($this->array()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Modules/Detection/Device/UserAgentDeviceDetector.php: -------------------------------------------------------------------------------- 1 | header('User-Agent', $this->fakeUA()); 29 | if (! is_string($ua) || empty($ua)) { 30 | return null; 31 | } 32 | 33 | $key = UserAgentCache::key($ua); 34 | 35 | $this->dd = new DeviceDetector( 36 | userAgent: $ua, 37 | clientHints: ClientHints::factory($_SERVER) 38 | ); 39 | 40 | $this->dd->parse(); 41 | 42 | return UserAgentCache::remember($key, function () { 43 | return Device::from([ 44 | 'browser' => $this->browser(), 45 | 'platform' => $this->platform(), 46 | 'device' => $this->device(), 47 | 'grade' => null, 48 | 'source' => $this->dd->getUserAgent(), 49 | 'bot' => $this->dd->isBot(), 50 | ]); 51 | }); 52 | } 53 | 54 | private function browser(): Browser 55 | { 56 | return Browser::from( 57 | $this->dd->getClient() 58 | ); 59 | } 60 | 61 | private function platform(): Platform 62 | { 63 | return Platform::from( 64 | $this->dd->getOs() 65 | ); 66 | } 67 | 68 | private function device(): DeviceType 69 | { 70 | return DeviceType::from([ 71 | 'family' => $this->dd->getBrandName(), 72 | 'model' => $this->dd->getModel(), 73 | 'type' => $this->dd->getDeviceName(), 74 | ]); 75 | } 76 | 77 | private function fakeUA(): ?string 78 | { 79 | if (app()->environment('local')) { 80 | $uas = config('devices.development_ua_pool'); 81 | shuffle($uas); 82 | 83 | return $uas[0] ?? null; 84 | } 85 | 86 | return null; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/AbstractRequestDetector.php: -------------------------------------------------------------------------------- 1 | header('Accept'); 21 | $contentType = $request->header('Content-Type'); 22 | 23 | if ($accept === null && $contentType === null) { 24 | return false; 25 | } 26 | 27 | return 28 | is_string($accept) && str_contains($accept, 'application/json') || 29 | is_string($contentType) && str_contains($contentType, 'application/json'); 30 | } 31 | 32 | protected function html(mixed $response): bool 33 | { 34 | $contentType = $response->headers->get('Content-Type'); 35 | if (! $contentType) { 36 | return false; 37 | } 38 | 39 | return 40 | $response instanceof Response && 41 | is_string($contentType) && str_contains($contentType, 'text/html'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/AjaxRequestDetector.php: -------------------------------------------------------------------------------- 1 | ajax() || 15 | $request->hasHeader('X-Requested-With') && 16 | ! $request->hasHeader('X-Livewire'); 17 | } 18 | 19 | public function detect(Request $request, mixed $response): EventType 20 | { 21 | return $request->isMethod('POST') ? EventType::Submit : EventType::Click; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/ApiRequestDetector.php: -------------------------------------------------------------------------------- 1 | is('api/*') || 15 | $this->json($request) || 16 | $request->expectsJson(); 17 | } 18 | 19 | public function detect(Request $request, mixed $response): EventType 20 | { 21 | return EventType::ApiRequest; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/AuthenticationRequestDetector.php: -------------------------------------------------------------------------------- 1 | , methods: list}> 14 | */ 15 | private array $signatures = [ 16 | EventType::Login->value => [ 17 | 'paths' => ['login', 'auth/login'], 18 | 'methods' => ['POST'], 19 | ], 20 | EventType::Logout->value => [ 21 | 'paths' => ['logout', 'auth/logout'], 22 | 'methods' => ['POST', 'GET'], 23 | ], 24 | EventType::Signup->value => [ 25 | 'paths' => ['register', 'auth/register', 'signup'], 26 | 'methods' => ['POST'], 27 | ], 28 | ]; 29 | 30 | public function supports(Request $request, mixed $response): bool 31 | { 32 | $path = trim($request->path(), '/'); 33 | $method = strtoupper($request->method()); 34 | 35 | foreach ($this->signatures as $signature) { 36 | if (in_array($method, $signature['methods'], true)) { 37 | foreach ($signature['paths'] as $signaturePath) { 38 | if (fnmatch($signaturePath, $path)) { 39 | return true; 40 | } 41 | } 42 | } 43 | } 44 | 45 | return false; 46 | } 47 | 48 | public function detect(Request $request, mixed $response): ?EventType 49 | { 50 | $path = trim($request->path(), '/'); 51 | 52 | foreach ($this->signatures as $event => $signature) { 53 | if ($this->matches($path, $request->method(), $signature)) { 54 | return EventType::from($event); 55 | } 56 | } 57 | 58 | return null; 59 | } 60 | 61 | /** 62 | * @param array> $signature 63 | */ 64 | private function matches(string $path, string $method, array $signature): bool 65 | { 66 | return in_array(strtoupper($method), $signature['methods'], true) && 67 | collect($signature['paths'])->contains(fn ($p) => fnmatch($p, $path)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/DetectorRegistry.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private Collection $detectors; 17 | 18 | public function __construct() 19 | { 20 | $this->detectors = collect([ 21 | new AuthenticationRequestDetector, 22 | new LivewireRequestDetector, 23 | new ApiRequestDetector, 24 | new AjaxRequestDetector, 25 | new RedirectResponseDetector, 26 | new PageViewDetector, 27 | ])->sortByDesc(fn ($detector) => $detector->priority()); 28 | } 29 | 30 | public function priority(): int 31 | { 32 | return 0; 33 | } 34 | 35 | public function detect(Request $request, mixed $response): ?EventType 36 | { 37 | $cache = EventTypeCache::withRequest($request); 38 | 39 | return $cache::remember($cache::key(''), function () use ($request, $response) { 40 | return $this->detectors->first( 41 | function (RequestTypeDetector $detector) use ($request, $response): bool { 42 | return $detector->supports($request, $response); 43 | } 44 | )?->detect($request, $response); 45 | }); 46 | } 47 | 48 | public function supports(Request $request, mixed $response): bool 49 | { 50 | return $this->detectors->contains(fn (RequestTypeDetector $detector) => $detector->supports($request, $response)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/LivewireRequestDetector.php: -------------------------------------------------------------------------------- 1 | hasHeader('X-Livewire') || 15 | str_starts_with($request->path(), 'livewire'); 16 | } 17 | 18 | public function detect(Request $request, mixed $response): EventType 19 | { 20 | return EventType::LivewireUpdate; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/PageViewDetector.php: -------------------------------------------------------------------------------- 1 | isMethod('GET') && 15 | ! $request->ajax() && 16 | $this->html($response); 17 | } 18 | 19 | public function detect(Request $request, mixed $response): EventType 20 | { 21 | return EventType::PageView; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Modules/Detection/Request/RedirectResponseDetector.php: -------------------------------------------------------------------------------- 1 | redirect($response)) { 31 | return $response; 32 | } 33 | 34 | if (! $this->html($response)) { 35 | return $response; 36 | } 37 | 38 | return $this->addFingerprint($response); 39 | } 40 | 41 | return $response; 42 | } 43 | 44 | /** 45 | * @throws FingerprintDuplicatedException 46 | */ 47 | private function addFingerprint(Response $response): Response 48 | { 49 | $clientCookie = Config::get('devices.client_fingerprint_key'); 50 | $serverCookie = Config::get('devices.fingerprint_id_cookie_name'); 51 | 52 | $library = Config::get('devices.fingerprint_client_library', Library::FingerprintJS); 53 | 54 | if (! request()->cookie($clientCookie)) { 55 | return InjectorFactory::make($library)->inject($response); 56 | } else { 57 | $fingerprint = request()->cookie($clientCookie); 58 | if (! is_string($fingerprint)) { 59 | return $response; 60 | } 61 | 62 | device()?->fingerprint($fingerprint, $serverCookie); 63 | Cookie::queue(Cookie::forget($clientCookie)); 64 | } 65 | 66 | return $response; 67 | } 68 | 69 | private function html(mixed $response): bool 70 | { 71 | if (! $response instanceof Response) { 72 | return false; 73 | } 74 | 75 | $contentType = $response->headers->get('Content-Type'); 76 | if (! $contentType || ! str_contains($contentType, 'text/html')) { 77 | return false; 78 | } 79 | 80 | return true; 81 | } 82 | 83 | private function redirect(mixed $response): bool 84 | { 85 | return $response instanceof RedirectResponse; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Modules/Fingerprinting/Injector/AbstractInjector.php: -------------------------------------------------------------------------------- 1 | $device->fingerprint, 23 | 'transport' => [ 24 | 'type' => config('devices.client_fingerprint_transport'), 25 | 'key' => config('devices.client_fingerprint_key'), 26 | ], 27 | 'library' => [ 28 | 'name' => static::LIBRARY_NAME, 29 | 'url' => static::LIBRARY_URL, 30 | ], 31 | ])->render(); 32 | } 33 | 34 | public function inject(Response $response): Response 35 | { 36 | $content = $response->getContent(); 37 | if (! $content) { 38 | return $response; 39 | } 40 | 41 | $device = DeviceManager::current(); 42 | if ($device) { 43 | $script = self::script($device); 44 | $response->setContent(str_replace('', $script.'', $content)); 45 | } 46 | 47 | return $response; 48 | } 49 | 50 | public function library(): Library 51 | { 52 | return Library::from(static::LIBRARY_NAME); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Modules/Fingerprinting/Injector/ClientJSInjector.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | private static array $injectors = [ 17 | FingerprintJSInjector::class, 18 | ClientJSInjector::class, 19 | ]; 20 | 21 | public static function make(Library $library): Injector 22 | { 23 | foreach (self::$injectors as $injectorClass) { 24 | $injector = new $injectorClass; 25 | if ($injector->library() === $library) { 26 | return new $injectorClass; 27 | } 28 | } 29 | 30 | throw new InvalidArgumentException(sprintf('Injector for library %s not found', $library->value)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Modules/Fingerprinting/Injector/FingerprintJSInjector.php: -------------------------------------------------------------------------------- 1 | location->country; 17 | } 18 | 19 | public function region(): ?string 20 | { 21 | return $this->location->region; 22 | } 23 | 24 | public function city(): ?string 25 | { 26 | return $this->location->city; 27 | } 28 | 29 | public function postal(): ?string 30 | { 31 | return $this->location->postal; 32 | } 33 | 34 | public function latitude(): ?string 35 | { 36 | return $this->location->latitude; 37 | } 38 | 39 | public function longitude(): ?string 40 | { 41 | return $this->location->longitude; 42 | } 43 | 44 | public function timezone(): ?string 45 | { 46 | return $this->location->timezone; 47 | } 48 | 49 | public function location(): ?Location 50 | { 51 | return $this->location; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Modules/Location/Contracts/LocationProvider.php: -------------------------------------------------------------------------------- 1 | $location 27 | */ 28 | public static function fromArray(array $location): self 29 | { 30 | return new self( 31 | ip: $location['ip'] ?? null, 32 | hostname: $location['hostname'] ?? null, 33 | country: $location['country'] ?? null, 34 | region: $location['region'] ?? null, 35 | city: $location['city'] ?? null, 36 | postal: $location['postal'] ?? null, 37 | latitude: $location['latitude'] ?? null, 38 | longitude: $location['longitude'] ?? null, 39 | timezone: $location['timezone'] ?? null, 40 | accuracyRadius: $location['accuracyRadius'] ?? null, 41 | ); 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | public function array(): array 48 | { 49 | return [ 50 | 'ip' => $this->ip, 51 | 'hostname' => $this->hostname, 52 | 'country' => $this->country, 53 | 'region' => $this->region, 54 | 'city' => $this->city, 55 | 'postal' => $this->postal, 56 | 'latitude' => $this->latitude, 57 | 'longitude' => $this->longitude, 58 | 'timezone' => $this->timezone, 59 | 'label' => (string) $this, 60 | 'accuracyRadius' => $this->accuracyRadius, 61 | ]; 62 | } 63 | 64 | public function __toString() 65 | { 66 | return sprintf('%s %s, %s, %s', $this->postal, $this->city, $this->region, $this->country); 67 | } 68 | 69 | /** 70 | * @return array 71 | */ 72 | public function jsonSerialize(): array 73 | { 74 | return $this->array(); 75 | } 76 | 77 | public function json(): string|false 78 | { 79 | return json_encode($this->array()); 80 | } 81 | 82 | public function key(): string 83 | { 84 | return sprintf('%s:%s', LocationCache::KEY_PREFIX, $this->ip); 85 | } 86 | 87 | public function ttl(): ?int 88 | { 89 | return null; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Modules/Location/Exception/LocationLookupFailedException.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private Collection $providers; 17 | 18 | public function __construct() 19 | { 20 | $this->providers = new Collection([]); 21 | } 22 | 23 | /** 24 | * @throws LocationLookupFailedException 25 | */ 26 | public function locate(string $ip): Location 27 | { 28 | foreach ($this->providers as $provider) { 29 | try { 30 | $this->location = $provider->locate($ip); 31 | 32 | return $this->location; 33 | } catch (\Exception $e) { 34 | Log::warning('Location provider failed', [ 35 | 'provider' => get_class($provider), 36 | 'ip' => $ip, 37 | 'error' => $e->getMessage(), 38 | ]); 39 | 40 | continue; 41 | } 42 | } 43 | 44 | throw LocationLookupFailedException::forIp($ip, null); 45 | } 46 | 47 | public function addProvider(LocationProvider $provider): void 48 | { 49 | $this->providers->add($provider); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Modules/Location/IpinfoLocationProvider.php: -------------------------------------------------------------------------------- 1 | location = LocationCache::remember($key, function () use ($ip) { 20 | try { 21 | $url = sprintf(self::API_URL, $ip); 22 | $data = file_get_contents($url); 23 | 24 | if ($data === false) { 25 | throw new Exception('Failed to fetch location data'); 26 | } 27 | 28 | $locationData = json_decode($data, true); 29 | 30 | [$lat, $long] = explode(',', $locationData['loc']); 31 | 32 | $locationData['latitude'] = $lat; 33 | $locationData['longitude'] = $long; 34 | 35 | return Location::fromArray($locationData); 36 | } catch (Exception $e) { 37 | throw LocationLookupFailedException::forIp($ip, $e); 38 | } 39 | }); 40 | 41 | return $this->location; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Modules/Location/MaxmindLocationProvider.php: -------------------------------------------------------------------------------- 1 | location = LocationCache::remember($key, function () use ($ip) { 20 | try { 21 | return $this->lookup($ip); 22 | } catch (AddressNotFoundException|InvalidDatabaseException $e) { 23 | throw LocationLookupFailedException::forIp($ip, $e); 24 | } 25 | }); 26 | 27 | return $this->location; 28 | } 29 | 30 | /** 31 | * @throws AddressNotFoundException 32 | * @throws InvalidDatabaseException 33 | */ 34 | private function lookup(string $ip): Location 35 | { 36 | $record = $this->reader->city($ip); 37 | 38 | return Location::fromArray([ 39 | 'ip' => $ip, 40 | 'country' => $record->country->isoCode, 41 | 'region' => $record->mostSpecificSubdivision->name, 42 | 'city' => $record->city->name, 43 | 'postal' => $record->postal->code, 44 | 'latitude' => (string) $record->location->latitude, 45 | 'longitude' => (string) $record->location->longitude, 46 | 'timezone' => $record->location->timeZone, 47 | 'accuracyRadius' => $record->location->accuracyRadius, 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Modules/Tracking/Cache/EventTypeCache.php: -------------------------------------------------------------------------------- 1 | request = $request; 24 | } 25 | 26 | public function request(): Request 27 | { 28 | return $this->request; 29 | } 30 | 31 | public static function key(string $key): string 32 | { 33 | $instance = self::instance(); 34 | if (! $instance instanceof self) { 35 | return self::KEY_PREFIX.':'.md5($key); 36 | } 37 | 38 | $hash = md5(sprintf( 39 | '%s:%s:%s', 40 | $instance->request()->method(), 41 | $instance->request()->path(), 42 | $instance->request()->ajax() ? 'ajax' : 'regular' 43 | )); 44 | 45 | return sprintf('%s:%s', self::KEY_PREFIX, $hash); 46 | } 47 | 48 | public static function withRequest(Request $request): AbstractCache 49 | { 50 | $instance = self::instance(); 51 | if ($instance instanceof self) { 52 | $instance->setRequest($request); 53 | } 54 | 55 | return $instance; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Modules/Tracking/Enums/EventType.php: -------------------------------------------------------------------------------- 1 | value === $eventType->value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Modules/Tracking/Http/Middleware/EventTracker.php: -------------------------------------------------------------------------------- 1 | ignore($request)) { 31 | return $next($request); 32 | } 33 | 34 | $response = $next($request); 35 | $type = $this->registry->detect($request, $response); 36 | 37 | if ($type === null) { 38 | return $response; 39 | } 40 | 41 | $this->log($type, $request, $response); 42 | 43 | return $response; 44 | } 45 | 46 | private function ignore(Request $request): bool 47 | { 48 | return collect([ 49 | '_debugbar', 50 | '_ignition', 51 | 'telescope', 52 | 'horizon', 53 | 'sanctum', 54 | ])->contains(fn ($path) => str_starts_with($request->path(), $path)); 55 | } 56 | 57 | private function log(EventType $type, Request $request, mixed $response): Event 58 | { 59 | /** @var Route|null $route */ 60 | $route = $request->route(); 61 | $metadata = new Metadata([ 62 | 'request' => [ 63 | 'method' => $request->method(), 64 | 'url' => $request->fullUrl(), 65 | 'path' => $request->path(), 66 | 'ajax' => $request->ajax(), 67 | 'livewire' => $type->equals(EventType::LivewireUpdate), 68 | 'referrer' => $request->header('referer'), 69 | 'user_agent' => $request->userAgent(), 70 | ], 71 | 'response' => [ 72 | 'status' => $response->getStatusCode(), 73 | 'type' => $this->type($response), 74 | ], 75 | 'route' => [ 76 | 'name' => $route?->getName(), 77 | 'action' => $route?->getActionName(), 78 | ], 79 | 'performance' => [ 80 | 'duration' => defined('LARAVEL_START') ? 81 | (microtime(true) - LARAVEL_START) * 1000 : null, 82 | ], 83 | 'client' => [ 84 | 'timezone' => $request->header('X-Timezone'), 85 | 'language' => $request->getPreferredLanguage(), 86 | 'screen' => $request->header('X-Screen-Size'), 87 | ], 88 | 'security' => [ 89 | 'ip' => $request->ip(), 90 | 'proxies' => $request->getClientIps(), 91 | 'secure' => $request->secure(), 92 | ], 93 | ]); 94 | 95 | return Event::log( 96 | type: $type, 97 | session: SessionManager::current(), 98 | metadata: $metadata 99 | ); 100 | } 101 | 102 | private function type(mixed $response): string 103 | { 104 | return match (true) { 105 | $response instanceof JsonResponse => 'json', 106 | $response instanceof RedirectResponse => 'redirect', 107 | $response instanceof BinaryFileResponse => 'file', 108 | $response instanceof Response => 'html', 109 | default => 'unknown' 110 | }; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Modules/Tracking/Models/Event.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | * @property int $id unsigned int 24 | * @property StorableId $uuid string 25 | * @property StorableId $device_uuid string 26 | * @property StorableId $session_uuid string 27 | * @property EventType $type string 28 | * @property string $ip_address string 29 | * @property Metadata $metadata json 30 | * @property Carbon $occurred_at datetime 31 | * @property-read Device|null $device 32 | * @property-read Session|null $session 33 | */ 34 | class Event extends Model 35 | { 36 | use PropertyProxy; 37 | 38 | protected $table = 'device_events'; 39 | 40 | public $timestamps = false; 41 | 42 | protected $fillable = [ 43 | 'uuid', 44 | 'device_uuid', 45 | 'session_uuid', 46 | 'type', 47 | 'metadata', 48 | 'ip_address', 49 | 'occurred_at', 50 | ]; 51 | 52 | public function metadata(): Attribute 53 | { 54 | return Attribute::make( 55 | get: fn (?string $value) => $value ? Metadata::from(json_decode($value, true)) : new Metadata([]), 56 | set: fn (Metadata $value) => $value->json() 57 | ); 58 | } 59 | 60 | public function type(): Attribute 61 | { 62 | return Attribute::make( 63 | get: fn (?string $value) => $value ? EventType::tryFrom($value) : null, 64 | set: fn (EventType $value) => $value->value 65 | ); 66 | } 67 | 68 | /** 69 | * @return BelongsTo 70 | */ 71 | public function device(): BelongsTo 72 | { 73 | return $this->belongsTo(Device::class, 'device_uuid', 'uuid'); 74 | } 75 | 76 | /** 77 | * @return BelongsTo 78 | */ 79 | public function session(): BelongsTo 80 | { 81 | return $this->belongsTo(Session::class, 'session_uuid', 'uuid'); 82 | } 83 | 84 | public static function log(EventType $type, ?Session $session, ?Metadata $metadata): Event 85 | { 86 | /** @var Event $event */ 87 | $event = static::create([ 88 | 'uuid' => EventIdFactory::generate(), 89 | 'device_uuid' => $session->device_uuid ?? device_uuid(), 90 | 'session_uuid' => $session?->uuid, 91 | 'type' => $type, 92 | 'metadata' => $metadata, 93 | 'ip_address' => request()->ip(), 94 | 'occurred_at' => now(), 95 | ]); 96 | 97 | return $event; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Modules/Tracking/Models/Relations/HasManyEvents.php: -------------------------------------------------------------------------------- 1 | 16 | * 17 | * @phpstan-param Device|Session $parent 18 | */ 19 | class HasManyEvents extends HasMany 20 | { 21 | /** 22 | * @return HasMany|Builder 23 | */ 24 | public function type(EventType $type): HasMany|Builder 25 | { 26 | return $this->where('type', $type); 27 | 28 | } 29 | 30 | /** 31 | * @return HasMany|Builder 32 | */ 33 | public function login(): HasMany|Builder 34 | { 35 | return $this->type(EventType::Login); 36 | } 37 | 38 | /** 39 | * @return HasMany|Builder 40 | */ 41 | public function logout(): HasMany|Builder 42 | { 43 | return $this->type(EventType::Logout); 44 | } 45 | 46 | /** 47 | * @return HasMany|Builder 48 | */ 49 | public function signup(): HasMany|Builder 50 | { 51 | return $this->type(EventType::Signup); 52 | } 53 | 54 | /** 55 | * @return HasMany|Builder 56 | */ 57 | public function views(): HasMany|Builder 58 | { 59 | return $this->type(EventType::PageView); 60 | } 61 | 62 | /** 63 | * @return Collection 64 | */ 65 | public function last(int $count = 1): Collection 66 | { 67 | return $this->orderByDesc('occurred_at')->limit($count)->get(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/SessionManager.php: -------------------------------------------------------------------------------- 1 | app = $app; 24 | } 25 | 26 | public function current(): ?Session 27 | { 28 | return Session::current(); 29 | } 30 | 31 | /** 32 | * @throws DeviceNotFoundException 33 | */ 34 | public function start(): Session 35 | { 36 | $device = DeviceManager::current(); 37 | if (! $device) { 38 | throw new DeviceNotFoundException('Device not found.'); 39 | } 40 | 41 | if ($device->hijacked()) { 42 | throw new DeviceNotFoundException('Device is flagged as hijacked.'); 43 | } 44 | 45 | return Session::start(device: $device); 46 | } 47 | 48 | public function end(?StorableId $sessionId = null, ?Authenticatable $user = null): bool 49 | { 50 | $sessionId ??= session_uuid(); 51 | 52 | if ($sessionId === null) { 53 | return false; 54 | } 55 | 56 | $session = Session::byUuid($sessionId); 57 | if (! $session) { 58 | return false; 59 | } 60 | 61 | return $session->end( 62 | user: $user, 63 | ); 64 | } 65 | 66 | public function renew(Authenticatable $user): ?bool 67 | { 68 | return Session::current()?->renew($user); 69 | } 70 | 71 | public function restart(Request $request): ?bool 72 | { 73 | return Session::current()?->restart($request); 74 | } 75 | 76 | /** 77 | * @throws DeviceNotFoundException 78 | */ 79 | public function refresh(?Authenticatable $user = null): Session 80 | { 81 | $current = Session::current(); 82 | if (! $current) { 83 | return $this->start(); 84 | } 85 | 86 | if (Config::get('devices.start_new_session_on_login')) { 87 | $current->end($user); 88 | 89 | return $this->start(); 90 | } 91 | 92 | $current->renew($user); 93 | 94 | return $current; 95 | } 96 | 97 | /** 98 | * @throws Exception 99 | */ 100 | public function inactive(?Authenticatable $user = null): bool 101 | { 102 | return $user?->inactive() ?? false; 103 | } 104 | 105 | /** 106 | * @throws SessionNotFoundException 107 | */ 108 | public function block(StorableId $sessionId): bool 109 | { 110 | $session = Session::byUuidOrFail($sessionId); 111 | 112 | return $session->block(); 113 | } 114 | 115 | /** 116 | * @throws SessionNotFoundException 117 | */ 118 | public function blocked(StorableId $sessionId): bool 119 | { 120 | $session = Session::byUuidOrFail($sessionId); 121 | 122 | return $session->blocked(); 123 | } 124 | 125 | /** 126 | * @throws SessionNotFoundException 127 | */ 128 | public function locked(StorableId $sessionId): bool 129 | { 130 | $session = Session::byUuidOrFail($sessionId); 131 | 132 | return $session->locked(); 133 | } 134 | 135 | public function delete(): void 136 | { 137 | if (session_uuid() !== null) { 138 | SessionTransport::forget(); 139 | Session::destroy(session_uuid()); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Traits/Has2FA.php: -------------------------------------------------------------------------------- 1 | hasOne(\Ninja\DeviceTracker\Models\Google2FA::class, 'user_id'); 25 | } 26 | 27 | public function google2faQrCode(string $format = 'SVG'): string 28 | { 29 | return $format === 'SVG' 30 | ? $this->createSvgQrCode($this->google2faQrCodeUrl()) 31 | : $this->createPngQrCode($this->google2faQrCodeUrl()); 32 | } 33 | 34 | public function google2faEnabled(): bool 35 | { 36 | if (! Config::get('devices.google_2fa_enabled')) { 37 | return false; 38 | } 39 | 40 | return 41 | $this->google2fa && 42 | $this->google2fa->enabled(); 43 | } 44 | 45 | public function enable2fa(string $secret): void 46 | { 47 | $google2fa = new \Ninja\DeviceTracker\Models\Google2FA; 48 | $google2fa->user_id = $this->id; 49 | $google2fa->enable($secret); 50 | 51 | $this->save(); 52 | } 53 | 54 | public function google2faQrCodeUrl(): string 55 | { 56 | $google2fa = app(Google2FA::class); 57 | 58 | return $google2fa->getQRCodeUrl( 59 | company: config('app.name'), 60 | holder: $this->email, 61 | secret: $this->google2fa->secret() 62 | ); 63 | } 64 | 65 | private function createSvgQrCode(string $url): string 66 | { 67 | $svg = (new Writer( 68 | new ImageRenderer( 69 | new RendererStyle(192, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))), 70 | new SvgImageBackEnd 71 | ) 72 | ))->writeString($url); 73 | 74 | return trim(substr($svg, strpos($svg, "\n") + 1)); 75 | } 76 | 77 | private function createPngQrCode(string $url): string 78 | { 79 | $png = (new Writer( 80 | new ImageRenderer( 81 | new RendererStyle(192, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))), 82 | new ImagickImageBackEnd 83 | ) 84 | ))->writeString($url); 85 | 86 | return base64_encode($png); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Traits/HasDevices.php: -------------------------------------------------------------------------------- 1 | newRelatedInstance(Session::class); 21 | 22 | return new HasManySessions( 23 | query: $instance->newQuery(), 24 | parent: $this, 25 | foreignKey: 'user_id', 26 | localKey: 'id' 27 | ); 28 | } 29 | 30 | public function devices(): BelongsToManyDevices 31 | { 32 | $table = sprintf('%s_devices', str(\config('devices.authenticatable_table'))->singular()); 33 | $field = sprintf('%s_id', str(\config('devices.authenticatable_table'))->singular()); 34 | 35 | $instance = $this->newRelatedInstance(Device::class); 36 | 37 | return new BelongsToManyDevices( 38 | query: $instance->newQuery(), 39 | parent: $this, 40 | table: $table, 41 | foreignPivotKey: $field, 42 | relatedPivotKey: 'device_uuid', 43 | parentKey: 'id', 44 | relatedKey: 'uuid', 45 | ); 46 | } 47 | 48 | public function device(): ?Device 49 | { 50 | return $this->devices()->current(); 51 | } 52 | 53 | public function session(): Session 54 | { 55 | return $this->sessions()->current(); 56 | } 57 | 58 | public function hasDevice(Device|string $device): bool 59 | { 60 | $deviceId = $device instanceof Device ? $device->uuid : DeviceIdFactory::from($device); 61 | 62 | return in_array($deviceId, $this->devices()->uuids()); 63 | } 64 | 65 | public function addDevice(Device $device): bool 66 | { 67 | if ($this->hasDevice($device->uuid)) { 68 | return true; 69 | } 70 | 71 | $this->devices()->attach($device->uuid); 72 | $this->save(); 73 | 74 | return true; 75 | } 76 | 77 | public function inactive(): bool 78 | { 79 | if ($this->sessions()->count() > 0) { 80 | $lastActivity = $this->sessions()->recent()->last_activity_at; 81 | 82 | return $lastActivity && abs(strtotime($lastActivity) - strtotime(now())) > Config::get('devices.inactivity_seconds', 1200); 83 | } 84 | 85 | return true; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Traits/PropertyProxy.php: -------------------------------------------------------------------------------- 1 | extract($method); 12 | 13 | if ($this->getter($method)) { 14 | if ($this->metadata->has($property)) { 15 | return $this->metadata->get($property); 16 | } 17 | } 18 | 19 | if ($this->setter($method)) { 20 | $this->metadata->set($property, $parameters[0]); 21 | 22 | return $this; 23 | } 24 | 25 | try { 26 | return parent::__call($method, $parameters); 27 | } catch (BadMethodCallException $e) { 28 | return null; 29 | } 30 | } 31 | 32 | public function getter(string $method): bool 33 | { 34 | if (str_starts_with($method, 'get')) { 35 | return true; 36 | } 37 | 38 | return false; 39 | } 40 | 41 | public function setter(string $method): bool 42 | { 43 | if (str_starts_with($method, 'set')) { 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | private function extract(string $method): string 51 | { 52 | return str()->snake(substr($method, 3)); 53 | } 54 | 55 | public function has(string $property): bool 56 | { 57 | return property_exists($this, $property); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ValueObject/AbstractStorableId.php: -------------------------------------------------------------------------------- 1 | id = $id; 17 | } 18 | 19 | public static function from(StorableId|string $id): StorableId 20 | { 21 | return new static(Uuid::fromString($id)); 22 | } 23 | 24 | public static function build(): self 25 | { 26 | return new static(Uuid::uuid7()); 27 | } 28 | 29 | public function toString(): string 30 | { 31 | return $this->id->toString(); 32 | } 33 | 34 | public function equals(AbstractStorableId $other): bool 35 | { 36 | return $this->id->equals($other->id); 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return $this->toString(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ValueObject/DeviceId.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'middlewareException' => true, 22 | ], 23 | '403' => [ 24 | 'middlewareException' => false, 25 | ], 26 | ]; 27 | } 28 | 29 | #[DataProvider('unavailableDeviceDataProvider')] 30 | public function test_with_unavailable_device(bool $middlewareException): void 31 | { 32 | $this->setConfig([ 33 | 'devices.middlewares.device-checker.exception_on_unavailable_devices' => $middlewareException, 34 | ]); 35 | 36 | $request = request(); 37 | $next = function (Request $request) { 38 | return new Response(null, 200); 39 | }; 40 | 41 | $this->expectException($middlewareException ? DeviceNotFoundException::class : HttpException::class); 42 | 43 | $middleware = new DeviceChecker; 44 | $middleware->handle($request, $next); 45 | } 46 | 47 | public function test_with_available_device(): void 48 | { 49 | $device = Device::factory() 50 | ->create(); 51 | $request = DeviceTransport::propagate($device->uuid); 52 | 53 | $next = function (Request $request) { 54 | return new Response(null, 200); 55 | }; 56 | $middleware = new DeviceChecker; 57 | 58 | /** @var Response $response */ 59 | $response = $middleware->handle($request, $next); 60 | $this->assertTrue($response->isOk()); 61 | } 62 | 63 | public function test_custom_http_error_code(): void 64 | { 65 | $code = 412; 66 | $this->setConfig([ 67 | 'devices.middlewares.device-checker.exception_on_unavailable_devices' => false, 68 | 'devices.middlewares.device-checker.http_error_code' => $code, 69 | ]); 70 | 71 | $request = request(); 72 | $next = function (Request $request) { 73 | $this->fail('It should not have entered the next() function'); 74 | }; 75 | 76 | $middleware = new DeviceChecker; 77 | try { 78 | $middleware->handle($request, $next); 79 | $this->fail('It should generate an exception'); 80 | } catch (HttpException $e) { 81 | $this->assertEquals($code, $e->getStatusCode()); 82 | } 83 | } 84 | 85 | public function test_custom_http_error_code_with_invalid_code(): void 86 | { 87 | $code = 10000; 88 | $this->setConfig([ 89 | 'devices.middlewares.device-checker.exception_on_unavailable_devices' => false, 90 | 'devices.middlewares.device-checker.http_error_code' => $code, 91 | ]); 92 | 93 | $request = request(); 94 | $next = function (Request $request) { 95 | $this->fail('It should not have entered the next() function'); 96 | }; 97 | 98 | $middleware = new DeviceChecker; 99 | try { 100 | $middleware->handle($request, $next); 101 | $this->fail('It should generate an exception'); 102 | } catch (HttpException $e) { 103 | $this->assertEquals(403, $e->getStatusCode()); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/FeatureTestCase.php: -------------------------------------------------------------------------------- 1 | setConfig([ 20 | 'devices.authenticatable_class' => User::class, 21 | ]); 22 | } 23 | 24 | protected function setConfig(array $config = []): void 25 | { 26 | foreach ($config as $key => $value) { 27 | Config::set($key, $value); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | extend(FeatureTestCase::class)->in('Feature'); 18 | pest()->extend(TestCase::class)->in('Unit'); 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Expectations 23 | |-------------------------------------------------------------------------- 24 | | 25 | | When you're writing tests, you often need to check that values meet certain conditions. The 26 | | "expect()" function gives you access to a set of "expectations" methods that you can use 27 | | to assert different things. Of course, you may extend the Expectation API at any time. 28 | | 29 | */ 30 | 31 | expect()->extend('toBeOne', function () { 32 | return $this->toBe(1); 33 | }); 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Functions 38 | |-------------------------------------------------------------------------- 39 | | 40 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 41 | | project that you don't want to repeat in every file. Here you can also expose helpers as 42 | | global functions to help you to reduce the number of lines of code in your test files. 43 | | 44 | */ 45 | 46 | function something() 47 | { 48 | // .. 49 | } 50 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |