From f927798e613479e82a8edbce18bbedef81a99f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Tue, 10 Feb 2026 11:08:37 -0300 Subject: [PATCH 1/4] feat(devices): new features (location, attempt, failed, logout session) --- config/user-devices.php | 37 ++++++++ ...002_add_location_to_user_devices_table.php | 28 ++++++ ...3_add_session_id_to_user_devices_table.php | 28 ++++++ src/DTO/DeviceContext.php | 37 ++++++++ src/DeviceCreator.php | 19 ++++ src/Http/Requests/BlockDeviceRequest.php | 7 ++ src/Listeners/AttemptingLoginListener.php | 31 +++++++ src/Listeners/AuthenticatedLoginListener.php | 31 +++++++ src/Listeners/FailedLoginListener.php | 31 +++++++ src/Listeners/SaveUserDevice.php | 60 ------------- .../AttemptingLoginNotification.php | 88 ++++++++++++++++++ ...php => AuthenticatedLoginNotification.php} | 8 +- src/Notifications/FailedLoginNotification.php | 88 ++++++++++++++++++ src/ServiceProvider.php | 43 ++++++++- src/Services/LocationResolver.php | 24 +++++ src/Traits/HandlesAuthEvents.php | 90 +++++++++++++++++++ src/Traits/HasUserDevices.php | 28 ++++-- tests/Feature/UserDevicesTest.php | 6 +- tests/Unit/HasUserDevicesTest.php | 6 +- .../000002_create_user_devices_table.php | 2 + 20 files changed, 615 insertions(+), 77 deletions(-) create mode 100644 config/user-devices.php create mode 100644 database/migrations/2026_01_30_000002_add_location_to_user_devices_table.php create mode 100644 database/migrations/2026_01_30_000003_add_session_id_to_user_devices_table.php create mode 100644 src/DTO/DeviceContext.php create mode 100644 src/Listeners/AttemptingLoginListener.php create mode 100644 src/Listeners/AuthenticatedLoginListener.php create mode 100644 src/Listeners/FailedLoginListener.php delete mode 100644 src/Listeners/SaveUserDevice.php create mode 100644 src/Notifications/AttemptingLoginNotification.php rename src/Notifications/{NewLoginDeviceNotification.php => AuthenticatedLoginNotification.php} (93%) create mode 100644 src/Notifications/FailedLoginNotification.php create mode 100644 src/Services/LocationResolver.php create mode 100644 src/Traits/HandlesAuthEvents.php diff --git a/config/user-devices.php b/config/user-devices.php new file mode 100644 index 0000000..fba78b7 --- /dev/null +++ b/config/user-devices.php @@ -0,0 +1,37 @@ + [ + 'failed' => true, + + 'attempting' => false, + + 'authenticated' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Credential Key + |-------------------------------------------------------------------------- + | + | The credential key used to identify the user (for attempting/failed events). + | Typically 'email' for Laravel's default authentication. + | + */ + + 'credential_key' => 'email', + +]; diff --git a/database/migrations/2026_01_30_000002_add_location_to_user_devices_table.php b/database/migrations/2026_01_30_000002_add_location_to_user_devices_table.php new file mode 100644 index 0000000..386066e --- /dev/null +++ b/database/migrations/2026_01_30_000002_add_location_to_user_devices_table.php @@ -0,0 +1,28 @@ +string('location')->nullable()->after('user_agent'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_devices', function (Blueprint $table) { + $table->dropColumn('location'); + }); + } +}; diff --git a/database/migrations/2026_01_30_000003_add_session_id_to_user_devices_table.php b/database/migrations/2026_01_30_000003_add_session_id_to_user_devices_table.php new file mode 100644 index 0000000..40c7b7e --- /dev/null +++ b/database/migrations/2026_01_30_000003_add_session_id_to_user_devices_table.php @@ -0,0 +1,28 @@ +string('session_id')->nullable()->after('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_devices', function (Blueprint $table) { + $table->dropColumn('session_id'); + }); + } +}; diff --git a/src/DTO/DeviceContext.php b/src/DTO/DeviceContext.php new file mode 100644 index 0000000..d6c4844 --- /dev/null +++ b/src/DTO/DeviceContext.php @@ -0,0 +1,37 @@ + geoip($ip)->city ?? geoip($ip)->country); + */ + public static ?Closure $resolveLocation = null; + /** * The fully qualified class name of the user model. * @@ -87,6 +96,16 @@ public static function useUserDeviceModel(string $model): void static::$userDeviceModel = $model; } + /** + * Set a callback to resolve location from an IP address. + * + * The callback receives the IP string and should return a location string or null. + */ + public static function resolveLocationUsing(Closure $callback): void + { + static::$resolveLocation = $callback; + } + /** * Set a callback to determine whether to send the new login device notification. * diff --git a/src/Http/Requests/BlockDeviceRequest.php b/src/Http/Requests/BlockDeviceRequest.php index b6ba7c6..9386886 100644 --- a/src/Http/Requests/BlockDeviceRequest.php +++ b/src/Http/Requests/BlockDeviceRequest.php @@ -3,6 +3,7 @@ namespace UserDevices\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Session; use UserDevices\DeviceCreator; use UserDevices\Models\UserDevice; @@ -33,6 +34,12 @@ public function authorize(): bool public function fulfill(): void { $this->getDevice()?->block(); + + $sessionId = $this->getDevice()?->session_id; + + if (filled($sessionId)) { + Session::getHandler()->destroy($sessionId); + } } /** diff --git a/src/Listeners/AttemptingLoginListener.php b/src/Listeners/AttemptingLoginListener.php new file mode 100644 index 0000000..0f6d81a --- /dev/null +++ b/src/Listeners/AttemptingLoginListener.php @@ -0,0 +1,31 @@ +shouldSkipListener()) { + return; + } + + $user = $this->resolveUser(null, $event->credentials); + + if (blank($user)) { + return; + } + + $this->createOrUpdateDeviceAndNotifyIfNew($user, function ($user, $device) { + $user->sendAttemptingLoginNotification($device); + }); + } +} diff --git a/src/Listeners/AuthenticatedLoginListener.php b/src/Listeners/AuthenticatedLoginListener.php new file mode 100644 index 0000000..c6e2c6f --- /dev/null +++ b/src/Listeners/AuthenticatedLoginListener.php @@ -0,0 +1,31 @@ +shouldSkipListener()) { + return; + } + + $user = $this->resolveUser($event->user); + + if (blank($user)) { + return; + } + + $this->createOrUpdateDeviceAndNotifyIfNew($user, function ($user, $device) { + $user->sendAuthenticatedLoginNotification($device); + }); + } +} diff --git a/src/Listeners/FailedLoginListener.php b/src/Listeners/FailedLoginListener.php new file mode 100644 index 0000000..ca62331 --- /dev/null +++ b/src/Listeners/FailedLoginListener.php @@ -0,0 +1,31 @@ +shouldSkipListener()) { + return; + } + + $user = $this->resolveUser($event->user, $event->credentials); + + if (blank($user)) { + return; + } + + $this->createOrUpdateDeviceAndNotifyIfNew($user, function ($user, $device) { + $user->sendFailedLoginNotification($device); + }); + } +} diff --git a/src/Listeners/SaveUserDevice.php b/src/Listeners/SaveUserDevice.php deleted file mode 100644 index 516f653..0000000 --- a/src/Listeners/SaveUserDevice.php +++ /dev/null @@ -1,60 +0,0 @@ -user; - - if (Context::get('user_devices.ignore_listener', false)) { - return; - } - - if (! in_array(HasUserDevices::class, class_uses_recursive($user))) { - return; - } - - $device = $user->userDevices()->firstOrNew([ - 'ip_address' => Request::ip(), - 'user_agent' => with(Request::userAgent(), DeviceCreator::$userAgent), - ]); - - tap($device->exists, function ($exists) use ($user, $device) { - $device->fill([ - 'last_activity' => Carbon::now()->timestamp, - ])->save(); - - $shouldSend = $this->shouldSendNotification($user, $device); - - if (! $exists && $shouldSend) { - $user->sendNewLoginDeviceNotification($device); - } - }); - } - - /** - * Determine whether to send the new login device notification. - */ - private function shouldSendNotification(mixed $user, UserDevice $device): bool - { - if (is_callable(DeviceCreator::$shouldSendNotification)) { - return (DeviceCreator::$shouldSendNotification)($user, $device); - } - - return ! Context::get('user_devices.ignore_notification', false); - } -} diff --git a/src/Notifications/AttemptingLoginNotification.php b/src/Notifications/AttemptingLoginNotification.php new file mode 100644 index 0000000..1e899dc --- /dev/null +++ b/src/Notifications/AttemptingLoginNotification.php @@ -0,0 +1,88 @@ +device); + } + + return $this->buildMailMessage(); + } + + /** + * Get the attempting login notification mail message. + */ + protected function buildMailMessage(): MailMessage + { + $deviceInfo = $this->formatDeviceInfo(); + + return (new MailMessage) + ->subject(Lang::get('Login Attempt to Your Account')) + ->line(Lang::get('Someone attempted to log in to your account.')) + ->line(Lang::get('Device details: :details', ['details' => $deviceInfo])) + ->line(Lang::get('If this was you, you can safely ignore this email. If you did not attempt this login, we recommend changing your password immediately.')); + } + + /** + * Format the device information for display. + */ + protected function formatDeviceInfo(): string + { + $parts = []; + + if ($this->device->ip_address) { + $parts[] = Lang::get('IP Address: :ip', ['ip' => $this->device->ip_address]); + } + + if ($this->device->user_agent) { + $parts[] = Lang::get('Device: :device', ['device' => $this->device->user_agent]); + } + + if ($this->device->location) { + $parts[] = Lang::get('Location: :location', ['location' => $this->device->location]); + } + + return ! empty($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); + } + + /** + * Set a callback that should be used when building the notification mail message. + */ + public static function toMailUsing(Closure $callback): void + { + static::$toMailCallback = $callback; + } +} diff --git a/src/Notifications/NewLoginDeviceNotification.php b/src/Notifications/AuthenticatedLoginNotification.php similarity index 93% rename from src/Notifications/NewLoginDeviceNotification.php rename to src/Notifications/AuthenticatedLoginNotification.php index a6ce142..ca96aed 100644 --- a/src/Notifications/NewLoginDeviceNotification.php +++ b/src/Notifications/AuthenticatedLoginNotification.php @@ -11,7 +11,7 @@ use Illuminate\Support\Facades\URL; use UserDevices\Models\UserDevice; -class NewLoginDeviceNotification extends Notification +class AuthenticatedLoginNotification extends Notification { /** * The callback that should be used to build the mail message. @@ -51,7 +51,7 @@ public function toMail(mixed $notifiable): MailMessage } /** - * Get the new login device notification mail message. + * Get the authenticated login notification mail message. */ protected function buildMailMessage(): MailMessage { @@ -102,6 +102,10 @@ protected function formatDeviceInfo(): string $parts[] = Lang::get('Device: :device', ['device' => $this->device->user_agent]); } + if ($this->device->location) { + $parts[] = Lang::get('Location: :location', ['location' => $this->device->location]); + } + return ! empty($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); } diff --git a/src/Notifications/FailedLoginNotification.php b/src/Notifications/FailedLoginNotification.php new file mode 100644 index 0000000..909f4ba --- /dev/null +++ b/src/Notifications/FailedLoginNotification.php @@ -0,0 +1,88 @@ +device); + } + + return $this->buildMailMessage(); + } + + /** + * Get the failed login notification mail message. + */ + protected function buildMailMessage(): MailMessage + { + $deviceInfo = $this->formatDeviceInfo(); + + return (new MailMessage) + ->subject(Lang::get('Failed Login Attempt to Your Account')) + ->line(Lang::get('A failed login attempt was detected for your account.')) + ->line(Lang::get('Device details: :details', ['details' => $deviceInfo])) + ->line(Lang::get('If this was you, you may have entered the wrong password. If you did not attempt this login, we recommend changing your password immediately.')); + } + + /** + * Format the device information for display. + */ + protected function formatDeviceInfo(): string + { + $parts = []; + + if ($this->device->ip_address) { + $parts[] = Lang::get('IP Address: :ip', ['ip' => $this->device->ip_address]); + } + + if ($this->device->user_agent) { + $parts[] = Lang::get('Device: :device', ['device' => $this->device->user_agent]); + } + + if ($this->device->location) { + $parts[] = Lang::get('Location: :location', ['location' => $this->device->location]); + } + + return ! empty($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); + } + + /** + * Set a callback that should be used when building the notification mail message. + */ + public static function toMailUsing(Closure $callback): void + { + static::$toMailCallback = $callback; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index a018114..c4943ae 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,11 +2,16 @@ namespace UserDevices; +use Illuminate\Auth\Events\Attempting; use Illuminate\Auth\Events\Authenticated; +use Illuminate\Auth\Events\Failed; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; -use UserDevices\Listeners\SaveUserDevice; +use UserDevices\Listeners\AttemptingLoginListener; +use UserDevices\Listeners\AuthenticatedLoginListener; +use UserDevices\Listeners\FailedLoginListener; use UserDevices\Middleware\CheckCurrentDevice; class ServiceProvider extends LaravelServiceProvider @@ -16,6 +21,8 @@ class ServiceProvider extends LaravelServiceProvider */ public function boot(): void { + $this->bootConfig(); + $this->bootMigrations(); $this->bootMiddleware(); @@ -24,11 +31,21 @@ public function boot(): void } /** - * Register the package event listeners. + * Register any application services. */ - private function bootEventListeners(): void + public function register(): void { - Event::listen(Authenticated::class, SaveUserDevice::class); + $this->mergeConfigFrom(__DIR__.'/../config/user-devices.php', 'user-devices'); + } + + /** + * Publish the package config file. + */ + private function bootConfig(): void + { + $this->publishes([ + __DIR__.'/../config/user-devices.php' => config_path('user-devices.php'), + ], 'user-devices-config'); } /** @@ -54,4 +71,22 @@ private function bootMiddleware(): void $router->aliasMiddleware('check.device', CheckCurrentDevice::class); } + + /** + * Register the package event listeners. + */ + private function bootEventListeners(): void + { + if (Config::get('user-devices.events.failed', true)) { + Event::listen(Failed::class, FailedLoginListener::class); + } + + if (Config::get('user-devices.events.attempting', false)) { + Event::listen(Attempting::class, AttemptingLoginListener::class); + } + + if (Config::get('user-devices.events.authenticated', true)) { + Event::listen(Authenticated::class, AuthenticatedLoginListener::class); + } + } } diff --git a/src/Services/LocationResolver.php b/src/Services/LocationResolver.php new file mode 100644 index 0000000..ecbb361 --- /dev/null +++ b/src/Services/LocationResolver.php @@ -0,0 +1,24 @@ +resolveUserFromCredentials($credentials) : null); + + if (blank($user) || in_array(HasUserDevices::class, class_uses_recursive($user)) === false) { + return null; + } + + return $user; + } + + /** + * Resolve the user from credentials (e.g. email). + */ + protected function resolveUserFromCredentials(array $credentials): mixed + { + $userModel = DeviceCreator::$userModel; + + $key = Config::get('user-devices.credential_key', 'email'); + + $value = data_get($credentials, $key); + + if (blank($value)) { + return null; + } + + return resolve($userModel)::where($key, $value)->first(); + } + + /** + * Create or update the user device and optionally send notification if it's new. + */ + protected function createOrUpdateDeviceAndNotifyIfNew(mixed $user, callable $notify): void + { + $context = DeviceContext::fromRequest(); + + $device = $user->userDevices()->firstOrNew([ + 'ip_address' => $context->ipAddress, + 'user_agent' => $context->userAgent, + ]); + + tap($device->exists, function ($exists) use ($user, $device, $context, $notify) { + $device->fill([ + 'location' => $context->location, + 'session_id' => $context->sessionId, + 'last_activity' => Carbon::now()->timestamp, + ])->save(); + + if (! $exists && $this->shouldSendNotification($user, $device)) { + $notify($user, $device); + } + }); + } +} diff --git a/src/Traits/HasUserDevices.php b/src/Traits/HasUserDevices.php index 16615e8..fc77a10 100644 --- a/src/Traits/HasUserDevices.php +++ b/src/Traits/HasUserDevices.php @@ -5,12 +5,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use UserDevices\DeviceCreator; use UserDevices\Models\UserDevice; -use UserDevices\Notifications\NewLoginDeviceNotification; +use UserDevices\Notifications\AttemptingLoginNotification; +use UserDevices\Notifications\AuthenticatedLoginNotification; +use UserDevices\Notifications\FailedLoginNotification; trait HasUserDevices { /** - * Get the personal tokens that belong to model. + * Get the user devices that belong to the model. */ public function userDevices(): HasMany { @@ -20,10 +22,26 @@ public function userDevices(): HasMany } /** - * Send the new login device notification. + * Send the failed login notification. */ - public function sendNewLoginDeviceNotification(UserDevice $device): void + public function sendFailedLoginNotification(UserDevice $device): void { - $this->notify(new NewLoginDeviceNotification($device)); + $this->notify(new FailedLoginNotification($device)); + } + + /** + * Send the attempting login notification. + */ + public function sendAttemptingLoginNotification(UserDevice $device): void + { + $this->notify(new AttemptingLoginNotification($device)); + } + + /** + * Send the authenticated login notification. + */ + public function sendAuthenticatedLoginNotification(UserDevice $device): void + { + $this->notify(new AuthenticatedLoginNotification($device)); } } diff --git a/tests/Feature/UserDevicesTest.php b/tests/Feature/UserDevicesTest.php index 4d12b96..a0297d3 100644 --- a/tests/Feature/UserDevicesTest.php +++ b/tests/Feature/UserDevicesTest.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\URL; use UserDevices\DeviceCreator; -use UserDevices\Notifications\NewLoginDeviceNotification; +use UserDevices\Notifications\AuthenticatedLoginNotification; use Workbench\App\Models\User; use Workbench\App\Models\UserDevice; @@ -55,7 +55,7 @@ ->withHeader('User-Agent', 'Mozilla/5.0 Brand New Device') ->get('/dashboard'); - Notification::assertSentTo($user, NewLoginDeviceNotification::class); + Notification::assertSentTo($user, AuthenticatedLoginNotification::class); }); test('it should not send notification when shouldSendNotificationUsing returns false', function () { @@ -85,7 +85,7 @@ ->withHeader('User-Agent', 'Mozilla/5.0 Custom Callback Device') ->get('/dashboard'); - Notification::assertSentTo($user, NewLoginDeviceNotification::class); + Notification::assertSentTo($user, AuthenticatedLoginNotification::class); DeviceCreator::$shouldSendNotification = null; }); diff --git a/tests/Unit/HasUserDevicesTest.php b/tests/Unit/HasUserDevicesTest.php index f126979..b6ad41a 100644 --- a/tests/Unit/HasUserDevicesTest.php +++ b/tests/Unit/HasUserDevicesTest.php @@ -3,7 +3,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Notification; use UserDevices\DeviceCreator; -use UserDevices\Notifications\NewLoginDeviceNotification; +use UserDevices\Notifications\AuthenticatedLoginNotification; use Workbench\App\Models\User; use Workbench\App\Models\UserDevice; @@ -31,7 +31,7 @@ $user = User::factory()->create(); $device = UserDevice::factory()->create(['user_id' => $user->id]); - $user->sendNewLoginDeviceNotification($device); + $user->sendAuthenticatedLoginNotification($device); - Notification::assertSentTo($user, NewLoginDeviceNotification::class); + Notification::assertSentTo($user, AuthenticatedLoginNotification::class); }); diff --git a/workbench/database/migrations/000002_create_user_devices_table.php b/workbench/database/migrations/000002_create_user_devices_table.php index 6fef40b..8b5d2da 100644 --- a/workbench/database/migrations/000002_create_user_devices_table.php +++ b/workbench/database/migrations/000002_create_user_devices_table.php @@ -14,8 +14,10 @@ public function up(): void Schema::create('user_devices', function (Blueprint $table) { $table->uuid('id')->primary(); $table->foreignId('user_id')->constrained()->onUpdate('cascade')->onDelete('cascade'); + $table->string('session_id')->nullable(); $table->string('ip_address', 45)->nullable(); $table->text('user_agent')->nullable(); + $table->string('location')->nullable(); $table->integer('last_activity')->index(); $table->boolean('blocked')->default(false); $table->timestamps(); From b2891ade01b6eb6d1cc530125b041dd2513ff37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Tue, 10 Feb 2026 11:37:45 -0300 Subject: [PATCH 2/4] test(devices): adjust tests and readme --- README.md | 122 +++++++++++------- src/Listeners/AttemptingLoginListener.php | 5 + src/Listeners/AuthenticatedLoginListener.php | 5 + src/Listeners/FailedLoginListener.php | 5 + src/ServiceProvider.php | 14 +- tests/Feature/AttemptingLoginListenerTest.php | 63 +++++++++ tests/Feature/ConfigEventsTest.php | 18 +++ tests/Feature/FailedLoginListenerTest.php | 62 +++++++++ tests/Feature/LocationAndSessionTest.php | 35 +++++ tests/Feature/UserDevicesTest.php | 44 +++---- tests/Unit/DeviceContextTest.php | 17 +++ tests/Unit/LocationResolverTest.php | 18 +++ workbench/routes/web.php | 13 ++ 13 files changed, 345 insertions(+), 76 deletions(-) create mode 100644 tests/Feature/AttemptingLoginListenerTest.php create mode 100644 tests/Feature/ConfigEventsTest.php create mode 100644 tests/Feature/FailedLoginListenerTest.php create mode 100644 tests/Feature/LocationAndSessionTest.php create mode 100644 tests/Unit/DeviceContextTest.php create mode 100644 tests/Unit/LocationResolverTest.php diff --git a/README.md b/README.md index a1cc357..170f301 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ This package provides support for managing user devices in Laravel. Track login ### 🧩 Features -- ✅ **Device tracking**: Automatic tracking of IP address and user agent +- ✅ **Device tracking**: Automatic tracking of IP address, user agent, location, and session ID - ✅ **New login detection**: Identifies first-time logins from new devices -- ✅ **Email notifications**: Sends alerts when a new device logs in -- ✅ **Block device**: Signed links to block suspicious devices +- ✅ **Email notifications**: Sends alerts when a new device logs in, on login attempts, and on failed logins +- ✅ **Configurable events**: Enable or disable listeners per auth event (authenticated, attempting, failed) +- ✅ **Location from IP**: Optional geolocation via callback +- ✅ **Block device**: Signed links to block suspicious devices (invalidates session when blocked) - ✅ **Integrated middleware**: Protect routes from blocked devices - ✅ **Model trait**: Simple Eloquent integration - ✅ **Flexible configuration**: Custom models and callbacks @@ -20,7 +22,13 @@ You can install the package via Composer: composer require pijler/user-devices ``` -### 🗄️ Publishing Migrations +### 🗄️ Publishing + +Publish the package config (optional): + +```bash +php artisan vendor:publish --tag=user-devices-config +``` Publish the package migrations: @@ -36,7 +44,21 @@ php artisan migrate ### ⚙️ Configuration -#### Basic Configuration +#### Config File + +```php +// config/user-devices.php +return [ + 'events' => [ + 'failed' => true, // Track failures, notify when new device + 'attempting' => false, // Track attempts, notify when new device + 'authenticated' => true, // Save device + send new login notification + ], + 'credential_key' => 'email', // Key to find user from credentials (attempting/failed) +]; +``` + +#### DeviceCreator The package works out-of-the-box, but you can customize the behavior: @@ -46,13 +68,10 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\URL; use UserDevices\DeviceCreator; -use UserDevices\Notifications\NewLoginDeviceNotification; +use UserDevices\Notifications\AuthenticatedLoginNotification; class AppServiceProvider extends ServiceProvider { - /** - * Bootstrap services. - */ public function boot(): void { // Use custom user model @@ -64,13 +83,19 @@ class AppServiceProvider extends ServiceProvider // Customize user agent generation DeviceCreator::userAgentUsing(fn ($userAgent) => substr($userAgent, 0, 255)); - // Control when to send new login notifications (e.g. disable in local/staging) + // Control when to send notifications (e.g. disable in local/staging) DeviceCreator::shouldSendNotificationUsing(function ($user, $device) { return ! app()->environment('local'); }); + // Resolve location from IP (optional) + DeviceCreator::resolveLocationUsing(function (string $ip) { + $geo = geoip($ip); + return $geo->city ? "{$geo->city}, {$geo->country}" : $geo->country; + }); + // Customize the notification email - NewLoginDeviceNotification::toMailUsing(function ($notifiable, $device) { + AuthenticatedLoginNotification::toMailUsing(function ($notifiable, $device) { $expire = Config::get('auth.verification.expire', 60); $blockUrl = URL::temporarySignedRoute( @@ -89,7 +114,7 @@ class AppServiceProvider extends ServiceProvider }); // Customize the block device URL - NewLoginDeviceNotification::createBlockUrlUsing(function ($device) { + AuthenticatedLoginNotification::createBlockUrlUsing(function ($device) { return URL::temporarySignedRoute( name: 'your-custom-route-name', expiration: Carbon::now()->addMinutes(120), @@ -124,50 +149,39 @@ class User extends Authenticatable #### 2. Saving User Devices -The package automatically saves user devices when the `Authenticated` event is fired (on every authenticated request). No manual setup required—just add the `HasUserDevices` trait to your User model. +The package automatically saves user devices when auth events fire. No manual setup required—just add the `HasUserDevices` trait to your User model. + +- **Authenticated**: Saves/updates device (IP, user agent, location, session ID). Sends notification only on first login from that device. +- **Attempting**: Same as above. Finds user by email in credentials (when `events.attempting` is enabled). +- **Failed**: Same as above. Uses user from event or resolves from credentials (when `events.failed` is enabled). + +All three events use `firstOrNew` by IP + user agent, so the same device is updated across requests. To skip saving the device entirely for a request (e.g. in middleware or controller before authentication): ```php use UserDevices\DeviceCreator; -// In middleware or controller—call before the user is authenticated DeviceCreator::ignoreListener(); ``` To ignore only the new login notification (device is still saved, but no email is sent): ```php -use UserDevices\DeviceCreator; - -// In middleware or controller—call before the user is authenticated DeviceCreator::ignoreNotification(); ``` To control notifications globally (e.g. disable in local/staging, or custom logic per user/device): ```php -use UserDevices\DeviceCreator; - -// Disable notifications entirely DeviceCreator::shouldSendNotificationUsing(fn () => false); - -// Custom logic (e.g. skip for admins) DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => ! $user->isAdmin()); - -// Only send in production DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => app()->environment('production')); ``` -This will: - -- Create or update the device record (IP + user agent) -- Update last activity timestamp -- Send a notification email on **first login** from that device (unless `ignoreListener()` was called, or `ignoreNotification()` was called, or `shouldSendNotificationUsing()` returns false) - #### 3. Block Device Route -When a user receives the new login notification email, they can click a link to block the device. Register a route that handles this request. The route must be **signed** and named `user-devices.block`: +When a user receives the new login notification email, they can click a link to block the device. Register a route that handles this request. Blocking invalidates the device's session when using session-based auth. The route must be **signed** and named `user-devices.block`: ```php use UserDevices\Http\Requests\BlockDeviceRequest; @@ -179,16 +193,13 @@ Route::get('/devices/block/{id}/{hash}', function (BlockDeviceRequest $request) })->middleware(['signed', 'throttle:6,1'])->name('user-devices.block'); ``` -You can use any path you prefer (e.g. `/user-devices/block/{id}/{hash}`) as long as the route is named `user-devices.block` and includes the `{id}` and `{hash}` parameters. +You can use any path you prefer as long as the route is named `user-devices.block` and includes the `{id}` and `{hash}` parameters. #### 4. Using the Middleware The package includes middleware to block requests from devices the user has blocked: ```php -// routes/web.php -use Illuminate\Support\Facades\Route; - Route::middleware(['auth', 'check.device'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index']); }); @@ -204,7 +215,7 @@ use UserDevices\Models\UserDevice; // Get user's devices $devices = $user->userDevices; -// Block a device +// Block a device (invalidates session if session_id is set) $device = UserDevice::find($id); $device->block(); @@ -221,7 +232,22 @@ UserDevice::markAsUnblocked($id); #### 6. Sending Notifications Manually ```php -$user->sendNewLoginDeviceNotification($device); +$user->sendFailedLoginNotification($device); +$user->sendAttemptingLoginNotification($device); +$user->sendAuthenticatedLoginNotification($device); +``` + +#### 7. Customizing Attempting & Failed Login Notifications + +```php +use UserDevices\Notifications\AttemptingLoginNotification; +use UserDevices\Notifications\FailedLoginNotification; + +AttemptingLoginNotification::toMailUsing(fn ($notifiable, $device) => (new MailMessage) + ->subject('Login attempt')->line("IP: {$device->ip_address}")); + +FailedLoginNotification::toMailUsing(fn ($notifiable, $device) => (new MailMessage) + ->subject('Failed login')->line("IP: {$device->ip_address}")); ``` ### 🧩 API Reference @@ -233,6 +259,7 @@ $user->sendNewLoginDeviceNotification($device); DeviceCreator::useUserModel(string $model): void DeviceCreator::useUserDeviceModel(string $model): void DeviceCreator::userAgentUsing(Closure $callback): void +DeviceCreator::resolveLocationUsing(Closure $callback): void // (string $ip) => ?string DeviceCreator::shouldSendNotificationUsing(Closure $callback): void // (user, device) => bool // Request context (call before authentication) @@ -247,7 +274,7 @@ DeviceCreator::ignoreNotification(): void // Skip the new login notification for $device->user(): BelongsTo // Actions -$device->block(): void +$device->block(): void // Also invalidates session when session_id is set $device->unblock(): void // Static methods @@ -260,21 +287,28 @@ UserDevice::markAsUnblocked(mixed $id): void ```php // Methods available on model $model->userDevices(): HasMany -$model->sendNewLoginDeviceNotification(UserDevice $device): void +$model->sendFailedLoginNotification(UserDevice $device): void +$model->sendAttemptingLoginNotification(UserDevice $device): void +$model->sendAuthenticatedLoginNotification(UserDevice $device): void ``` -#### NewLoginDeviceNotification +#### AuthenticatedLoginNotification ```php -// Configuration -NewLoginDeviceNotification::toMailUsing(Closure $callback): void -NewLoginDeviceNotification::createBlockUrlUsing(Closure $callback): void +AuthenticatedLoginNotification::toMailUsing(Closure $callback): void +AuthenticatedLoginNotification::createBlockUrlUsing(Closure $callback): void +``` + +#### AttemptingLoginNotification & FailedLoginNotification + +```php +FailedLoginNotification::toMailUsing(Closure $callback): void +AttemptingLoginNotification::toMailUsing(Closure $callback): void ``` #### BlockDeviceRequest ```php -// Methods $request->fulfill(): void $request->getDevice(): ?UserDevice ``` diff --git a/src/Listeners/AttemptingLoginListener.php b/src/Listeners/AttemptingLoginListener.php index 0f6d81a..d6b4a89 100644 --- a/src/Listeners/AttemptingLoginListener.php +++ b/src/Listeners/AttemptingLoginListener.php @@ -3,6 +3,7 @@ namespace UserDevices\Listeners; use Illuminate\Auth\Events\Attempting; +use Illuminate\Support\Facades\Config; use UserDevices\Traits\HandlesAuthEvents; class AttemptingLoginListener @@ -14,6 +15,10 @@ class AttemptingLoginListener */ public function handle(Attempting $event): void { + if (! Config::get('user-devices.events.attempting', false)) { + return; + } + if ($this->shouldSkipListener()) { return; } diff --git a/src/Listeners/AuthenticatedLoginListener.php b/src/Listeners/AuthenticatedLoginListener.php index c6e2c6f..51966b2 100644 --- a/src/Listeners/AuthenticatedLoginListener.php +++ b/src/Listeners/AuthenticatedLoginListener.php @@ -3,6 +3,7 @@ namespace UserDevices\Listeners; use Illuminate\Auth\Events\Authenticated; +use Illuminate\Support\Facades\Config; use UserDevices\Traits\HandlesAuthEvents; class AuthenticatedLoginListener @@ -14,6 +15,10 @@ class AuthenticatedLoginListener */ public function handle(Authenticated $event): void { + if (! Config::get('user-devices.events.authenticated', true)) { + return; + } + if ($this->shouldSkipListener()) { return; } diff --git a/src/Listeners/FailedLoginListener.php b/src/Listeners/FailedLoginListener.php index ca62331..d8f82eb 100644 --- a/src/Listeners/FailedLoginListener.php +++ b/src/Listeners/FailedLoginListener.php @@ -3,6 +3,7 @@ namespace UserDevices\Listeners; use Illuminate\Auth\Events\Failed; +use Illuminate\Support\Facades\Config; use UserDevices\Traits\HandlesAuthEvents; class FailedLoginListener @@ -14,6 +15,10 @@ class FailedLoginListener */ public function handle(Failed $event): void { + if (! Config::get('user-devices.events.failed', true)) { + return; + } + if ($this->shouldSkipListener()) { return; } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index c4943ae..6302dd3 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -77,16 +77,10 @@ private function bootMiddleware(): void */ private function bootEventListeners(): void { - if (Config::get('user-devices.events.failed', true)) { - Event::listen(Failed::class, FailedLoginListener::class); - } + Event::listen(Failed::class, FailedLoginListener::class); - if (Config::get('user-devices.events.attempting', false)) { - Event::listen(Attempting::class, AttemptingLoginListener::class); - } - - if (Config::get('user-devices.events.authenticated', true)) { - Event::listen(Authenticated::class, AuthenticatedLoginListener::class); - } + Event::listen(Attempting::class, AttemptingLoginListener::class); + + Event::listen(Authenticated::class, AuthenticatedLoginListener::class); } } diff --git a/tests/Feature/AttemptingLoginListenerTest.php b/tests/Feature/AttemptingLoginListenerTest.php new file mode 100644 index 0000000..cb36869 --- /dev/null +++ b/tests/Feature/AttemptingLoginListenerTest.php @@ -0,0 +1,63 @@ +create(['password' => 'password']); + + expect($user->userDevices)->toHaveCount(0); + + $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.100'])->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ], ['User-Agent' => 'Mozilla/5.0 Attempt Test Browser']); + + $user->refresh(); + $device = $user->userDevices->first(); + + expect($user->userDevices)->toHaveCount(1); + expect($device->ip_address)->toBe('192.168.1.100'); + expect($device->user_agent)->toBe('Mozilla/5.0 Attempt Test Browser'); +}); + +test('it should send attempting login notification for new device', function () { + Notification::fake(); + + $user = User::factory()->create(['password' => 'password']); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ], ['User-Agent' => 'Mozilla/5.0 New Device For Attempt']); + + Notification::assertSentTo($user, AttemptingLoginNotification::class); +}); + +test('it should not send attempting login notification for existing device', function () { + Notification::fake(); + + $user = User::factory()->create(['password' => 'password']); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ], ['User-Agent' => 'Mozilla/5.0 Same Device']); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ], ['User-Agent' => 'Mozilla/5.0 Same Device']); + + Notification::assertSentToTimes($user, AttemptingLoginNotification::class, 1); +}); diff --git a/tests/Feature/ConfigEventsTest.php b/tests/Feature/ConfigEventsTest.php new file mode 100644 index 0000000..ae7a696 --- /dev/null +++ b/tests/Feature/ConfigEventsTest.php @@ -0,0 +1,18 @@ +create(); + + $this->actingAs($user)->get('/dashboard'); + + $user->refresh(); + + expect($user->userDevices)->toHaveCount(0); + + Config::set('user-devices.events.authenticated', true); +}); diff --git a/tests/Feature/FailedLoginListenerTest.php b/tests/Feature/FailedLoginListenerTest.php new file mode 100644 index 0000000..f03c9c3 --- /dev/null +++ b/tests/Feature/FailedLoginListenerTest.php @@ -0,0 +1,62 @@ +create(['password' => 'password']); + + expect($user->userDevices)->toHaveCount(0); + + $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.1'])->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ], ['User-Agent' => 'Mozilla/5.0 Failed Test Browser']); + + $user->refresh(); + $device = $user->userDevices->first(); + + expect($user->userDevices)->toHaveCount(1); + expect($device->user_agent)->toBe('Mozilla/5.0 Failed Test Browser'); +}); + +test('it should send failed login notification for new device', function () { + Notification::fake(); + + $user = User::factory()->create(['password' => 'password']); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ], ['User-Agent' => 'Mozilla/5.0 New Failed Device']); + + Notification::assertSentTo($user, FailedLoginNotification::class); +}); + +test('it should not send failed login notification for existing device', function () { + Notification::fake(); + + $user = User::factory()->create(['password' => 'password']); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong', + ], ['User-Agent' => 'Mozilla/5.0 Repeated Failed Device']); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong', + ], ['User-Agent' => 'Mozilla/5.0 Repeated Failed Device']); + + Notification::assertSentToTimes($user, FailedLoginNotification::class, 1); +}); diff --git a/tests/Feature/LocationAndSessionTest.php b/tests/Feature/LocationAndSessionTest.php new file mode 100644 index 0000000..bd90613 --- /dev/null +++ b/tests/Feature/LocationAndSessionTest.php @@ -0,0 +1,35 @@ + $ip === '127.0.0.1' ? 'Localhost, Test' : null); + + $user = User::factory()->create(); + + $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Location Test', + ]); + + $device = $user->userDevices()->first(); + + expect($device)->not->toBeNull(); + expect($device->location)->toBe('Localhost, Test'); + + DeviceCreator::$resolveLocation = null; +}); + +test('it should block device with session_id without throwing', function () { + $user = User::factory()->create(); + + $device = UserDevice::factory()->create([ + 'user_id' => $user->id, + 'session_id' => 'some-session-id', + ]); + + $device->block(); + + expect($device->fresh()->blocked)->toBeTrue(); +}); diff --git a/tests/Feature/UserDevicesTest.php b/tests/Feature/UserDevicesTest.php index a0297d3..2018087 100644 --- a/tests/Feature/UserDevicesTest.php +++ b/tests/Feature/UserDevicesTest.php @@ -39,9 +39,9 @@ DeviceCreator::ignoreNotification(); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Silent Device Browser') - ->get('/dashboard'); + $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Silent Device Browser', + ]); Notification::assertNothingSent(); }); @@ -51,9 +51,9 @@ $user = User::factory()->create(); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Brand New Device') - ->get('/dashboard'); + $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Brand New Device', + ]); Notification::assertSentTo($user, AuthenticatedLoginNotification::class); }); @@ -65,9 +65,9 @@ DeviceCreator::shouldSendNotificationUsing(fn () => false); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Yet Another New Device') - ->get('/dashboard'); + $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Yet Another New Device', + ]); Notification::assertNothingSent(); @@ -81,9 +81,9 @@ DeviceCreator::shouldSendNotificationUsing(fn () => true); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Custom Callback Device') - ->get('/dashboard'); + $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Custom Callback Device', + ]); Notification::assertSentTo($user, AuthenticatedLoginNotification::class); @@ -119,12 +119,12 @@ $device = UserDevice::factory()->create(['user_id' => $user->id]); $url = URL::temporarySignedRoute( - 'user-devices.block', - Carbon::now()->addMinutes(60), - [ + name: 'user-devices.block', + expiration: Carbon::now()->addMinutes(60), + parameters: [ 'id' => $device->id, 'hash' => 'invalid-hash', - ] + ], ); $response = $this->get($url); @@ -157,9 +157,9 @@ 'user_agent' => 'Mozilla/5.0 Test Browser', ]); - $response = $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Test Browser') - ->get('/dashboard'); + $response = $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Test Browser', + ]); $response->assertOk(); $response->assertJson(['message' => 'Access granted']); @@ -174,9 +174,9 @@ 'user_agent' => 'Mozilla/5.0 Blocked Browser', ]); - $response = $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Blocked Browser') - ->get('/dashboard'); + $response = $this->actingAs($user)->get('/dashboard', [ + 'User-Agent' => 'Mozilla/5.0 Blocked Browser', + ]); $response->assertStatus(423); }); diff --git a/tests/Unit/DeviceContextTest.php b/tests/Unit/DeviceContextTest.php new file mode 100644 index 0000000..8a1c574 --- /dev/null +++ b/tests/Unit/DeviceContextTest.php @@ -0,0 +1,17 @@ +ipAddress)->toBe('1.2.3.4'); + expect($context->userAgent)->toBe('Mozilla/5.0'); + expect($context->sessionId)->toBe('session-123'); + expect($context->location)->toBe('Paris, France'); +}); diff --git a/tests/Unit/LocationResolverTest.php b/tests/Unit/LocationResolverTest.php new file mode 100644 index 0000000..b819b3f --- /dev/null +++ b/tests/Unit/LocationResolverTest.php @@ -0,0 +1,18 @@ +toBeNull(); +}); + +test('it should return location when callback is configured', function () { + DeviceCreator::resolveLocationUsing(fn (string $ip) => "Test City, {$ip}"); + + expect(LocationResolver::resolve('8.8.8.8'))->toBe('Test City, 8.8.8.8'); + + DeviceCreator::$resolveLocation = null; +}); diff --git a/workbench/routes/web.php b/workbench/routes/web.php index dce82de..779f57d 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -1,14 +1,27 @@ only('email', 'password'), $request->boolean('remember'))) { + $request->session()->regenerate(); + + return redirect('/'); + } + + return back()->withErrors(['email' => 'Invalid credentials']); +}); + Route::middleware(['auth', 'check.device'])->group(function () { Route::get('/dashboard', function () { return response()->json(['message' => 'Access granted']); }); }); +Route::get('/', fn () => response()->json(['ok' => true])); + Route::get('/devices/block/{id}/{hash}', function (BlockDeviceRequest $request) { $request->fulfill(); From 99ecf23a854645d93a75144401aa12489c4d9de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Tue, 10 Feb 2026 11:45:18 -0300 Subject: [PATCH 3/4] wip --- src/Notifications/AttemptingLoginNotification.php | 8 ++++---- src/Notifications/AuthenticatedLoginNotification.php | 8 ++++---- src/Notifications/FailedLoginNotification.php | 8 ++++---- src/ServiceProvider.php | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Notifications/AttemptingLoginNotification.php b/src/Notifications/AttemptingLoginNotification.php index 1e899dc..2bcc3f5 100644 --- a/src/Notifications/AttemptingLoginNotification.php +++ b/src/Notifications/AttemptingLoginNotification.php @@ -63,19 +63,19 @@ protected function formatDeviceInfo(): string { $parts = []; - if ($this->device->ip_address) { + if (filled($this->device->ip_address)) { $parts[] = Lang::get('IP Address: :ip', ['ip' => $this->device->ip_address]); } - if ($this->device->user_agent) { + if (filled($this->device->user_agent)) { $parts[] = Lang::get('Device: :device', ['device' => $this->device->user_agent]); } - if ($this->device->location) { + if (filled($this->device->location)) { $parts[] = Lang::get('Location: :location', ['location' => $this->device->location]); } - return ! empty($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); + return filled($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); } /** diff --git a/src/Notifications/AuthenticatedLoginNotification.php b/src/Notifications/AuthenticatedLoginNotification.php index ca96aed..d1ce1d3 100644 --- a/src/Notifications/AuthenticatedLoginNotification.php +++ b/src/Notifications/AuthenticatedLoginNotification.php @@ -94,19 +94,19 @@ protected function formatDeviceInfo(): string { $parts = []; - if ($this->device->ip_address) { + if (filled($this->device->ip_address)) { $parts[] = Lang::get('IP Address: :ip', ['ip' => $this->device->ip_address]); } - if ($this->device->user_agent) { + if (filled($this->device->user_agent)) { $parts[] = Lang::get('Device: :device', ['device' => $this->device->user_agent]); } - if ($this->device->location) { + if (filled($this->device->location)) { $parts[] = Lang::get('Location: :location', ['location' => $this->device->location]); } - return ! empty($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); + return filled($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); } /** diff --git a/src/Notifications/FailedLoginNotification.php b/src/Notifications/FailedLoginNotification.php index 909f4ba..948f13d 100644 --- a/src/Notifications/FailedLoginNotification.php +++ b/src/Notifications/FailedLoginNotification.php @@ -63,19 +63,19 @@ protected function formatDeviceInfo(): string { $parts = []; - if ($this->device->ip_address) { + if (filled($this->device->ip_address)) { $parts[] = Lang::get('IP Address: :ip', ['ip' => $this->device->ip_address]); } - if ($this->device->user_agent) { + if (filled($this->device->user_agent)) { $parts[] = Lang::get('Device: :device', ['device' => $this->device->user_agent]); } - if ($this->device->location) { + if (filled($this->device->location)) { $parts[] = Lang::get('Location: :location', ['location' => $this->device->location]); } - return ! empty($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); + return filled($parts) ? implode(' | ', $parts) : Lang::get('Unknown device'); } /** diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6302dd3..7332a8e 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -80,7 +80,7 @@ private function bootEventListeners(): void Event::listen(Failed::class, FailedLoginListener::class); Event::listen(Attempting::class, AttemptingLoginListener::class); - + Event::listen(Authenticated::class, AuthenticatedLoginListener::class); } } From 0d1a68ce61bfe7bb04254691ac885240e853504a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Lopes?= Date: Tue, 10 Feb 2026 12:06:46 -0300 Subject: [PATCH 4/4] feat(devices): some adjusts --- README.md | 49 ++++++++++++++++--- src/Middleware/CheckCurrentDevice.php | 4 +- src/Models/UserDevice.php | 8 ++- .../AttemptingLoginNotification.php | 39 +++++++++++++++ src/Notifications/FailedLoginNotification.php | 39 +++++++++++++++ src/Traits/HasUserDevices.php | 16 ++++++ tests/Feature/UserDevicesTest.php | 43 ++++++++++++++-- tests/Unit/CheckCurrentDeviceTest.php | 6 ++- workbench/routes/web.php | 27 ++++++---- 9 files changed, 206 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 170f301..f8a99d0 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ This package provides support for managing user devices in Laravel. Track login - ✅ **Email notifications**: Sends alerts when a new device logs in, on login attempts, and on failed logins - ✅ **Configurable events**: Enable or disable listeners per auth event (authenticated, attempting, failed) - ✅ **Location from IP**: Optional geolocation via callback -- ✅ **Block device**: Signed links to block suspicious devices (invalidates session when blocked) +- ✅ **Block device**: Signed links to block suspicious devices in all notification types (invalidates session when blocked) - ✅ **Integrated middleware**: Protect routes from blocked devices +- ✅ **Block login check**: Prevent blocked devices from attempting login via `isCurrentDeviceBlocked()` - ✅ **Model trait**: Simple Eloquent integration - ✅ **Flexible configuration**: Custom models and callbacks @@ -193,9 +194,35 @@ Route::get('/devices/block/{id}/{hash}', function (BlockDeviceRequest $request) })->middleware(['signed', 'throttle:6,1'])->name('user-devices.block'); ``` -You can use any path you prefer as long as the route is named `user-devices.block` and includes the `{id}` and `{hash}` parameters. +You can use any path you prefer as long as the route is named `user-devices.block` and includes the `{id}` and `{hash}` parameters. All three notification types (Authenticated, Attempting, Failed) include a block link in the email. -#### 4. Using the Middleware +#### 4. Check Blocked Device Before Login + +To prevent blocked devices from attempting login, call `isCurrentDeviceBlocked()` after resolving the user (e.g. by email) and before validating the password. In a custom login controller or FormRequest: + +```php +// In your login logic, after resolving the user from credentials (e.g. email) +$user = User::where('email', $request->email)->first(); + +if ($user && $user->isCurrentDeviceBlocked()) { + return response()->json(['message' => 'This device has been blocked.'], 423); +} + +// Proceed with login attempt... +``` + +Or in a FormRequest's `authorize` or custom validation: + +```php +public function authorize(): bool +{ + $user = User::where('email', $this->email)->first(); + + return ! ($user && $user->isCurrentDeviceBlocked()); +} +``` + +#### 5. Using the Middleware The package includes middleware to block requests from devices the user has blocked: @@ -207,7 +234,7 @@ Route::middleware(['auth', 'check.device'])->group(function () { When a blocked device tries to access a protected route, the middleware returns `423 Locked`. -#### 5. Working with the UserDevice Model +#### 6. Working with the UserDevice Model ```php use UserDevices\Models\UserDevice; @@ -229,7 +256,7 @@ UserDevice::markAsBlocked($id); UserDevice::markAsUnblocked($id); ``` -#### 6. Sending Notifications Manually +#### 7. Sending Notifications Manually ```php $user->sendFailedLoginNotification($device); @@ -237,7 +264,7 @@ $user->sendAttemptingLoginNotification($device); $user->sendAuthenticatedLoginNotification($device); ``` -#### 7. Customizing Attempting & Failed Login Notifications +#### 8. Customizing Attempting & Failed Login Notifications ```php use UserDevices\Notifications\AttemptingLoginNotification; @@ -246,8 +273,12 @@ use UserDevices\Notifications\FailedLoginNotification; AttemptingLoginNotification::toMailUsing(fn ($notifiable, $device) => (new MailMessage) ->subject('Login attempt')->line("IP: {$device->ip_address}")); +AttemptingLoginNotification::createBlockUrlUsing(fn ($device) => URL::temporarySignedRoute(/* ... */)); + FailedLoginNotification::toMailUsing(fn ($notifiable, $device) => (new MailMessage) ->subject('Failed login')->line("IP: {$device->ip_address}")); + +FailedLoginNotification::createBlockUrlUsing(fn ($device) => URL::temporarySignedRoute(/* ... */)); ``` ### 🧩 API Reference @@ -287,6 +318,7 @@ UserDevice::markAsUnblocked(mixed $id): void ```php // Methods available on model $model->userDevices(): HasMany +$model->isCurrentDeviceBlocked(): bool // Check if current request's device is blocked (use before login) $model->sendFailedLoginNotification(UserDevice $device): void $model->sendAttemptingLoginNotification(UserDevice $device): void $model->sendAuthenticatedLoginNotification(UserDevice $device): void @@ -302,8 +334,11 @@ AuthenticatedLoginNotification::createBlockUrlUsing(Closure $callback): void #### AttemptingLoginNotification & FailedLoginNotification ```php -FailedLoginNotification::toMailUsing(Closure $callback): void AttemptingLoginNotification::toMailUsing(Closure $callback): void +AttemptingLoginNotification::createBlockUrlUsing(Closure $callback): void + +FailedLoginNotification::toMailUsing(Closure $callback): void +FailedLoginNotification::createBlockUrlUsing(Closure $callback): void ``` #### BlockDeviceRequest diff --git a/src/Middleware/CheckCurrentDevice.php b/src/Middleware/CheckCurrentDevice.php index 6b9efd4..2bc2845 100644 --- a/src/Middleware/CheckCurrentDevice.php +++ b/src/Middleware/CheckCurrentDevice.php @@ -38,8 +38,10 @@ private function checkUserAgent(Request $request): bool { $user = $request->user(); + $ipAddress = $request->ip(); + $userAgent = $this->getUserAgent($request); - return $user->userDevices()->isBlocked($userAgent); + return $user->userDevices()->isBlocked($ipAddress, $userAgent); } } diff --git a/src/Models/UserDevice.php b/src/Models/UserDevice.php index 6103229..a0c9d2f 100644 --- a/src/Models/UserDevice.php +++ b/src/Models/UserDevice.php @@ -75,8 +75,12 @@ public static function markAsUnblocked(mixed $id): void * Scope a query to check if the device is blocked. */ #[Scope] - protected function isBlocked(Builder $query, mixed $userAgent): bool + protected function isBlocked(Builder $query, mixed $ipAddress = null, mixed $userAgent = null): bool { - return $query->where('user_agent', $userAgent)->where('blocked', true)->exists(); + return $query->when(filled($ipAddress), function (Builder $query) use ($ipAddress) { + $query->where('ip_address', $ipAddress); + })->when(filled($userAgent), function (Builder $query) use ($userAgent) { + $query->where('user_agent', $userAgent); + })->where('blocked', true)->exists(); } } diff --git a/src/Notifications/AttemptingLoginNotification.php b/src/Notifications/AttemptingLoginNotification.php index 2bcc3f5..de399fc 100644 --- a/src/Notifications/AttemptingLoginNotification.php +++ b/src/Notifications/AttemptingLoginNotification.php @@ -5,7 +5,10 @@ use Closure; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Lang; +use Illuminate\Support\Facades\URL; use UserDevices\Models\UserDevice; class AttemptingLoginNotification extends Notification @@ -15,6 +18,11 @@ class AttemptingLoginNotification extends Notification */ public static ?Closure $toMailCallback = null; + /** + * The callback that should be used to create the block device URL. + */ + public static ?Closure $createBlockUrlCallback = null; + /** * Create a notification instance. */ @@ -47,15 +55,38 @@ public function toMail(mixed $notifiable): MailMessage */ protected function buildMailMessage(): MailMessage { + $blockUrl = $this->blockDeviceUrl(); $deviceInfo = $this->formatDeviceInfo(); return (new MailMessage) ->subject(Lang::get('Login Attempt to Your Account')) ->line(Lang::get('Someone attempted to log in to your account.')) ->line(Lang::get('Device details: :details', ['details' => $deviceInfo])) + ->action(Lang::get('Block this device'), $blockUrl) ->line(Lang::get('If this was you, you can safely ignore this email. If you did not attempt this login, we recommend changing your password immediately.')); } + /** + * Get the block device URL for the given device. + */ + protected function blockDeviceUrl(): string + { + if (static::$createBlockUrlCallback) { + return call_user_func(static::$createBlockUrlCallback, $this->device); + } + + $expire = Config::get('auth.verification.expire', 60); + + return URL::temporarySignedRoute( + name: 'user-devices.block', + expiration: Carbon::now()->addMinutes($expire), + parameters: [ + 'id' => $this->device->getKey(), + 'hash' => sha1($this->device->getKey()), + ], + ); + } + /** * Format the device information for display. */ @@ -85,4 +116,12 @@ public static function toMailUsing(Closure $callback): void { static::$toMailCallback = $callback; } + + /** + * Set a callback that should be used when creating the block device URL. + */ + public static function createBlockUrlUsing(Closure $callback): void + { + static::$createBlockUrlCallback = $callback; + } } diff --git a/src/Notifications/FailedLoginNotification.php b/src/Notifications/FailedLoginNotification.php index 948f13d..1393801 100644 --- a/src/Notifications/FailedLoginNotification.php +++ b/src/Notifications/FailedLoginNotification.php @@ -5,7 +5,10 @@ use Closure; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Lang; +use Illuminate\Support\Facades\URL; use UserDevices\Models\UserDevice; class FailedLoginNotification extends Notification @@ -15,6 +18,11 @@ class FailedLoginNotification extends Notification */ public static ?Closure $toMailCallback = null; + /** + * The callback that should be used to create the block device URL. + */ + public static ?Closure $createBlockUrlCallback = null; + /** * Create a notification instance. */ @@ -47,15 +55,38 @@ public function toMail(mixed $notifiable): MailMessage */ protected function buildMailMessage(): MailMessage { + $blockUrl = $this->blockDeviceUrl(); $deviceInfo = $this->formatDeviceInfo(); return (new MailMessage) ->subject(Lang::get('Failed Login Attempt to Your Account')) ->line(Lang::get('A failed login attempt was detected for your account.')) ->line(Lang::get('Device details: :details', ['details' => $deviceInfo])) + ->action(Lang::get('Block this device'), $blockUrl) ->line(Lang::get('If this was you, you may have entered the wrong password. If you did not attempt this login, we recommend changing your password immediately.')); } + /** + * Get the block device URL for the given device. + */ + protected function blockDeviceUrl(): string + { + if (static::$createBlockUrlCallback) { + return call_user_func(static::$createBlockUrlCallback, $this->device); + } + + $expire = Config::get('auth.verification.expire', 60); + + return URL::temporarySignedRoute( + name: 'user-devices.block', + expiration: Carbon::now()->addMinutes($expire), + parameters: [ + 'id' => $this->device->getKey(), + 'hash' => sha1($this->device->getKey()), + ], + ); + } + /** * Format the device information for display. */ @@ -85,4 +116,12 @@ public static function toMailUsing(Closure $callback): void { static::$toMailCallback = $callback; } + + /** + * Set a callback that should be used when creating the block device URL. + */ + public static function createBlockUrlUsing(Closure $callback): void + { + static::$createBlockUrlCallback = $callback; + } } diff --git a/src/Traits/HasUserDevices.php b/src/Traits/HasUserDevices.php index fc77a10..873f25a 100644 --- a/src/Traits/HasUserDevices.php +++ b/src/Traits/HasUserDevices.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use UserDevices\DeviceCreator; +use UserDevices\DTO\DeviceContext; use UserDevices\Models\UserDevice; use UserDevices\Notifications\AttemptingLoginNotification; use UserDevices\Notifications\AuthenticatedLoginNotification; @@ -44,4 +45,19 @@ public function sendAuthenticatedLoginNotification(UserDevice $device): void { $this->notify(new AuthenticatedLoginNotification($device)); } + + /** + * Check if the current request's device (IP + user agent) is blocked for this user. + * Use in login controller or FormRequest to prevent blocked devices from attempting login. + */ + public function isCurrentDeviceBlocked(): bool + { + $context = DeviceContext::fromRequest(); + + if (blank($context->ipAddress) && blank($context->userAgent)) { + return false; + } + + return $this->userDevices()->isBlocked($context->ipAddress, $context->userAgent); + } } diff --git a/tests/Feature/UserDevicesTest.php b/tests/Feature/UserDevicesTest.php index 2018087..c8779b5 100644 --- a/tests/Feature/UserDevicesTest.php +++ b/tests/Feature/UserDevicesTest.php @@ -171,12 +171,49 @@ UserDevice::factory()->create([ 'blocked' => true, 'user_id' => $user->id, + 'ip_address' => '127.0.0.1', 'user_agent' => 'Mozilla/5.0 Blocked Browser', ]); - $response = $this->actingAs($user)->get('/dashboard', [ - 'User-Agent' => 'Mozilla/5.0 Blocked Browser', - ]); + $response = $this->actingAs($user) + ->withServerVariables(['REMOTE_ADDR' => '127.0.0.1']) + ->get('/dashboard', ['User-Agent' => 'Mozilla/5.0 Blocked Browser']); $response->assertStatus(423); }); + +test('it should return true from isCurrentDeviceBlocked when device is blocked', function () { + $user = User::factory()->create(); + + UserDevice::factory()->create([ + 'blocked' => true, + 'user_id' => $user->id, + 'ip_address' => '192.168.50.1', + 'user_agent' => 'Mozilla/5.0 IsBlocked Test Browser', + ]); + + $response = $this->withServerVariables(['REMOTE_ADDR' => '192.168.50.1'])->get('/test/current-device-blocked?email='.urlencode($user->email), [ + 'User-Agent' => 'Mozilla/5.0 IsBlocked Test Browser', + ]); + + $response->assertOk(); + expect($response->json('blocked'))->toBeTrue(); +}); + +test('it should return false from isCurrentDeviceBlocked when device is not blocked', function () { + $user = User::factory()->create(); + + UserDevice::factory()->create([ + 'blocked' => false, + 'user_id' => $user->id, + 'ip_address' => '192.168.50.2', + 'user_agent' => 'Mozilla/5.0 NotBlocked Test Browser', + ]); + + $response = $this->withServerVariables(['REMOTE_ADDR' => '192.168.50.2'])->get('/test/current-device-blocked?email='.urlencode($user->email), [ + 'User-Agent' => 'Mozilla/5.0 NotBlocked Test Browser', + ]); + + $response->assertOk(); + expect($response->json('blocked'))->toBeFalse(); +}); diff --git a/tests/Unit/CheckCurrentDeviceTest.php b/tests/Unit/CheckCurrentDeviceTest.php index 1026056..dc4ea16 100644 --- a/tests/Unit/CheckCurrentDeviceTest.php +++ b/tests/Unit/CheckCurrentDeviceTest.php @@ -19,10 +19,11 @@ UserDevice::factory()->create([ 'blocked' => false, 'user_id' => $user->id, + 'ip_address' => '192.168.1.100', 'user_agent' => 'Mozilla/5.0 Test', ]); - $request = Request::create('/dashboard', 'GET'); + $request = Request::create('/dashboard', 'GET', [], [], [], ['REMOTE_ADDR' => '192.168.1.100']); $request->headers->set('User-Agent', 'Mozilla/5.0 Test'); $request->setUserResolver(fn () => $user); @@ -38,10 +39,11 @@ UserDevice::factory()->create([ 'blocked' => true, 'user_id' => $user->id, + 'ip_address' => '192.168.1.100', 'user_agent' => 'Mozilla/5.0 Blocked', ]); - $request = Request::create('/dashboard', 'GET'); + $request = Request::create('/dashboard', 'GET', [], [], [], ['REMOTE_ADDR' => '192.168.1.100']); $request->headers->set('User-Agent', 'Mozilla/5.0 Blocked'); $request->setUserResolver(fn () => $user); diff --git a/workbench/routes/web.php b/workbench/routes/web.php index 779f57d..57b46bf 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -3,16 +3,9 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; use UserDevices\Http\Requests\BlockDeviceRequest; +use Workbench\App\Models\User; -Route::post('/login', function (\Illuminate\Http\Request $request) { - if (Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) { - $request->session()->regenerate(); - - return redirect('/'); - } - - return back()->withErrors(['email' => 'Invalid credentials']); -}); +Route::get('/', fn () => response()->json(['ok' => true])); Route::middleware(['auth', 'check.device'])->group(function () { Route::get('/dashboard', function () { @@ -20,10 +13,24 @@ }); }); -Route::get('/', fn () => response()->json(['ok' => true])); +Route::get('/test/current-device-blocked', function () { + $user = User::where('email', request('email'))->first(); + + return response()->json(['blocked' => $user?->isCurrentDeviceBlocked() ?? false]); +}); Route::get('/devices/block/{id}/{hash}', function (BlockDeviceRequest $request) { $request->fulfill(); return redirect('/')->with('message', 'Device blocked successfully.'); })->middleware(['signed', 'throttle:6,1'])->name('user-devices.block'); + +Route::post('/login', function (\Illuminate\Http\Request $request) { + if (Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) { + $request->session()->regenerate(); + + return redirect('/'); + } + + return back()->withErrors(['email' => 'Invalid credentials']); +});