From 11f794790dd618bac070a988c194387ee3e6e4c6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:39 +0100 Subject: [PATCH] feat(team): let owners and admins require 2FA for team members Co-Authored-By: Claude Opus 5.5 --- .ai/lessons.md | 1 + app/Http/Kernel.php | 2 + app/Http/Middleware/RequireTeamTwoFactor.php | 40 ++++++ app/Livewire/Team/Index.php | 11 ++ app/Livewire/TwoFactorRequired.php | 22 ++++ app/Models/Team.php | 3 + bootstrap/helpers/subscriptions.php | 22 ++++ ..._is_two_factor_required_to_teams_table.php | 22 ++++ resources/views/livewire/team/index.blade.php | 9 ++ .../livewire/two-factor-required.blade.php | 59 +++++++++ routes/web.php | 2 + .../Feature/TeamTwoFactorRequirementTest.php | 115 ++++++++++++++++++ .../v4/Browser/TeamTwoFactorRequiredTest.php | 61 ++++++++++ 13 files changed, 369 insertions(+) create mode 100644 app/Http/Middleware/RequireTeamTwoFactor.php create mode 100644 app/Livewire/TwoFactorRequired.php create mode 100644 database/migrations/2026_10_25_115811_add_is_two_factor_required_to_teams_table.php create mode 100644 resources/views/livewire/two-factor-required.blade.php create mode 100644 tests/Feature/TeamTwoFactorRequirementTest.php create mode 100644 tests/v4/Browser/TeamTwoFactorRequiredTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 79f47a68e6..7bf7113805 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -30,6 +30,7 @@ - `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`). - In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. +- The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it. - SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. ## Verify the complete user flow diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 48710f88fe..3a6cfef948 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -20,6 +20,7 @@ use App\Http\Middleware\KeepConcurrentTeamSwitch; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RequirePassword; +use App\Http\Middleware\RequireTeamTwoFactor; use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrustProxies; use App\Http\Middleware\ValidateSignature; @@ -80,6 +81,7 @@ class Kernel extends HttpKernel SubstituteBindings::class, CheckForcePasswordReset::class, DecideWhatToDoWithUser::class, + RequireTeamTwoFactor::class, ], diff --git a/app/Http/Middleware/RequireTeamTwoFactor.php b/app/Http/Middleware/RequireTeamTwoFactor.php new file mode 100644 index 0000000000..bb5421458e --- /dev/null +++ b/app/Http/Middleware/RequireTeamTwoFactor.php @@ -0,0 +1,40 @@ +user(); + if (! $user || filled($user->two_factor_confirmed_at)) { + return $next($request); + } + + $teamId = data_get(session('currentTeam'), 'id'); + if (is_null($teamId) || in_array($request->path(), allowedPathsForTwoFactorSetup(), true)) { + return $next($request); + } + + // Read the flag from the database: the session and cache can hold a team from before the change. + if (! Team::query()->whereKey($teamId)->value('is_two_factor_required')) { + return $next($request); + } + + if ($request->expectsJson() || $request->routeIs('*livewire.update')) { + abort(403, 'Your team requires two-factor authentication. Configure it to continue.'); + } + + return redirect()->route('two-factor.required'); + } +} diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 58b940f932..62c03d57ed 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -28,6 +28,8 @@ class Index extends Component public bool $is_build_server_fallback_enabled = true; + public bool $is_two_factor_required = false; + public $avatar; protected function rules(): array @@ -37,6 +39,7 @@ class Index extends Component 'description' => ValidationPatterns::descriptionRules(), 'is_mcp_server_enabled' => 'boolean', 'is_build_server_fallback_enabled' => 'boolean', + 'is_two_factor_required' => 'boolean', ]; } @@ -68,6 +71,7 @@ class Index extends Component $this->team->description = $this->description; $this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->team->is_build_server_fallback_enabled = $this->is_build_server_fallback_enabled; + $this->team->is_two_factor_required = $this->is_two_factor_required; } else { // Sync FROM model (on load/refresh) $this->name = $this->team->name; @@ -76,6 +80,7 @@ class Index extends Component // hydrated onto the in-memory model stored in session. $this->is_mcp_server_enabled = (bool) ($this->team->is_mcp_server_enabled ?? true); $this->is_build_server_fallback_enabled = (bool) ($this->team->is_build_server_fallback_enabled ?? true); + $this->is_two_factor_required = (bool) $this->team->is_two_factor_required; } } @@ -133,6 +138,12 @@ class Index extends Component $this->validate(); try { $this->authorize('update', $this->team); + if ($this->is_two_factor_required && ! $this->team->is_two_factor_required && blank(auth()->user()->two_factor_confirmed_at)) { + $this->is_two_factor_required = false; + $this->dispatch('error', 'Enable two-factor authentication on your profile before you require it for the team.'); + + return; + } $this->syncData(true); $this->team->save(); refreshSession(); diff --git a/app/Livewire/TwoFactorRequired.php b/app/Livewire/TwoFactorRequired.php new file mode 100644 index 0000000000..72d4091b10 --- /dev/null +++ b/app/Livewire/TwoFactorRequired.php @@ -0,0 +1,22 @@ +user()->two_factor_confirmed_at) && session('status') !== 'two-factor-authentication-confirmed') { + return redirect()->route('dashboard'); + } + } + + public function render() + { + return view('livewire.two-factor-required', [ + 'teamName' => data_get(session('currentTeam'), 'name'), + ])->layout('layouts.simple'); + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index fb4076eaec..8fb8497c33 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -60,17 +60,20 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen 'custom_server_limit', 'is_mcp_server_enabled', 'is_build_server_fallback_enabled', + 'is_two_factor_required', ]; protected $attributes = [ 'is_mcp_server_enabled' => true, 'is_build_server_fallback_enabled' => true, + 'is_two_factor_required' => false, ]; protected $casts = [ 'personal_team' => 'boolean', 'is_mcp_server_enabled' => 'boolean', 'is_build_server_fallback_enabled' => 'boolean', + 'is_two_factor_required' => 'boolean', ]; protected static function booted() diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 95442ca359..f91c165e48 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -91,6 +91,7 @@ function allowedPathsForUnsubscribedAccounts() 'user/passkeys/options', 'passkeys/confirm', 'passkeys/confirm/options', + ...allowedPathsForTwoFactorSetup(), ]; } function allowedPathsForBoardingAccounts() @@ -109,6 +110,27 @@ function allowedPathsForInvalidAccounts() 'force-password-reset', 'two-factor-challenge', 'livewire/update', + ...allowedPathsForTwoFactorSetup(), + ]; +} +/** + * Paths a user needs to configure 2FA when their team requires it. + * + * @return array + */ +function allowedPathsForTwoFactorSetup(): array +{ + return [ + 'two-factor-required', + 'two-factor-challenge', + 'logout', + 'user/confirm-password', + 'user/confirmed-password-status', + 'user/two-factor-authentication', + 'user/confirmed-two-factor-authentication', + 'user/two-factor-qr-code', + 'user/two-factor-secret-key', + 'user/two-factor-recovery-codes', ]; } diff --git a/database/migrations/2026_10_25_115811_add_is_two_factor_required_to_teams_table.php b/database/migrations/2026_10_25_115811_add_is_two_factor_required_to_teams_table.php new file mode 100644 index 0000000000..ecc79886c3 --- /dev/null +++ b/database/migrations/2026_10_25_115811_add_is_two_factor_required_to_teams_table.php @@ -0,0 +1,22 @@ +boolean('is_two_factor_required')->default(false); + }); + } + + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumn('is_two_factor_required'); + }); + } +}; diff --git a/resources/views/livewire/team/index.blade.php b/resources/views/livewire/team/index.blade.php index fdf38bf818..064b28d737 100644 --- a/resources/views/livewire/team/index.blade.php +++ b/resources/views/livewire/team/index.blade.php @@ -95,6 +95,15 @@ ['value' => false, 'label' => 'Fail the deployment'], ]" /> +
+ +
diff --git a/resources/views/livewire/two-factor-required.blade.php b/resources/views/livewire/two-factor-required.blade.php new file mode 100644 index 0000000000..84ce7ffad6 --- /dev/null +++ b/resources/views/livewire/two-factor-required.blade.php @@ -0,0 +1,59 @@ + +
+ @if (request()->user()->two_factor_confirmed_at) +

