🔑 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

@@ -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);
}
},
});

View File

@@ -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();
}
}; ?>
<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="w-80 max-w-80 space-y-6">
<!-- Logo -->