İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Firebase Auth mit eigenem PHP-Server: Token-Prüfung und Kontolöschung ohne Composer

Ahmet Balaman
FirebasePHPAuthentifizierungJWTSicherheitAnilogFlutterMySQLApp StorePlay Store

Die folgenreichste Entscheidung beim Bau von Anılog war, Firebase nur für die Identität zu nutzen. Gruppen, Clips, Kommentare, Mediendateien — alles liegt auf meinem eigenen Hostinger-Server, auf reinem PHP und MySQL.

Diese Trennung hat einen Preis: der Server muss selbst prüfen, dass das vom Client gesendete Firebase-ID-Token wirklich von Google signiert wurde. Und da ich auf Shared Hosting kein Composer ausführen kann, gab es auch kein firebase/php-jwt.

Dieser Beitrag zeigt zwei Dinge vollständig:

  1. Ein Firebase-ID-Token in abhängigkeitsfreiem PHP prüfen
  2. Den von den Stores geforderten Kontolöschungs-Ablauf aufbauen — in der App und im Web

Teil 1: das Token prüfen

Ein Firebase-ID-Token ist ein mit RS256 signiertes JWT. Die Prüfung besteht aus drei Schritten:

  • Die Signatur gegen Googles öffentlichen Schlüssel prüfen
  • Prüfen, dass Empfänger (aud) und Aussteller (iss) zum eigenen Projekt gehören
  • Prüfen, dass es nicht abgelaufen ist

PHPs openssl-Erweiterung deckt alle drei ab.

Googles Zertifikate holen

Google veröffentlicht die x509-Zertifikate zur Signaturprüfung unter einer festen Adresse:

const GOOGLE_CERT_URL =
    'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]';

Sie rotieren regelmäßig, aber sie bei jeder Anfrage zu laden wäre absurd — das wäre ein ausgehender HTTP-Aufruf pro API-Zugriff. Die Antwort gehört auf die Platte zwischengespeichert, mit Respekt vor der Lebensdauer im Cache-Control-Header.

Die Signatur prüfen

Der entscheidende Teil sind diese Zeilen:

[$h, $p, $s] = explode('.', $jwt);

$header = json_decode(b64url_decode($h), true);
if (($header['alg'] ?? '') !== 'RS256') {
    fail('Nicht unterstützter Signaturtyp.', 401, 'token_bozuk');
}

$certs = google_certs();
$pem = $certs[$header['kid']] ?? null;
if ($pem === null) fail('Unbekannter Schlüssel.', 401, 'token_bozuk');

$pub = openssl_pkey_get_public($pem);
$valid = openssl_verify("$h.$p", b64url_decode($s), $pub, OPENSSL_ALGO_SHA256);
if ($valid !== 1) fail('Signaturprüfung fehlgeschlagen.', 401, 'token_bozuk');

Signiert wird der Text "$h.$p" — Header und Payload in base64url, mit dem Punkt dazwischen. Sie zu dekodieren und neu zu kodieren zerstört die Signatur; man muss die rohen Zeichenketten verwenden.

Die Prüfung von alg ist nicht optional. Ohne sie kann ein Angreifer alg: none senden und ein unsigniertes Token akzeptieren lassen — die bekannteste JWT-Schwachstelle überhaupt.

Die Claims prüfen

Eine gültige Signatur allein genügt nicht. Das Token könnte aus einem anderen Firebase-Projekt stammen:

$iss = 'https://securetoken.google.com/' . FIREBASE_PROJECT_ID;

if (($claims['iss'] ?? '') !== $iss)                fail('Falscher Aussteller.', 401);
if (($claims['aud'] ?? '') !== FIREBASE_PROJECT_ID) fail('Falscher Empfänger.', 401);
if (($claims['exp'] ?? 0) < time())                 fail('Abgelaufen.', 401);

Die aud-Prüfung auszulassen heißt, dass ein Token aus irgendeinem Firebase-Projekt gegen die eigene API gültig ist. Ein häufiger Fehler.

Auf den eigenen Nutzer abbilden

Nach bestandener Prüfung steht die Firebase-UID in sub. In der eigenen users-Tabelle liegt eine Zeile mit dieser UID, angelegt bei der ersten Anmeldung.

Die Feinheit: nicht die E-Mail als Schlüssel nehmen. "Anmelden mit Apple" unterstützt "E-Mail verbergen", und sie kann sich später ändern. Der einzige stabile Anker ist die UID.

Teil 2: Kontolöschung

