Key the password-confirmation marker by guard and identity
confirmPassword() already checked the right account per guard, but the session marker it stamped, auth.password_confirmed_at, was one flat key shared by every guard. In shared-host mode the portal and console guards keep their login in the same session, so confirming a customer's portal password left the marker in place for an operator-only gate too. One private method now computes the key for all three callers so they cannot drift apart again.feat/operator-identity
parent
e8bd8268fd
commit
a5db914696
|
|
@ -14,10 +14,20 @@ use Illuminate\Support\Str;
|
|||
* credentials. The threat is not a stranger — it is a colleague, or anyone, at
|
||||
* an unlocked machine.
|
||||
*
|
||||
* Stored in the SAME session key Laravel's own `password.confirm` middleware
|
||||
* uses, so a confirmation made here also satisfies any framework route behind
|
||||
* that middleware, and vice versa. Reinventing the marker would have produced
|
||||
* two independent notions of "recently confirmed", one of which would drift.
|
||||
* 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.
|
||||
|
|
@ -34,7 +44,7 @@ trait ConfirmsPassword
|
|||
|
||||
public function passwordRecentlyConfirmed(): bool
|
||||
{
|
||||
$at = (int) session('auth.password_confirmed_at', 0);
|
||||
$at = (int) session($this->passwordConfirmationSessionKey(), 0);
|
||||
|
||||
return $at > 0 && (time() - $at) < $this->confirmationWindow();
|
||||
}
|
||||
|
|
@ -80,13 +90,30 @@ trait ConfirmsPassword
|
|||
// a session id that was already known to someone else must not be the
|
||||
// one that gets the extra authority.
|
||||
session()->regenerate();
|
||||
session(['auth.password_confirmed_at' => time()]);
|
||||
session([$this->passwordConfirmationSessionKey() => time()]);
|
||||
}
|
||||
|
||||
/** Give up the confirmation early — leaving a page should not keep it open. */
|
||||
public function forgetPasswordConfirmation(): void
|
||||
{
|
||||
session()->forget('auth.password_confirmed_at');
|
||||
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}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -86,3 +86,42 @@ it('resolves the operator explicitly by guard, not by whichever guard happens to
|
|||
->call('confirmPassword')
|
||||
->assertHasNoErrors();
|
||||
});
|
||||
|
||||
it("does not let a portal customer's password confirmation satisfy the console's own gate", function () {
|
||||
// Codex R15 P1a: confirmPassword() always checked the RIGHT account per
|
||||
// guard (the tests above), but until now it stamped a single
|
||||
// guard-agnostic session marker — auth.password_confirmed_at, no guard or
|
||||
// identity in the key — that ANY confirmationGuard() then accepted. In
|
||||
// shared-host mode (this suite's default, and this VM's own mode) the
|
||||
// portal and console guards keep their login key in the SAME session, so
|
||||
// signing into both and confirming the CUSTOMER's password on the portal
|
||||
// settings page left the marker in place for the OPERATOR-only Secrets
|
||||
// page too. This is the escalation itself, not a proxy for it.
|
||||
$operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']);
|
||||
$customer = User::factory()->create(['password' => 'kunden-passwort']);
|
||||
|
||||
// Signed into both guards, one shared session — the login() calls write
|
||||
// real session data, unlike actingAs() (in-memory user only), so the
|
||||
// marker written by the first call below is genuinely there for the
|
||||
// second to find, exactly as it would be for a real browser carrying
|
||||
// both identities.
|
||||
Auth::guard('web')->login($customer);
|
||||
Auth::guard('operator')->login($operator);
|
||||
|
||||
// Confirm the CUSTOMER's password on the portal settings page.
|
||||
// App\Livewire\Settings::confirmationGuard() defaults to 'web', resolved
|
||||
// explicitly by guard — no actingAs needed to reach the right account,
|
||||
// the login() call above already put it in the session.
|
||||
Livewire::test(App\Livewire\Settings::class)
|
||||
->set('confirmablePassword', 'kunden-passwort')
|
||||
->call('confirmPassword')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Walk into the console as the OPERATOR with that confirmation still
|
||||
// sitting in the session. actingAs() only sets the AMBIENT default guard
|
||||
// Gate reads for Secrets::mount()'s authorize() call — it does not touch
|
||||
// the 'web' guard's own session entry set above, so this is the real
|
||||
// shape of the attack: one browser, two identities, one session.
|
||||
Livewire::actingAs($operator, 'operator')->test(Secrets::class)
|
||||
->assertViewHas('unlocked', false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue