mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-02-15 03:23:17 +00:00
## Security Audit: Fehlende zentralisierte Autorisierung
### Problem
Die Anwendung hat **keine Laravel Policy-Klassen**. Autorisierungslogik ist verstreut in:
- **Blade Templates:** Inline `@if`-Checks gegen `config('einundzwanzig.config.current_board')`
- **Livewire Trait:** `app/Livewire/Traits/WithNostrAuth.php` setzt `$isAllowed` und `$canEdit` Booleans
- **Component-Mount-Methoden:** z.B. in `project-support/form/create.blade.php` (Zeile 41-47)
Die Board-Member-Autorisierung funktioniert über einen Vergleich mit hartkodierten npubs in `config/einundzwanzig/config.php`:
```php
'current_board' => [
'npub1pt0kw36...', 'npub1gvqkjc...', // etc.
]
```
**Probleme:**
- Keine zentrale Stelle für Berechtigungsprüfungen
- Autorisierung kann leicht vergessen werden wenn neue Endpoints hinzukommen
- Board-Member-Check wird an vielen Stellen dupliziert
- Livewire-Actions können ggf. direkt aufgerufen werden ohne UI-seitige Prüfung
### Lösung
**1. Laravel Policies erstellen:**
```bash
php artisan make:policy ProjectProposalPolicy --model=ProjectProposal --no-interaction
php artisan make:policy VotePolicy --model=Vote --no-interaction
php artisan make:policy ElectionPolicy --model=Election --no-interaction
```
**2. Policy-Methoden implementieren:**
Für `ProjectProposalPolicy`:
- `viewAny()` – Jeder darf die Liste sehen
- `view()` – Jeder darf ein Proposal sehen
- `create()` – Nur authentifizierte User mit `association_status > 1` und bezahlter Mitgliedschaft im aktuellen Jahr
- `update()` – Nur der Ersteller ODER Board-Members
- `delete()` – Nur Board-Members
- `accept()` – Nur Board-Members (custom method für `accepted`-Flag)
Für `VotePolicy`:
- `create()` – Authentifizierte User, die noch nicht für dieses Proposal abgestimmt haben
- `update()` / `delete()` – Nur der eigene Vote
Für `ElectionPolicy`:
- `viewAny()` / `view()` – Jeder
- `create()` / `update()` / `delete()` – Nur Board-Members
- `vote()` – Authentifizierte Mitglieder mit gültigem Status
**3. Auth-Checks in der Nostr-Auth-Architektur:**
Die App nutzt eine Custom NostrAuth (`app/Support/NostrAuth.php`, `app/Auth/NostrUser.php`). Die Policies müssen mit `NostrUser` funktionieren. Prüfe ob `NostrUser` das `Authenticatable` Interface korrekt implementiert, damit `$this->authorize()` und `Gate::allows()` in Livewire-Components funktionieren.
**4. Policies in Livewire-Components nutzen:**
Ersetze die inline-Checks in den Components durch Policy-Aufrufe:
```php
// Vorher (verstreut in Blade/Component):
if (in_array($this->currentPleb->npub, config('einundzwanzig.config.current_board'), true)) { ... }
// Nachher (zentralisiert):
$this->authorize('update', $projectProposal);
```
### Betroffene Dateien
- **Neue Dateien:** `app/Policies/ProjectProposalPolicy.php`, `app/Policies/VotePolicy.php`, `app/Policies/ElectionPolicy.php`
- **Anpassen:** `app/Livewire/Traits/WithNostrAuth.php` – Board-Check in Policy auslagern
- **Anpassen:** Livewire-Components in `app/Livewire/Association/ProjectSupport/` – `$this->authorize()` nutzen
- **Anpassen:** Livewire-Components in `app/Livewire/Association/Election/` – `$this->authorize()` nutzen
- **Prüfen:** `app/Auth/NostrUser.php` – Kompatibilität mit Policy-System
- **Prüfen:** `config/einundzwanzig/config.php` – Board-Member-Liste wird weiterhin als Datenquelle genutzt
### Vorgehen
1. `search-docs` nutzen: `queries: ['policies', 'authorization', 'gates']` und `packages: ['laravel/framework']`
2. Prüfe wie `NostrUser` mit Laravel's Authorization-System zusammenspielt
3. Policies mit `php artisan make:policy` erstellen
4. Policy-Methoden implementieren (Board-Check-Logik zentralisieren)
5. Livewire-Components auf Policy-Aufrufe umstellen
6. Blade-Templates: `@can` / `@cannot` Directives nutzen statt inline `@if`
7. Pest Feature-Tests für jede Policy-Methode schreiben
8. `vendor/bin/pint --dirty` und `php artisan test --compact`
### Akzeptanzkriterien
- 3 Policy-Klassen existieren und sind registriert
- Board-Member-Check ist an EINER Stelle definiert (in Policy oder Helper)
- Livewire-Components nutzen `$this->authorize()` statt inline-Checks
- Blade-Templates nutzen `@can` / `@cannot` Directives
- Pest-Tests decken alle Policy-Methoden ab (allow & deny)
- Bestehende Funktionalität bleibt erhalten
272 lines
9.1 KiB
PHP
272 lines
9.1 KiB
PHP
<?php
|
|
|
|
use App\Models\Election;
|
|
use App\Support\NostrAuth;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Component;
|
|
use swentel\nostr\Filter\Filter;
|
|
use swentel\nostr\Message\RequestMessage;
|
|
use swentel\nostr\Relay\Relay;
|
|
use swentel\nostr\Relay\RelaySet;
|
|
use swentel\nostr\Request\Request;
|
|
use swentel\nostr\Subscription\Subscription;
|
|
|
|
new class extends Component {
|
|
#[Locked]
|
|
public bool $isAllowed = false;
|
|
|
|
#[Locked]
|
|
public ?string $currentPubkey = null;
|
|
|
|
#[Locked]
|
|
public ?\App\Models\EinundzwanzigPleb $currentPleb = null;
|
|
|
|
public ?array $votes = null;
|
|
|
|
public ?array $boardVotes = null;
|
|
|
|
public ?array $events = null;
|
|
|
|
public ?array $boardEvents = null;
|
|
|
|
public ?Election $election = null;
|
|
|
|
public string $signThisEvent = '';
|
|
|
|
public array $plebs = [];
|
|
|
|
public array $electionConfig = [];
|
|
|
|
protected $listeners = [
|
|
'nostrLoggedOut' => 'handleNostrLoggedOut',
|
|
'nostrLoggedIn' => 'handleNostrLoggedIn',
|
|
'echo:votes,.newVote' => 'handleNewVote',
|
|
];
|
|
|
|
public function mount(Election $election): void
|
|
{
|
|
$this->election = $election;
|
|
$this->loadEvents();
|
|
$this->loadBoardEvents();
|
|
$this->loadVotes();
|
|
$this->loadBoardVotes();
|
|
|
|
$nostrUser = NostrAuth::user();
|
|
if ($nostrUser) {
|
|
$this->currentPubkey = $nostrUser->getPubkey();
|
|
$this->currentPleb = $nostrUser->getPleb();
|
|
$this->isAllowed = Gate::forUser($nostrUser)->allows('update', $this->election);
|
|
}
|
|
}
|
|
|
|
public function handleNostrLoggedIn(string $pubkey): void
|
|
{
|
|
NostrAuth::login($pubkey);
|
|
|
|
$this->currentPubkey = $pubkey;
|
|
$this->currentPleb = \App\Models\EinundzwanzigPleb::query()
|
|
->where('pubkey', $pubkey)->first();
|
|
|
|
$nostrUser = NostrAuth::user();
|
|
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', $this->election);
|
|
}
|
|
|
|
public function handleNostrLoggedOut(): void
|
|
{
|
|
NostrAuth::logout();
|
|
|
|
$this->currentPubkey = null;
|
|
$this->currentPleb = null;
|
|
$this->isAllowed = false;
|
|
}
|
|
|
|
public function handleNewVote(): void
|
|
{
|
|
$this->loadEvents();
|
|
$this->loadBoardEvents();
|
|
$this->loadVotes();
|
|
$this->loadBoardVotes();
|
|
}
|
|
|
|
public function loadVotes(): void
|
|
{
|
|
$this->votes = collect($this->events)
|
|
->map(fn ($event) => [
|
|
'created_at' => $event['created_at'],
|
|
'pubkey' => $event['pubkey'],
|
|
'forpubkey' => $this->fetchProfile($event['content']),
|
|
'type' => str($event['content'])->after(',')->toString(),
|
|
])
|
|
->sortByDesc('created_at')
|
|
->unique(fn ($event) => $event['pubkey'].$event['type'])
|
|
->values()
|
|
->groupBy('type')
|
|
->map(fn ($votes) => [
|
|
'type' => $votes[0]['type'],
|
|
'votes' => $votes->groupBy('forpubkey')->map(fn ($group) => ['count' => $group->count()])->toArray(),
|
|
])
|
|
->values()
|
|
->toArray();
|
|
}
|
|
|
|
public function loadBoardVotes(): void
|
|
{
|
|
$this->boardVotes = collect($this->boardEvents)
|
|
->map(fn ($event) => [
|
|
'created_at' => $event['created_at'],
|
|
'pubkey' => $event['pubkey'],
|
|
'forpubkey' => $this->fetchProfile($event['content']),
|
|
'type' => str($event['content'])->after(',')->toString(),
|
|
])
|
|
->sortByDesc('created_at')
|
|
->values()
|
|
->groupBy('type')
|
|
->map(fn ($votes) => [
|
|
'type' => $votes[0]['type'],
|
|
'votes' => $votes->groupBy('forpubkey')->map(fn ($group) => ['count' => $group->count()])->toArray(),
|
|
])
|
|
->values()
|
|
->toArray();
|
|
}
|
|
|
|
public function loadEvents(): void
|
|
{
|
|
$this->events = $this->loadNostrEvents([32122]);
|
|
}
|
|
|
|
public function loadBoardEvents(): void
|
|
{
|
|
$this->boardEvents = $this->loadNostrEvents([2121]);
|
|
}
|
|
|
|
public function fetchProfile($content): string
|
|
{
|
|
$pubkey = str($content)->before(',')->toString();
|
|
$profile = \App\Models\Profile::query()->where('pubkey', $pubkey)->first();
|
|
if (! $profile) {
|
|
\Artisan::call(\App\Console\Commands\Nostr\FetchProfile::class, ['--pubkey' => $pubkey]);
|
|
$profile = \App\Models\Profile::query()->where('pubkey', $pubkey)->first();
|
|
}
|
|
|
|
return $profile->pubkey;
|
|
}
|
|
|
|
public function loadNostrEvents($kinds): array
|
|
{
|
|
$relayUrl = config('services.relay');
|
|
if (! $relayUrl) {
|
|
return [];
|
|
}
|
|
|
|
$subscription = new Subscription;
|
|
$subscriptionId = $subscription->setId();
|
|
$filter = new Filter;
|
|
$filter->setKinds($kinds);
|
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
|
|
$relaySet = new RelaySet;
|
|
$relaySet->setRelays([new Relay($relayUrl)]);
|
|
$request = new Request($relaySet, $requestMessage);
|
|
$response = $request->send();
|
|
|
|
return collect($response[$relayUrl] ?? [])
|
|
->map(function ($event) {
|
|
if (! isset($event->event)) {
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
'id' => $event->event->id,
|
|
'kind' => $event->event->kind,
|
|
'content' => $event->event->content,
|
|
'pubkey' => $event->event->pubkey,
|
|
'tags' => $event->event->tags,
|
|
'created_at' => $event->event->created_at,
|
|
];
|
|
})
|
|
->filter()
|
|
->toArray();
|
|
}
|
|
};
|
|
?>
|
|
|
|
<div>
|
|
@php
|
|
$positions = [
|
|
'presidency' => ['icon' => 'fa-crown', 'title' => 'Präsidium'],
|
|
'board' => ['icon' => 'fa-users', 'title' => 'Vorstandsmitglieder'],
|
|
];
|
|
@endphp
|
|
|
|
@if($isAllowed)
|
|
|
|
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto" x-data="electionAdminCharts()">
|
|
|
|
<!-- Dashboard actions -->
|
|
<div class="sm:flex sm:justify-between sm:items-center mb-8">
|
|
|
|
<!-- Left: Title -->
|
|
<div class="mb-4 sm:mb-0">
|
|
<h1 class="text-2xl md:text-3xl text-zinc-800 dark:text-zinc-100 font-bold">
|
|
Wahl des Vorstands {{ $election->year }}
|
|
</h1>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@php
|
|
$president = $positions['presidency'];
|
|
$board = $positions['board'];
|
|
@endphp
|
|
|
|
<!-- Cards -->
|
|
<div class="grid gap-y-4">
|
|
<div wire:key="presidency" wire:ignore
|
|
class="flex flex-col bg-white dark:bg-zinc-800 shadow-sm rounded-xl">
|
|
<header class="px-5 py-4 border-b border-zinc-100 dark:border-zinc-700/60">
|
|
<h2 class="font-semibold text-zinc-800 dark:text-zinc-100"><i
|
|
class="fa-sharp-duotone fa-solid {{ $president['icon'] }} w-5 h-5 fill-current text-white mr-4"></i>{{ $president['title'] }}
|
|
</h2>
|
|
</header>
|
|
<div class="grow">
|
|
<!-- Change| height attribute to adjust chart height -->
|
|
<canvas x-ref="chart_presidency" width="724" height="288"
|
|
style="display: block; box-sizing: border-box; height: 288px; width: 724px;"></canvas>
|
|
</div>
|
|
</div>
|
|
<div wire:key="board" wire:ignore
|
|
class="flex flex-col bg-white dark:bg-zinc-800 shadow-sm rounded-xl">
|
|
<header class="px-5 py-4 border-b border-zinc-100 dark:border-zinc-700/60">
|
|
<h2 class="font-semibold text-zinc-800 dark:text-zinc-100"><i
|
|
class="fa-sharp-duotone fa-solid {{ $board['icon'] }} w-5 h-5 fill-current text-white mr-4"></i>{{ $board['title'] }}
|
|
</h2>
|
|
</header>
|
|
<div class="grow">
|
|
<!-- Change| height attribute to adjust chart height -->
|
|
<canvas x-ref="chart_board" width="724" height="288"
|
|
style="display: block; box-sizing: border-box; height: 288px; width: 724px;"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@else
|
|
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
|
|
<flux:callout variant="warning" icon="exclamation-circle">
|
|
<flux:heading>Wahlergebnisse können nicht eingesehen werden</flux:heading>
|
|
<p>
|
|
Zugriff auf die Wahlergebnisse und Admin-Funktionen ist nur für spezielle autorisierte Benutzer möglich.
|
|
</p>
|
|
<p class="mt-3">
|
|
@if(!NostrAuth::check())
|
|
Bitte melde dich zunächst mit Nostr an.
|
|
@else
|
|
Dein Benutzer-Account ist nicht für diese Funktion autorisiert. Bitte kontaktiere den Vorstand, wenn du Zugriff benötigst.
|
|
@endif
|
|
</p>
|
|
</flux:callout>
|
|
</div>
|
|
@endif
|
|
</div>
|