CluPilotCloud/app/Livewire/Concerns/ConfirmsPassword.php

136 lines
5.1 KiB
PHP

<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
/**
* A password re-entry gate in front of the dangerous parts of a page.
*
* Used by anything where holding a signed-in session should not be enough on
* its own: setting up two-factor authentication, and reading or changing stored
* credentials. The threat is not a stranger — it is a colleague, or anyone, at
* an unlocked machine.
*
* The marker is keyed on the confirming GUARD and that guard's own ACCOUNT id
* (passwordConfirmationSessionKey()) — it used to be one flat key shared by
* every guard, the same one Laravel's own `password.confirm` middleware
* uses. That worked only as long as one session ever held one identity. In
* shared-host mode (this VM's own mode) the portal and console guards
* coexist in the SAME session, so a customer confirming their portal
* password and an operator confirming an operator-only action both wrote and
* read that identical flat key — confirming as one identity silently
* satisfied the gate for the other (Codex R15, P1a). Scoping to guard AND
* identity closes both halves at once: a marker cannot cross a guard
* boundary, and cannot survive switching to a different account on the same
* guard either. No framework route in this app runs Laravel's own
* password.confirm middleware (checked) — if that changes, it needs the same
* scoping or this gap reopens.
*
* Rate-limited, because a confirmation form is a password oracle: without a
* limit it is an unauthenticated-feeling place to guess at a known account.
*/
trait ConfirmsPassword
{
public string $confirmablePassword = '';
/** How long one confirmation lasts. Laravel's own default. */
protected function confirmationWindow(): int
{
return (int) config('auth.password_timeout', 10800);
}
public function passwordRecentlyConfirmed(): bool
{
$at = (int) session($this->passwordConfirmationSessionKey(), 0);
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
{
$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', [
'seconds' => RateLimiter::availableIn($key),
]));
return;
}
if ($account === null || ! Hash::check($this->confirmablePassword, $account->password)) {
RateLimiter::hit($key, 60);
$this->addError('confirmablePassword', __('admin_settings.password_wrong'));
$this->reset('confirmablePassword');
return;
}
RateLimiter::clear($key);
$this->reset('confirmablePassword');
// Regenerated first: a confirmation raises what this session can do, and
// a session id that was already known to someone else must not be the
// one that gets the extra authority.
session()->regenerate();
session([$this->passwordConfirmationSessionKey() => time()]);
}
/** Give up the confirmation early — leaving a page should not keep it open. */
public function forgetPasswordConfirmation(): void
{
session()->forget($this->passwordConfirmationSessionKey());
}
/**
* The one place this marker's session key is computed.
*
* passwordRecentlyConfirmed(), confirmPassword() and
* forgetPasswordConfirmation() all go through this rather than each
* building the string themselves, so they cannot drift apart from one
* another — which is exactly how the guard-agnostic key this replaces
* came to be read by one guard and written by another.
*/
private function passwordConfirmationSessionKey(): string
{
$guard = $this->confirmationGuard();
$identity = auth()->guard($guard)->user()?->getAuthIdentifier() ?? 'guest';
return "auth.password_confirmed_at.{$guard}.{$identity}";
}
/**
* A value shown only in outline: the last four characters, and nothing else.
*
* Enough to tell two keys apart, useless to anyone reading over a shoulder
* or scrolling back through a screen recording.
*/
protected function outline(?string $value, int $keep = 4): ?string
{
if ($value === null || $value === '') {
return null;
}
return Str::length($value) <= $keep
? str_repeat('•', Str::length($value))
: str_repeat('•', 8).Str::substr($value, -$keep);
}
}