Skip to content

Commit ae4ab64

Browse files
committed
Enable mfa, store last login time
1 parent 95acfa3 commit ae4ab64

File tree

7 files changed

+147
-5
lines changed

7 files changed

+147
-5
lines changed

app/Filament/Resources/Users/Tables/UsersTable.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ public static function configure(Table $table): Table
2525
TextColumn::make('role')
2626
->badge()
2727
->sortable(),
28+
TextColumn::make('last_logged_in_at')
29+
->label('Last login')
30+
->dateTime()
31+
->sortable(),
2832
TextColumn::make('created_at')
2933
->dateTime()
3034
->sortable()

app/Models/User.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66

77
// use Illuminate\Contracts\Auth\MustVerifyEmail;
88
use App\Enums\UserRole;
9+
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
10+
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
911
use Filament\Models\Contracts\FilamentUser;
1012
use Filament\Panel;
1113
use Illuminate\Database\Eloquent\Factories\HasFactory;
1214
use Illuminate\Foundation\Auth\User as Authenticatable;
1315
use Illuminate\Notifications\Notifiable;
1416

15-
class User extends Authenticatable implements FilamentUser
17+
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery
1618
{
1719
/** @use HasFactory<\Database\Factories\UserFactory> */
1820
use HasFactory;
@@ -39,6 +41,8 @@ class User extends Authenticatable implements FilamentUser
3941
protected $hidden = [
4042
'password',
4143
'remember_token',
44+
'app_authentication_secret',
45+
'app_authentication_recovery_codes',
4246
];
4347

4448
/**
@@ -50,11 +54,47 @@ protected function casts(): array
5054
{
5155
return [
5256
'email_verified_at' => 'datetime',
57+
'last_logged_in_at' => 'datetime',
5358
'password' => 'hashed',
5459
'role' => UserRole::class,
60+
'app_authentication_secret' => 'encrypted',
61+
'app_authentication_recovery_codes' => 'encrypted:array',
5562
];
5663
}
5764

65+
public function getAppAuthenticationSecret(): ?string
66+
{
67+
return $this->app_authentication_secret;
68+
}
69+
70+
public function saveAppAuthenticationSecret(?string $secret): void
71+
{
72+
$this->app_authentication_secret = $secret;
73+
$this->save();
74+
}
75+
76+
public function getAppAuthenticationHolderName(): string
77+
{
78+
return $this->email;
79+
}
80+
81+
/**
82+
* @return ?array<string>
83+
*/
84+
public function getAppAuthenticationRecoveryCodes(): ?array
85+
{
86+
return $this->app_authentication_recovery_codes;
87+
}
88+
89+
/**
90+
* @param array<string> | null $codes
91+
*/
92+
public function saveAppAuthenticationRecoveryCodes(?array $codes): void
93+
{
94+
$this->app_authentication_recovery_codes = $codes;
95+
$this->save();
96+
}
97+
5898
public function isMaintainer(): bool
5999
{
60100
return in_array($this->role, [UserRole::Maintainer, UserRole::Admin], true);

app/Providers/AppServiceProvider.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use App\Observers\SrvRecordObserver;
1717
use App\Observers\UserObserver;
1818
use App\Observers\WhitelistUserObserver;
19+
use Illuminate\Auth\Events\Login;
20+
use Illuminate\Support\Facades\Event;
1921
use Illuminate\Support\ServiceProvider;
2022

2123
class AppServiceProvider extends ServiceProvider
@@ -39,5 +41,13 @@ public function boot(): void
3941
WhitelistUser::observe(WhitelistUserObserver::class);
4042
OverrideRule::observe(OverrideRuleObserver::class);
4143
SrvRecord::observe(SrvRecordObserver::class);
44+
45+
Event::listen(Login::class, function (Login $event): void {
46+
$user = $event->user;
47+
if (is_object($user)) {
48+
$user->last_logged_in_at = now();
49+
$user->save();
50+
}
51+
});
4252
}
4353
}

