mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-05-23 13:15:36 +00:00
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.
This commit is contained in:
@@ -39,6 +39,25 @@ class NostrAuth
|
|||||||
return $challenge;
|
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.
|
* 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
|
&& $expiresAt >= now()->timestamp
|
||||||
&& hash_equals($expectedChallenge, $challengeFromEvent);
|
&& 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'],
|
'id' => (string) $signedEvent['id'],
|
||||||
'pubkey' => (string) $signedEvent['pubkey'],
|
'pubkey' => (string) $signedEvent['pubkey'],
|
||||||
'created_at' => $createdAt,
|
'created_at' => $createdAt,
|
||||||
@@ -114,11 +136,11 @@ class NostrAuth
|
|||||||
'tags' => $signedEvent['tags'],
|
'tags' => $signedEvent['tags'],
|
||||||
'content' => (string) $signedEvent['content'],
|
'content' => (string) $signedEvent['content'],
|
||||||
'sig' => (string) $signedEvent['sig'],
|
'sig' => (string) $signedEvent['sig'],
|
||||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
];
|
||||||
|
|
||||||
$sigValid = false;
|
$sigValid = false;
|
||||||
try {
|
try {
|
||||||
$sigValid = (new NostrEvent)->verify($eventJson);
|
$sigValid = (new NostrEvent)->verify($normalizedEvent);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$sigValid = false;
|
$sigValid = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ new class extends Component
|
|||||||
Flux::toast('Watchtower-Adresse in die Zwischenablage kopiert!');
|
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();
|
$this->mount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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->currentPubkey = $pubkey;
|
||||||
$this->currentPleb = \App\Models\EinundzwanzigPleb::query()
|
$this->currentPleb = \App\Models\EinundzwanzigPleb::query()
|
||||||
|
|||||||
@@ -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->currentPubkey = $pubkey;
|
||||||
$this->currentPleb = EinundzwanzigPleb::query()
|
$this->currentPleb = EinundzwanzigPleb::query()
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleNostrLoggedIn(string $pubkey): void
|
public function handleNostrLoggedIn($signedEvent = null): void
|
||||||
{
|
{
|
||||||
$executed = RateLimiter::attempt(
|
$executed = RateLimiter::attempt(
|
||||||
'nostr-login:'.request()->ip(),
|
'nostr-login:'.request()->ip(),
|
||||||
@@ -219,7 +219,7 @@ new class extends Component {
|
|||||||
abort(429, 'Too many login attempts.');
|
abort(429, 'Too many login attempts.');
|
||||||
}
|
}
|
||||||
|
|
||||||
NostrAuth::login($pubkey);
|
$pubkey = NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
|
||||||
$this->currentPubkey = $pubkey;
|
$this->currentPubkey = $pubkey;
|
||||||
$this->currentPleb = EinundzwanzigPleb::query()
|
$this->currentPleb = EinundzwanzigPleb::query()
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ new class extends Component
|
|||||||
$this->plebs = $this->loadPlebs();
|
$this->plebs = $this->loadPlebs();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleNostrLoggedIn(string $pubkey): void
|
public function handleNostrLoggedIn($signedEvent = null): void
|
||||||
{
|
{
|
||||||
|
$pubkey = NostrAuth::loginWithSignedEvent($signedEvent);
|
||||||
|
|
||||||
$this->currentPubkey = $pubkey;
|
$this->currentPubkey = $pubkey;
|
||||||
$this->currentPleb = EinundzwanzigPleb::query()
|
$this->currentPleb = EinundzwanzigPleb::query()
|
||||||
->where('pubkey', $pubkey)->first();
|
->where('pubkey', $pubkey)->first();
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ new class extends Component {
|
|||||||
$this->profileForm->nip05Handle = strtolower($this->profileForm->nip05Handle);
|
$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->currentPubkey = $pubkey;
|
||||||
$this->currentPleb = EinundzwanzigPleb::query()
|
$this->currentPleb = EinundzwanzigPleb::query()
|
||||||
->with([
|
->with([
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ new class extends Component
|
|||||||
$this->isLoggedIn = NostrAuth::check();
|
$this->isLoggedIn = NostrAuth::check();
|
||||||
|
|
||||||
if (! $this->isLoggedIn) {
|
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"
|
x-bind:aria-busy="nostrLoginInProgress"
|
||||||
aria-labelledby="nostr-login-progress-heading-{{ $location }}"
|
aria-labelledby="nostr-login-progress-heading-{{ $location }}"
|
||||||
aria-describedby="nostr-login-progress-description-{{ $location }}"
|
aria-describedby="nostr-login-progress-description-{{ $location }}"
|
||||||
@keydown.window.escape.prevent.stop
|
@keydown.window.escape="if (nostrLoginInProgress) { $event.preventDefault(); $event.stopPropagation(); }"
|
||||||
@keydown.window.tab.prevent.stop
|
@keydown.window.tab="if (nostrLoginInProgress) { $event.preventDefault(); $event.stopPropagation(); }"
|
||||||
x-effect="document.body.style.overflow = nostrLoginInProgress ? 'hidden' : ''"
|
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">
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-zinc-950/70 backdrop-blur-md">
|
||||||
<div class="mx-4 w-full max-w-md rounded-2xl bg-white px-8 py-10 text-center shadow-2xl ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
<div class="mx-4 w-full max-w-md rounded-2xl bg-white px-8 py-10 text-center shadow-2xl ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||||
|
|||||||
@@ -57,8 +57,9 @@ it('grants access to authorized users in election admin', function () {
|
|||||||
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
|
$pleb = EinundzwanzigPleb::factory()->boardMember()->create();
|
||||||
$election = Election::factory()->create();
|
$election = Election::factory()->create();
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.election.admin', ['election' => $election])
|
Livewire::test('association.election.admin', ['election' => $election])
|
||||||
->call('handleNostrLoggedIn', $pleb->pubkey)
|
|
||||||
->assertSet('isAllowed', true);
|
->assertSet('isAllowed', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,8 +95,9 @@ it('can create vote event', function () {
|
|||||||
$pleb = EinundzwanzigPleb::factory()->active()->create();
|
$pleb = EinundzwanzigPleb::factory()->active()->create();
|
||||||
$candidatePubkey = 'test-candidate-pubkey';
|
$candidatePubkey = 'test-candidate-pubkey';
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.election.show', ['election' => $election])
|
Livewire::test('association.election.show', ['election' => $election])
|
||||||
->call('handleNostrLoggedIn', $pleb->pubkey)
|
|
||||||
->call('vote', $candidatePubkey, 'presidency', false)
|
->call('vote', $candidatePubkey, 'presidency', false)
|
||||||
->assertSet('signThisEvent', function ($event) use ($candidatePubkey) {
|
->assertSet('signThisEvent', function ($event) use ($candidatePubkey) {
|
||||||
return str_contains($event, $candidatePubkey);
|
return str_contains($event, $candidatePubkey);
|
||||||
@@ -116,8 +118,9 @@ it('displays log for authorized users', function () {
|
|||||||
$pleb = EinundzwanzigPleb::factory()->active()->create();
|
$pleb = EinundzwanzigPleb::factory()->active()->create();
|
||||||
$election = Election::factory()->create();
|
$election = Election::factory()->create();
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.election.show', ['election' => $election])
|
Livewire::test('association.election.show', ['election' => $election])
|
||||||
->call('handleNostrLoggedIn', $pleb->pubkey)
|
|
||||||
->assertSet('isAllowed', true)
|
->assertSet('isAllowed', true)
|
||||||
->assertSet('currentPubkey', $pleb->pubkey);
|
->assertSet('currentPubkey', $pleb->pubkey);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,26 +34,28 @@ it('grants access to authorized pubkeys', function () {
|
|||||||
->assertSet('isAllowed', true);
|
->assertSet('isAllowed', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles nostr login for authorized user', function () {
|
it('reflects an authorized nostr session on mount', function () {
|
||||||
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
|
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
|
||||||
$pleb = EinundzwanzigPleb::factory()->create([
|
EinundzwanzigPleb::factory()->create([
|
||||||
'pubkey' => $allowedPubkey,
|
'pubkey' => $allowedPubkey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
NostrAuth::login($allowedPubkey);
|
||||||
|
|
||||||
Livewire::test('association.members.admin')
|
Livewire::test('association.members.admin')
|
||||||
->call('handleNostrLoggedIn', $allowedPubkey)
|
|
||||||
->assertSet('isAllowed', true)
|
->assertSet('isAllowed', true)
|
||||||
->assertSet('currentPubkey', $allowedPubkey);
|
->assertSet('currentPubkey', $allowedPubkey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles nostr logout', function () {
|
it('clears state on nostr logout', function () {
|
||||||
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
|
$allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
|
||||||
$pleb = EinundzwanzigPleb::factory()->create([
|
EinundzwanzigPleb::factory()->create([
|
||||||
'pubkey' => $allowedPubkey,
|
'pubkey' => $allowedPubkey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
NostrAuth::login($allowedPubkey);
|
||||||
|
|
||||||
Livewire::test('association.members.admin')
|
Livewire::test('association.members.admin')
|
||||||
->call('handleNostrLoggedIn', $allowedPubkey)
|
|
||||||
->call('handleNostrLoggedOut')
|
->call('handleNostrLoggedOut')
|
||||||
->assertSet('isAllowed', false)
|
->assertSet('isAllowed', false)
|
||||||
->assertSet('currentPubkey', null);
|
->assertSet('currentPubkey', null);
|
||||||
|
|||||||
@@ -24,22 +24,24 @@ it('rejects non-string values for the nip05Handle field', function () {
|
|||||||
->assertStatus(422);
|
->assertStatus(422);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles nostr login correctly', function () {
|
it('reflects an authenticated nostr session on mount', function () {
|
||||||
$pleb = EinundzwanzigPleb::factory()->create();
|
$pleb = EinundzwanzigPleb::factory()->create();
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.profile')
|
Livewire::test('association.profile')
|
||||||
->call('handleNostrLoggedIn', $pleb->pubkey)
|
|
||||||
->assertSet('currentPubkey', $pleb->pubkey)
|
->assertSet('currentPubkey', $pleb->pubkey)
|
||||||
->assertSet('currentPleb.pubkey', $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();
|
$pleb = EinundzwanzigPleb::factory()->active()->create();
|
||||||
|
|
||||||
expect($pleb->paymentEvents()->count())->toBe(0);
|
expect($pleb->paymentEvents()->count())->toBe(0);
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.profile')
|
Livewire::test('association.profile')
|
||||||
->call('handleNostrLoggedIn', $pleb->pubkey)
|
|
||||||
->assertSet('currentPubkey', $pleb->pubkey)
|
->assertSet('currentPubkey', $pleb->pubkey)
|
||||||
->assertSet('currentPleb.pubkey', $pleb->pubkey)
|
->assertSet('currentPleb.pubkey', $pleb->pubkey)
|
||||||
->assertSet('amountToPay', config('app.env') === 'production' ? 21000 : 1);
|
->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);
|
expect($pleb->paymentEvents()->count())->toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles nostr logout correctly', function () {
|
it('clears state on nostr logout', function () {
|
||||||
$pleb = EinundzwanzigPleb::factory()->create();
|
$pleb = EinundzwanzigPleb::factory()->create();
|
||||||
|
|
||||||
|
NostrAuth::login($pleb->pubkey);
|
||||||
|
|
||||||
Livewire::test('association.profile')
|
Livewire::test('association.profile')
|
||||||
->call('handleNostrLoggedIn', $pleb->pubkey)
|
|
||||||
->call('handleNostrLoggedOut')
|
->call('handleNostrLoggedOut')
|
||||||
->assertSet('currentPubkey', null)
|
->assertSet('currentPubkey', null)
|
||||||
->assertSet('currentPleb', null);
|
->assertSet('currentPleb', null);
|
||||||
|
|||||||
Reference in New Issue
Block a user