diff --git a/app/Http/Middleware/TrackLastActiveAt.php b/app/Http/Middleware/TrackLastActiveAt.php new file mode 100644 index 00000000..be5298cf --- /dev/null +++ b/app/Http/Middleware/TrackLastActiveAt.php @@ -0,0 +1,40 @@ +user(); + + if ($user instanceof User) { + $lastActiveAt = $user->last_active_at; + + if ($lastActiveAt === null + || $lastActiveAt->lte(now()->subSeconds(self::THROTTLE_SECONDS))) { + $user->last_active_at = now(); + $user->saveQuietly(); + } + } + + return $response; + } +} diff --git a/app/Listeners/UpdateLastLoggedInAt.php b/app/Listeners/UpdateLastLoggedInAt.php new file mode 100644 index 00000000..47084ef5 --- /dev/null +++ b/app/Listeners/UpdateLastLoggedInAt.php @@ -0,0 +1,22 @@ +user; + + if (! $user instanceof User) { + return; + } + + $user->forceFill([ + 'last_logged_in_at' => $user->freshTimestamp(), + ])->saveQuietly(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 43faf88b..5ccb5784 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\DripEmailType; use App\Notifications\VerifyEmailNotification; +use Carbon\Carbon; use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; @@ -22,6 +23,10 @@ use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Pennant\Concerns\HasFeatures; +/** + * @property ?Carbon $last_logged_in_at + * @property ?Carbon $last_active_at + */ class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail { /** @use HasFactory */ @@ -69,6 +74,8 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'two_factor_confirmed_at' => 'datetime', 'onboarded_at' => 'datetime', 'paywall_seen_at' => 'datetime', + 'last_logged_in_at' => 'datetime', + 'last_active_at' => 'datetime', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e50792b1..2bc1b8c1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,8 +11,10 @@ use App\Listeners\ApplyAutomationRules; use App\Listeners\AssignTransactionToBudget; use App\Listeners\PostStripeEventToDiscord; use App\Listeners\UnassignTransactionFromBudget; +use App\Listeners\UpdateLastLoggedInAt; use App\Services\Banking\EnableBankingProvider; use App\Services\Discord\DiscordWebhook; +use Illuminate\Auth\Events\Login; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; @@ -54,6 +56,7 @@ class AppServiceProvider extends ServiceProvider Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class); Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class); Event::listen(WebhookReceived::class, PostStripeEventToDiscord::class); + Event::listen(Login::class, UpdateLastLoggedInAt::class); RateLimiter::for('emails', function (object $job): Limit { return Limit::perSecond(30); diff --git a/bootstrap/app.php b/bootstrap/app.php index 402cb719..c04f75df 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\SetLocale; use App\Http\Middleware\SetSentryUser; +use App\Http\Middleware\TrackLastActiveAt; use App\Jobs\SyncBankingConnectionJob; use App\Services\AuthEntryPointService; use Illuminate\Foundation\Application; @@ -43,6 +44,7 @@ return Application::configure(basePath: dirname(__DIR__)) SetSentryUser::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, + TrackLastActiveAt::class, BlockDemoAccountActions::class.':auto', ]); diff --git a/database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php b/database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php new file mode 100644 index 00000000..20bff3f1 --- /dev/null +++ b/database/migrations/2026_06_10_083644_add_last_logged_in_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('last_logged_in_at')->nullable()->after('onboarded_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_logged_in_at'); + }); + } +}; diff --git a/database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php b/database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php new file mode 100644 index 00000000..b444167e --- /dev/null +++ b/database/migrations/2026_06_10_084055_add_last_active_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('last_active_at')->nullable()->after('last_logged_in_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_active_at'); + }); + } +}; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 077259b3..6d760e74 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -41,6 +41,18 @@ test('users can authenticate using the login screen', function () { ->assertCookie('whisper_money_returning_user'); }); +test('logging in records the last logged in date', function () { + $user = User::factory()->withoutTwoFactor()->create(['last_logged_in_at' => null]); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + expect($user->fresh()->last_logged_in_at)->not->toBeNull(); +}); + test('users with two factor enabled are redirected to two factor challenge', function () { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); diff --git a/tests/Feature/TrackLastActiveAtTest.php b/tests/Feature/TrackLastActiveAtTest.php new file mode 100644 index 00000000..9dd5d5f0 --- /dev/null +++ b/tests/Feature/TrackLastActiveAtTest.php @@ -0,0 +1,29 @@ +onboarded()->create(['last_active_at' => null]); + + $this->actingAs($user)->get(route('dashboard'))->assertOk(); + + expect($user->fresh()->last_active_at)->not->toBeNull(); +}); + +test('the last active date is not updated again within the throttle window', function () { + $recent = now()->subMinute(); + $user = User::factory()->onboarded()->create(['last_active_at' => $recent]); + + $this->actingAs($user)->get(route('dashboard'))->assertOk(); + + expect($user->fresh()->last_active_at->timestamp)->toBe($recent->timestamp); +}); + +test('the last active date is refreshed once the throttle window passes', function () { + $stale = now()->subHour(); + $user = User::factory()->onboarded()->create(['last_active_at' => $stale]); + + $this->actingAs($user)->get(route('dashboard'))->assertOk(); + + expect($user->fresh()->last_active_at->timestamp)->toBeGreaterThan($stale->timestamp); +});