mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
5ed8565edf
commit
11f794790d
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user