feat(team): let owners and admins require 2FA for team members

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-25 14:47:39 +01:00
co-authored by Claude Opus 5.5
parent 5ed8565edf
commit 11f794790d
13 changed files with 369 additions and 0 deletions
+1
View File
@@ -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
+2
View File
@@ -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,
],
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Models\Team;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireTeamTwoFactor
{
/**
* Send users without confirmed 2FA to the setup page while their current team requires it.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->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');
}
}
+11
View File
@@ -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();
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Livewire;
use Livewire\Component;
class TwoFactorRequired extends Component
{
public function mount()
{
if (filled(auth()->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');
}
}
+3
View File
@@ -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()
+22
View File
@@ -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<int, string>
*/
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',
];
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->boolean('is_two_factor_required')->default(false);
});
}
public function down(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->dropColumn('is_two_factor_required');
});
}
};
@@ -95,6 +95,15 @@
['value' => false, 'label' => 'Fail the deployment'],
]" />
</div>
<div class="lg:col-span-2">
<x-forms.listbox canGate="update" :canResource="$team" id="is_two_factor_required"
label="Two-factor authentication"
helper="When required, members without two-factor authentication must configure it before they can use this team."
:disabled="! auth()->user()->can('update', $team)" :options="[
['value' => false, 'label' => 'Optional for members'],
['value' => true, 'label' => 'Required for all members'],
]" />
</div>
</div>
</x-application.settings-section>
</form>
@@ -0,0 +1,59 @@
<x-auth.shell title="Two-factor authentication required"
description="{{ $teamName ?? 'Your team' }} requires two-factor authentication. Configure it to continue.">
<div class="flex flex-col gap-4">
@if (request()->user()->two_factor_confirmed_at)
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Two-factor authentication is on. Save these recovery codes in a safe place.
</p>
<div
class="grid gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-4 font-mono text-xs text-neutral-700 sm:grid-cols-2 dark:border-white/[0.07] dark:bg-white/[0.05] dark:text-fg-dim">
@foreach (request()->user()->recoveryCodes() as $code)
<div>{{ $code }}</div>
@endforeach
</div>
<a href="{{ route('dashboard') }}" class="button w-full justify-center">Continue</a>
@elseif (session('status') === 'two-factor-authentication-enabled' || request()->user()->two_factor_secret)
<div
class="flex items-center justify-center rounded-[10px] border border-neutral-200 bg-white p-5 dark:border-white/[0.07]">
{!! request()->user()->twoFactorQrCodeSvg() !!}
</div>
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Scan the QR code, then enter the current code from your authenticator.
</p>
<form action="/user/confirmed-two-factor-authentication" method="POST" class="flex flex-col gap-4"
x-init="$nextTick(() => $el.querySelector('input[name=code]')?.focus())">
@csrf
<x-forms.input name="code" type="text" inputmode="numeric" pattern="[0-9]*" id="code"
label="One-time code" required />
{{-- Livewire views replace $errors with the component bag, so read the Fortify error from the session. --}}
@if ($codeError = session('errors')?->getBag('confirmTwoFactorAuthentication')->first('code'))
<x-auth.alert type="error">{{ $codeError }}</x-auth.alert>
@endif
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>Validate 2FA</x-forms.button>
</form>
<div x-data="{ showCode: false }">
<div x-cloak x-show="showCode" class="space-y-2 pb-3">
<x-forms.copy-input :text="decrypt(request()->user()->two_factor_secret)" />
<x-forms.copy-input :text="request()->user()->twoFactorQrCodeUrl()" />
</div>
<x-forms.button class="w-full justify-center" type="button" x-on:click="showCode = !showCode">
<span x-text="showCode ? 'Hide manual setup' : 'Show manual setup'"></span>
</x-forms.button>
</div>
@else
<form action="/user/two-factor-authentication" method="POST">
@csrf
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>Configure 2FA</x-forms.button>
</form>
@endif
</div>
@unless (request()->user()->two_factor_confirmed_at)
<x-slot:footer>
<span>Not now?</span>
<form action="/logout" method="POST">
@csrf
<button type="submit" class="auth-text-link">Log out</button>
</form>
</x-slot:footer>
@endunless
</x-auth.shell>
+2
View File
@@ -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');
@@ -0,0 +1,115 @@
<?php
use App\Livewire\Team\Index;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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.');
});
@@ -0,0 +1,61 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Laravel\Fortify\Fortify;
use PragmaRX\Google2FA\Google2FA;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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');
});