refactor: move admin route into middleware group and harden authorization
Move the admin panel route into the existing auth middleware group and replace client-side redirects with server-side abort calls in the Livewire component. Extract shared authorization logic into reusable private methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e39678aea5
commit
aea201fcba
3 changed files with 141 additions and 11 deletions
|
|
@ -6,7 +6,6 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
|
|
@ -22,16 +21,15 @@ class Index extends Component
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (! isCloud() && ! isDev()) {
|
if (! isCloud() && ! isDev()) {
|
||||||
return redirect()->route('dashboard');
|
abort(403);
|
||||||
}
|
|
||||||
if (Auth::id() !== 0 && ! session('impersonating')) {
|
|
||||||
return redirect()->route('dashboard');
|
|
||||||
}
|
}
|
||||||
|
$this->authorizeAdminAccess();
|
||||||
$this->getSubscribers();
|
$this->getSubscribers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function back()
|
public function back()
|
||||||
{
|
{
|
||||||
|
$this->authorizeAdminAccess();
|
||||||
if (session('impersonating')) {
|
if (session('impersonating')) {
|
||||||
session()->forget('impersonating');
|
session()->forget('impersonating');
|
||||||
$user = User::find(0);
|
$user = User::find(0);
|
||||||
|
|
@ -45,6 +43,7 @@ public function back()
|
||||||
|
|
||||||
public function submitSearch()
|
public function submitSearch()
|
||||||
{
|
{
|
||||||
|
$this->authorizeAdminAccess();
|
||||||
if ($this->search !== '') {
|
if ($this->search !== '') {
|
||||||
$this->foundUsers = User::where(function ($query) {
|
$this->foundUsers = User::where(function ($query) {
|
||||||
$query->where('name', 'like', "%{$this->search}%")
|
$query->where('name', 'like', "%{$this->search}%")
|
||||||
|
|
@ -61,19 +60,33 @@ public function getSubscribers()
|
||||||
|
|
||||||
public function switchUser(int $user_id)
|
public function switchUser(int $user_id)
|
||||||
{
|
{
|
||||||
if (Auth::id() !== 0) {
|
$this->authorizeRootOnly();
|
||||||
return redirect()->route('dashboard');
|
|
||||||
}
|
|
||||||
session(['impersonating' => true]);
|
session(['impersonating' => true]);
|
||||||
$user = User::find($user_id);
|
$user = User::find($user_id);
|
||||||
|
if (! $user) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
$team_to_switch_to = $user->teams->first();
|
$team_to_switch_to = $user->teams->first();
|
||||||
// Cache::forget("team:{$user->id}");
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
refreshSession($team_to_switch_to);
|
refreshSession($team_to_switch_to);
|
||||||
|
|
||||||
return redirect(request()->header('Referer'));
|
return redirect(request()->header('Referer'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authorizeAdminAccess(): void
|
||||||
|
{
|
||||||
|
if (! Auth::check() || (Auth::id() !== 0 && ! session('impersonating'))) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeRootOnly(): void
|
||||||
|
{
|
||||||
|
if (! Auth::check() || Auth::id() !== 0) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.admin.index');
|
return view('livewire.admin.index');
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,6 @@
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
Route::get('/admin', AdminIndex::class)->name('admin.index');
|
|
||||||
|
|
||||||
Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('password.forgot')->middleware('throttle:forgot-password');
|
Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('password.forgot')->middleware('throttle:forgot-password');
|
||||||
Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth');
|
Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth');
|
||||||
Route::get('/verify', [Controller::class, 'verify'])->middleware('auth')->name('verify.email');
|
Route::get('/verify', [Controller::class, 'verify'])->middleware('auth')->name('verify.email');
|
||||||
|
|
@ -109,6 +107,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/', Dashboard::class)->name('dashboard');
|
Route::get('/', Dashboard::class)->name('dashboard');
|
||||||
|
Route::get('/admin', AdminIndex::class)->name('admin.index');
|
||||||
Route::get('/onboarding', BoardingIndex::class)->name('onboarding');
|
Route::get('/onboarding', BoardingIndex::class)->name('onboarding');
|
||||||
|
|
||||||
Route::get('/subscription', SubscriptionShow::class)->name('subscription.show');
|
Route::get('/subscription', SubscriptionShow::class)->name('subscription.show');
|
||||||
|
|
|
||||||
118
tests/Feature/AdminAccessAuthorizationTest.php
Normal file
118
tests/Feature/AdminAccessAuthorizationTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Admin\Index as AdminIndex;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('unauthenticated user cannot access admin route', function () {
|
||||||
|
$response = $this->get('/admin');
|
||||||
|
|
||||||
|
$response->assertRedirect('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated non-root user gets 403 on admin page', function () {
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$team->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
session(['currentTeam' => ['id' => $team->id]]);
|
||||||
|
|
||||||
|
Livewire::test(AdminIndex::class)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('root user can access admin page in cloud mode', function () {
|
||||||
|
config()->set('constants.coolify.self_hosted', false);
|
||||||
|
|
||||||
|
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||||
|
$rootUser = User::factory()->create(['id' => 0]);
|
||||||
|
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($rootUser);
|
||||||
|
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||||
|
|
||||||
|
Livewire::test(AdminIndex::class)
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('root user gets 403 on admin page in self-hosted non-dev mode', function () {
|
||||||
|
config()->set('constants.coolify.self_hosted', true);
|
||||||
|
config()->set('app.env', 'production');
|
||||||
|
|
||||||
|
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||||
|
$rootUser = User::factory()->create(['id' => 0]);
|
||||||
|
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($rootUser);
|
||||||
|
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||||
|
|
||||||
|
Livewire::test(AdminIndex::class)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitSearch requires admin authorization', function () {
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$team->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
session(['currentTeam' => ['id' => $team->id]]);
|
||||||
|
|
||||||
|
Livewire::test(AdminIndex::class)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switchUser requires root user id 0', function () {
|
||||||
|
config()->set('constants.coolify.self_hosted', false);
|
||||||
|
|
||||||
|
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||||
|
$rootUser = User::factory()->create(['id' => 0]);
|
||||||
|
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$targetUser = User::factory()->create();
|
||||||
|
$targetTeam = Team::factory()->create();
|
||||||
|
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($rootUser);
|
||||||
|
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||||
|
|
||||||
|
Livewire::test(AdminIndex::class)
|
||||||
|
->assertOk()
|
||||||
|
->call('switchUser', $targetUser->id)
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switchUser rejects non-root user', function () {
|
||||||
|
config()->set('constants.coolify.self_hosted', false);
|
||||||
|
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$team->members()->attach($user->id, ['role' => 'admin']);
|
||||||
|
|
||||||
|
// Must set impersonating session to bypass mount() check
|
||||||
|
$this->actingAs($user);
|
||||||
|
session([
|
||||||
|
'currentTeam' => ['id' => $team->id],
|
||||||
|
'impersonating' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(AdminIndex::class)
|
||||||
|
->call('switchUser', 999)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin route has auth middleware applied', function () {
|
||||||
|
$route = collect(app('router')->getRoutes()->getRoutesByName())
|
||||||
|
->get('admin.index');
|
||||||
|
|
||||||
|
expect($route)->not->toBeNull();
|
||||||
|
|
||||||
|
$middleware = $route->gatherMiddleware();
|
||||||
|
|
||||||
|
expect($middleware)->toContain('auth');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue