mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-01-24 12:03: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:
198
app/Http/Controllers/LnurlAuthController.php
Normal file
198
app/Http/Controllers/LnurlAuthController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
database/factories/LoginKeyFactory.php
Normal file
18
database/factories/LoginKeyFactory.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -94,6 +100,7 @@ class extends Component {
|
|||||||
absolute: false),
|
absolute: false),
|
||||||
navigate: true,
|
navigate: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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,19 +142,16 @@ 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',
|
||||||
@@ -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');
|
||||||
|
|||||||
119
tests/Feature/LnurlAuthTest.php
Normal file
119
tests/Feature/LnurlAuthTest.php
Normal 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]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user