1
Anonymous User Identification — Browser-ID System
lukas.uptmoor edited this page 2026-04-27 15:39:53 +02:00

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

-- 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

-- Beschleunigt die Suche nach Abstimmungen eines bestimmten Browsers
CREATE INDEX idx_votes_browser ON votes(browser_id);

Constraint-Änderung

-- 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

// 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

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

// 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

// 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

<!-- 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

$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

// 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

// 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:

{
    "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

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:

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:
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.

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