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:
parent
21f8f3b277
commit
fcf2d3d1ad
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue