authError = null; } } /** * Generate a fresh Nostr login challenge, persist it to the session, and * return the value. Used both during mount() and as a JS-driven fallback * (see requestNostrChallenge()) when the rendered challenge is missing * on the client — e.g. behind an HTTP cache that stripped the snapshot. */ protected function issueNostrChallenge(): string { $challenge = bin2hex(random_bytes(32)); $this->nostrChallenge = $challenge; Session::put('nostr_login_challenge', $challenge); Session::put('nostr_login_challenge_expires_at', now()->addSeconds(self::NOSTR_CHALLENGE_TTL_SECONDS)->timestamp); return $challenge; } /** * Server-side fallback for the JS layer: returns a fresh challenge. * Always issues a new one so a stale rendered snapshot can be recovered * without forcing the user to reload the page. */ public function requestNostrChallenge(): string { return $this->issueNostrChallenge(); } public function mount(): void { $this->currentLangCountry = session('lang_country') ?? 'de-DE'; $this->issueNostrChallenge(); // Nur beim ersten Mount initialisieren if ($this->k1 === null) { $this->k1 = bin2hex(str()->random(32)); if (app()->environment('local')) { $this->url = 'https://mmy4dp8eab.sharedwithexpose.com/api/lnurl-auth-callback?tag=login&k1='.$this->k1.'&action=login'; } else { $this->url = url('/api/lnurl-auth-callback?tag=login&k1='.$this->k1.'&action=login'); } $this->lnurl = lnurl\encodeUrl($this->url); $image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg'; $checkIfFileExists = base_path($image); if (!file_exists($checkIfFileExists)) { $image = 'public/img/domains/de-DE.jpg'; } $this->qrCode = base64_encode(QrCode::format('png') ->size(300) ->merge('/'.$image, .3) ->errorCorrection('H') ->generate($this->lnurl)); } } #[On('nostrLoggedIn')] public function loginListener($signedEvent = null): void { $npub = $this->verifyNostrLoginEvent($signedEvent); $user = User::query()->where('nostr', $npub)->first(); if (!$user) { $fakeName = str()->random(10); $user = User::create([ 'public_key' => null, 'is_lecturer' => true, 'name' => $fakeName, 'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space', 'nostr' => $npub, 'lnbits' => [ 'read_key' => null, 'url' => null, 'wallet_id' => null, ], ]); } FetchNostrProfileJob::dispatch($user); // Auth::loginUsingId() already regenerates the session id (see // SessionGuard::updateSession), so an explicit Session::regenerate() // would just rotate the CSRF token a second time. We also avoid // wire:navigate here: it preserves the tag // from the previous page, so any subsequent Livewire action on the // destination would 419 (TokenMismatch). A full-page redirect gives // the browser a fresh document with a fresh token. Auth::loginUsingId($user->id); $this->redirectIntended( default: route('dashboard', ['country' => str(session('lang_country', config('app.domain_country')))->after('-')->lower()], absolute: false), ); return; $this->validate(); $this->ensureIsNotRateLimited(); if (!Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ 'email' => __('auth.failed'), ]); } RateLimiter::clear($this->throttleKey()); Session::regenerate(); session([ 'lang_country' => $this->currentLangCountry, ]); $this->redirectIntended( default: route('dashboard', ['country' => str(session('lang_country', config('app.domain_country')))->after('-')->lower()], absolute: false), navigate: true, ); } /** * Verify a NIP-42-style signed login event and return the user's npub. * * Throws ValidationException on any invalid input — never trust client data. */ protected function verifyNostrLoginEvent(mixed $signedEvent): string { if (!is_array($signedEvent)) { throw ValidationException::withMessages(['email' => __('auth.failed')]); } $required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']; foreach ($required as $key) { if (!array_key_exists($key, $signedEvent)) { throw ValidationException::withMessages(['email' => __('auth.failed')]); } } if ((int) $signedEvent['kind'] !== 22242) { throw ValidationException::withMessages(['email' => __('auth.failed')]); } $expectedChallenge = Session::get('nostr_login_challenge'); $expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0); if (!is_string($expectedChallenge) || $expectedChallenge === '' || $expiresAt < now()->timestamp) { Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); throw ValidationException::withMessages(['email' => __('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 || !hash_equals($expectedChallenge, $challengeFromEvent)) { throw ValidationException::withMessages(['email' => __('auth.failed')]); } $createdAt = (int) $signedEvent['created_at']; if (abs(now()->timestamp - $createdAt) > self::NOSTR_CHALLENGE_TTL_SECONDS) { throw ValidationException::withMessages(['email' => __('auth.failed')]); } $eventJson = json_encode([ 'id' => (string) $signedEvent['id'], 'pubkey' => (string) $signedEvent['pubkey'], 'created_at' => $createdAt, 'kind' => 22242, 'tags' => $signedEvent['tags'], 'content' => (string) $signedEvent['content'], 'sig' => (string) $signedEvent['sig'], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $isValid = false; try { $isValid = (new NostrEvent())->verify($eventJson); } catch (\Throwable $e) { $isValid = false; } if (!$isValid) { throw ValidationException::withMessages(['email' => __('auth.failed')]); } Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); return (new NostrKey())->convertPublicKeyToBech32((string) $signedEvent['pubkey']); } /** * Ensure the authentication request is not rate limited. */ protected function ensureIsNotRateLimited(): void { if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { return; } event(new Lockout(request())); $seconds = RateLimiter::availableIn($this->throttleKey()); throw ValidationException::withMessages([ 'email' => __('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ]), ]); } /** * Get the authentication rate limiting throttle key. */ protected function throttleKey(): string { return Str::transliterate(Str::lower($this->email).'|'.request()->ip()); } public function checkAuth() { $loginKey = LoginKey::query() ->where('k1', $this->k1) ->whereDate('created_at', '>=', now()->subMinutes(5)) ->first(); if ($loginKey) { $user = User::find($loginKey->user_id); // auth()->login() already migrates the session id (rotates cookie). // An additional Session::regenerate() races with the in-flight // wire:poll request and produces 419s on the next round-trip. auth()->login($user); session([ 'lang_country' => $this->currentLangCountry, ]); return to_route('dashboard', ['country' => str(session('lang_country', config('app.domain_country')))->after('-')->lower()]); } // Check if k1 has expired (older than 5 minutes) $k1CreatedAt = now()->subMinutes(5); if ($this->k1 && now()->diffInMinutes($k1CreatedAt) >= 5) { $this->authError = 'Session expired. Please try again.'; return true; } return true; } /** * Get the current authentication error state. */ public function getAuthError(): ?string { return $this->authError; } /** * Reset authentication by generating a new k1 challenge. */ public function resetAuth(): void { $this->k1 = null; $this->url = null; $this->lnurl = null; $this->qrCode = null; $this->authError = null; $this->mount(); } }; ?>
{{ __('Willkommen zurück') }}
{{ __('Log in mit Nostr') }}
{{ __('Login with lightning ⚡') }}
{{ __('Copy') }}
{{ __('Click to connect') }}
Bitcoin, not blockchain. Bitcoin, not crypto.
Gigi
bitcoiner and software engineer
{{-- Pause Livewire polling while a Nostr signature round-trip is in flight. Otherwise wire:poll can fire a parallel /livewire/update request that races with auth()->login()'s session migration and lands on an invalidated session id, producing 419 TokenMismatch. --}} {{-- Full-viewport progress overlay. Visible while the wallet-signing round-trip is running. Locks input by capturing pointer events and intercepting Escape/Tab so the user cannot interact with anything underneath until the redirect resolves (or the flow errors out). --}}