mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
1dc274e896
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.
153 lines
5.6 KiB
PHP
153 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
test('by default nobody is forced into two-factor setup', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get('/dashboard')->assertOk();
|
|
});
|
|
|
|
test('staff enforcement walks un-enrolled staff to the 2fa setup screen', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'staff');
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get('/dashboard')->assertRedirect(route('two-factor.show'));
|
|
});
|
|
|
|
test('staff enforcement leaves clients alone', function () {
|
|
User::factory()->create();
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'staff');
|
|
|
|
$this->actingAs(User::factory()->client()->create());
|
|
|
|
$this->get('/dashboard')->assertOk();
|
|
});
|
|
|
|
test('client enforcement walks un-enrolled clients to the 2fa setup screen', function () {
|
|
User::factory()->create();
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'clients');
|
|
|
|
$this->actingAs(User::factory()->client()->create());
|
|
|
|
$this->get('/dashboard')->assertRedirect(route('two-factor.show'));
|
|
});
|
|
|
|
test('everyone enforcement covers both types', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
$this->get('/dashboard')->assertRedirect(route('two-factor.show'));
|
|
|
|
$this->actingAs(User::factory()->client()->create());
|
|
$this->get('/dashboard')->assertRedirect(route('two-factor.show'));
|
|
});
|
|
|
|
test('the 2fa setup screen itself and logout stay reachable under enforcement', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get('/settings/two-factor')->assertOk();
|
|
// Named rather than bare: enabling is behind password.confirm, so the
|
|
// redirect it answers with is the confirm-password screen. A bare
|
|
// assertRedirect() passes on any target, including this middleware
|
|
// bouncing the request back to two-factor.show, which is the shape
|
|
// this file exists to refuse.
|
|
$this->post('/settings/two-factor')->assertRedirect(route('password.confirm'));
|
|
$this->post('/logout')->assertRedirect('/');
|
|
});
|
|
|
|
test('enrolled users are not redirected under enforcement', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
|
|
|
$user = User::factory()->create(['two_factor_confirmed_at' => now()]);
|
|
|
|
$this->actingAs($user);
|
|
|
|
$this->get('/dashboard')->assertOk();
|
|
});
|
|
|
|
test('staff can change the enforcement setting', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get('/system/settings/security')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('system/settings/security')
|
|
->where('two_factor_enforcement', 'none'),
|
|
);
|
|
|
|
$this->patch('/system/settings/security', [
|
|
'two_factor_enforcement' => 'clients',
|
|
'password_min_length' => 12,
|
|
'password_reject_breached' => true,
|
|
])->assertRedirect();
|
|
|
|
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('clients');
|
|
});
|
|
|
|
test('an invalid enforcement value is rejected', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->patch('/system/settings/security', ['two_factor_enforcement' => 'sometimes'])
|
|
->assertSessionHasErrors('two_factor_enforcement');
|
|
});
|
|
|
|
test('clients cannot access security settings', function () {
|
|
User::factory()->create();
|
|
|
|
$this->actingAs(User::factory()->client()->create());
|
|
|
|
$this->get('/system/settings/security')->assertRedirect(route('dashboard'));
|
|
$this->patch('/system/settings/security', ['two_factor_enforcement' => 'all'])->assertForbidden();
|
|
});
|
|
|
|
/**
|
|
* The exemption list matches on route names, and only the GET half of the
|
|
* confirm-password screen had one. So the form rendered and its submission
|
|
* did not: enforcement sent the POST back to two-factor.show, the password
|
|
* was never confirmed, and enrolment -- the one exit enforcement leaves
|
|
* open -- could not be started by anybody.
|
|
*/
|
|
test('an enforced user can confirm their password, which is what enrolling needs', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->post('/settings/two-factor')->assertRedirect(route('password.confirm'));
|
|
$this->get('/confirm-password')->assertOk();
|
|
|
|
$this->post('/confirm-password', ['password' => 'password'])
|
|
->assertSessionHasNoErrors();
|
|
|
|
expect(session()->has('auth.password_confirmed_at'))->toBeTrue();
|
|
});
|
|
|
|
test('enrolment can actually be started under enforcement', function () {
|
|
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
|
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
$this->post('/confirm-password', ['password' => 'password']);
|
|
|
|
// store() answers back(), so the target is the referer rather than a
|
|
// fixed route -- what matters is that it ran at all instead of being
|
|
// bounced to the confirm-password screen it can no longer get past.
|
|
$this->post('/settings/two-factor')->assertSessionHasNoErrors();
|
|
|
|
// The secret is what enabling writes; without it the enrolment screen
|
|
// has no QR code to show and there is nothing to confirm against.
|
|
expect($user->refresh()->two_factor_secret)->not->toBeNull();
|
|
|
|
$this->get('/settings/two-factor')->assertOk()->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('pending', true)
|
|
);
|
|
});
|