From 52cf81abca12455583ce80706268e20e19ce9701 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Wed, 20 May 2026 01:51:31 +0200 Subject: [PATCH] fix(auth): route all nostrLoggedIn listeners through signed-event verification The previous commit only updated auth-button + the WithNostrAuth trait, but six Volt pages (profile, benefits, election/*, members/admin) carry their own handleNostrLoggedIn(string $pubkey) handlers. The dispatched payload is now an array, so Livewire's container could not resolve the string parameter and threw BindingResolutionException on every login. - All six per-page handlers now accept the signed event and route it through NostrAuth::loginWithSignedEvent() like the trait does. - NostrAuth: add currentOrIssueChallenge() so the sidebar + navbar auth-button mounts share one live session challenge instead of overwriting each other. - verifySignedEvent: pass a normalized stdClass to swentel's verify() directly, skipping an unnecessary json_encode + json_decode round-trip. - auth-button: gate the global Escape/Tab capture so it only intercepts keys while the overlay is actually visible. - Update three test files that still called handleNostrLoggedIn with a raw pubkey to authenticate via NostrAuth::login() instead. --- app/Support/NostrAuth.php | 28 +++++++++++++++++-- .../livewire/association/benefits.blade.php | 4 +-- .../association/election/admin.blade.php | 4 +-- .../association/election/index.blade.php | 4 +-- .../association/election/show.blade.php | 4 +-- .../association/members/admin.blade.php | 4 ++- .../livewire/association/profile.blade.php | 4 ++- .../views/livewire/auth-button.blade.php | 9 ++++-- .../Livewire/Association/ElectionTest.php | 9 ++++-- .../Association/Members/AdminTest.php | 14 ++++++---- .../Livewire/Association/ProfileTest.php | 15 ++++++---- 11 files changed, 68 insertions(+), 31 deletions(-) diff --git a/app/Support/NostrAuth.php b/app/Support/NostrAuth.php index dd1bbce..2a74d40 100644 --- a/app/Support/NostrAuth.php +++ b/app/Support/NostrAuth.php @@ -39,6 +39,25 @@ class NostrAuth return $challenge; } + /** + * Return the active session challenge if still valid, otherwise issue a + * fresh one. Keeps the sidebar + navbar auth-button mounts in sync — + * without this, the second mount would overwrite the first in the + * session and leave the first instance's rendered data-attribute + * pointing at a stale challenge. + */ + public static function currentOrIssueChallenge(): string + { + $existing = Session::get(self::CHALLENGE_SESSION_KEY); + $expiresAt = (int) Session::get(self::CHALLENGE_EXPIRES_SESSION_KEY, 0); + + if (is_string($existing) && $existing !== '' && $expiresAt >= now()->timestamp) { + return $existing; + } + + return self::issueChallenge(); + } + /** * Verify a signed NIP-42-style login event and log the holder of the pubkey in. * @@ -106,7 +125,10 @@ class NostrAuth && $expiresAt >= now()->timestamp && hash_equals($expectedChallenge, $challengeFromEvent); - $eventJson = json_encode([ + // swentel's verify() accepts an object directly, so we pass a normalized + // stdClass and skip an unnecessary json_encode + json_decode round-trip. + // Casts here enforce the property types swentel checks against. + $normalizedEvent = (object) [ 'id' => (string) $signedEvent['id'], 'pubkey' => (string) $signedEvent['pubkey'], 'created_at' => $createdAt, @@ -114,11 +136,11 @@ class NostrAuth 'tags' => $signedEvent['tags'], 'content' => (string) $signedEvent['content'], 'sig' => (string) $signedEvent['sig'], - ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + ]; $sigValid = false; try { - $sigValid = (new NostrEvent)->verify($eventJson); + $sigValid = (new NostrEvent)->verify($normalizedEvent); } catch (\Throwable) { $sigValid = false; } diff --git a/resources/views/livewire/association/benefits.blade.php b/resources/views/livewire/association/benefits.blade.php index 03ca70a..f3a07a9 100644 --- a/resources/views/livewire/association/benefits.blade.php +++ b/resources/views/livewire/association/benefits.blade.php @@ -109,9 +109,9 @@ new class extends Component Flux::toast('Watchtower-Adresse in die Zwischenablage kopiert!'); } - public function handleNostrLoggedIn(string $pubkey): void + public function handleNostrLoggedIn($signedEvent = null): void { - NostrAuth::login($pubkey); + NostrAuth::loginWithSignedEvent($signedEvent); $this->mount(); } diff --git a/resources/views/livewire/association/election/admin.blade.php b/resources/views/livewire/association/election/admin.blade.php index eeb59dc..f75fb4d 100644 --- a/resources/views/livewire/association/election/admin.blade.php +++ b/resources/views/livewire/association/election/admin.blade.php @@ -60,9 +60,9 @@ new class extends Component { } } - public function handleNostrLoggedIn(string $pubkey): void + public function handleNostrLoggedIn($signedEvent = null): void { - NostrAuth::login($pubkey); + $pubkey = NostrAuth::loginWithSignedEvent($signedEvent); $this->currentPubkey = $pubkey; $this->currentPleb = \App\Models\EinundzwanzigPleb::query() diff --git a/resources/views/livewire/association/election/index.blade.php b/resources/views/livewire/association/election/index.blade.php index 5f0b385..fc8c5ad 100644 --- a/resources/views/livewire/association/election/index.blade.php +++ b/resources/views/livewire/association/election/index.blade.php @@ -39,9 +39,9 @@ new class extends Component { } } - public function handleNostrLoggedIn(string $pubkey): void + public function handleNostrLoggedIn($signedEvent = null): void { - NostrAuth::login($pubkey); + $pubkey = NostrAuth::loginWithSignedEvent($signedEvent); $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() diff --git a/resources/views/livewire/association/election/show.blade.php b/resources/views/livewire/association/election/show.blade.php index c13737b..7f59117 100644 --- a/resources/views/livewire/association/election/show.blade.php +++ b/resources/views/livewire/association/election/show.blade.php @@ -207,7 +207,7 @@ new class extends Component { } } - public function handleNostrLoggedIn(string $pubkey): void + public function handleNostrLoggedIn($signedEvent = null): void { $executed = RateLimiter::attempt( 'nostr-login:'.request()->ip(), @@ -219,7 +219,7 @@ new class extends Component { abort(429, 'Too many login attempts.'); } - NostrAuth::login($pubkey); + $pubkey = NostrAuth::loginWithSignedEvent($signedEvent); $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() diff --git a/resources/views/livewire/association/members/admin.blade.php b/resources/views/livewire/association/members/admin.blade.php index e6f36e3..15a3eb4 100644 --- a/resources/views/livewire/association/members/admin.blade.php +++ b/resources/views/livewire/association/members/admin.blade.php @@ -69,8 +69,10 @@ new class extends Component $this->plebs = $this->loadPlebs(); } - public function handleNostrLoggedIn(string $pubkey): void + public function handleNostrLoggedIn($signedEvent = null): void { + $pubkey = NostrAuth::loginWithSignedEvent($signedEvent); + $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() ->where('pubkey', $pubkey)->first(); diff --git a/resources/views/livewire/association/profile.blade.php b/resources/views/livewire/association/profile.blade.php index 169bad5..02bc110 100644 --- a/resources/views/livewire/association/profile.blade.php +++ b/resources/views/livewire/association/profile.blade.php @@ -166,8 +166,10 @@ new class extends Component { $this->profileForm->nip05Handle = strtolower($this->profileForm->nip05Handle); } - public function handleNostrLoggedIn(string $pubkey): void + public function handleNostrLoggedIn($signedEvent = null): void { + $pubkey = NostrAuth::loginWithSignedEvent($signedEvent); + $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() ->with([ diff --git a/resources/views/livewire/auth-button.blade.php b/resources/views/livewire/auth-button.blade.php index a246d8b..46a1f04 100644 --- a/resources/views/livewire/auth-button.blade.php +++ b/resources/views/livewire/auth-button.blade.php @@ -21,7 +21,10 @@ new class extends Component $this->isLoggedIn = NostrAuth::check(); if (! $this->isLoggedIn) { - $this->nostrChallenge = NostrAuth::issueChallenge(); + // Sidebar + navbar mount the same component on the same page; using + // currentOrIssueChallenge keeps both rendered data-attributes + // pointing at the same live session value. + $this->nostrChallenge = NostrAuth::currentOrIssueChallenge(); } } @@ -87,8 +90,8 @@ new class extends Component x-bind:aria-busy="nostrLoginInProgress" aria-labelledby="nostr-login-progress-heading-{{ $location }}" aria-describedby="nostr-login-progress-description-{{ $location }}" - @keydown.window.escape.prevent.stop - @keydown.window.tab.prevent.stop + @keydown.window.escape="if (nostrLoginInProgress) { $event.preventDefault(); $event.stopPropagation(); }" + @keydown.window.tab="if (nostrLoginInProgress) { $event.preventDefault(); $event.stopPropagation(); }" x-effect="document.body.style.overflow = nostrLoginInProgress ? 'hidden' : ''" class="fixed inset-0 z-[100] flex items-center justify-center bg-zinc-950/70 backdrop-blur-md">
diff --git a/tests/Feature/Livewire/Association/ElectionTest.php b/tests/Feature/Livewire/Association/ElectionTest.php index b72ec5a..8de522d 100644 --- a/tests/Feature/Livewire/Association/ElectionTest.php +++ b/tests/Feature/Livewire/Association/ElectionTest.php @@ -57,8 +57,9 @@ it('grants access to authorized users in election admin', function () { $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); $election = Election::factory()->create(); + NostrAuth::login($pleb->pubkey); + Livewire::test('association.election.admin', ['election' => $election]) - ->call('handleNostrLoggedIn', $pleb->pubkey) ->assertSet('isAllowed', true); }); @@ -94,8 +95,9 @@ it('can create vote event', function () { $pleb = EinundzwanzigPleb::factory()->active()->create(); $candidatePubkey = 'test-candidate-pubkey'; + NostrAuth::login($pleb->pubkey); + Livewire::test('association.election.show', ['election' => $election]) - ->call('handleNostrLoggedIn', $pleb->pubkey) ->call('vote', $candidatePubkey, 'presidency', false) ->assertSet('signThisEvent', function ($event) use ($candidatePubkey) { return str_contains($event, $candidatePubkey); @@ -116,8 +118,9 @@ it('displays log for authorized users', function () { $pleb = EinundzwanzigPleb::factory()->active()->create(); $election = Election::factory()->create(); + NostrAuth::login($pleb->pubkey); + Livewire::test('association.election.show', ['election' => $election]) - ->call('handleNostrLoggedIn', $pleb->pubkey) ->assertSet('isAllowed', true) ->assertSet('currentPubkey', $pleb->pubkey); }); diff --git a/tests/Feature/Livewire/Association/Members/AdminTest.php b/tests/Feature/Livewire/Association/Members/AdminTest.php index 1a3d6cc..4a7e2b8 100644 --- a/tests/Feature/Livewire/Association/Members/AdminTest.php +++ b/tests/Feature/Livewire/Association/Members/AdminTest.php @@ -34,26 +34,28 @@ it('grants access to authorized pubkeys', function () { ->assertSet('isAllowed', true); }); -it('handles nostr login for authorized user', function () { +it('reflects an authorized nostr session on mount', function () { $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033'; - $pleb = EinundzwanzigPleb::factory()->create([ + EinundzwanzigPleb::factory()->create([ 'pubkey' => $allowedPubkey, ]); + NostrAuth::login($allowedPubkey); + Livewire::test('association.members.admin') - ->call('handleNostrLoggedIn', $allowedPubkey) ->assertSet('isAllowed', true) ->assertSet('currentPubkey', $allowedPubkey); }); -it('handles nostr logout', function () { +it('clears state on nostr logout', function () { $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033'; - $pleb = EinundzwanzigPleb::factory()->create([ + EinundzwanzigPleb::factory()->create([ 'pubkey' => $allowedPubkey, ]); + NostrAuth::login($allowedPubkey); + Livewire::test('association.members.admin') - ->call('handleNostrLoggedIn', $allowedPubkey) ->call('handleNostrLoggedOut') ->assertSet('isAllowed', false) ->assertSet('currentPubkey', null); diff --git a/tests/Feature/Livewire/Association/ProfileTest.php b/tests/Feature/Livewire/Association/ProfileTest.php index 6c77d0f..d74d10c 100644 --- a/tests/Feature/Livewire/Association/ProfileTest.php +++ b/tests/Feature/Livewire/Association/ProfileTest.php @@ -24,22 +24,24 @@ it('rejects non-string values for the nip05Handle field', function () { ->assertStatus(422); }); -it('handles nostr login correctly', function () { +it('reflects an authenticated nostr session on mount', function () { $pleb = EinundzwanzigPleb::factory()->create(); + NostrAuth::login($pleb->pubkey); + Livewire::test('association.profile') - ->call('handleNostrLoggedIn', $pleb->pubkey) ->assertSet('currentPubkey', $pleb->pubkey) ->assertSet('currentPleb.pubkey', $pleb->pubkey); }); -it('handles nostr login for active member and initializes payment state', function () { +it('initializes payment state for an active member on mount', function () { $pleb = EinundzwanzigPleb::factory()->active()->create(); expect($pleb->paymentEvents()->count())->toBe(0); + NostrAuth::login($pleb->pubkey); + Livewire::test('association.profile') - ->call('handleNostrLoggedIn', $pleb->pubkey) ->assertSet('currentPubkey', $pleb->pubkey) ->assertSet('currentPleb.pubkey', $pleb->pubkey) ->assertSet('amountToPay', config('app.env') === 'production' ? 21000 : 1); @@ -47,11 +49,12 @@ it('handles nostr login for active member and initializes payment state', functi expect($pleb->paymentEvents()->count())->toBeGreaterThan(0); }); -it('handles nostr logout correctly', function () { +it('clears state on nostr logout', function () { $pleb = EinundzwanzigPleb::factory()->create(); + NostrAuth::login($pleb->pubkey); + Livewire::test('association.profile') - ->call('handleNostrLoggedIn', $pleb->pubkey) ->call('handleNostrLoggedOut') ->assertSet('currentPubkey', null) ->assertSet('currentPleb', null);