app/Providers/Filament/AdminPanelProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace App\Providers\Filament;
66

7+
use Filament\Auth\MultiFactor\App\AppAuthentication;
78
use Filament\Http\Middleware\Authenticate;
89
use Filament\Http\Middleware\AuthenticateSession;
910
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -39,6 +40,9 @@ public function panel(Panel $panel): Panel
3940
->path('')
4041
->login()
4142
->profile()
43+
->multiFactorAuthentication([
44+
AppAuthentication::make()->recoverable(),
45+
])
4246
->viteTheme('resources/css/filament/admin/theme.css')
4347
->passwordReset()
4448
->emailVerification()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\Schema;
8+
9+
return new class extends Migration
10+
{
11+
/**
12+
* Run the migrations.
13+
*/
14+
public function up(): void
15+
{
16+
Schema::table('users', function (Blueprint $table): void {
17+
$table->timestamp('last_logged_in_at')->nullable()->after('remember_token');
18+
});
19+
}
20+
21+
/**
22+
* Reverse the migrations.
23+
*/
24+
public function down(): void
25+
{
26+
Schema::table('users', function (Blueprint $table): void {
27+
$table->dropColumn('last_logged_in_at');
28+
});
29+
}
30+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\Schema;
8+
9+
return new class extends Migration
10+
{
11+
/**
12+
* Run the migrations.
13+
*/
14+
public function up(): void
15+
{
16+
Schema::table('users', function (Blueprint $table) {
17+
$table->text('app_authentication_secret')->nullable()->after('remember_token');
18+
$table->text('app_authentication_recovery_codes')->nullable()->after('app_authentication_secret');
19+
});
20+
}
21+
22+
/**
23+
* Reverse the migrations.
24+
*/
25+
public function down(): void
26+
{
27+
Schema::table('users', function (Blueprint $table) {
28+
$table->dropColumn('app_authentication_secret');
29+
$table->dropColumn('app_authentication_recovery_codes');
30+
});
31+
}
32+
};

tests/Feature/Auth/AuthenticationTest.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
namespace Tests\Feature\Auth;
66

77
use App\Models\User;
8+
use Filament\Auth\Pages\Login;
9+
use Filament\Facades\Filament;
810
use Illuminate\Foundation\Testing\RefreshDatabase;
911
use Illuminate\Support\Facades\Auth;
12+
use Livewire\Livewire;
1013
use Tests\TestCase;
1114

1215
class AuthenticationTest extends TestCase
@@ -23,17 +26,36 @@ public function test_login_screen_can_be_rendered(): void
2326

2427
public function test_users_can_authenticate_using_the_login_screen(): void
2528
{
26-
$user = User::factory()->create();
27-
$this->actingAs($user);
29+
$this->assertGuest();
30+
31+
$userToAuthenticate = User::factory()->create();
2832

29-
$this->get('/')->assertOk();
33+
Livewire::test(Login::class)
34+
->fillForm([
35+
'email' => $userToAuthenticate->email,
36+
'password' => 'password',
37+
])
38+
->call('authenticate')
39+
->assertRedirect(Filament::getUrl());
3040

31-
$this->assertAuthenticatedAs($user);
41+
$this->assertAuthenticatedAs($userToAuthenticate);
42+
$this->assertNotNull($userToAuthenticate->refresh()->last_logged_in_at);
3243
}
3344

3445
public function test_users_can_not_authenticate_with_invalid_password(): void
3546
{
3647
$this->assertGuest();
48+
49+
$userToAuthenticate = User::factory()->create();
50+
51+
Livewire::test(Login::class)
52+
->fillForm([
53+
'email' => $userToAuthenticate->email,
54+
'password' => 'password123',
55+
])
56+
->call('authenticate')
57+
->assertHasFormErrors(['email']);
58+
$this->assertGuest();
3759
}
3860

3961
public function test_navigation_menu_can_be_rendered(): void

0 commit comments

Comments
 (0)