diff --git a/app/Http/Controllers/DownloadMeetupCalendar.php b/app/Http/Controllers/DownloadMeetupCalendar.php index fb12aaa..62d3460 100644 --- a/app/Http/Controllers/DownloadMeetupCalendar.php +++ b/app/Http/Controllers/DownloadMeetupCalendar.php @@ -29,7 +29,17 @@ class DownloadMeetupCalendar extends Controller $events = $meetup->meetupEvents()->where('start', '>=', now())->get(); $image = $meetup->getFirstMediaUrl('logo'); } elseif ($request->has('my')) { - $ids = $request->input('my'); + $validated = $request->validate([ + 'my' => ['required', 'array'], + 'my.*' => ['integer'], + ]); + + $ids = $validated['my']; + if (auth()->check()) { + $ownedIds = auth()->user()->meetups->pluck('id')->all(); + $ids = array_values(array_intersect($ids, $ownedIds)); + } + $events = MeetupEvent::query() ->with([ 'meetup', diff --git a/resources/js/nostrLogin.js b/resources/js/nostrLogin.js index ec0f21e..98cda94 100644 --- a/resources/js/nostrLogin.js +++ b/resources/js/nostrLogin.js @@ -1,5 +1,3 @@ -import {npubEncode} from "nostr-tools/nip19"; - export default () => ({ pollingInterval: null, errorCheckInterval: null, @@ -13,11 +11,21 @@ export default () => ({ }, async openNostrLogin() { - const pubkey = await window.nostr.getPublicKey(); - const npub = npubEncode(pubkey); - console.log(pubkey); - console.log(npub); - this.$dispatch('nostrLoggedIn', {pubkey: npub}); + const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire; + const challenge = livewireComponent?.$wire?.nostrChallenge; + if (!challenge) { + this.showAuthError('Login challenge missing. Please reload and try again.'); + return; + } + + const signedEvent = await window.nostr.signEvent({ + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + tags: [['challenge', challenge]], + content: '', + }); + + this.$dispatch('nostrLoggedIn', {signedEvent}); }, initErrorPolling() { diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 33ae6c5..eeb5e24 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -13,10 +13,13 @@ use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; +use Livewire\Attributes\Locked; use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; use SimpleSoftwareIO\QrCode\Facades\QrCode; +use swentel\nostr\Event\Event as NostrEvent; +use swentel\nostr\Key\Key as NostrKey; new #[Layout('components.layouts.auth')] class extends Component { @@ -42,6 +45,11 @@ class extends Component { public ?string $authError = null; + #[Locked] + public ?string $nostrChallenge = null; + + private const NOSTR_CHALLENGE_TTL_SECONDS = 300; + /** * Handle authError property type conversion. * Ensure array values from frontend are converted to string or null. @@ -57,6 +65,10 @@ class extends Component { { $this->currentLangCountry = session('lang_country') ?? 'de-DE'; + $this->nostrChallenge = bin2hex(random_bytes(32)); + Session::put('nostr_login_challenge', $this->nostrChallenge); + Session::put('nostr_login_challenge_expires_at', now()->addSeconds(self::NOSTR_CHALLENGE_TTL_SECONDS)->timestamp); + // Nur beim ersten Mount initialisieren if ($this->k1 === null) { $this->k1 = bin2hex(str()->random(32)); @@ -80,18 +92,19 @@ class extends Component { } #[On('nostrLoggedIn')] - public function loginListener($pubkey): void + public function loginListener($signedEvent = null): void { - $user = \App\Models\User::query()->where('nostr', $pubkey)->first(); + $npub = $this->verifyNostrLoginEvent($signedEvent); + + $user = User::query()->where('nostr', $npub)->first(); if (!$user) { $fakeName = str()->random(10); - // create User $user = User::create([ 'public_key' => null, 'is_lecturer' => true, 'name' => $fakeName, - 'email' => str($pubkey)->substr(-12).'@portal.einundzwanzig.space', - 'nostr' => $pubkey, + 'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space', + 'nostr' => $npub, 'lnbits' => [ 'read_key' => null, 'url' => null, @@ -137,6 +150,79 @@ class extends Component { ); } + /** + * Verify a NIP-42-style signed login event and return the user's npub. + * + * Throws ValidationException on any invalid input — never trust client data. + */ + protected function verifyNostrLoginEvent(mixed $signedEvent): string + { + if (!is_array($signedEvent)) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']; + foreach ($required as $key) { + if (!array_key_exists($key, $signedEvent)) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + } + + if ((int) $signedEvent['kind'] !== 22242) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $expectedChallenge = Session::get('nostr_login_challenge'); + $expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0); + + if (!is_string($expectedChallenge) || $expectedChallenge === '' || $expiresAt < now()->timestamp) { + Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $challengeFromEvent = null; + foreach ($signedEvent['tags'] as $tag) { + if (is_array($tag) && ($tag[0] ?? null) === 'challenge') { + $challengeFromEvent = (string) ($tag[1] ?? ''); + break; + } + } + + if ($challengeFromEvent === null || !hash_equals($expectedChallenge, $challengeFromEvent)) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $createdAt = (int) $signedEvent['created_at']; + if (abs(now()->timestamp - $createdAt) > self::NOSTR_CHALLENGE_TTL_SECONDS) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + $eventJson = json_encode([ + 'id' => (string) $signedEvent['id'], + 'pubkey' => (string) $signedEvent['pubkey'], + 'created_at' => $createdAt, + 'kind' => 22242, + 'tags' => $signedEvent['tags'], + 'content' => (string) $signedEvent['content'], + 'sig' => (string) $signedEvent['sig'], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $isValid = false; + try { + $isValid = (new NostrEvent())->verify($eventJson); + } catch (\Throwable $e) { + $isValid = false; + } + + if (!$isValid) { + throw ValidationException::withMessages(['email' => __('auth.failed')]); + } + + Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']); + + return (new NostrKey())->convertPublicKeyToBech32((string) $signedEvent['pubkey']); + } + /** * Ensure the authentication request is not rate limited. */ diff --git a/resources/views/livewire/lecturers/edit.blade.php b/resources/views/livewire/lecturers/edit.blade.php index f9fffbf..c86e98f 100644 --- a/resources/views/livewire/lecturers/edit.blade.php +++ b/resources/views/livewire/lecturers/edit.blade.php @@ -45,8 +45,17 @@ class extends Component { #[Locked] public ?string $updated_at = null; + protected function authorizeAccess(): void + { + if (!is_null($this->lecturer->created_by) && auth()->id() !== $this->lecturer->created_by) { + abort(403); + } + } + public function mount(): void { + $this->authorizeAccess(); + $this->lecturer->load('media'); $this->name = $this->lecturer->name ?? ''; @@ -70,6 +79,8 @@ class extends Component { public function updateLecturer(): void { + $this->authorizeAccess(); + $validated = $this->validate([ 'name' => ['required', 'string', 'max:255', Rule::unique('lecturers')->ignore($this->lecturer->id)], 'subtitle' => ['nullable', 'string'], diff --git a/resources/views/livewire/meetups/edit.blade.php b/resources/views/livewire/meetups/edit.blade.php index 7ea8e30..effdf91 100644 --- a/resources/views/livewire/meetups/edit.blade.php +++ b/resources/views/livewire/meetups/edit.blade.php @@ -83,8 +83,17 @@ class extends Component { \Flux\Flux::modal('add-city')->close(); } + protected function authorizeAccess(): void + { + if (!is_null($this->meetup->created_by) && auth()->id() !== $this->meetup->created_by) { + abort(403); + } + } + public function mount(): void { + $this->authorizeAccess(); + $this->meetup->load('media'); // Basic Information @@ -117,6 +126,8 @@ class extends Component { public function updateMeetup(): void { + $this->authorizeAccess(); + $validated = $this->validate([ 'name' => ['required', 'string', 'max:255', Rule::unique('meetups')->ignore($this->meetup->id)], 'city_id' => ['nullable', 'exists:cities,id'], diff --git a/routes/web.php b/routes/web.php index 830959b..b2e3e82 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,20 +2,13 @@ use App\Http\Controllers\DownloadMeetupCalendar; use App\Http\Controllers\ImageController; -use App\Jobs\FetchNostrProfileJob; use App\Livewire\Helper\FollowTheRabbit; -use App\Models\User; use Illuminate\Support\Facades\Route; use Laravel\Nightwatch\Http\Middleware\Sample; // Redirect root URL to 'welcome' page Route::redirect('/', 'welcome'); -// Test route that dispatches a job to fetch Nostr profile for user with ID 1426 -Route::get('test', function () { - FetchNostrProfileJob::dispatchSync(User::find(1426)); -}); - // Error page route that aborts with given HTTP status code (digits only, // constrained to valid 4xx/5xx range to avoid TypeErrors from bot scans). Route::get('error/{code}', function (string $code) {