Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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

Expand Down
24 changes: 9 additions & 15 deletions src/Listeners/SaveUserDevice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}
}

/**
Expand Down
41 changes: 41 additions & 0 deletions src/Listeners/UpdateDeviceLastActivity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace UserDevices\Listeners;

use Illuminate\Auth\Events\Authenticated;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Request;
use UserDevices\DeviceCreator;
use UserDevices\Traits\HasUserDevices;

class UpdateDeviceLastActivity
{
/**
* Handle the event. Only updates last_activity for the current device; does not create devices or send notifications.
*/
public function handle(Authenticated $event): void
{
/** @var mixed $user */
$user = $event->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]);
}
}
}
6 changes: 5 additions & 1 deletion src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down
48 changes: 33 additions & 15 deletions tests/Feature/UserDevicesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -26,7 +27,7 @@

DeviceCreator::ignoreListener();

$this->actingAs($user)->get('/dashboard');
$this->login($user)->get('/dashboard');

$user->refresh();
expect($user->userDevices)->toHaveCount(0);
Expand All @@ -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();
});
Expand All @@ -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);
});
Expand All @@ -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();

Expand All @@ -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();

Expand Down
14 changes: 14 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
}