From d1fb4644d6963276a1ec1b88c89686a255f5087f Mon Sep 17 00:00:00 2001 From: JSn1nj4 Date: Sun, 4 Aug 2024 23:20:01 -0400 Subject: [PATCH] #191: Reimplement Login Activity logging (#213) * Start replacing old Logins log * Remove a couple of old panel widgets * Register login activity and jobs * Update model helpers * Create/register Last Login widget * Update weekly report email * Add job tests * Create login activity job, factory, and seeding * Test pruning job * Add login activity to dashboard * Update CHANGELOG --- CHANGELOG.md | 5 ++ app/Actions/LogUserLogin.php | 14 ---- app/Filament/Pages/Login.php | 50 +++++++++--- .../Resources/LoginActivityResource.php | 76 +++++++++++++++++++ .../Pages/ListLoginActivities.php | 16 ++++ app/Filament/Widgets/LastLoginWidget.php | 25 ++++++ app/Jobs/LoginFailed.php | 33 ++++++++ app/Jobs/LoginSucceeded.php | 31 ++++++++ app/Jobs/PruneLoginActivityJob.php | 26 +++++++ app/Mail/WeeklyReportEmail.php | 26 +++++-- app/Models/CommandEvent.php | 3 + app/Models/Login.php | 47 ------------ app/Models/LoginActivity.php | 60 +++++++++++++++ app/Models/User.php | 15 ---- app/Providers/AppServiceProvider.php | 2 - app/Providers/Filament/AdminPanelProvider.php | 65 ++++++++-------- config/auth.php | 4 + database/factories/LoginActivityFactory.php | 26 +++++++ .../2021_05_25_174853_create_logins_table.php | 32 -------- ..._26_123523_create_login_activity_table.php | 30 ++++++++ database/seeders/DemoSeeder.php | 16 ++-- database/seeders/LoginActivitySeeder.php | 20 +++++ .../views/emails/reports/weekly.blade.php | 3 + .../widgets/last-login-widget.blade.php | 19 +++++ routes/console.php | 3 +- tests/Feature/Jobs/LoginFailedTest.php | 12 +++ tests/Feature/Jobs/LoginSucceededTest.php | 12 +++ .../Jobs/PruneLoginActivityJobTest.php | 40 ++++++++++ 28 files changed, 543 insertions(+), 168 deletions(-) delete mode 100644 app/Actions/LogUserLogin.php create mode 100644 app/Filament/Resources/LoginActivityResource.php create mode 100644 app/Filament/Resources/LoginActivityResource/Pages/ListLoginActivities.php create mode 100644 app/Filament/Widgets/LastLoginWidget.php create mode 100644 app/Jobs/LoginFailed.php create mode 100644 app/Jobs/LoginSucceeded.php create mode 100644 app/Jobs/PruneLoginActivityJob.php delete mode 100644 app/Models/Login.php create mode 100644 app/Models/LoginActivity.php create mode 100644 database/factories/LoginActivityFactory.php delete mode 100644 database/migrations/2021_05_25_174853_create_logins_table.php create mode 100644 database/migrations/2024_07_26_123523_create_login_activity_table.php create mode 100644 database/seeders/LoginActivitySeeder.php create mode 100644 resources/views/filament/widgets/last-login-widget.blade.php create mode 100644 tests/Feature/Jobs/LoginFailedTest.php create mode 100644 tests/Feature/Jobs/LoginSucceededTest.php create mode 100644 tests/Feature/Jobs/PruneLoginActivityJobTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 313689ce..1ccfdf97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - [#202][is_202]: Condense migrations - [#203][is_203]: Slim down cookie banner ([#210][pr_210]) - Minor housekeeping +- [#191][is_191]: Reimplement login activity ([#213][pr_213]) ### Fixed @@ -25,6 +26,8 @@ [is_184]: https://github.com/JSn1nj4/ElliotDerhay.com/issues/184 +[is_191]: https://github.com/JSn1nj4/ElliotDerhay.com/issues/191 + [is_198]: https://github.com/JSn1nj4/ElliotDerhay.com/issues/198 [is_200]: https://github.com/JSn1nj4/ElliotDerhay.com/issues/200 @@ -53,6 +56,8 @@ [pr_212]: https://github.com/JSn1nj4/ElliotDerhay.com/pull/212 +[pr_213]: https://github.com/JSn1nj4/ElliotDerhay.com/pull/213 + ## Version 2.9.2 ### Changes diff --git a/app/Actions/LogUserLogin.php b/app/Actions/LogUserLogin.php deleted file mode 100644 index 78d1f9ba..00000000 --- a/app/Actions/LogUserLogin.php +++ /dev/null @@ -1,14 +0,0 @@ - $user->id]); - } -} diff --git a/app/Filament/Pages/Login.php b/app/Filament/Pages/Login.php index 293d9945..964a7110 100644 --- a/app/Filament/Pages/Login.php +++ b/app/Filament/Pages/Login.php @@ -3,25 +3,53 @@ namespace App\Filament\Pages; use App\Features\AdminLogin; +use App\Jobs\LoginFailed; +use App\Jobs\LoginSucceeded; use Filament\Http\Responses\Auth\Contracts\LoginResponse; use Filament\Notifications\Notification; use Filament\Pages\Auth\Login as BaseLoginPage; +use Illuminate\Support\Facades\Request; use Laravel\Pennant\Feature; class Login extends BaseLoginPage { - public function authenticate(): LoginResponse|null + public function authenticate(): LoginResponse|null { - if (Feature::inactive(AdminLogin::class)) { - Notification::make() - ->title(__('Login Disabled')) - ->body(__('Login is disabled right now. Please come back later.')) - ->danger() - ->send(); - - return null; - } + try { + if (Feature::inactive(AdminLogin::class)) { + Notification::make() + ->title(__('Login Disabled')) + ->body(__('Login is disabled right now. Please come back later.')) + ->danger() + ->send(); + + return null; + } + + $response = parent::authenticate(); - return parent::authenticate(); + if ($response instanceof LoginResponse) { + LoginSucceeded::dispatch( + $this->getCredentialsFromFormData($this->form->getState())['email'], + Request::ip(), + ); + } else { + LoginFailed::dispatch( + $this->getCredentialsFromFormData($this->form->getState())['email'], + 'reason unknown', + Request::ip(), + ); + } + + return $response; + } catch (\Throwable $exception) { + LoginFailed::dispatch( + $this->getCredentialsFromFormData($this->form->getState())['email'], + $exception->getMessage(), + Request::ip(), + ); + + throw $exception; + } } } diff --git a/app/Filament/Resources/LoginActivityResource.php b/app/Filament/Resources/LoginActivityResource.php new file mode 100644 index 00000000..833d63a9 --- /dev/null +++ b/app/Filament/Resources/LoginActivityResource.php @@ -0,0 +1,76 @@ +columns([ + TextColumn::make('created_at') + ->label('Time') + ->dateTime() + ->sortable(), + + TextColumn::make('email') + ->color(fn ($state) => match ($state) { + Filament::auth()->user()->email => 'white', + default => Color::Neutral, + }) + ->searchable(isIndividual: true, isGlobal: false), + + IconColumn::make('succeeded') + ->boolean(), + + TextColumn::make('info') + ->searchable(isIndividual: true, isGlobal: false), + + TextColumn::make('ip_address') + ->searchable(isIndividual: true, isGlobal: false) + ->fontFamily('mono'), + ]) + ->filters([ + SelectFilter::make('succeeded') + ->options([ + true => 'Succeeded', + false => 'Failed', + ]), + ]) + ->actions([]) + ->bulkActions([]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLoginActivities::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/LoginActivityResource/Pages/ListLoginActivities.php b/app/Filament/Resources/LoginActivityResource/Pages/ListLoginActivities.php new file mode 100644 index 00000000..cb52d59f --- /dev/null +++ b/app/Filament/Resources/LoginActivityResource/Pages/ListLoginActivities.php @@ -0,0 +1,16 @@ +latest()->first(); + + $this->time = $latest->created_at; + $this->ip_address = $latest->ip_address; + } +} diff --git a/app/Jobs/LoginFailed.php b/app/Jobs/LoginFailed.php new file mode 100644 index 00000000..6e911edc --- /dev/null +++ b/app/Jobs/LoginFailed.php @@ -0,0 +1,33 @@ + $this->email, + 'succeeded' => false, + 'info' => $this->info, + 'ip_address' => $this->ip_address, + ]); + } +} diff --git a/app/Jobs/LoginSucceeded.php b/app/Jobs/LoginSucceeded.php new file mode 100644 index 00000000..b6ac5c90 --- /dev/null +++ b/app/Jobs/LoginSucceeded.php @@ -0,0 +1,31 @@ + $this->email, + 'succeeded' => true, + 'ip_address' => $this->ip_address, + ]); + } +} diff --git a/app/Jobs/PruneLoginActivityJob.php b/app/Jobs/PruneLoginActivityJob.php new file mode 100644 index 00000000..315d8154 --- /dev/null +++ b/app/Jobs/PruneLoginActivityJob.php @@ -0,0 +1,26 @@ +startOfDay() + ->subDays(config('auth.activity.days_to_keep')) + )->delete(); + } +} diff --git a/app/Mail/WeeklyReportEmail.php b/app/Mail/WeeklyReportEmail.php index b153ab82..0c1182c4 100644 --- a/app/Mail/WeeklyReportEmail.php +++ b/app/Mail/WeeklyReportEmail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Models\LoginActivity; use App\Models\Post; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; @@ -16,14 +17,16 @@ class WeeklyReportEmail extends Mailable /** * Create a new message instance. */ - public function __construct() { + public function __construct() + { // } /** * Get the message envelope. */ - public function envelope(): Envelope { + public function envelope(): Envelope + { return new Envelope( subject: 'Weekly Admin Report', ); @@ -32,11 +35,23 @@ public function envelope(): Envelope { /** * Get the message content definition. */ - public function content(): Content { + public function content(): Content + { return new Content( markdown: 'emails.reports.weekly', with: [ - 'lastLogin' => '(needs implementing)', + 'lastLogin' => LoginActivity::succeeded() + ->latest() + ->first() + ?->created_at + ->toDayDateTimeString() ?? 'No recent logins.', + + 'lastFailure' => LoginActivity::failed() + ->latest() + ->first() + ?->created_at + ->toDayDateTimeString() ?? 'No recent login failures.', + 'postsPublished' => Post::publishedRecently()->count(), ], ); @@ -47,7 +62,8 @@ public function content(): Content { * * @return array */ - public function attachments(): array { + public function attachments(): array + { return []; } } diff --git a/app/Models/CommandEvent.php b/app/Models/CommandEvent.php index 2a81db9b..4e18729b 100644 --- a/app/Models/CommandEvent.php +++ b/app/Models/CommandEvent.php @@ -31,6 +31,9 @@ * @method static \Illuminate\Database\Eloquent\Builder|CommandEvent whereMessage($value) * @method static \Illuminate\Database\Eloquent\Builder|CommandEvent whereSucceeded($value) * @method static \Illuminate\Database\Eloquent\Builder|CommandEvent whereUpdatedAt($value) + * @property-read mixed $date_at_time + * @property-read mixed $long_date_at_time + * @property-read mixed $short_date * @mixin \Eloquent */ class CommandEvent extends Model diff --git a/app/Models/Login.php b/app/Models/Login.php deleted file mode 100644 index 73ad93ce..00000000 --- a/app/Models/Login.php +++ /dev/null @@ -1,47 +0,0 @@ -belongsTo(User::class); - } - - public function scopeMostRecent(Builder $query): null|Builder|Model - { - return $query->latest()->first(); - } -} diff --git a/app/Models/LoginActivity.php b/app/Models/LoginActivity.php new file mode 100644 index 00000000..53afe72b --- /dev/null +++ b/app/Models/LoginActivity.php @@ -0,0 +1,60 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + protected $fillable = [ + 'email', + 'succeeded', + 'info', + 'ip_address', + ]; + + public function scopeFailed(Builder $query): void + { + $query->where('succeeded', false); + } + + public function scopeSucceeded(Builder $query): void + { + $query->where('succeeded', true); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 423cdc1f..46d414c2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,7 +5,6 @@ use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -20,8 +19,6 @@ * @property string|null $remember_token * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at - * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Login[] $logins - * @property-read int|null $logins_count * @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications * @property-read int|null $notifications_count * @method static \Database\Factories\UserFactory factory(...$parameters) @@ -73,20 +70,8 @@ class User extends Authenticatable implements FilamentUser 'remember_token', ]; - public static function booted() - { - static::deleted(static function (self $user): void { - Login::whereUserId($user->id)->delete(); - }); - } - public function canAccessPanel(Panel $panel): bool { return $this->hasVerifiedEmail(); } - - public function logins(): HasMany - { - return $this->hasMany(Login::class); - } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7e799c22..0039cb39 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,7 +6,6 @@ use App\Actions\AddTagToPost; use App\Actions\HashPassword; use App\Actions\LogCommandEvent; -use App\Actions\LogUserLogin; use App\Contracts\GitHostService; use App\DataTransferObjects\XApiCredentials; use App\Models\Token; @@ -20,7 +19,6 @@ class AppServiceProvider extends ServiceProvider { public array $bindings = [ LogCommandEvent::class => LogCommandEvent::class, - LogUserLogin::class => LogUserLogin::class, ]; public array $singletons = [ diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7819690c..7072124c 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -22,16 +22,16 @@ class AdminPanelProvider extends PanelProvider { - public function panel(Panel $panel): Panel - { - return $panel - ->default() - ->id('admin') - ->path('admin') - ->login(Login::class) + public function panel(Panel $panel): Panel + { + return $panel + ->default() + ->id('admin') + ->path('admin') + ->login(Login::class) ->globalSearchKeyBindings(['command+k', 'ctrl+k']) ->maxContentWidth('full') - ->colors([ + ->colors([ 'primary' => [ 50 => '#eafff7', 100 => '#cdfeeb', @@ -46,36 +46,35 @@ public function panel(Panel $panel): Panel 950 => '#00302a', ], 'danger' => Color::Rose, - ]) + ]) ->favicon(asset_url("avatar.png")) - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') + ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->navigationGroups([ NavigationGroup::make('Content'), NavigationGroup::make('Administration'), ]) - ->pages([ - Pages\Dashboard::class, - ]) - ->widgets([ - \Filament\Widgets\AccountWidget::class, - \Filament\Widgets\FilamentInfoWidget::class, + ->pages([ + Pages\Dashboard::class, + ]) + ->widgets([ + Widgets\LastLoginWidget::class, Widgets\LatestPosts::class, Widgets\CommandLog::class, - ]) - ->middleware([ - EncryptCookies::class, - AddQueuedCookiesToResponse::class, - StartSession::class, - AuthenticateSession::class, - ShareErrorsFromSession::class, - VerifyCsrfToken::class, - SubstituteBindings::class, - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - ]) - ->authMiddleware([ - Authenticate::class, - ]); - } + ]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + } } diff --git a/config/auth.php b/config/auth.php index 206f0611..82d014f6 100644 --- a/config/auth.php +++ b/config/auth.php @@ -2,6 +2,10 @@ return [ + 'activity' => [ + 'days_to_retain' => env('AUTH_ACTIVITY_DAYS_TO_KEEP', 60), + ], + /* |-------------------------------------------------------------------------- | Authentication Defaults diff --git a/database/factories/LoginActivityFactory.php b/database/factories/LoginActivityFactory.php new file mode 100644 index 00000000..b141e29c --- /dev/null +++ b/database/factories/LoginActivityFactory.php @@ -0,0 +1,26 @@ + + */ +class LoginActivityFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'email' => fake()->email, + 'succeeded' => fake()->boolean, + 'info' => fake()->sentence, + 'ip_address' => fake()->ipv4, + ]; + } +} diff --git a/database/migrations/2021_05_25_174853_create_logins_table.php b/database/migrations/2021_05_25_174853_create_logins_table.php deleted file mode 100644 index 1918f1ca..00000000 --- a/database/migrations/2021_05_25_174853_create_logins_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - $table->foreignIdFor(\App\Models\User::class); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down(): void - { - Schema::dropIfExists('logins'); - } -} diff --git a/database/migrations/2024_07_26_123523_create_login_activity_table.php b/database/migrations/2024_07_26_123523_create_login_activity_table.php new file mode 100644 index 00000000..10e559b9 --- /dev/null +++ b/database/migrations/2024_07_26_123523_create_login_activity_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('email'); + $table->boolean('succeeded'); + $table->text('info')->nullable(); + $table->ipAddress(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('login_activity'); + } +}; diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index b8ccda87..f291ec62 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -2,17 +2,16 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DemoSeeder extends Seeder { - /** - * Run the database seeds. - * - * @return void - */ - public function run(): void + /** + * Run the database seeds. + * + * @return void + */ + public function run(): void { if (app()->environment('production')) { throw new \Exception("This seeder must not be run in Production! It will likely destroy live data."); @@ -24,6 +23,7 @@ public function run(): void PostSeeder::class, ProjectSeeder::class, UserSeeder::class, + LoginActivitySeeder::class, ]); - } + } } diff --git a/database/seeders/LoginActivitySeeder.php b/database/seeders/LoginActivitySeeder.php new file mode 100644 index 00000000..9da1f5f1 --- /dev/null +++ b/database/seeders/LoginActivitySeeder.php @@ -0,0 +1,20 @@ +getTable())->truncate(); + + LoginActivity::factory(60)->create(); + } +} diff --git a/resources/views/emails/reports/weekly.blade.php b/resources/views/emails/reports/weekly.blade.php index f98897bf..633f0d6f 100644 --- a/resources/views/emails/reports/weekly.blade.php +++ b/resources/views/emails/reports/weekly.blade.php @@ -7,6 +7,9 @@ ## Last Login {{ $lastLogin }} +## Last Login Failure +{{ $lastFailure }} + ## Post Published {{ $postsPublished }} diff --git a/resources/views/filament/widgets/last-login-widget.blade.php b/resources/views/filament/widgets/last-login-widget.blade.php new file mode 100644 index 00000000..2fccaefa --- /dev/null +++ b/resources/views/filament/widgets/last-login-widget.blade.php @@ -0,0 +1,19 @@ + + +
+

