136 lines
5.1 KiB
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);
|
|
}
|
|
}
|