diff --git a/app/Auth/NostrSessionGuard.php b/app/Auth/NostrSessionGuard.php index 0d2fc13..fb4e7ed 100644 --- a/app/Auth/NostrSessionGuard.php +++ b/app/Auth/NostrSessionGuard.php @@ -38,4 +38,17 @@ class NostrSessionGuard extends SessionGuard return $this->user; } + + /** + * Nostr auth has no remember-me cookie, so we override the parent's + * cookie-clearing logout to avoid CookieJar dependencies in code paths + * (e.g. tests) where the cookie jar isn't bound. + */ + public function logout(): void + { + $this->session->remove($this->getName()); + + $this->user = null; + $this->loggedOut = true; + } } diff --git a/app/Livewire/Traits/WithNostrAuth.php b/app/Livewire/Traits/WithNostrAuth.php index dd44469..8bc8baf 100644 --- a/app/Livewire/Traits/WithNostrAuth.php +++ b/app/Livewire/Traits/WithNostrAuth.php @@ -18,7 +18,7 @@ trait WithNostrAuth public bool $canEdit = false; #[On('nostrLoggedIn')] - public function handleNostrLogin(string $pubkey): void + public function handleNostrLogin($signedEvent = null): void { $executed = RateLimiter::attempt( 'nostr-login:'.request()->ip(), @@ -30,7 +30,7 @@ trait WithNostrAuth abort(429, 'Too many login attempts.'); } - NostrAuth::login($pubkey); + $pubkey = NostrAuth::loginWithSignedEvent($signedEvent); $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() diff --git a/app/Support/NostrAuth.php b/app/Support/NostrAuth.php index a2ba9b0..dd1bbce 100644 --- a/app/Support/NostrAuth.php +++ b/app/Support/NostrAuth.php @@ -5,12 +5,19 @@ namespace App\Support; use App\Auth\NostrUser; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Session; +use Illuminate\Validation\ValidationException; +use swentel\nostr\Event\Event as NostrEvent; class NostrAuth { - /** - * Login a user by their Nostr pubkey - */ + private const CHALLENGE_SESSION_KEY = 'nostr_login_challenge'; + + private const CHALLENGE_EXPIRES_SESSION_KEY = 'nostr_login_challenge_expires_at'; + + private const CHALLENGE_TTL_SECONDS = 300; + + private const LOGIN_EVENT_KIND = 22242; + public static function login(string $pubkey): void { Auth::guard('nostr')->loginByPubkey($pubkey); @@ -18,34 +25,145 @@ class NostrAuth } /** - * Logout the current Nostr user + * Generate a fresh NIP-42-style login challenge, persist it to the session, + * and return it. The frontend embeds this challenge into a kind-22242 event + * that the Nostr signer must sign before we accept the login. */ + public static function issueChallenge(): string + { + $challenge = bin2hex(random_bytes(32)); + + Session::put(self::CHALLENGE_SESSION_KEY, $challenge); + Session::put(self::CHALLENGE_EXPIRES_SESSION_KEY, now()->addSeconds(self::CHALLENGE_TTL_SECONDS)->timestamp); + + return $challenge; + } + + /** + * Verify a signed NIP-42-style login event and log the holder of the pubkey in. + * + * Idempotent across concurrent Livewire listeners: once the challenge has been + * consumed, a second call with the same event still succeeds as long as the + * caller's session is already authenticated with the matching pubkey. + * + * @return string the verified pubkey + */ + public static function loginWithSignedEvent(mixed $signedEvent): string + { + $pubkey = self::verifySignedEvent($signedEvent); + + if (! self::check() || self::pubkey() !== $pubkey) { + self::login($pubkey); + } + + return $pubkey; + } + + /** + * Verify the cryptographic signature of a kind-22242 event and that its + * challenge tag matches the value stored on this session. Consumes the + * stored challenge on success so it cannot be reused. + * + * @return string the verified pubkey + */ + public static function verifySignedEvent(mixed $signedEvent): string + { + if (! is_array($signedEvent)) { + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + + foreach (['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'] as $field) { + if (! array_key_exists($field, $signedEvent)) { + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + } + + if ((int) $signedEvent['kind'] !== self::LOGIN_EVENT_KIND) { + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + + $createdAt = (int) $signedEvent['created_at']; + if (abs(now()->timestamp - $createdAt) > self::CHALLENGE_TTL_SECONDS) { + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + + $challengeFromEvent = null; + foreach ($signedEvent['tags'] as $tag) { + if (is_array($tag) && ($tag[0] ?? null) === 'challenge') { + $challengeFromEvent = (string) ($tag[1] ?? ''); + break; + } + } + + if ($challengeFromEvent === null || $challengeFromEvent === '') { + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + + $expectedChallenge = Session::get(self::CHALLENGE_SESSION_KEY); + $expiresAt = (int) Session::get(self::CHALLENGE_EXPIRES_SESSION_KEY, 0); + $challengeMatchesSession = is_string($expectedChallenge) + && $expectedChallenge !== '' + && $expiresAt >= now()->timestamp + && hash_equals($expectedChallenge, $challengeFromEvent); + + $eventJson = json_encode([ + 'id' => (string) $signedEvent['id'], + 'pubkey' => (string) $signedEvent['pubkey'], + 'created_at' => $createdAt, + 'kind' => self::LOGIN_EVENT_KIND, + '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); + } catch (\Throwable) { + $sigValid = false; + } + + if (! $sigValid) { + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + + $eventPubkey = (string) $signedEvent['pubkey']; + + if ($challengeMatchesSession) { + Session::forget([self::CHALLENGE_SESSION_KEY, self::CHALLENGE_EXPIRES_SESSION_KEY]); + + return $eventPubkey; + } + + // Idempotent path: the challenge has already been consumed (e.g. a + // sibling Livewire listener processed the same event microseconds + // earlier). Only accept if the current session is already + // authenticated with this exact pubkey. + if (self::check() && self::pubkey() === $eventPubkey) { + return $eventPubkey; + } + + throw ValidationException::withMessages(['nostr' => __('auth.failed')]); + } + public static function logout(): void { if (Auth::guard('nostr')->check()) { + Auth::guard('nostr')->logout(); Session::flush(); } } - /** - * Get the currently authenticated Nostr user - */ public static function user(): ?NostrUser { return Auth::guard('nostr')->user(); } - /** - * Check if a Nostr user is authenticated - */ public static function check(): bool { return Auth::guard('nostr')->check(); } - /** - * Get the current pubkey (convenience method) - */ public static function pubkey(): ?string { return self::user()?->getPubkey(); diff --git a/resources/js/nostrLogin.js b/resources/js/nostrLogin.js index dfdc866..219a6fb 100644 --- a/resources/js/nostrLogin.js +++ b/resources/js/nostrLogin.js @@ -1,49 +1,126 @@ export default () => ({ + // Toggled while window.nostr.signEvent is awaiting the wallet so the + // button disables itself, the loading overlay renders, and downstream + // wire:poll consumers can pause if needed. Stays true through the + // backend round-trip — the auth-button component reloads the page on + // success, so resetting the flag here would just flicker the overlay + // away while navigation is already underway. + nostrLoginInProgress: false, - async init() { + /** + * Resolve the active NIP-42 login challenge issued by the auth-button + * Volt component. Falls back to the Livewire $wire proxy and then to a + * server-side re-issue so the user does not have to reload if the + * rendered snapshot drifted out of sync with the session (e.g. a tab + * left open past the challenge TTL). + */ + async resolveChallenge() { + const fromDataset = this.$root?.dataset?.nostrChallenge; + if (typeof fromDataset === 'string' && fromDataset !== '') { + return fromDataset; + } + const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire; + const fromWire = livewireComponent?.$wire?.nostrChallenge; + if (typeof fromWire === 'string' && fromWire !== '') { + return fromWire; + } + + if (livewireComponent?.$wire?.requestNostrChallenge) { + try { + const refreshed = await livewireComponent.$wire.requestNostrChallenge(); + if (typeof refreshed === 'string' && refreshed !== '') { + return refreshed; + } + } catch (error) { + console.error('requestNostrChallenge failed:', error); + } + } + + return null; }, async openNostrLogin() { - console.log('Starting Nostr login...'); - console.log('window.nostr available:', !!window.nostr); + this.nostrLoginInProgress = true; - const pubkey = await window.nostr.getPublicKey(); - console.log('Fetched pubkey:', pubkey); + try { + if (!window.nostr || typeof window.nostr.signEvent !== 'function') { + this.showAuthError('Keine Nostr-Erweiterung gefunden. Bitte installiere einen Nostr-Signer (z.B. nos2x, Alby).'); + this.nostrLoginInProgress = false; + return; + } - // fetch profile from /api/nostr/profile/{publicKey} - const url = '/api/nostr/profile/' + pubkey; - console.log('Fetching profile from:', url); + const challenge = await this.resolveChallenge(); + if (!challenge) { + this.showAuthError('Login-Challenge fehlt. Bitte lade die Seite neu und versuche es erneut.'); + this.nostrLoginInProgress = false; + return; + } - fetch(url) - .then(response => { - console.log('Response status:', response.status); - console.log('Response ok:', response.ok); - console.log('Response headers:', response.headers); + const pubkey = await window.nostr.getPublicKey(); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + const event = { + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + tags: [['challenge', challenge]], + content: '', + }; - return response.text(); - }) - .then(text => { - console.log('Response text:', text); - try { - const data = JSON.parse(text); - console.log('Profile fetched', data); - // store in AlpineJS store + let signedEvent; + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.error('Nostr signEvent failed:', error); + this.showAuthError('Signatur abgebrochen oder fehlgeschlagen. Bitte versuche es erneut.'); + this.nostrLoginInProgress = false; + return; + } + + // Some Nostr extensions return objects wrapped in extension-specific + // proxies (e.g. cloneInto results) that Livewire cannot serialize. + // Round-trip through JSON to guarantee a plain, cloneable object. + let plainSignedEvent; + try { + plainSignedEvent = JSON.parse(JSON.stringify(signedEvent)); + } catch (error) { + console.error('Nostr signedEvent serialization failed:', error); + this.showAuthError('Wallet-Signatur konnte nicht verarbeitet werden. Bitte versuche eine andere Erweiterung.'); + this.nostrLoginInProgress = false; + return; + } + + // Pre-fetch the profile so it lands in the Alpine store before the + // reload completes. Non-critical: failures are logged but ignored. + try { + const response = await fetch('/api/nostr/profile/' + pubkey); + if (response.ok) { + const data = await response.json(); Alpine.store('nostr', {user: data}); - // dispatch Livewire event - this.$dispatch('nostrLoggedIn', {pubkey: pubkey}); - } catch (e) { - console.error('JSON parse error:', e); - throw e; } - }) - .catch(error => { - console.error('Error during Nostr login:', error); - }); + } catch (error) { + console.warn('Profile prefetch failed:', error); + } + + // Leave nostrLoginInProgress = true: the auth-button listener + // will trigger a full page reload on success. + this.$dispatch('nostrLoggedIn', {signedEvent: plainSignedEvent}); + } catch (error) { + console.error('openNostrLogin unexpected error:', error); + this.showAuthError('Authentifizierung fehlgeschlagen. Bitte versuche es erneut.'); + this.nostrLoginInProgress = false; + } }, + showAuthError(message) { + if (window.Flux && window.Flux.toast) { + window.Flux.toast({ + heading: 'Authentifizierung fehlgeschlagen', + text: message, + variant: 'danger', + duration: 8000, + }); + } else { + console.error(message); + } + }, }); diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 23b0f74..38780ae 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -20,9 +20,7 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) @fluxAppearance -
+