Files
denkfabrik-li 1dc274e896 Let an enforced user reach the far side of the confirm-password screen
EnforceTwoFactor exempts by route name, and only the GET half of
confirm-password has one. routes/auth.php:95 names the form
`password.confirm`; :98 registers its submission with no name at all, and
Route::named() answers false for a null name.

So the loop the exemption exists to prevent is still there, one step
further along. With Setting::TwoFactorEnforcement set to staff, clients
or all, an un-enrolled account walks:

  GET   /dashboard                  -> two-factor.show
  GET   /system/settings/security   -> two-factor.show
  PATCH /system/settings/security   -> two-factor.show
  POST  /settings/two-factor        -> /confirm-password   (RequirePassword)
  GET   /confirm-password           -> 200, the form renders
  POST  /confirm-password           -> two-factor.show     <- not exempt

`auth.password_confirmed_at` is never written, so enrolling can never
start, and every route that is not on the exemption list stays shut --
including Settings -> Security, the one screen that could turn
enforcement back off. Logout is the only door left; recovery is CLI or
database access. It takes one administrator turning the setting on to
reach it, and it reaches every account on the installation at once,
including their own.

The fix is the name. `password.confirm*` then covers both halves of one
screen, matching `two-factor.*` in the same expression; the namespace
belongs entirely to a flow enrolment already depends on being reachable,
and the route table has nothing else under it -- `password.confirm` (GET)
and `password.confirm.store` (POST) are the two it reaches.

Exempting the submission grants nothing further. store() validates the
password, writes a session flag and redirects; the redirect it issues
enters this middleware like any other request, so Settings -> Security is
still answered with two-factor.show after confirming. What changes is
that enrolment can now be started.

Two tests, both measured red against the unfixed middleware: the password
confirmation sticks, and enrolment can be started afterwards (the secret
is written and the screen reports `pending`).

Also named the redirect the existing test settles for. `->assertRedirect()`
with no target passes on this middleware bouncing the request back to
two-factor.show, which is the shape that file exists to refuse. It is a
clarification rather than a guard -- that assertion is green either way,
since the redirect it sees comes from RequirePassword.

Full suite passes (2050 passed / 2 skipped), PHPStan level 8 clean.
2026-08-27 23:53:53 +02:00

60 lines
2.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Middleware;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\WriteSafeRedirect;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* When the installation enforces two-factor authentication for the
* user's type, an un-enrolled user can only reach the 2FA setup screen
* (and the exits: logout, locale) until they enable it.
*/
class EnforceTwoFactor
{
public function __construct(
private readonly Settings $settings,
) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null || $user->hasTwoFactorEnabled()) {
return $next($request);
}
$value = $this->settings->get(Setting::TwoFactorEnforcement);
$enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null)
?? TwoFactorEnforcement::None;
if (! $enforcement->appliesTo($user->type)) {
return $next($request);
}
// password.confirm* is on this list because the two-factor mutation
// routes now require it: without the exemption, enrolling would
// redirect to the confirm-password screen, which this middleware
// would redirect straight back to two-factor.show — a loop that
// locks the user out of the only exit.
//
// The pattern covers both halves of that screen. Naming only the
// GET left the form rendering and its submission redirected away,
// so the password was never confirmed and the loop stayed shut
// one step further along than before.
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
return $next($request);
}
return WriteSafeRedirect::apply($request, redirect()->route('two-factor.show')->with('two_factor_enforced_notice', true));
}
}