Stop the console serving the portal's sign-in page

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.
feat/operator-identity
nexxo 2026-07-28 11:45:34 +02:00
parent 3f318c3f6a
commit 8f8a0ad4f7
7 changed files with 42 additions and 254 deletions

View File

@ -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',
];

View File

@ -1,21 +0,0 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\LoginResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Where an ordinary sign-in lands. See LandsWhereSignedIn for the decision;
* TwoFactorLoginResponse below makes the same one after a challenge.
*/
class ConsoleAwareLoginResponse implements LoginResponse
{
use LandsWhereSignedIn;
public function toResponse($request): Response
{
return $this->landing($request, new JsonResponse(['two_factor' => false], 200));
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Where a sign-in completed by two-factor challenge lands.
*
* Fortify never reaches LoginResponse on this path, so binding only that one
* left every account with two-factor enabled which is the ones most likely to
* be operators landing in the customer portal.
*/
class ConsoleAwareTwoFactorLoginResponse implements TwoFactorLoginResponse
{
use LandsWhereSignedIn;
public function toResponse($request): Response
{
// Fortify answers an empty 204 here, not a body. Kept.
return $this->landing($request, new JsonResponse('', 204));
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Http\Responses;
use App\Support\AdminArea;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;
/**
* Where a completed sign-in lands, shared by both ways of completing one.
*
* Fortify has two exits: LoginResponse for an ordinary sign-in, and
* TwoFactorLoginResponse after a challenge. Binding only the first left every
* account with two-factor enabled on the old behaviour landing in the
* customer portal, and on a console-only host landing on a 404. Whichever exit
* is taken, the decision has to be the same one.
*/
trait LandsWhereSignedIn
{
protected function landing($request, JsonResponse $success): Response
{
// Where they were heading counts as much as where they posted from: in
// shared mode everyone signs in at /login, so the request itself never
// looks like the console even when /admin is the destination.
$onConsole = AdminArea::isConsole($request)
|| AdminArea::pointsAtConsole($request->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'));
}
}

View File

@ -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);

View File

@ -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');

View File

@ -1,123 +0,0 @@
<?php
use App\Models\Customer;
use App\Models\User;
/**
* Signing in lands where it was performed.
*
* Fortify sends everyone to /dashboard. That is why an operator who signed in
* ended up in the customer portal and once the console has a hostname to
* itself, /dashboard does not exist there at all, so it would have been a 404.
*/
beforeEach(function () {
config()->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();
});