Send an already signed-in operator back to the console, not the portal

guest:operator's authenticated-user redirect had no console-aware
override, so it fell to Laravel's own default: the named 'dashboard'
route, the portal. An operator revisiting admin.login while already
signed in bounced to the portal's own login instead — in exclusive
mode, to a 404, since dashboard isn't a console route there.

redirectUsersTo() now mirrors the existing redirectGuestsTo() override
right above it, keyed on the same AdminArea::isConsole($request)
condition, so the two read as a pair.
feat/operator-identity
nexxo 2026-07-28 15:55:39 +02:00
parent 92dd6cc2d8
commit f970dda0bb
3 changed files with 53 additions and 7 deletions

View File

@ -1,5 +1,11 @@
<?php
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\PublicSiteGate;
use App\Http\Middleware\RequireOperatorTwoFactor;
use App\Http\Middleware\RestrictAdminHost;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Support\AdminArea;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@ -15,25 +21,25 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
'admin.host' => \App\Http\Middleware\RestrictAdminHost::class,
'customer.active' => \App\Http\Middleware\EnsureCustomerActive::class,
'operator.2fa' => \App\Http\Middleware\RequireOperatorTwoFactor::class,
'admin' => EnsureAdmin::class,
'admin.host' => RestrictAdminHost::class,
'customer.active' => EnsureCustomerActive::class,
'operator.2fa' => RequireOperatorTwoFactor::class,
]);
// Runs before the whole route stack (incl. `auth`), so /admin on a public
// hostname 404s instead of redirecting to /login and thereby revealing
// that an operator console is hosted here. Self-scoped to /admin*.
$middleware->prependToGroup('web', \App\Http\Middleware\RestrictAdminHost::class);
$middleware->prependToGroup('web', RestrictAdminHost::class);
// And WHO may reach it, by network address — the VPN plus whatever the
// owner has listed in the console. The host guard above cannot answer
// that question: a Host header is chosen by the caller.
$middleware->prependToGroup('web', \App\Http\Middleware\RestrictConsoleNetwork::class);
$middleware->prependToGroup('web', RestrictConsoleNetwork::class);
// Runs after the host guard: the console must stay reachable even while
// the public site is switched off.
$middleware->appendToGroup('web', \App\Http\Middleware\PublicSiteGate::class);
$middleware->appendToGroup('web', PublicSiteGate::class);
// TLS is terminated by the reverse proxy (Zoraxy on the private LAN), so
// without this Laravel sees plain http and builds http:// URLs into an
@ -69,6 +75,19 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->redirectGuestsTo(
fn (Request $request) => AdminArea::isConsole($request) ? route('admin.login') : route('login'),
);
// The mirror image of redirectGuestsTo() above, same reasoning: left
// at its default, `guest:operator` sends an already signed-in
// operator revisiting admin.login through Laravel's own fallback —
// the named 'dashboard' route, the PORTAL, where the web guard is
// usually unauthenticated, bouncing them to ITS login instead. In
// exclusive mode it is worse: dashboard is not a console route at
// all, so RestrictAdminHost 404s it on the console host. Keyed on
// the same AdminArea::isConsole($request) condition as the guest
// redirect, so the two read as a pair and neither can drift from it.
$middleware->redirectUsersTo(
fn (Request $request) => AdminArea::isConsole($request) ? route('admin.overview') : route('dashboard'),
);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

View File

@ -28,6 +28,18 @@ it('logs in with valid credentials and redirects to the dashboard', function ()
$this->assertAuthenticatedAs($user);
});
it('still sends an already signed-in customer to the portal dashboard, not the console', function () {
// The mirror of OperatorLoginTest's "sends an already signed-in operator
// back to the console" — the redirectUsersTo() override that fixes that
// is keyed on AdminArea::isConsole($request), so a plain portal request
// must stay on the path it always took.
$user = User::factory()->create();
$this->actingAs($user)
->get('/login')
->assertRedirect('/dashboard');
});
it('rejects invalid credentials', function () {
$user = User::factory()->create(['password' => Hash::make('secret-password')]);

View File

@ -13,6 +13,21 @@ it('offers no way to register — an operator account is not self-served', funct
->assertDontSee(__('auth.sign_up'));
});
it('sends an already signed-in operator back to the console instead of the portal', function () {
// Codex + whole-branch review (M1), independently: guest:operator's
// authenticated-user redirect had no console-aware override, so it fell
// to Laravel's own default — the named 'dashboard' route, the PORTAL.
// The web guard is usually unauthenticated there, so an operator
// revisiting admin.login bounced straight to the portal's OWN login. In
// exclusive mode it is worse: dashboard is not a console route at all,
// so RestrictAdminHost 404s it on the console host.
$operator = Operator::factory()->role('Owner')->create();
$this->actingAs($operator, 'operator')
->get(route('admin.login'))
->assertRedirect(route('admin.overview'));
});
it('signs an operator in on the operator guard', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'richtig-langes-pw']);