151 lines
5.4 KiB
PHP
151 lines
5.4 KiB
PHP
<?php
|
|
|
|
use App\Livewire\ConfirmDisableTwoFactor;
|
|
use App\Livewire\Settings as PortalSettings;
|
|
use App\Models\Customer;
|
|
use Livewire\Livewire;
|
|
use PragmaRX\Google2FA\Google2FA;
|
|
|
|
/**
|
|
* Customers setting up two-factor themselves.
|
|
*
|
|
* The endpoints existed; only the screen was missing. What matters here is the
|
|
* gate: every action is re-checked against a recent password confirmation on
|
|
* the server, because a Livewire action is reachable by anyone who can post to
|
|
* /livewire/update, and "the button was not on screen" has never stopped anyone.
|
|
*/
|
|
|
|
function portalUser(): App\Models\User
|
|
{
|
|
$customer = Customer::factory()->create(['status' => 'active']);
|
|
$user = $customer->ensureUser();
|
|
$user->forceFill(['password' => bcrypt('password')])->save();
|
|
|
|
return $user;
|
|
}
|
|
|
|
it('refuses every two-factor action without a recent password confirmation', function () {
|
|
$user = portalUser();
|
|
|
|
// onTwoFaDisableConfirmed is the R23 forwarding listener
|
|
// ConfirmDisableTwoFactor reaches — it must lead straight into the same
|
|
// guard as disableTwoFactor, not around it.
|
|
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes', 'onTwoFaDisableConfirmed'] as $action) {
|
|
Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->call($action)
|
|
->assertForbidden();
|
|
}
|
|
|
|
expect($user->refresh()->two_factor_secret)->toBeNull();
|
|
});
|
|
|
|
it('takes a password, then sets two-factor up and confirms it with a real code', function () {
|
|
$user = portalUser();
|
|
|
|
$page = Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->assertHasNoErrors();
|
|
|
|
$page->call('enableTwoFactor');
|
|
expect($user->refresh()->two_factor_secret)->not->toBeNull()
|
|
// Not on until a code has been accepted: someone who scans nothing and
|
|
// closes the tab must not be locked out of their own account.
|
|
->and($user->two_factor_confirmed_at)->toBeNull();
|
|
|
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($user->two_factor_secret));
|
|
|
|
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
|
|
|
|
expect($user->refresh()->two_factor_confirmed_at)->not->toBeNull()
|
|
// Shown once, here, because this is the only moment anyone reads them.
|
|
->and($page->get('recoveryCodes'))->toBeArray()->not->toBeEmpty();
|
|
});
|
|
|
|
it('rejects a wrong code instead of switching it on', function () {
|
|
$user = portalUser();
|
|
|
|
Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->call('enableTwoFactor')
|
|
->set('twoFactorCode', '000000')
|
|
->call('confirmTwoFactor')
|
|
->assertHasErrors('twoFactorCode');
|
|
|
|
expect($user->refresh()->two_factor_confirmed_at)->toBeNull();
|
|
});
|
|
|
|
it('does not accept a wrong password, and says so without hinting', function () {
|
|
$user = portalUser();
|
|
|
|
Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->set('confirmablePassword', 'falsch')
|
|
->call('confirmPassword')
|
|
->assertHasErrors('confirmablePassword')
|
|
->call('enableTwoFactor')
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('never puts the secret where the browser can see it', function () {
|
|
// A Livewire property travels to the browser and back in the component
|
|
// snapshot. The QR image is derived from the secret; the secret is not.
|
|
$user = portalUser();
|
|
|
|
$page = Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->call('enableTwoFactor');
|
|
|
|
$secret = decrypt($user->refresh()->two_factor_secret);
|
|
|
|
expect(json_encode($page->snapshot))->not->toContain($secret);
|
|
});
|
|
|
|
// R23: confirmation moved from wire:confirm to ConfirmDisableTwoFactor, which
|
|
// dispatches back to Settings::disableTwoFactor() instead of mutating
|
|
// anything itself.
|
|
|
|
it('confirms disabling two-factor through the modal without turning it off itself', function () {
|
|
$user = portalUser();
|
|
|
|
$page = Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->call('enableTwoFactor');
|
|
|
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($user->refresh()->two_factor_secret));
|
|
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
|
|
|
|
Livewire::actingAs($user)
|
|
->test(ConfirmDisableTwoFactor::class)
|
|
->call('confirm')
|
|
->assertDispatched('twofa-disable-confirmed');
|
|
|
|
expect($user->refresh()->two_factor_confirmed_at)->not->toBeNull();
|
|
});
|
|
|
|
it('disables two-factor once the page receives the confirmed event', function () {
|
|
$user = portalUser();
|
|
|
|
$page = Livewire::actingAs($user)
|
|
->test(PortalSettings::class)
|
|
->set('confirmablePassword', 'password')
|
|
->call('confirmPassword')
|
|
->call('enableTwoFactor');
|
|
|
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($user->refresh()->two_factor_secret));
|
|
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
|
|
|
|
$page->call('onTwoFaDisableConfirmed')->assertHasNoErrors();
|
|
|
|
expect($user->refresh()->two_factor_confirmed_at)->toBeNull()
|
|
->and($user->two_factor_secret)->toBeNull();
|
|
});
|