Last Login

+ + + + + + + + + + + +
Time:{{ $time->toDayDateTimeString() }}
IP Address:{{ $ip_address }}
+
+
+
diff --git a/routes/console.php b/routes/console.php index c1425eb8..e79dc8a1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,7 +6,7 @@ TokenPruneCommand, TweetPullCommand, TwitterUserUpdateCommand}; -use App\Jobs\CleanTempStorageJob; +use App\Jobs\{CleanTempStorageJob, PruneLoginActivityJob}; use Illuminate\Queue\Console\WorkCommand; use Illuminate\Support\Facades\Schedule; @@ -18,6 +18,7 @@ Schedule::command(TwitterUserUpdateCommand::class)->weekly(); Schedule::job(CleanTempStorageJob::class)->weekly(); +Schedule::job(PruneLoginActivityJob::class)->weekly(); Schedule::command(WorkCommand::class, ['--stop-when-empty'])->daily(); // reports can run after everything else honestly diff --git a/tests/Feature/Jobs/LoginFailedTest.php b/tests/Feature/Jobs/LoginFailedTest.php new file mode 100644 index 00000000..f4269b8f --- /dev/null +++ b/tests/Feature/Jobs/LoginFailedTest.php @@ -0,0 +1,12 @@ +toEqual(0); + + LoginFailed::dispatch(fake()->email, fake()->sentence, fake()->ipv4); + + expect(LoginActivity::count())->toEqual(1); +}); diff --git a/tests/Feature/Jobs/LoginSucceededTest.php b/tests/Feature/Jobs/LoginSucceededTest.php new file mode 100644 index 00000000..393ae05a --- /dev/null +++ b/tests/Feature/Jobs/LoginSucceededTest.php @@ -0,0 +1,12 @@ +toEqual(0); + + LoginSucceeded::dispatch(fake()->email, fake()->ipv4); + + expect(LoginActivity::count())->toEqual(1); +}); diff --git a/tests/Feature/Jobs/PruneLoginActivityJobTest.php b/tests/Feature/Jobs/PruneLoginActivityJobTest.php new file mode 100644 index 00000000..f35007df --- /dev/null +++ b/tests/Feature/Jobs/PruneLoginActivityJobTest.php @@ -0,0 +1,40 @@ +randomDigitNot(0)) + ->state(['created_at' => now()->subDays( + config('auth.activity.days_to_keep') + 1, + )]) + ->create()->count() + + + LoginActivity::factory(fake()->randomDigitNot(0)) + ->state(['created_at' => now()->subDays( + config('auth.activity.days_to_keep'), + )]) + ->create()->count(); + + $remaining_count = LoginActivity::factory(fake()->randomDigitNot(0)) + ->state(['created_at' => now()->subDays( + config('auth.activity.days_to_keep') - 1, + )]) + ->create()->count(); + + expect(LoginActivity::count()) + ->toEqual($removed_count + $remaining_count) + ->toBeGreaterThan($remaining_count); + + PruneLoginActivityJob::dispatchSync(); + + $activity = LoginActivity::all(); + + expect($activity->count()) + ->toEqual($remaining_count) + ->toBeLessThan($removed_count + $remaining_count); + + $activity->each(fn (LoginActivity $item) => assert(now() + ->subDays(config('auth.activity.days_to_keep')) + ->lessThan($item->created_at))); +});