🔑 Implement LNURL-Auth support with error handling, frontend polling, and test coverage

- 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.
This commit is contained in:
HolgerHatGarKeineNode
2026-01-17 15:23:38 +01:00
parent fb185d7226
commit e5ea65fa77
6 changed files with 475 additions and 75 deletions

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\LoginKey;
use App\Models\User;
use eza\lnurl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
final class LnurlAuthController extends Controller
{
/**
* Handle LNURL authentication callback.
*
* This endpoint is called by Lightning wallets during LNURL-Auth authentication flow.
* It validates the signature provided by wallet against the stored challenge (k1).
*/
public function callback(Request $request): JsonResponse
{
try {
$validated = $request->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]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class LoginKeyFactory extends Factory
{
protected $model = \App\Models\LoginKey::class;
public function definition(): array
{
return [
'k1' => str()->random(64),
'user_id' => \App\Models\User::factory(),
];
}
}

View File

@@ -1,9 +1,15 @@
import {npubEncode} from "nostr-tools/nip19"; import {npubEncode} from "nostr-tools/nip19";
export default () => ({ export default () => ({
pollingInterval: null,
errorCheckInterval: null,
authErrorShown: false,
startTime: null,
pollCount: 0,
MAX_POLL_COUNT: 30,
async init() { async init() {
this.startTime = Date.now();
}, },
async openNostrLogin() { async openNostrLogin() {
@@ -14,4 +20,78 @@ export default () => ({
this.$dispatch('nostrLoggedIn', {pubkey: npub}); 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);
}
},
}); });

View File

@@ -4,7 +4,6 @@ use App\Attributes\SeoDataAttribute;
use App\Jobs\FetchNostrProfileJob; use App\Jobs\FetchNostrProfileJob;
use App\Models\LoginKey; use App\Models\LoginKey;
use App\Models\User; use App\Models\User;
use App\Notifications\ModelCreatedNotification;
use App\Traits\SeoTrait; use App\Traits\SeoTrait;
use eza\lnurl; use eza\lnurl;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
@@ -22,7 +21,8 @@ use SimpleSoftwareIO\QrCode\Facades\QrCode;
new new
#[Layout('components.layouts.auth')] #[Layout('components.layouts.auth')]
#[SeoDataAttribute(key: 'login')] #[SeoDataAttribute(key: 'login')]
class extends Component { class extends Component
{
use SeoTrait; use SeoTrait;
#[Validate('required|string|email')] #[Validate('required|string|email')]
@@ -34,11 +34,17 @@ class extends Component {
public bool $remember = false; public bool $remember = false;
public ?string $k1 = null; public ?string $k1 = null;
public ?string $url = null; public ?string $url = null;
public ?string $lnurl = null; public ?string $lnurl = null;
public ?string $qrCode = null; public ?string $qrCode = null;
public ?string $currentLangCountry = 'de-DE'; public ?string $currentLangCountry = 'de-DE';
public ?string $authError = null;
public function mount(): void public function mount(): void
{ {
$this->currentLangCountry = session('lang_country') ?? 'de-DE'; $this->currentLangCountry = session('lang_country') ?? 'de-DE';
@@ -54,7 +60,7 @@ class extends Component {
$this->lnurl = lnurl\encodeUrl($this->url); $this->lnurl = lnurl\encodeUrl($this->url);
$image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg'; $image = 'public/img/domains/'.session('lang_country', 'de-DE').'.jpg';
$checkIfFileExists = base_path($image); $checkIfFileExists = base_path($image);
if (!file_exists($checkIfFileExists)) { if (! file_exists($checkIfFileExists)) {
$image = 'public/img/domains/de-DE.jpg'; $image = 'public/img/domains/de-DE.jpg';
} }
$this->qrCode = base64_encode(QrCode::format('png') $this->qrCode = base64_encode(QrCode::format('png')
@@ -69,7 +75,7 @@ class extends Component {
public function loginListener($pubkey): void public function loginListener($pubkey): void
{ {
$user = \App\Models\User::query()->where('nostr', $pubkey)->first(); $user = \App\Models\User::query()->where('nostr', $pubkey)->first();
if (!$user) { if (! $user) {
$fakeName = str()->random(10); $fakeName = str()->random(10);
// create User // create User
$user = User::create([ $user = User::create([
@@ -94,13 +100,14 @@ class extends Component {
absolute: false), absolute: false),
navigate: true, navigate: true,
); );
return; return;
$this->validate(); $this->validate();
$this->ensureIsNotRateLimited(); $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()); RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -127,7 +134,7 @@ class extends Component {
*/ */
protected function ensureIsNotRateLimited(): void protected function ensureIsNotRateLimited(): void
{ {
if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return; return;
} }
@@ -170,11 +177,42 @@ class extends Component {
['country' => str(session('lang_country', config('app.domain_country')))->after('-')->lower()]); ['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; 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();
}
}; ?> }; ?>
<div class="flex min-h-screen" x-data="nostrLogin"> <div class="flex min-h-screen" x-data="nostrLogin"
x-init="initErrorPolling"
@auth-error.window="showAuthError($event.detail)">
<div class="flex-1 flex justify-center items-center"> <div class="flex-1 flex justify-center items-center">
<div class="w-80 max-w-80 space-y-6"> <div class="w-80 max-w-80 space-y-6">
<!-- Logo --> <!-- Logo -->

View File

@@ -6,9 +6,7 @@ use App\Http\Controllers\Api\CourseController;
use App\Http\Controllers\Api\LecturerController; use App\Http\Controllers\Api\LecturerController;
use App\Http\Controllers\Api\MeetupController; use App\Http\Controllers\Api\MeetupController;
use App\Http\Controllers\Api\VenueController; use App\Http\Controllers\Api\VenueController;
use App\Models\LoginKey;
use App\Models\User; use App\Models\User;
use eza\lnurl;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -50,11 +48,10 @@ Route::middleware([])
]) ])
->orderByDesc('id') ->orderByDesc('id')
->get() ->get()
->map(fn($item) ->map(fn ($item) => [
=> [
'id' => $item->id, 'id' => $item->id,
'name' => $item->name, 'name' => $item->name,
'link' => strtok($item->value, "?"), 'link' => strtok($item->value, '?'),
'image' => $item->getFirstMediaUrl('main'), 'image' => $item->getFirstMediaUrl('main'),
]); ]);
}); });
@@ -67,8 +64,7 @@ Route::middleware([])
'media', 'media',
]) ])
->get() ->get()
->map(fn($meetup) ->map(fn ($meetup) => [
=> [
'name' => $meetup->name, 'name' => $meetup->name,
'portalLink' => url()->route( 'portalLink' => url()->route(
'meetups.landingpage', 'meetups.landingpage',
@@ -103,15 +99,13 @@ Route::middleware([])
]) ])
->when( ->when(
$date, $date,
fn($query) fn ($query) => $query
=> $query
->where('start', '>=', $date) ->where('start', '>=', $date)
->where('start', '<=', $date->copy()->endOfMonth()), ->where('start', '<=', $date->copy()->endOfMonth()),
) )
->get(); ->get();
return $events->map(fn($event) return $events->map(fn ($event) => [
=> [
'start' => $event->start->format('Y-m-d H:i'), 'start' => $event->start->format('Y-m-d H:i'),
'location' => $event->location, 'location' => $event->location,
'description' => $event->description, 'description' => $event->description,
@@ -148,31 +142,28 @@ Route::middleware([])
->where('community', '=', 'einundzwanzig') ->where('community', '=', 'einundzwanzig')
->when( ->when(
app()->environment('production'), app()->environment('production'),
fn($query) fn ($query) => $query->whereHas(
=> $query->whereHas(
'city', 'city',
fn($query) fn ($query) => $query
=> $query
->whereNotNull('cities.simplified_geojson') ->whereNotNull('cities.simplified_geojson')
->whereNotNull('cities.population') ->whereNotNull('cities.population')
->whereNotNull('cities.population_date'), ->whereNotNull('cities.population_date'),
), ),
) )
->get() ->get()
->map(fn($meetup) ->map(fn ($meetup) => [
=> [
'id' => $meetup->slug, 'id' => $meetup->slug,
'tags' => [ 'tags' => [
'type' => 'community', 'type' => 'community',
'name' => $meetup->name, 'name' => $meetup->name,
'continent' => 'europe', 'continent' => 'europe',
'icon:square' => $meetup->logoSquare, 'icon:square' => $meetup->logoSquare,
//'contact:email' => null, // 'contact:email' => null,
'contact:twitter' => $meetup->twitter_username ? 'https://twitter.com/'.$meetup->twitter_username : null, 'contact:twitter' => $meetup->twitter_username ? 'https://twitter.com/'.$meetup->twitter_username : null,
'contact:website' => $meetup->webpage, 'contact:website' => $meetup->webpage,
'contact:telegram' => $meetup->telegram_link, 'contact:telegram' => $meetup->telegram_link,
'contact:nostr' => $meetup->nostr, 'contact:nostr' => $meetup->nostr,
//'tips:lightning_address' => null, // 'tips:lightning_address' => null,
'organization' => 'einundzwanzig', 'organization' => 'einundzwanzig',
'language' => $meetup->city->country->language_codes[0] ?? 'de', 'language' => $meetup->city->country->language_codes[0] ?? 'de',
'geo_json' => $meetup->city->simplified_geojson, 'geo_json' => $meetup->city->simplified_geojson,
@@ -188,52 +179,8 @@ Route::middleware([])
}); });
}); });
Route::get('/lnurl-auth-callback', function (Request $request) { Route::get('/lnurl-auth-callback', [\App\Http\Controllers\LnurlAuthController::class, 'callback'])
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']);
})
->name('auth.ln.callback'); ->name('auth.ln.callback');
Route::post('/check-auth-error', [\App\Http\Controllers\LnurlAuthController::class, 'checkError'])
->name('auth.check-error');

View File

@@ -0,0 +1,119 @@
<?php
use App\Models\LoginKey;
use App\Models\User;
beforeEach(function () {
LoginKey::query()->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]);
});