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
|
* credentials. The threat is not a stranger — it is a colleague, or anyone, at
|
||||||
* an unlocked machine.
|
* an unlocked machine.
|
||||||
*
|
*
|
||||||
* Stored in the SAME session key Laravel's own `password.confirm` middleware
|
* The marker is keyed on the confirming GUARD and that guard's own ACCOUNT id
|
||||||
* uses, so a confirmation made here also satisfies any framework route behind
|
* (passwordConfirmationSessionKey()) — it used to be one flat key shared by
|
||||||
* that middleware, and vice versa. Reinventing the marker would have produced
|
* every guard, the same one Laravel's own `password.confirm` middleware
|
||||||
* two independent notions of "recently confirmed", one of which would drift.
|
* 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
|
* 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.
|
* limit it is an unauthenticated-feeling place to guess at a known account.
|
||||||
|
|
@ -34,7 +44,7 @@ trait ConfirmsPassword
|
||||||
|
|
||||||
public function passwordRecentlyConfirmed(): bool
|
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();
|
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
|
// a session id that was already known to someone else must not be the
|
||||||
// one that gets the extra authority.
|
// one that gets the extra authority.
|
||||||
session()->regenerate();
|
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. */
|
/** Give up the confirmation early — leaving a page should not keep it open. */
|
||||||
public function forgetPasswordConfirmation(): void
|
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')
|
->call('confirmPassword')
|
||||||
->assertHasNoErrors();
|
->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