diff --git a/Anonymous User Identification %E2%80%94 Browser-ID System.-.md b/Anonymous User Identification %E2%80%94 Browser-ID System.-.md new file mode 100644 index 0000000..2d19da1 --- /dev/null +++ b/Anonymous User Identification %E2%80%94 Browser-ID System.-.md @@ -0,0 +1,525 @@ +# Anonymous User Identification — Browser-ID System + +## Übersicht + +Das Bürgerbeteiligungsportal identifiziert Nutzer anonym über eine **Browser-ID** (UUID), die als Cookie gespeichert wird. Dieses System ermöglicht niedrigschwellige Bürgerbeteiligung ohne Registrierung oder Passwort, während gleichzeitig Beiträge und Abstimmungen zuverlässig einem Browser zugeordnet werden können. + +--- + +## Architektur-Entscheidung + +### Problem + +- Bürger sollen ohne Registrierung Beiträge erstellen und abstimmen können. +- Nur der eingegebene Name reicht nicht zur Identifikation — jeder könnte sich als jemand anderes ausgeben und fremde Beiträge bearbeiten oder löschen. +- Abstimmungen (Likes/Dislikes) müssen pro Nutzer eindeutig sein, auch wenn mehrere Personen denselben Namen eingeben. +- Abstimmungen sollen nach dem Neuladen der Seite sichtbar bleiben (farbliche Markierung der Buttons). + +### Lösung: Zwei-Schicht-Identifikation + +| Schicht | Zweck | Speicherort | +|---------|-------|-------------| +| **Browser-ID (UUID)** | Technische Identifikation für Rechte und Abstimmungen | Cookie (`webgis_browser_id`, 1 Jahr gültig) | +| **Benutzername** | Anzeigename in Beiträgen und Abstimmungen | sessionStorage (`webgis_user`) | + +### Rollen + +| Rolle | Zugang | Rechte | +|-------|--------|--------| +| **Bürger** | Name eingeben (freiwillig) | Beiträge erstellen, abstimmen, eigene Beiträge bearbeiten/löschen | +| **Moderator/Admin** | Passwort-Login über Lock-Icon im Header | Alle Beiträge moderieren, bearbeiten, löschen, News verwalten | + +--- + +## Betroffene Dateien + +| Datei | Änderung | +|-------|----------| +| `migrations/005_browser_id.sql` | Neue Spalten und Constraints in der Datenbank | +| `public/js/app.js` | Browser-ID generieren, bei API-Calls mitsenden, Rechte prüfen | +| `public/index.php` | `IS_ADMIN`-Variable für JavaScript bereitstellen | +| `api/contributions.php` | Browser-ID bei Create, Vote und Read verarbeiten | + +--- + +## Datenbankänderungen (Migration 005) + +### Neue Spalten + +```sql +-- Speichert die Browser-ID des Erstellers eines Beitrags +ALTER TABLE contributions + ADD COLUMN browser_id VARCHAR(36) DEFAULT NULL; + +-- Speichert die Browser-ID des Abstimmenden +ALTER TABLE votes + ADD COLUMN browser_id VARCHAR(36) DEFAULT NULL; +``` + +### Neuer Index + +```sql +-- Beschleunigt die Suche nach Abstimmungen eines bestimmten Browsers +CREATE INDEX idx_votes_browser ON votes(browser_id); +``` + +### Constraint-Änderung + +```sql +-- VORHER: Ein Vote pro voter_name + contribution_id +-- → Problem: Zwei verschiedene Browser mit gleichem Namen +-- konnten nicht unabhängig abstimmen. + +-- Entfernt den alten Constraint (Name-basiert) +ALTER TABLE votes + DROP CONSTRAINT IF EXISTS votes_unique_per_voter; + +-- NACHHER: Ein Vote pro browser_id + contribution_id +-- → Jeder Browser kann genau einmal pro Beitrag abstimmen, +-- unabhängig vom eingegebenen Namen. +ALTER TABLE votes + ADD CONSTRAINT votes_contribution_browser_unique + UNIQUE (contribution_id, browser_id); +``` + +### Auswirkung auf bestehende Daten + +- Bestehende Beiträge erhalten `browser_id = NULL`. Diese Beiträge können von niemandem über das Portal bearbeitet werden (nur über das Moderationsportal). +- Bestehende Votes erhalten `browser_id = NULL`. Diese Votes werden beim Neuladen nicht als farbliche Markierung angezeigt, bleiben aber in der Datenbank und zählen weiterhin. + +--- + +## JavaScript-Änderungen (app.js) + +### Block 1: Browser-ID generieren + +```javascript +// Browser Identification Number for anonymous User Identification stored as Cookie +let browserId = getBrowserId(); + +function getBrowserId() { + // Versucht, bestehende Browser-ID aus Cookie zu lesen + let id = document.cookie.replace( + /(?:(?:^|.*;\s*)webgis_browser_id\s*=\s*([^;]*).*$)|^.*$/, '$1' + ); + + // Falls keine ID vorhanden, wird eine neue UUID v4 generiert + if (!id) { + id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); + // Cookie wird für ein Jahr gespeichert + document.cookie = 'webgis_browser_id=' + id + ';path=/;max-age=31536000;SameSite=Lax'; + } + return id; +} +``` + +**Wie es funktioniert:** + +1. Beim ersten Seitenaufruf existiert kein Cookie → neue UUID wird generiert (z.B. `b5f326e1-8c83-4780-99a6-c98c6586ec07`). +2. Die UUID wird als Cookie `webgis_browser_id` gespeichert (1 Jahr Laufzeit). +3. Bei jedem weiteren Aufruf wird die bestehende UUID aus dem Cookie gelesen. +4. Die UUID ist pro Browser einzigartig — verschiedene Browser auf demselben Computer haben verschiedene IDs. + +### Block 7: Browser-ID bei Read mitsenden + +```javascript +function loadContributions() { + const readParams = { action: 'read', municipality_id: MUNICIPALITY.id }; + + // Sendet Browser-ID, damit die API die eigenen Votes zurückgeben kann + readParams.browser_id = browserId; + + apiCall(readParams, function (data) { + // ... + contributionsData = data.features || []; + + // Stellt die Vote-Markierungen aus der API-Antwort wieder her + if (data.user_votes) { + userVotes = {}; + for (const key in data.user_votes) { + userVotes[key] = data.user_votes[key]; + } + } + // ... + }); +} +``` + +**Wie es funktioniert:** + +1. Beim Laden der Beiträge wird die `browser_id` an die API gesendet. +2. Die API sucht alle Votes dieser Browser-ID und gibt sie als `user_votes`-Objekt zurück. +3. Das `userVotes`-Objekt wird befüllt (z.B. `{ "2": "like", "5": "dislike" }`). +4. Wenn ein Popup geöffnet wird, prüft `buildPopupHtml()` dieses Objekt und setzt die CSS-Klassen `liked` / `disliked` auf die Buttons. + +### Block 9: Edit/Delete nur für eigenen Browser oder Admin + +```javascript +// VORHER: Prüfte nur den Benutzernamen +(currentUser === props.author_name ? /* Buttons anzeigen */ : '') + +// NACHHER: Prüft die Browser-ID oder Admin-Status +(props.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) ? /* Buttons anzeigen */ : '') +``` + +**Wie es funktioniert:** + +- `props.browser_id` ist die Browser-ID, die beim Erstellen des Beitrags gespeichert wurde. +- `browserId` ist die aktuelle Browser-ID des Betrachters. +- Stimmen sie überein → der Betrachter ist der Ersteller → Bearbeiten/Löschen-Buttons werden angezeigt. +- `IS_ADMIN` ist `true`, wenn der Nutzer über das Moderationsportal als Admin eingeloggt ist → sieht die Buttons bei allen Beiträgen. + +### Block 10: Browser-ID bei Create und Vote mitsenden + +```javascript +// CREATE — Browser-ID wird mit dem Beitrag gespeichert +apiCall({ + action: 'create', + // ... + author_name: currentUser, + browser_id: browserId // ← NEU +}, function (response) { /* ... */ }); + +// VOTE — Browser-ID identifiziert den Abstimmenden +apiCall({ + action: 'vote', + contribution_id: contributionId, + voter_name: currentUser, + vote_type: voteType, + browser_id: browserId // ← NEU +}, function (response) { /* ... */ }); +``` + +--- + +## PHP-Änderungen (index.php) + +### Admin-Status als JavaScript-Variable + +```php + + + + +``` + +**Wie es funktioniert:** + +- `is_admin()` prüft `$_SESSION['is_admin']`. +- Die Session wird gesetzt, wenn sich jemand über `admin.php` mit dem korrekten Passwort anmeldet. +- Da `index.php` und `admin.php` dieselbe Session teilen, erkennt das Portal einen eingeloggten Moderator. + +--- + +## API-Änderungen (contributions.php) + +### handle_create — Speichert Browser-ID + +```php +$stmt = $pdo->prepare(" + INSERT INTO contributions + (municipality_id, geom, geom_type, category, title, description, author_name, browser_id) + VALUES + (:mid, ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326), :geom_type, + :category, :title, :description, :author_name, :browser_id) +"); + +$stmt->execute([ + // ... + ':browser_id' => $input['browser_id'] ?? null // ← NEU +]); +``` + +### handle_vote — Identifiziert über Browser-ID + +```php +// Prüft ob Browser-ID vorhanden ist (Pflichtfeld für Voting) +$browser_id = $input['browser_id'] ?? ''; +if (empty($browser_id)) { + error_response('Browser ID required for Voting.'); +} + +// Sucht bestehenden Vote über Browser-ID statt über Name +$stmt = $pdo->prepare(" + SELECT vote_id, vote_type FROM votes + WHERE contribution_id = :cid AND browser_id = :bid +"); +$stmt->execute([':cid' => $input['contribution_id'], ':bid' => $browser_id]); + +// Bei INSERT wird die Browser-ID mitgespeichert +$stmt = $pdo->prepare(" + INSERT INTO votes (contribution_id, voter_name, vote_type, browser_id) + VALUES (:cid, :voter, :vtype, :bid) +"); +``` + +**Ablauf eines Votes:** + +1. Browser sendet `contribution_id`, `voter_name`, `vote_type` und `browser_id`. +2. API prüft ob bereits ein Vote mit dieser `browser_id` für diesen Beitrag existiert. +3. Falls ja und gleicher Typ → Vote wird entfernt (Toggle). +4. Falls ja und anderer Typ → Vote wird gewechselt (Like → Dislike oder umgekehrt). +5. Falls nein → neuer Vote wird eingefügt. +6. Der DB-Trigger `update_vote_counts()` aktualisiert `likes_count`/`dislikes_count` automatisch. + +### handle_read — Liefert eigene Votes mit + +```php +// Sucht alle Votes der aktuellen Browser-ID +$browser_id = $input['browser_id'] ?? ''; +if ($browser_id !== '') { + $stmt = $pdo->prepare(" + SELECT contribution_id, vote_type + FROM votes + WHERE browser_id = :bid + "); + $stmt->execute([':bid' => $browser_id]); + $user_votes = []; + foreach ($stmt->fetchAll() as $v) { + $user_votes[$v['contribution_id']] = $v['vote_type']; + } + // Hängt die Votes an die GeoJSON-Antwort an + $featureCollection['user_votes'] = $user_votes; +} +``` + +**Beispiel API-Antwort mit Votes:** + +```json +{ + "type": "FeatureCollection", + "features": [ /* ... Beiträge ... */ ], + "user_votes": { + "2": "like", + "5": "dislike", + "7": "like" + } +} +``` + +--- + +## Datenfluss-Diagramm + +``` +ERSTER BESUCH: +┌──────────┐ Cookie nicht vorhanden ┌──────────────┐ +│ Browser │ ─────────────────────────────→ │ getBrowserId()│ +│ │ ← UUID generiert + Cookie ──── │ │ +└──────────┘ gesetzt (1 Jahr) └──────────────┘ + +BEITRAG ERSTELLEN: +┌──────────┐ POST: create + browser_id ┌──────────────┐ INSERT mit ┌──────────┐ +│ Browser │ ─────────────────────────────→│ PHP API │ ──────────────────→ │ DB │ +│ │ │ │ browser_id │ │ +└──────────┘ └──────────────┘ └──────────┘ + +ABSTIMMEN: +┌──────────┐ POST: vote + browser_id ┌──────────────┐ SELECT WHERE bid ┌──────────┐ +│ Browser │ ─────────────────────────────→│ PHP API │ ──────────────────→ │ DB │ +│ │ ← action: created/removed ─── │ Prüft Vote │ ← Ergebnis ─────── │ │ +└──────────┘ │ Toggle-Logik │ INSERT/DELETE │ │ + └──────────────┘ └──────────┘ + +SEITE LADEN (Votes persistent): +┌──────────┐ POST: read + browser_id ┌──────────────┐ SELECT votes ┌──────────┐ +│ Browser │ ────────────────────────────→│ PHP API │ ──────────────────→ │ DB │ +│ │ ← features + user_votes ──── │ │ ← Votes ─────────── │ │ +│ │ └──────────────┘ └──────────┘ +│ │ +│ buildPopupHtml() liest userVotes +│ → Setzt CSS-Klasse "liked"/"disliked" +│ → Buttons sind farbig markiert +└──────────┘ +``` + +--- + +## Testszenarien + +### Test 1: Erste Nutzung — Cookie wird generiert + +**Vorbereitung:** Browser-Cookies löschen oder Inkognito-Modus öffnen. + +**Schritte:** +1. Portal öffnen. +2. Browser-Entwicklertools öffnen (F12) → Reiter "Application" → "Cookies". + +**Erwartung:** +- Ein Cookie `webgis_browser_id` existiert mit einem UUID-Wert (z.B. `a1b2c3d4-e5f6-4789-abcd-ef0123456789`). +- Der Cookie hat `max-age=31536000` (1 Jahr). +- Der Cookie hat `SameSite=Lax`. + +--- + +### Test 2: Vote bleibt nach Popup schließen/öffnen + +**Schritte:** +1. Anmelden mit einem Namen. +2. Einen Beitrag anklicken → Popup öffnet sich. +3. Like-Button klicken → Button wird grün, Zähler erhöht sich. +4. Popup schließen (Klick auf Karte). +5. Denselben Beitrag erneut anklicken. + +**Erwartung:** +- Like-Button ist weiterhin grün markiert. +- Zähler zeigt den korrekten Wert. + +--- + +### Test 3: Vote bleibt nach Seite neu laden + +**Schritte:** +1. Einen Beitrag liken (wie Test 2, Schritt 1-3). +2. Seite komplett neu laden (F5 oder Strg+R). +3. Denselben Beitrag anklicken. + +**Erwartung:** +- Like-Button ist weiterhin grün markiert. +- Der Vote wurde aus der Datenbank geladen, nicht aus dem Browser-Speicher. + +**Verifikation in der Datenbank:** +```sql +SELECT * FROM votes WHERE browser_id = 'deine-browser-id-hier'; +``` + +--- + +### Test 4: Vote Toggle — Like entfernen + +**Schritte:** +1. Einen Beitrag liken (Button wird grün). +2. Denselben Like-Button erneut klicken. + +**Erwartung:** +- Like-Button wird wieder normal (nicht mehr grün). +- Zähler verringert sich um 1. +- In der Datenbank existiert kein Vote mehr für diesen Beitrag und diese Browser-ID. + +--- + +### Test 5: Vote wechseln — Like zu Dislike + +**Schritte:** +1. Einen Beitrag liken (Like-Button wird grün). +2. Dislike-Button klicken. + +**Erwartung:** +- Like-Button wird normal, Dislike-Button wird rot. +- Like-Zähler verringert sich um 1, Dislike-Zähler erhöht sich um 1. + +--- + +### Test 6: Eigene Beiträge bearbeiten/löschen + +**Schritte:** +1. Anmelden und einen neuen Beitrag erstellen. +2. Warten bis der Beitrag vom Moderator freigegeben wird (oder über Admin selbst freigeben). +3. Den eigenen Beitrag auf der Karte anklicken. + +**Erwartung:** +- Popup zeigt "Bearbeiten" und "Löschen" Buttons. + +--- + +### Test 7: Fremde Beiträge — kein Bearbeiten/Löschen + +**Schritte:** +1. Einen anderen Browser oder Inkognito-Modus öffnen. +2. Einen Beitrag anklicken, der im ersten Browser erstellt wurde. + +**Erwartung:** +- Popup zeigt KEINE "Bearbeiten"/"Löschen" Buttons. +- Like/Dislike-Buttons sind weiterhin verfügbar. + +--- + +### Test 8: Admin sieht alle Bearbeiten/Löschen-Buttons + +**Schritte:** +1. Über das Lock-Icon im Header das Moderationsportal öffnen. +2. Mit dem Admin-Passwort anmelden. +3. Zurück zur Karte navigieren (Link "Bürgerportal" im Mod-Header). +4. Einen beliebigen Beitrag anklicken. + +**Erwartung:** +- Popup zeigt "Bearbeiten" und "Löschen" Buttons bei ALLEN Beiträgen, nicht nur eigenen. + +--- + +### Test 9: Verschiedene Browser = verschiedene Identitäten + +**Schritte:** +1. In Chrome einen Beitrag erstellen und liken. +2. Denselben Beitrag in Firefox öffnen. + +**Erwartung:** +- In Firefox ist der Like-Button NICHT grün markiert (anderer Browser = andere Browser-ID). +- In Firefox sind keine Bearbeiten/Löschen-Buttons sichtbar. +- In Firefox kann derselbe Beitrag unabhängig geliked werden (eigener Vote). + +--- + +### Test 10: Doppel-Vote wird verhindert + +**Schritte:** +1. Einen Beitrag liken. +2. In der Datenbank nachschauen: +```sql +SELECT * FROM votes +WHERE contribution_id = [ID] AND browser_id = '[BROWSER-ID]'; +``` + +**Erwartung:** +- Genau ein Eintrag existiert. +- Ein erneuter Like vom selben Browser entfernt den Vote (Toggle). +- Die Datenbank-Constraint `votes_contribution_browser_unique` verhindert technisch jeden Versuch, zwei Votes pro Browser pro Beitrag einzufügen. + +--- + +### Test 11: Cookie löschen = neuer Nutzer + +**Schritte:** +1. Einen Beitrag erstellen und liken. +2. Browser-Cookies löschen (Entwicklertools → Application → Cookies → alle löschen). +3. Seite neu laden. + +**Erwartung:** +- Like-Markierung ist weg (neue Browser-ID). +- Bearbeiten/Löschen-Buttons beim eigenen Beitrag sind weg. +- Der alte Vote und Beitrag bleiben in der Datenbank (mit der alten Browser-ID). +- Der Nutzer kann den Beitrag erneut liken (mit neuer Browser-ID). + +**Hinweis:** Das ist eine bekannte Einschränkung. Für ein Bürgerbeteiligungsportal ohne Registrierung ist das ein akzeptabler Kompromiss. + +--- + +## Sicherheitsbetrachtung + +| Szenario | Geschützt? | Erklärung | +|----------|------------|-----------| +| Jemand gibt einen fremden Namen ein | ✅ Ja | Edit/Delete prüft Browser-ID, nicht den Namen | +| Jemand versucht doppelt abzustimmen | ✅ Ja | DB-Constraint `votes_contribution_browser_unique` verhindert das | +| Jemand löscht seine Cookies | ⚠️ Teilweise | Wird als neuer Nutzer behandelt, kann erneut abstimmen | +| Jemand kopiert die Browser-ID eines anderen | ⚠️ Theoretisch möglich | Erfordert Zugriff auf den anderen Browser — unrealistisch für Bürgerbeteiligung | +| Admin-Zugang ohne Passwort | ✅ Ja | `IS_ADMIN` wird serverseitig aus der PHP-Session gesetzt | + +--- + +## Zukünftige Verbesserungen + +| Verbesserung | Phase | Beschreibung | +|-------------|-------|-------------| +| Rate Limiting | Phase 5 | Maximal X Votes pro Stunde pro Browser-ID | +| Cookie-Warnung (DSGVO) | Phase 5 | Hinweis auf Cookie-Nutzung beim ersten Besuch | +| Fingerprinting als Fallback | Phase 5 | Falls Cookies deaktiviert sind, Browser-Fingerprint als Alternative | +| users-Tabelle für Moderatoren | Phase 5 | Mehrere Moderatoren mit individuellen Passwörtern und Audit-Log | \ No newline at end of file