From e5ea65fa774d235501095eb9048f2e8d7d9f4b2b Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 17 Jan 2026 15:23:38 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=91=20Implement=20LNURL-Auth=20support?= =?UTF-8?q?=20with=20error=20handling,=20frontend=20polling,=20and=20test?= =?UTF-8?q?=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `LnurlAuthController` to handle LNURL authentication flow with signature verification, user creation, and session expiry checks. - Integrated authentication error polling in `nostrLogin.js`. - Added `LoginKeyFactory` for testing and database seed purposes. - Created feature tests (`LnurlAuthTest`) to validate LNURL callback, error responses, and session handling. - Extended `login.blade.php` with dynamic error handling and reset logic for expired sessions. --- app/Http/Controllers/LnurlAuthController.php | 198 ++++++++++++++++++ database/factories/LoginKeyFactory.php | 18 ++ resources/js/nostrLogin.js | 82 +++++++- resources/views/livewire/auth/login.blade.php | 52 ++++- routes/api.php | 81 ++----- tests/Feature/LnurlAuthTest.php | 119 +++++++++++ 6 files changed, 475 insertions(+), 75 deletions(-) create mode 100644 app/Http/Controllers/LnurlAuthController.php create mode 100644 database/factories/LoginKeyFactory.php create mode 100644 tests/Feature/LnurlAuthTest.php 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(); + } }; ?> -
+
diff --git a/routes/api.php b/routes/api.php index 2d14d54..c6dd9d1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,9 +6,7 @@ use App\Http\Controllers\Api\CourseController; use App\Http\Controllers\Api\LecturerController; use App\Http\Controllers\Api\MeetupController; use App\Http\Controllers\Api\VenueController; -use App\Models\LoginKey; use App\Models\User; -use eza\lnurl; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -50,11 +48,10 @@ Route::middleware([]) ]) ->orderByDesc('id') ->get() - ->map(fn($item) - => [ + ->map(fn ($item) => [ 'id' => $item->id, 'name' => $item->name, - 'link' => strtok($item->value, "?"), + 'link' => strtok($item->value, '?'), 'image' => $item->getFirstMediaUrl('main'), ]); }); @@ -67,8 +64,7 @@ Route::middleware([]) 'media', ]) ->get() - ->map(fn($meetup) - => [ + ->map(fn ($meetup) => [ 'name' => $meetup->name, 'portalLink' => url()->route( 'meetups.landingpage', @@ -103,15 +99,13 @@ Route::middleware([]) ]) ->when( $date, - fn($query) - => $query + fn ($query) => $query ->where('start', '>=', $date) ->where('start', '<=', $date->copy()->endOfMonth()), ) ->get(); - return $events->map(fn($event) - => [ + return $events->map(fn ($event) => [ 'start' => $event->start->format('Y-m-d H:i'), 'location' => $event->location, 'description' => $event->description, @@ -148,31 +142,28 @@ Route::middleware([]) ->where('community', '=', 'einundzwanzig') ->when( app()->environment('production'), - fn($query) - => $query->whereHas( + fn ($query) => $query->whereHas( 'city', - fn($query) - => $query + fn ($query) => $query ->whereNotNull('cities.simplified_geojson') ->whereNotNull('cities.population') ->whereNotNull('cities.population_date'), ), ) ->get() - ->map(fn($meetup) - => [ + ->map(fn ($meetup) => [ 'id' => $meetup->slug, 'tags' => [ 'type' => 'community', 'name' => $meetup->name, 'continent' => 'europe', 'icon:square' => $meetup->logoSquare, - //'contact:email' => null, + // 'contact:email' => null, 'contact:twitter' => $meetup->twitter_username ? 'https://twitter.com/'.$meetup->twitter_username : null, 'contact:website' => $meetup->webpage, 'contact:telegram' => $meetup->telegram_link, 'contact:nostr' => $meetup->nostr, - //'tips:lightning_address' => null, + // 'tips:lightning_address' => null, 'organization' => 'einundzwanzig', 'language' => $meetup->city->country->language_codes[0] ?? 'de', 'geo_json' => $meetup->city->simplified_geojson, @@ -188,52 +179,8 @@ Route::middleware([]) }); }); -Route::get('/lnurl-auth-callback', function (Request $request) { - if (lnurl\auth($request->k1, $request->sig, $request->key)) { - // find User by $wallet_public_key - if ( - $user = User::query() - ->where('change', $request->k1) - ->where('change_time', '>', now()->subMinutes(5)) - ->first() - ) { - $user->public_key = $request->key; - $user->change = null; - $user->change_time = null; - $user->save(); - } else { - $user = User::query() - ->whereBlind('public_key', 'public_key_index', $request->key) - ->first(); - } - if (!$user) { - $fakeName = str()->random(10); - // create User - $user = User::create([ - 'public_key' => $request->key, - 'is_lecturer' => true, - 'name' => $fakeName, - 'email' => str($request->key)->substr(-12).'@portal.einundzwanzig.space', - 'lnbits' => [ - 'read_key' => null, - 'url' => null, - 'wallet_id' => null, - ], - ]); - } - // check if $k1 is in the database, if not, add it - $loginKey = LoginKey::where('k1', $request->k1) - ->first(); - if (!$loginKey) { - LoginKey::create([ - 'k1' => $request->k1, - 'user_id' => $user->id, - ]); - } - - return response()->json(['status' => 'OK']); - } - - return response()->json(['status' => 'ERROR', 'reason' => 'Signature was NOT VERIFIED']); -}) +Route::get('/lnurl-auth-callback', [\App\Http\Controllers\LnurlAuthController::class, 'callback']) ->name('auth.ln.callback'); + +Route::post('/check-auth-error', [\App\Http\Controllers\LnurlAuthController::class, 'checkError']) + ->name('auth.check-error'); diff --git a/tests/Feature/LnurlAuthTest.php b/tests/Feature/LnurlAuthTest.php new file mode 100644 index 0000000..8bc7172 --- /dev/null +++ b/tests/Feature/LnurlAuthTest.php @@ -0,0 +1,119 @@ +delete(); + User::query()->delete(); +}); + +test('lnurl auth callback validates required parameters', function () { + $response = $this->get(route('auth.ln.callback')); + + $response->assertStatus(400) + ->assertJson([ + 'status' => 'ERROR', + 'reason' => 'Invalid request parameters', + ]); +}); + +test('lnurl auth callback handles signature verification failures', function () { + $k1 = str()->random(64); + $sig = str()->random(128); + $key = str()->random(64); + + $response = $this->get(route('auth.ln.callback').'?k1='.$k1.'&sig='.$sig.'&key='.$key); + + $response->assertStatus(400) + ->assertJson([ + 'status' => 'ERROR', + 'reason' => 'Authentication failed. Please try again.', + ]); +}); + +test('check error returns null when login key exists', function () { + $k1 = str()->random(64); + + LoginKey::factory()->create([ + 'k1' => $k1, + 'created_at' => now(), + ]); + + $response = $this->postJson(route('auth.check-error'), [ + 'k1' => $k1, + 'elapsed_seconds' => 120, + ]); + + $response->assertStatus(200) + ->assertJson(['error' => null]); +}); + +test('check error returns null when k1 not expired', function () { + $k1 = str()->random(64); + + $response = $this->postJson(route('auth.check-error'), [ + 'k1' => $k1, + 'elapsed_seconds' => 120, + ]); + + $response->assertStatus(200) + ->assertJson(['error' => null]); +}); + +test('check error returns expired message when k1 is expired', function () { + $k1 = str()->random(64); + + $response = $this->postJson(route('auth.check-error'), [ + 'k1' => $k1, + 'elapsed_seconds' => 300, + ]); + + $response->assertStatus(200) + ->assertJson([ + 'error' => 'Session expired. Please try again.', + ]); +}); + +test('check error returns null when no k1 provided', function () { + $response = $this->postJson(route('auth.check-error')); + + $response->assertStatus(200) + ->assertJson(['error' => null]); +}); + +test('check error returns null when login key is too old', function () { + $k1 = str()->random(64); + + LoginKey::factory()->create([ + 'k1' => $k1, + 'created_at' => now()->subMinutes(10), + ]); + + $response = $this->postJson(route('auth.check-error'), [ + 'k1' => $k1, + 'elapsed_seconds' => 600, + ]); + + $response->assertStatus(200) + ->assertJson([ + 'error' => 'Session expired. Please try again.', + ]); +}); + +test('check error finds valid login key within 5 minutes', function () { + $k1 = str()->random(64); + + LoginKey::factory()->create([ + 'k1' => $k1, + 'created_at' => now()->subMinutes(3), + ]); + + $response = $this->postJson(route('auth.check-error'), [ + 'k1' => $k1, + 'elapsed_seconds' => 180, + ]); + + $response->assertStatus(200) + ->assertJson(['error' => null]); +});