+ Two-factor authentication is on. Save these recovery codes in a safe place. +

+
+ @foreach (request()->user()->recoveryCodes() as $code) +
{{ $code }}
+ @endforeach +
+ Continue + @elseif (session('status') === 'two-factor-authentication-enabled' || request()->user()->two_factor_secret) +
+ {!! request()->user()->twoFactorQrCodeSvg() !!} +
+

+ Scan the QR code, then enter the current code from your authenticator. +

+
+ @csrf + + {{-- Livewire views replace $errors with the component bag, so read the Fortify error from the session. --}} + @if ($codeError = session('errors')?->getBag('confirmTwoFactorAuthentication')->first('code')) + {{ $codeError }} + @endif + Validate 2FA + +
+
+ + +
+ + + +
+ @else +
+ @csrf + Configure 2FA +
+ @endif +
+ @unless (request()->user()->two_factor_confirmed_at) + + Not now? +
+ @csrf + +
+
+ @endunless +
diff --git a/routes/web.php b/routes/web.php index c5a782725b..49648295be 100644 --- a/routes/web.php +++ b/routes/web.php @@ -138,6 +138,7 @@ use App\Livewire\Team\DangerZone as TeamDangerZone; use App\Livewire\Team\Index as TeamIndex; use App\Livewire\Team\Member\Index as TeamMemberIndex; use App\Livewire\Terminal\Index as TerminalIndex; +use App\Livewire\TwoFactorRequired; use App\Models\ScheduledDatabaseBackupExecution; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; @@ -191,6 +192,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['throttle:force-password-reset'])->group(function () { Route::get('/force-password-reset', ForcePasswordReset::class)->name('auth.force-password-reset'); }); + Route::get('/two-factor-required', TwoFactorRequired::class)->name('two-factor.required'); Route::get('/', Dashboard::class)->name('dashboard'); Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); diff --git a/tests/Feature/TeamTwoFactorRequirementTest.php b/tests/Feature/TeamTwoFactorRequirementTest.php new file mode 100644 index 0000000000..45334f57da --- /dev/null +++ b/tests/Feature/TeamTwoFactorRequirementTest.php @@ -0,0 +1,115 @@ + 0]); + $this->team = Team::factory()->create(['show_boarding' => false]); +}); + +function twoFactorTeamUser(Team $team, string $role = 'member', bool $twoFactorEnabled = false): User +{ + $user = User::factory()->create([ + 'name' => fake()->name(), + 'two_factor_confirmed_at' => $twoFactorEnabled ? now() : null, + ]); + $team->members()->attach($user->id, ['role' => $role]); + + return $user; +} + +test('a member without 2fa is sent to the setup page when the team requires 2fa', function () { + $user = twoFactorTeamUser($this->team); + // The session holds the team from before the change. + $teamBeforeChange = $this->team->fresh(); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $teamBeforeChange]); + + $this->get('/profile')->assertRedirect(route('two-factor.required')); + $this->getJson('/profile')->assertForbidden(); +}); + +test('a member without 2fa can open the 2fa setup pages', function () { + $user = twoFactorTeamUser($this->team); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->get('/two-factor-required')->assertOk()->assertSee('Configure 2FA'); + $this->get('/user/confirm-password')->assertOk(); +}); + +test('a member with 2fa can use a team that requires 2fa', function () { + $user = twoFactorTeamUser($this->team, twoFactorEnabled: true); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->get('/profile')->assertOk(); + $this->get('/two-factor-required')->assertRedirect(route('dashboard')); +}); + +test('a member without 2fa can use a team that does not require 2fa', function () { + $user = twoFactorTeamUser($this->team); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->get('/profile')->assertOk(); +}); + +test('an admin with 2fa can require 2fa for the team', function () { + $admin = twoFactorTeamUser($this->team, 'admin', twoFactorEnabled: true); + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Index::class) + ->set('is_two_factor_required', true) + ->call('submit') + ->assertDispatched('success'); + + expect($this->team->fresh()->is_two_factor_required)->toBeTrue(); +}); + +test('an admin without 2fa cannot require 2fa for the team', function () { + $admin = twoFactorTeamUser($this->team, 'admin'); + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Index::class) + ->set('is_two_factor_required', true) + ->call('submit') + ->assertDispatched('error'); + + expect($this->team->fresh()->is_two_factor_required)->toBeFalse(); +}); + +test('a member cannot require 2fa for the team', function () { + $member = twoFactorTeamUser($this->team, twoFactorEnabled: true); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Livewire::test(Index::class) + ->set('is_two_factor_required', true) + ->call('submit'); + + expect($this->team->fresh()->is_two_factor_required)->toBeFalse(); +}); + +test('the setup page shows the error for a wrong 2fa code', function () { + $user = twoFactorTeamUser($this->team); + $user->forceFill(['two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP')])->save(); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team, 'auth.password_confirmed_at' => time()]); + + $this->from('/two-factor-required') + ->post('/user/confirmed-two-factor-authentication', ['code' => '000000']) + ->assertRedirect('/two-factor-required'); + + $this->get('/two-factor-required') + ->assertOk() + ->assertSee('The provided two factor authentication code was invalid.'); +}); diff --git a/tests/v4/Browser/TeamTwoFactorRequiredTest.php b/tests/v4/Browser/TeamTwoFactorRequiredTest.php new file mode 100644 index 0000000000..3b3d2a5b3c --- /dev/null +++ b/tests/v4/Browser/TeamTwoFactorRequiredTest.php @@ -0,0 +1,61 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_sponsorship_popup_enabled' => false, + ]); +}); + +it('makes a member configure 2fa before using a team that requires it', function () { + $user = User::forceCreate([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + $team = Team::query()->findOrFail(0); + $team->update(['show_boarding' => false, 'is_two_factor_required' => true]); + Cache::flush(); + + $page = visit('/login'); + $page->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertPathIs('/two-factor-required') + ->assertSee('Two-factor authentication required') + ->screenshot(filename: 'team-2fa-required'); + + $page->click('Configure 2FA') + ->assertPathIs('/user/confirm-password') + ->fill('input[name="password"]', 'password') + ->click('form[action="/user/confirm-password"] button[type=submit]') + ->assertPathIs('/two-factor-required') + ->click('Configure 2FA') + ->assertSee('Scan the QR code') + ->screenshot(filename: 'team-2fa-qr-code'); + + $secret = Fortify::currentEncrypter()->decrypt($user->fresh()->two_factor_secret); + $page->fill('input[name="code"]', (new Google2FA)->getCurrentOtp($secret)) + ->click('Validate 2FA') + ->assertSee('Save these recovery codes') + ->screenshot(filename: 'team-2fa-recovery-codes'); + + expect($user->fresh()->two_factor_confirmed_at)->not->toBeNull(); + + $page->navigate('/profile') + ->assertPathIs('/profile') + ->screenshot(filename: 'team-2fa-profile-after-setup'); +});