feat(security & admin): update firestore and storage rules with admin token check and enable admin deletion in app

This commit is contained in:
2026-09-14 11:33:17 +02:00
parent 2e9abac25f
commit e64b7a085c
4 changed files with 61 additions and 32 deletions
+32 -25
View File
@@ -1,35 +1,42 @@
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// News / Neuigkeiten: Öffentlich lesbar, Schreiben für angemeldete Nutzer // Hilfsfunktionen
match /News/{document=**} { function isAuthenticated() {
allow read: if true; return request.auth != null;
allow write: if request.auth != null;
} }
// Mängel & Anliegen: Öffentlich lesbar, Erstellen angemeldet, Löschen nur eigener Autor function isAdmin() {
match /Issues/{document} { return request.auth != null && request.auth.token.admin == true;
allow read: if true;
allow create: if request.auth != null;
allow update: if request.auth != null;
allow delete: if request.auth != null &&
(resource.data.author_uid == request.auth.uid);
} }
// Bürgerforum / Ideen: Öffentlich lesbar, Erstellen angemeldet, Löschen eigener Autor function isAuthorOrAdmin() {
match /CitizensForum/{document} { return isAuthenticated() && (
allow read: if true; (resource.data.author_uid != null && resource.data.author_uid == request.auth.uid) ||
allow create: if request.auth != null; (resource.data.authorUid != null && resource.data.authorUid == request.auth.uid) ||
allow update: if request.auth != null; isAdmin()
allow delete: if request.auth != null && );
(resource.data.author_uid == request.auth.uid);
} }
// Kontakt-Nachrichten: Nur registrierte Nutzer können einsenden, Lesen für berechtigte Nutzer // Öffentliche Sammlungen: News, Mängel (Issues) & Ideen (CitizensForum)
match /{collection}/{document=**} {
// Jeder darf News, Mängel & Ideen öffentlich lesen
allow read: if collection in ['News', 'Issues', 'CitizensForum'];
// Erstellen darf jeder freigeschaltete/angemeldete Nutzer
allow create: if isAuthenticated() && collection in ['News', 'Issues', 'CitizensForum'];
// Ändern/Löschen nur der eigene Autor oder Admin
allow update, delete: if isAuthorOrAdmin() && collection in ['News', 'Issues', 'CitizensForum'];
}
// Kontakt-Nachrichten: Geschützt (nur registrierte Nutzer senden, Lesen nur Admin & Autor)
match /ContactMessages/{document} { match /ContactMessages/{document} {
allow create: if request.auth != null && request.auth.token.firebase.sign_in_provider != 'anonymous'; // Senden nur für registrierte Nutzer (nicht-anonym)
allow read, update, delete: if request.auth != null; allow create: if isAuthenticated() && request.auth.token.firebase.sign_in_provider != 'anonymous';
// Lesen, Ändern und Löschen für Admin und Autor
allow read, update, delete: if isAuthorOrAdmin();
} }
} }
} }
+4 -3
View File
@@ -1,5 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -111,7 +110,8 @@ class _PostListScreenState extends State<PostListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = FirebaseAuth.instance.currentUser; final auth = Provider.of<AuthService>(context);
final currentUser = auth.user;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -240,6 +240,7 @@ class _PostListScreenState extends State<PostListScreen> {
final isSelected = _selectedItems.containsKey(item.id); final isSelected = _selectedItems.containsKey(item.id);
final isAuthor = final isAuthor =
currentUser != null && item.authorUid == currentUser.uid; currentUser != null && item.authorUid == currentUser.uid;
final canDelete = isAuthor || auth.isAdmin;
final formattedDate = final formattedDate =
DateFormat('dd.MM.yyyy').format(item.createdDate); DateFormat('dd.MM.yyyy').format(item.createdDate);
@@ -367,7 +368,7 @@ class _PostListScreenState extends State<PostListScreen> {
), ),
); );
if (isAuthor) { if (canDelete) {
return Dismissible( return Dismissible(
key: Key(item.id), key: Key(item.id),
direction: DismissDirection.endToStart, direction: DismissDirection.endToStart,
+20 -2
View File
@@ -20,6 +20,8 @@ class AuthService extends ChangeNotifier {
String? _cachedFirstName; String? _cachedFirstName;
String? _cachedLastName; String? _cachedLastName;
bool _isAdmin = false;
bool get isAdmin => _isAdmin;
String get firstName { String get firstName {
if (_cachedFirstName != null && _cachedFirstName!.isNotEmpty) { if (_cachedFirstName != null && _cachedFirstName!.isNotEmpty) {
@@ -45,14 +47,28 @@ class AuthService extends ChangeNotifier {
_initAuth(); _initAuth();
} }
Future<void> _checkAdminClaim(User? user) async {
if (user != null && !user.isAnonymous) {
try {
final tokenResult = await user.getIdTokenResult();
_isAdmin = tokenResult.claims?['admin'] == true;
} catch (_) {
_isAdmin = false;
}
} else {
_isAdmin = false;
}
}
Future<void> _initAuth() async { Future<void> _initAuth() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false; _isCommunityUnlocked = prefs.getBool(_prefKeyUnlocked) ?? false;
_cachedFirstName = prefs.getString('user_first_name'); _cachedFirstName = prefs.getString('user_first_name');
_cachedLastName = prefs.getString('user_last_name'); _cachedLastName = prefs.getString('user_last_name');
_auth.authStateChanges().listen((User? user) { _auth.authStateChanges().listen((User? user) async {
_user = user; _user = user;
await _checkAdminClaim(user);
notifyListeners(); notifyListeners();
}); });
@@ -152,10 +168,11 @@ class AuthService extends ChangeNotifier {
/// Check if user's email is verified /// Check if user's email is verified
bool get isEmailVerified => _user?.emailVerified ?? false; bool get isEmailVerified => _user?.emailVerified ?? false;
/// Reload current user from Firebase to refresh emailVerified status /// Reload current user from Firebase to refresh emailVerified status and admin claim
Future<void> reloadUser() async { Future<void> reloadUser() async {
await _user?.reload(); await _user?.reload();
_user = _auth.currentUser; _user = _auth.currentUser;
await _checkAdminClaim(_user);
notifyListeners(); notifyListeners();
} }
@@ -209,6 +226,7 @@ class AuthService extends ChangeNotifier {
await prefs.remove('user_last_name'); await prefs.remove('user_last_name');
_cachedFirstName = null; _cachedFirstName = null;
_cachedLastName = null; _cachedLastName = null;
_isAdmin = false;
await _auth.signOut(); await _auth.signOut();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
+5 -2
View File
@@ -12,8 +12,11 @@ service firebase.storage {
&& request.resource.size < 10 * 1024 * 1024 && request.resource.size < 10 * 1024 * 1024
&& request.resource.contentType.matches('image/.*'); && request.resource.contentType.matches('image/.*');
// Nur der Verfasser (oder berechtigte Nutzer) darf sein Foto löschen // Nur der Verfasser oder ein Admin darf sein Foto löschen
allow delete: if request.auth != null && request.auth.uid == userId; allow delete: if request.auth != null && (
request.auth.uid == userId ||
request.auth.token.admin == true
);
} }
} }
} }