Add Anonymous User Identification — Browser-ID System

2026-04-27 15:39:53 +02:00
parent a4adcca845
commit 6fdc2c0673

@@ -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
<!-- Benötigt auth.php für die is_admin()-Funktion -->
<?php
require_once __DIR__ . '/api/db.php';
require_once __DIR__ . '/api/auth.php';
?>
<script>
// Wird von app.js genutzt, um Edit/Delete-Buttons bei allen Beiträgen anzuzeigen
const IS_ADMIN = <?= (function_exists('is_admin') && is_admin()) ? 'true' : 'false' ?>;
</script>
```
**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 |