Confirm a console password against the console's own record

feat/operator-identity
nexxo 2026-07-28 11:17:34 +02:00
parent f54d00b3e7
commit 513cadc5ce
5 changed files with 71 additions and 2 deletions

View File

@ -50,6 +50,11 @@ class Mail extends Component
*/
public bool $usable = true;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
$this->authorize('mail.manage');

View File

@ -44,6 +44,11 @@ class Secrets extends Component
/** Result of the last connection test, for display only. */
public ?array $check = null;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(): void
{
$this->authorize('secrets.manage');

View File

@ -39,9 +39,23 @@ trait ConfirmsPassword
return $at > 0 && (time() - $at) < $this->confirmationWindow();
}
/**
* Which guard's record this component confirms against.
*
* Overridden to 'operator' by the console components. A default of 'web'
* keeps the portal working unchanged and an override is explicit, where
* sniffing the request would be one more thing to get wrong on a page that
* gates access to credentials.
*/
protected function confirmationGuard(): string
{
return 'web';
}
public function confirmPassword(): void
{
$key = 'confirm-password:'.auth()->id().'|'.request()->ip();
$account = auth()->guard($this->confirmationGuard())->user();
$key = 'confirm-password:'.$this->confirmationGuard().'|'.$account?->getAuthIdentifier().'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('confirmablePassword', __('auth.throttle', [
@ -51,7 +65,7 @@ trait ConfirmsPassword
return;
}
if (! Hash::check($this->confirmablePassword, auth()->user()->password)) {
if ($account === null || ! Hash::check($this->confirmablePassword, $account->password)) {
RateLimiter::hit($key, 60);
$this->addError('confirmablePassword', __('admin_settings.password_wrong'));
$this->reset('confirmablePassword');

View File

@ -41,6 +41,11 @@ class EditMailbox extends ModalComponent
/** Whether this mailbox logs in before it sends — Codex R15#4, P1b. */
public bool $authenticates = true;
protected function confirmationGuard(): string
{
return 'operator';
}
public function mount(string $uuid): void
{
$this->authorize('mail.manage');

View File

@ -0,0 +1,40 @@
<?php
use App\Livewire\Admin\Secrets;
use App\Models\Operator;
use App\Models\User;
use Livewire\Livewire;
beforeEach(fn () => config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))));
it('confirms a console password against the operator record, not a portal user', function () {
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
->set('confirmablePassword', 'operator-passwort')
->call('confirmPassword')
->assertHasNoErrors();
});
it('rejects a password that belongs to a portal user with the same address', function () {
// The trap: two tables, one address. Confirming against the wrong one would
// let a customer's password unlock the console.
$operator = Operator::factory()->role('Owner')->create([
'email' => 'doppelt@clupilot.test', 'password' => 'operator-passwort',
]);
User::factory()->create(['email' => 'doppelt@clupilot.test', 'password' => 'kunden-passwort']);
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
->set('confirmablePassword', 'kunden-passwort')
->call('confirmPassword')
->assertHasErrors('confirmablePassword');
});
it('still confirms a portal password on the web guard', function () {
$user = User::factory()->create(['password' => 'kunden-passwort']);
Livewire::actingAs($user)->test(App\Livewire\Settings::class)
->set('confirmablePassword', 'kunden-passwort')
->call('confirmPassword')
->assertHasNoErrors();
});