diff --git a/app/Http/Controllers/LnurlAuthController.php b/app/Http/Controllers/LnurlAuthController.php new file mode 100644 index 0000000..6f7e44b --- /dev/null +++ b/app/Http/Controllers/LnurlAuthController.php @@ -0,0 +1,198 @@ +validate([ + 'k1' => ['required', 'string', 'hex', 'size:128'], + 'sig' => ['required', 'string'], + 'key' => ['required', 'string', 'hex', 'min:64', 'max:66'], + ]); + + $isVerified = lnurl\auth($validated['k1'], $validated['sig'], $validated['key']); + + if (! $isVerified) { + Log::warning('LNURL auth verification failed', [ + 'k1' => $validated['k1'], + 'public_key' => $validated['key'], + 'reason' => 'Signature verification failed', + 'ip' => $request->ip(), + ]); + + return $this->errorResponse('Signature was NOT VERIFIED'); + } + + $user = $this->findOrCreateUser($validated['k1'], $validated['key']); + $this->ensureLoginKeyExists($validated['k1'], $user->id); + + Log::info('LNURL auth successful', [ + 'user_id' => $user->id, + 'public_key' => $validated['key'], + 'ip' => $request->ip(), + ]); + + return response()->json(['status' => 'OK']); + } catch (ValidationException $e) { + Log::warning('LNURL auth validation failed', [ + 'errors' => $e->errors(), + 'ip' => $request->ip(), + ]); + + return $this->errorResponse('Invalid request parameters'); + } catch (\ErrorException $e) { + Log::error('LNURL auth error from elliptic library', [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'k1' => $request->input('k1'), + 'key' => $request->input('key'), + 'sig' => $request->input('sig'), + 'ip' => $request->ip(), + ]); + + return $this->errorResponse('Wallet signature format incompatible. Please try a different wallet.'); + } catch (\Throwable $e) { + Log::error('LNURL auth unexpected error', [ + 'message' => $e->getMessage(), + 'exception' => get_class($e), + 'k1' => $request->input('k1'), + 'key' => $request->input('key'), + 'ip' => $request->ip(), + ]); + + return $this->errorResponse('Authentication failed. Please try again.'); + } + } + + /** + * Find or create a user based on authentication flow. + * + * First tries to find an existing user with a matching k1 challenge. + * If found, updates their public key. Otherwise, looks for a user by public key. + * If still not found, creates a new user. + * + * @param string $k1 The challenge identifier + * @param string $publicKey The wallet's public key + */ + private function findOrCreateUser(string $k1, string $publicKey): User + { + $user = User::query() + ->where('change', $k1) + ->where('change_time', '>', now()->subMinutes(5)) + ->first(); + + if ($user) { + $user->public_key = $publicKey; + $user->change = null; + $user->change_time = null; + $user->save(); + + return $user; + } + + $user = User::query() + ->whereBlind('public_key', 'public_key_index', $publicKey) + ->first(); + + if ($user) { + return $user; + } + + $fakeName = str()->random(10); + + return User::create([ + 'public_key' => $publicKey, + 'is_lecturer' => true, + 'name' => $fakeName, + 'email' => str($publicKey)->substr(-12).'@portal.einundzwanzig.space', + 'lnbits' => [ + 'read_key' => null, + 'url' => null, + 'wallet_id' => null, + ], + ]); + } + + /** + * Ensure a login key record exists for the given challenge. + * + * @param string $k1 The challenge identifier + * @param int $userId The user ID + */ + private function ensureLoginKeyExists(string $k1, int $userId): void + { + $loginKey = LoginKey::where('k1', $k1)->first(); + + if (! $loginKey) { + LoginKey::create([ + 'k1' => $k1, + 'user_id' => $userId, + ]); + } + } + + /** + * Return an LNURL-compliant error response. + * + * @param string $reason The error reason + */ + private function errorResponse(string $reason): JsonResponse + { + return response()->json([ + 'status' => 'ERROR', + 'reason' => $reason, + ], 400); + } + + /** + * Check for authentication errors based on k1 challenge. + * + * This endpoint is polled by the frontend to detect authentication failures. + */ + public function checkError(Request $request): JsonResponse + { + $k1 = $request->input('k1'); + $elapsedSeconds = $request->input('elapsed_seconds', 0); + + if (! $k1) { + return response()->json(['error' => null]); + } + + $loginKey = LoginKey::query() + ->where('k1', $k1) + ->where('created_at', '>=', now()->subMinutes(5)) + ->first(); + + if ($loginKey) { + return response()->json(['error' => null]); + } + + if ($elapsedSeconds >= 300) { + return response()->json([ + 'error' => 'Session expired. Please try again.', + ]); + } + + return response()->json(['error' => null]); + } +} diff --git a/database/factories/LoginKeyFactory.php b/database/factories/LoginKeyFactory.php new file mode 100644 index 0000000..8c8d74c --- /dev/null +++ b/database/factories/LoginKeyFactory.php @@ -0,0 +1,18 @@ + str()->random(64), + 'user_id' => \App\Models\User::factory(), + ]; + } +} diff --git a/resources/js/nostrLogin.js b/resources/js/nostrLogin.js index 50dfdcc..008de3a 100644 --- a/resources/js/nostrLogin.js +++ b/resources/js/nostrLogin.js @@ -1,9 +1,15 @@ import {npubEncode} from "nostr-tools/nip19"; export default () => ({ + pollingInterval: null, + errorCheckInterval: null, + authErrorShown: false, + startTime: null, + pollCount: 0, + MAX_POLL_COUNT: 30, async init() { - + this.startTime = Date.now(); }, async openNostrLogin() { @@ -14,4 +20,78 @@ export default () => ({ this.$dispatch('nostrLoggedIn', {pubkey: npub}); }, + initErrorPolling() { + this.errorCheckInterval = setInterval(() => { + this.checkForErrors(); + }, 4000); + }, + + async checkForErrors() { + if (this.authErrorShown) { + return; + } + + try { + const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire; + if (!livewireComponent) { + return; + } + + this.pollCount++; + const elapsedSeconds = Math.floor((Date.now() - this.startTime) / 1000); + + const response = await fetch('/api/check-auth-error', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content, + }, + body: JSON.stringify({ + k1: livewireComponent.entangle('k1')[0], + elapsed_seconds: elapsedSeconds, + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.error) { + this.showAuthError(data.error); + this.authErrorShown = true; + } + } + } catch (error) { + console.error('Error checking for auth errors:', error); + } + }, + + showAuthError(error) { + let message = error || 'Authentication failed. Please try again.'; + let variant = 'danger'; + + if (message.includes('incompatible') || message.includes('format')) { + message = 'Wallet signature format incompatible. Please try a different wallet.'; + variant = 'warning'; + } else if (message.includes('expired') || message.includes('Session')) { + message = 'Session expired. Please try again.'; + variant = 'warning'; + } + + if (window.Flux && window.Flux.toast) { + window.Flux.toast({ + heading: 'Authentication Error', + text: message, + variant: variant, + duration: 8000, + }); + } + + this.$dispatch('auth-error', {message, variant}); + }, + + destroy() { + if (this.errorCheckInterval) { + clearInterval(this.errorCheckInterval); + } + }, + }); diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 2aa58d2..c1504cb 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -4,7 +4,6 @@ use App\Attributes\SeoDataAttribute; use App\Jobs\FetchNostrProfileJob; use App\Models\LoginKey; use App\Models\User; -use App\Notifications\ModelCreatedNotification; use App\Traits\SeoTrait; use eza\lnurl; use Illuminate\Auth\Events\Lockout; @@ -22,7 +21,8 @@ use SimpleSoftwareIO\QrCode\Facades\QrCode; new #[Layout('components.layouts.auth')] #[SeoDataAttribute(key: 'login')] -class extends Component { +class extends Component +{ use SeoTrait; #[Validate('required|string|email')] @@ -34,11 +34,17 @@ class extends Component { public bool $remember = false; public ?string $k1 = null; + public ?string $url = null; + public ?string $lnurl = null; + public ?string $qrCode = null; + public ?string $currentLangCountry = 'de-DE'; + public ?string $authError = null; + public function mount(): void { $this->currentLangCountry = session('lang_country') ?? 'de-DE'; @@ -54,7 +60,7 @@ class extends Component { $this->lnurl = lnurl\encodeUrl($this->url); $image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg'; $checkIfFileExists = base_path($image); - if (!file_exists($checkIfFileExists)) { + if (! file_exists($checkIfFileExists)) { $image = 'public/img/domains/de-DE.jpg'; } $this->qrCode = base64_encode(QrCode::format('png') @@ -69,7 +75,7 @@ class extends Component { public function loginListener($pubkey): void { $user = \App\Models\User::query()->where('nostr', $pubkey)->first(); - if (!$user) { + if (! $user) { $fakeName = str()->random(10); // create User $user = User::create([ @@ -94,13 +100,14 @@ class extends Component { absolute: false), navigate: true, ); + return; $this->validate(); $this->ensureIsNotRateLimited(); - if (!Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { + if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ @@ -127,7 +134,7 @@ class extends Component { */ protected function ensureIsNotRateLimited(): void { - if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { return; } @@ -170,11 +177,42 @@ class extends Component { ['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(); + } }; ?> -