App Store und Play Store verlangen beide, dass eine App, in der man ein Konto anlegen kann, es auch löschen lässt. Play geht einen Schritt weiter: die Löschung muss ohne Installation der App über eine Webadresse möglich sein.

Man braucht also zwei getrennte Wege.

Weg 1: aus der App heraus

Profil → Konto löschen. Serverdaten und Firebase-Identität werden entfernt.

Die Reihenfolge ist entscheidend. In meiner ersten Fassung löschte ich erst die Serverdaten und versuchte dann, den Firebase-Nutzer zu löschen. Scheiterte dieser Aufruf mit requires-recent-login, blieb ein Zustand übrig: Daten weg, aber weiterhin angemeldet.

Die richtige Reihenfolge:

Future<void> hesabiSil() async {
  // Zuerst die Anmeldung auffrischen: Löschen verlangt einen frischen Login.
  await _yenidenDogrula();

  await _api.delete('/me');

  try {
    await _auth.currentUser?.delete();
  } finally {
    // Auch wenn die Firebase-Löschung scheitert: abmelden. Die Serverdaten
    // sind weg, niemand darf drinnen bleiben.
    await cikisYap();
  }
}

Der finally-Block ist der Punkt: sind die Serverdaten gelöscht, geht die Person hinaus, egal was Firebase tut.

Weg 2: im Web, ohne App

Das ist die schwierigere Hälfte. Wie handelt eine Webseite sicher auf ein "lösche dieses Konto" von einer nicht angemeldeten Person?

Der Ablauf hat zwei Schritte:

  1. Die Person gibt ihre E-Mail ein. Existiert ein Konto, erzeugt der Server einen sechsstelligen Code und schickt ihn per E-Mail.
  2. Die Person gibt den Code ein. Stimmt er, erfolgt die Löschung sofort — keine manuelle Freigabeschlange.

Worauf die Serverseite achtet:

  • Der Code wird gehasht gespeichert (HMAC-SHA256). Wer die Datenbank sieht, liest den Code nicht.
  • Ob ein Konto existiert, wird nie verraten. Die Antwort ist identisch, bis hin zur angeglichenen Antwortzeit. Sonst wird das Formular zum Werkzeug, um registrierte Adressen abzufragen.
  • Ratenbegrenzung pro E-Mail und pro IP.
  • Die Löschung läuft unter einer FOR UPDATE-Zeilensperre; zwei gleichzeitige Anfragen können nicht beide durchgehen.

Den Firebase-Nutzer vom Server aus löschen

Im Web-Ablauf gibt es keine Sitzung, ein clientseitiges currentUser.delete() fällt also aus. Man muss mit Firebase-Admin-Rechten vom Server löschen. Und wieder ohne Composer.

Die Kette hat drei Schritte:

Dienstkonto-Schlüssel (JSON)
  → ein mit RS256 signiertes JWT bauen
  → es bei oauth2.googleapis.com/token gegen ein Zugriffstoken tauschen
  → accounts:lookup / accounts:delete auf identitytoolkit.googleapis.com aufrufen

Diesmal signiert man das JWT selbst, statt eines zu prüfen:

$jwt_govde = [
    'iss'   => $sa['client_email'],
    'scope' => 'https://www.googleapis.com/auth/identitytoolkit',
    'aud'   => $sa['token_uri'] ?? 'https://oauth2.googleapis.com/token',
    'iat'   => time(),
    'exp'   => time() + 3600,
];

Danach macht accounts:lookup aus der E-Mail eine UID, und accounts:delete entfernt sie.

Eine Warnung zum Dienstkonto-Schlüssel: diese JSON-Datei ist die volle Kontrolle über das Projekt. Sie gehört in ein Verzeichnis, das das Web nicht erreicht, mit einer .htaccess, die Require all denied enthält. Danach die Adresse der Datei im Browser öffnen und den 403 bestätigen. Ich habe das bestätigt; sonst hätte ich es nicht gewusst.

Was dieses Vorgehen kostet und bringt

Kosten: man schreibt, was ein SDK geschrieben hätte. Rund 200 Zeilen PHP — und ein Fehler in der Prüflogik ist eine Sicherheitslücke, kein Bug.

Nutzen: Medien und Daten auf dem eigenen Server, vorhersehbare Kosten, Herstellerbindung nur bei der Identität. Bei einer App, die Videos hostet, ist dieser Unterschied nicht klein.

Die Gesamtarchitektur von Anılog und den Grund für diese Entscheidung habe ich in einem eigenen Beitrag beschrieben.

Kommentare