diff --git a/README.md b/README.md index a1cc357..bc4a2e7 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,12 @@ 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 uses two events so that new devices are tracked on **login** (e.g. avoiding impersonation) while **last activity** is updated on every authenticated request: + +- **Login**: Creates or updates the device (IP + user agent), sets `last_activity`, and sends the "new device" email when it's the first time from that device. +- **Authenticated**: Only updates `last_activity` for the current device (no new records, no notifications). Keeps "last seen" accurate across requests. + +No manual setup required—just add the `HasUserDevices` trait to your User model. To skip saving the device entirely for a request (e.g. in middleware or controller before authentication): @@ -159,11 +164,10 @@ DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => ! $user->isAdm DeviceCreator::shouldSendNotificationUsing(fn ($user, $device) => app()->environment('production')); ``` -This will: +Result: -- 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) +- **On login**: The device record is created or updated (IP + user agent), `last_activity` is set, and a notification email is sent on **first login** from that device (unless `ignoreListener()` was called, or `ignoreNotification()` was called, or `shouldSendNotificationUsing()` returns false). +- **On each authenticated request**: The existing device's `last_activity` is updated (so "last seen" stays current). Call `DeviceCreator::ignoreListener()` before impersonation (e.g. `onceUsingId`) to skip this for that request. #### 3. Block Device Route diff --git a/src/Listeners/SaveUserDevice.php b/src/Listeners/SaveUserDevice.php index 516f653..701e567 100644 --- a/src/Listeners/SaveUserDevice.php +++ b/src/Listeners/SaveUserDevice.php @@ -2,7 +2,7 @@ namespace UserDevices\Listeners; -use Illuminate\Auth\Events\Authenticated; +use Illuminate\Auth\Events\Login; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Request; @@ -13,9 +13,9 @@ class SaveUserDevice { /** - * Handle the event. + * Handle the event. Creates or updates the device on login and sends the new device notification when applicable. */ - public function handle(Authenticated $event): void + public function handle(Login $event): void { /** @var mixed $user */ $user = $event->user; @@ -28,22 +28,16 @@ public function handle(Authenticated $event): void return; } - $device = $user->userDevices()->firstOrNew([ + $device = $user->userDevices()->firstOrCreate([ 'ip_address' => Request::ip(), 'user_agent' => with(Request::userAgent(), DeviceCreator::$userAgent), - ]); + ], ['last_activity' => Carbon::now()->timestamp]); - tap($device->exists, function ($exists) use ($user, $device) { - $device->fill([ - 'last_activity' => Carbon::now()->timestamp, - ])->save(); + $shouldSend = $this->shouldSendNotification($user, $device); - $shouldSend = $this->shouldSendNotification($user, $device); - - if (! $exists && $shouldSend) { - $user->sendNewLoginDeviceNotification($device); - } - }); + if ($shouldSend && $device->wasRecentlyCreated) { + $user->sendNewLoginDeviceNotification($device); + } } /** diff --git a/src/Listeners/UpdateDeviceLastActivity.php b/src/Listeners/UpdateDeviceLastActivity.php new file mode 100644 index 0000000..79d9eb9 --- /dev/null +++ b/src/Listeners/UpdateDeviceLastActivity.php @@ -0,0 +1,41 @@ +user; + + if (Context::get('user_devices.ignore_listener', false)) { + return; + } + + if (! in_array(HasUserDevices::class, class_uses_recursive($user))) { + return; + } + + $userAgent = with(Request::userAgent(), DeviceCreator::$userAgent); + + $device = $user->userDevices() + ->where('ip_address', Request::ip()) + ->where('user_agent', $userAgent) + ->first(); + + if (filled($device)) { + $device->update(['last_activity' => Carbon::now()->timestamp]); + } + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index a018114..fe83ccf 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -3,10 +3,12 @@ namespace UserDevices; use Illuminate\Auth\Events\Authenticated; +use Illuminate\Auth\Events\Login; use Illuminate\Routing\Router; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; use UserDevices\Listeners\SaveUserDevice; +use UserDevices\Listeners\UpdateDeviceLastActivity; use UserDevices\Middleware\CheckCurrentDevice; class ServiceProvider extends LaravelServiceProvider @@ -28,7 +30,9 @@ public function boot(): void */ private function bootEventListeners(): void { - Event::listen(Authenticated::class, SaveUserDevice::class); + Event::listen(Login::class, SaveUserDevice::class); + + Event::listen(Authenticated::class, UpdateDeviceLastActivity::class); } /** diff --git a/tests/Feature/UserDevicesTest.php b/tests/Feature/UserDevicesTest.php index 4d12b96..78946e5 100644 --- a/tests/Feature/UserDevicesTest.php +++ b/tests/Feature/UserDevicesTest.php @@ -2,18 +2,19 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\URL; use UserDevices\DeviceCreator; use UserDevices\Notifications\NewLoginDeviceNotification; use Workbench\App\Models\User; use Workbench\App\Models\UserDevice; -test('it should save user device when authenticated', function () { +test('it should save user device on login', function () { $user = User::factory()->create(); expect($user->userDevices)->toHaveCount(0); - $this->actingAs($user)->get('/dashboard'); + $this->login($user)->get('/dashboard'); $user->refresh(); expect($user->userDevices)->toHaveCount(1); @@ -26,7 +27,7 @@ DeviceCreator::ignoreListener(); - $this->actingAs($user)->get('/dashboard'); + $this->login($user)->get('/dashboard'); $user->refresh(); expect($user->userDevices)->toHaveCount(0); @@ -39,9 +40,7 @@ DeviceCreator::ignoreNotification(); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Silent Device Browser') - ->get('/dashboard'); + $this->login($user)->get('/dashboard'); Notification::assertNothingSent(); }); @@ -51,9 +50,7 @@ $user = User::factory()->create(); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Brand New Device') - ->get('/dashboard'); + $this->login($user)->get('/dashboard'); Notification::assertSentTo($user, NewLoginDeviceNotification::class); }); @@ -65,9 +62,7 @@ DeviceCreator::shouldSendNotificationUsing(fn () => false); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Yet Another New Device') - ->get('/dashboard'); + $this->login($user)->get('/dashboard'); Notification::assertNothingSent(); @@ -81,15 +76,38 @@ DeviceCreator::shouldSendNotificationUsing(fn () => true); - $this->actingAs($user) - ->withHeader('User-Agent', 'Mozilla/5.0 Custom Callback Device') - ->get('/dashboard'); + $this->login($user)->get('/dashboard'); Notification::assertSentTo($user, NewLoginDeviceNotification::class); DeviceCreator::$shouldSendNotification = null; }); +test('it should update last_activity on authenticated requests', function () { + $user = User::factory()->create(); + $userAgent = 'Mozilla/5.0 Activity Browser'; + + $request = Request::instance(); + $request->headers->set('User-Agent', $userAgent); + + $this->login($user); + + $device = $user->userDevices()->first(); + $oldActivity = $device->last_activity; + + Carbon::setTestNow(Carbon::now()->addSeconds(10)); + + $this->actingAs($user) + ->withHeader('User-Agent', $userAgent) + ->get('/dashboard'); + + $newActivity = $device->fresh()->last_activity; + + expect($newActivity)->toBeGreaterThan($oldActivity); + + Carbon::setTestNow(); +}); + test('it should block device when accessing signed URL', function () { $user = User::factory()->create(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 2f74350..995e24a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,11 @@ namespace Tests; +use Illuminate\Auth\Events\Login; use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as OrchestraTestCase; +use Workbench\App\Models\User; use Workbench\Database\Seeders\DatabaseSeeder; abstract class TestCase extends OrchestraTestCase @@ -16,4 +18,16 @@ abstract class TestCase extends OrchestraTestCase * Run a specific seeder before each test. */ protected $seeder = DatabaseSeeder::class; + + /** + * Login a user. + */ + public function login(User $user): self + { + $this->actingAs($user); + + event(new Login('web', $user, false)); + + return $this; + } }