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(); -});