mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-01-23 23:53:17 +00:00
🔑 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:
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user