feat(users): track last login and last active timestamps (#516)

## What

Adds two timestamp columns to `users`:

- **`last_logged_in_at`** — set by an `UpdateLastLoggedInAt` listener on
Laravel's `Login` event. Records each explicit authentication (login
form or remember-me re-auth). Does **not** update on plain session-based
requests.
- **`last_active_at`** — set by a `TrackLastActiveAt` middleware on
authenticated web requests. Records the last moment the user did
anything. Throttled to at most one write per 5 minutes to avoid a DB
write on every request.

## Why

`last_logged_in_at` answers "when did they last authenticate";
`last_active_at` answers "when were they last using the app" (any
screen/activity), which a session-based login does not capture.

## Tests

- Login records `last_logged_in_at`
- Authenticated request records `last_active_at`
- Throttle window respected (no rewrite within 5 min) and refreshed once
it passes

Migrations not yet run on environments.
This commit is contained in:
Víctor Falcón 2026-06-10 11:01:30 +02:00 committed by GitHub
parent 21f8f3b277
commit fcf2d3d1ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 171 additions and 0 deletions

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TrackLastActiveAt
{
/**
* Only write once per this many seconds to avoid a database write on every request.
*/
private const THROTTLE_SECONDS = 300;
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$user = $request->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;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Listeners;
use App\Models\User;
use Illuminate\Auth\Events\Login;
class UpdateLastLoggedInAt
{
public function handle(Login $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$user->forceFill([
'last_logged_in_at' => $user->freshTimestamp(),
])->saveQuietly();
}
}

View File

@ -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<UserFactory> */
@ -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',
];
}

View File

@ -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);

View File

@ -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',
]);

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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.');

View File

@ -0,0 +1,29 @@
<?php
use App\Models\User;
test('an authenticated request records the last active date', function () {
$user = User::factory()->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);
});