From 8f8a0ad4f752840907aac9febdfca00f413a02d3 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 11:45:34 +0200 Subject: [PATCH] Stop the console serving the portal's sign-in page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php — it tested the three deleted response classes. Its intent (an operator's sign-in landing in the console, not the portal) is covered by OperatorLoginTest's "signs an operator in on the operator guard" and OperatorTwoFactorChallengeTest's "signs the operator in once the code actually checks out", both confirmed present before deletion; the route-separation half is covered by the new ConsoleHostSeparationTest cases added in this commit. --- app/Http/Middleware/RestrictAdminHost.php | 10 +- .../Responses/ConsoleAwareLoginResponse.php | 21 --- .../ConsoleAwareTwoFactorLoginResponse.php | 25 ---- app/Http/Responses/LandsWhereSignedIn.php | 62 --------- app/Providers/FortifyServiceProvider.php | 23 ++-- .../Admin/ConsoleHostSeparationTest.php | 32 ++++- .../ConsoleLoginLandsInTheConsoleTest.php | 123 ------------------ 7 files changed, 42 insertions(+), 254 deletions(-) delete mode 100644 app/Http/Responses/ConsoleAwareLoginResponse.php delete mode 100644 app/Http/Responses/ConsoleAwareTwoFactorLoginResponse.php delete mode 100644 app/Http/Responses/LandsWhereSignedIn.php delete mode 100644 tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index 912923f..ce8e147 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -34,6 +34,13 @@ class RestrictAdminHost * Endpoints both the console and the portal need, on whichever host the * caller is currently on. * + * `login`, `logout` and `two-factor-challenge` used to be here, from when + * one sign-in page served both. They are gone: the console has its own at + * `admin.login`, and leaving the portal's here is what put a registration + * link in front of an operator — with a 404 behind it, because `register` + * was never in this list and could not be, since the console has no + * registration by design. + * * Livewire's component endpoint is guarded separately and more strictly: * the console's own middleware is registered as persistent, so an action * posted to /livewire/update is re-checked against the component's real @@ -41,9 +48,6 @@ class RestrictAdminHost */ private const SHARED = [ 'livewire/*', - 'login', - 'logout', - 'two-factor-challenge', 'up', ]; diff --git a/app/Http/Responses/ConsoleAwareLoginResponse.php b/app/Http/Responses/ConsoleAwareLoginResponse.php deleted file mode 100644 index ee15680..0000000 --- a/app/Http/Responses/ConsoleAwareLoginResponse.php +++ /dev/null @@ -1,21 +0,0 @@ -landing($request, new JsonResponse(['two_factor' => false], 200)); - } -} diff --git a/app/Http/Responses/ConsoleAwareTwoFactorLoginResponse.php b/app/Http/Responses/ConsoleAwareTwoFactorLoginResponse.php deleted file mode 100644 index a857dba..0000000 --- a/app/Http/Responses/ConsoleAwareTwoFactorLoginResponse.php +++ /dev/null @@ -1,25 +0,0 @@ -landing($request, new JsonResponse('', 204)); - } -} diff --git a/app/Http/Responses/LandsWhereSignedIn.php b/app/Http/Responses/LandsWhereSignedIn.php deleted file mode 100644 index 1d10e73..0000000 --- a/app/Http/Responses/LandsWhereSignedIn.php +++ /dev/null @@ -1,62 +0,0 @@ -session()?->get('url.intended')); - - // Authorization first, before any branch. Answering JSON early let a - // non-operator keep the session on the console host that a browser - // request would have had taken away. - if ($onConsole && ! $request->user()?->can('console.view')) { - // Taken away at the moment it was created: the session exists as - // soon as Fortify authenticates, and a guard that merely refuses - // each page afterwards leaves it sitting in the browser. - Auth::guard('web')->logout(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - if ($request->wantsJson()) { - return new JsonResponse(['message' => __('auth.not_an_operator')], 403); - } - - return redirect()->to(AdminArea::home())->withErrors([ - Fortify::username() => __('auth.not_an_operator'), - ]); - } - - // Fortify answers JSON to a JSON client. Replacing that with a redirect - // would break every caller that is not a browser. - if ($request->wantsJson()) { - // The caller supplies it: Fortify answers {"two_factor":false} to an - // ordinary sign-in and an empty 204 after a challenge, and a client - // keying off the status code breaks if the two are merged. - return $success; - } - - return redirect()->intended($onConsole ? AdminArea::home() : Fortify::redirects('login')); - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 720117c..562de21 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -29,21 +29,14 @@ class FortifyServiceProvider extends ServiceProvider */ public function boot(): void { - // Sign-in lands where it was performed: the console on the console's - // hostname, the portal everywhere else. Fortify's own response sends - // everyone to the portal, which on a console-only host is a 404. - // BOTH exits: Fortify never reaches LoginResponse when a two-factor - // challenge was involved, and binding only that one leaves exactly the - // accounts most likely to be operators on the old behaviour. - $this->app->singleton( - \Laravel\Fortify\Contracts\LoginResponse::class, - \App\Http\Responses\ConsoleAwareLoginResponse::class, - ); - $this->app->singleton( - \Laravel\Fortify\Contracts\TwoFactorLoginResponse::class, - \App\Http\Responses\ConsoleAwareTwoFactorLoginResponse::class, - ); - + // No console-aware LoginResponse/TwoFactorLoginResponse bindings here + // any more. Those existed to route ONE shared sign-in to the right + // destination — the console on the console's hostname, the portal + // everywhere else. Now that the console has its own sign-in + // (admin.login) on the operator guard, Fortify only ever authenticates + // the portal, so its own default responses — which redirect to + // Fortify::redirects('login'), the portal dashboard — are already + // correct. Fortify::createUsersUsing(CreateNewUser::class); Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class); Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); diff --git a/tests/Feature/Admin/ConsoleHostSeparationTest.php b/tests/Feature/Admin/ConsoleHostSeparationTest.php index 36ab643..dcbf502 100644 --- a/tests/Feature/Admin/ConsoleHostSeparationTest.php +++ b/tests/Feature/Admin/ConsoleHostSeparationTest.php @@ -89,15 +89,37 @@ describe('exclusive host', function () { ->and(guard('admin.example.test', '/dashboard', 'dashboard'))->toBe('404'); }); - it('lets the shared authentication endpoints through on both hosts', function () { - // Sign-in posts and Livewire's own endpoints are used by both sides. If - // this list is wrong the symptom is "the console is broken", so it is - // written out rather than inferred. - foreach (['/login', '/logout', '/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { + it('lets the shared, nameless endpoints through on both hosts', function () { + // Livewire's own endpoints and the health check are used by both + // sides and never carry an admin.* route name. Sign-in used to be on + // this list too — it is not any more: the console has its own named + // routes (admin.login, admin.two-factor) and reaches them through + // isConsoleRoute(), not through this nameless allowlist. If this list + // is wrong the symptom is "the console is broken", so it is written + // out rather than inferred. + foreach (['/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { expect(guard('admin.example.test', $path, null))->toBe('through', $path); } }); + it('does not serve the portal sign-in or registration on the console host', function () { + // The reported fault, at its root: the console used to answer /login + // with the portal's page, which offers "Registrieren" — and /register + // was never in SHARED, so the link 404'd. + expect(guard('admin.example.test', '/register', 'register'))->toBe('404') + ->and(guard('admin.example.test', '/login', 'login'))->toBe('404'); + }); + + it('still serves the console\'s own sign-in on its host', function () { + expect(guard('admin.example.test', '/login', 'admin.login'))->toBe('through') + ->and(guard('admin.example.test', '/two-factor', 'admin.two-factor'))->toBe('through'); + }); + + it('keeps only the endpoints both sides genuinely share', function () { + expect(guard('admin.example.test', '/livewire/update', null))->toBe('through') + ->and(guard('admin.example.test', '/up', null))->toBe('through'); + }); + it('keeps the console off the public hosts', function () { expect(guard('app.example.test', '/', 'admin.overview'))->toBe('404') ->and(guard('www.example.test', '/customers', 'admin.customers'))->toBe('404'); diff --git a/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php b/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php deleted file mode 100644 index 18b40ce..0000000 --- a/tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php +++ /dev/null @@ -1,123 +0,0 @@ -set('admin_access.hosts', ['admin.example.test']); - config()->set('admin_access.exclusive', true); -}); - -it('refuses an operator at the shared portal login — the account is not in that table any more', function () { - // Before the identity move this landed in the console (that is what this - // file used to prove). Now there is no operator row Fortify's web-guard - // attempt could ever match, on any host — refusing it here is the correct - // behaviour, not a gap Task 5's dedicated console login has yet to close. - $operator = operator('Owner'); - - $this->post('http://admin.example.test/login', [ - 'email' => $operator->email, - 'password' => 'password', - ])->assertSessionHasErrors('email'); - - expect(auth()->check())->toBeFalse(); -}); - -it('sends a customer signing in on the portal host to the portal', function () { - $user = User::factory()->create(); - - $this->post('http://app.example.test/login', [ - 'email' => $user->email, - 'password' => 'password', - ])->assertRedirect('/dashboard'); -}); - -it('refuses to leave a customer holding a session on the console host', function () { - // Fortify authenticates before anything downstream can object, so the - // session already exists. Refusing each page afterwards would leave it in - // the browser; it is taken away at the moment it was created. - $customer = Customer::factory()->create(); - $user = $customer->ensureUser(); - $user->forceFill(['password' => bcrypt('password')])->save(); - - $this->post('http://admin.example.test/login', [ - 'email' => $user->email, - 'password' => 'password', - ])->assertSessionHasErrors('email'); - - expect(auth()->check())->toBeFalse(); -}); - -it('makes the same decision after a two-factor challenge', function () { - // Fortify never reaches LoginResponse on this path. Binding only that one - // left the accounts most likely to be operators landing in the portal. - $operator = operator('Owner'); - - $response = app(Laravel\Fortify\Contracts\TwoFactorLoginResponse::class) - ->toResponse(tap(request(), function ($request) use ($operator) { - $request->headers->set('HOST', 'admin.example.test'); - $request->setUserResolver(fn () => $operator); - })); - - // The console root on the console host — emphatically not the portal. - expect($response->getTargetUrl())->toContain('admin.example.test') - ->and($response->getTargetUrl())->not->toContain('/dashboard'); -}); - -it('gives a JSON client the same refusal as a browser gets', function () { - // bootstrap/app.php restricts JSON exception rendering to api/* paths, so - // a failed /login attempt redirects with session errors regardless of the - // caller's Accept header — there is no separate JSON error shape here for - // an operator's address to diverge from. What still matters is that a - // JSON-flavoured request does not somehow slip past that and end up - // authenticated. - $operator = operator('Owner'); - - $this->postJson('http://admin.example.test/login', [ - 'email' => $operator->email, - 'password' => 'password', - ])->assertRedirect(); - - expect(auth()->check())->toBeFalse(); -}); - -it('does not let a JSON client keep a session a browser would lose', function () { - // Answering JSON early skipped the permission check entirely. - $customer = Customer::factory()->create(); - $user = $customer->ensureUser(); - $user->forceFill(['password' => bcrypt('password')])->save(); - - $this->postJson('http://admin.example.test/login', [ - 'email' => $user->email, - 'password' => 'password', - ])->assertForbidden(); - - expect(auth()->check())->toBeFalse(); -}); - -it('removes the session of a non-operator who was heading for /admin on a shared host', function () { - // In shared mode everyone posts to /login, so the request never looks like - // the console. Without reading the intended destination, a non-operator was - // authenticated, shown a 403 — and left holding the session. - config()->set('admin_access.exclusive', false); - config()->set('admin_access.hosts', []); - - $customer = Customer::factory()->create(); - $user = $customer->ensureUser(); - $user->forceFill(['password' => bcrypt('password')])->save(); - - $this->get('/admin')->assertRedirect('/login'); - - $this->post('/login', ['email' => $user->email, 'password' => 'password']) - ->assertSessionHasErrors('email'); - - expect(auth()->check())->toBeFalse(); -});