Files
MABEA/flutter_app/lib/api/api_client.dart
T
patrickandClaude Sonnet 5 230237ac94
CI / backend-tests (push) Successful in 3m16s
CI / frontend-build (push) Successful in 20s
feat(flutter): Mangelmeldung-Offline-Queue + Threat-Model-Fund als Kachel
Android-Client zieht die Draft-first-Offline-Queue für Mangelmeldung nach
(bisher nur Kontrolle abgedeckt), inkl. Foto-Upload per Multipart und
eigenem Melde-Screen. Zusätzlich Backend-Threat-Model erstellt und daraus
DOC-006 (IDOR bei Dokument-Download) als Kachel im Backlog angelegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
2026-09-12 21:42:44 +02:00

210 lines
7.6 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
/// Gleiche Basis-Idee wie im React-Client (frontend/src/api/client.ts):
/// gemeinsamer JSON-Wrapper, Token aus persistentem Speicher, ApiException
/// mit HTTP-Status, damit aufrufender Code gezielt reagieren kann (z.B. 409
/// bei Objekt-Sperre).
///
/// Nutzer-Vorgabe 2026-09-10: Zugangsdaten werden zusätzlich zum Token
/// gespeichert, damit die App nach Token-Ablauf (Backend: 8h) automatisch
/// neu einloggen kann, sobald wieder eine Verbindung besteht - ohne dass der
/// Helfer im Feld erneut Login/Passwort eintippen muss. Speicherung über
/// flutter_secure_storage (Android Keystore/iOS Keychain, verschlüsselt),
/// NICHT SharedPreferences (Klartext) - Passwort-Speicherung auf dem Gerät
/// erhöht das Risiko bei Geräteverlust, daher zwingend verschlüsselter
/// Speicher statt der bisherigen Token-only-Lösung.
class ApiException implements Exception {
final int status;
final dynamic detail;
ApiException(this.status, this.detail);
@override
String toString() => 'ApiException($status, $detail)';
}
class ApiClient {
ApiClient({required this.baseUrl, http.Client? httpClient, FlutterSecureStorage? storage})
: _http = httpClient ?? http.Client(),
_storage = storage ?? const FlutterSecureStorage();
static const _tokenKey = 'mabea_token';
static const _loginKey = 'mabea_login';
static const _passwortKey = 'mabea_passwort';
final String baseUrl;
final http.Client _http;
final FlutterSecureStorage _storage;
String? _token;
Future<void> ladeToken() async {
_token = await _storage.read(key: _tokenKey);
}
Future<void> setzeToken(String? token) async {
_token = token;
if (token == null) {
await _storage.delete(key: _tokenKey);
} else {
await _storage.write(key: _tokenKey, value: token);
}
}
bool get istEingeloggt => _token != null;
/// [zugangsdatenSpeichern] ist die Nutzer-Entscheidung auf dem Login-Screen
/// (Checkbox) - bei false werden auch eventuell noch vorhandene, früher
/// gespeicherte Zugangsdaten gelöscht, nicht nur keine neuen angelegt
/// (sonst würde ein nachträgliches Abwählen wirkungslos bleiben).
Future<String> login(String username, String password, {bool zugangsdatenSpeichern = true}) async {
final token = await _fuehreLoginAus(username, password);
if (zugangsdatenSpeichern) {
// Erst NACH erfolgreichem Login speichern - kein Ablegen falscher
// Zugangsdaten bei einem Tippfehler.
await _storage.write(key: _loginKey, value: username);
await _storage.write(key: _passwortKey, value: password);
} else {
await _storage.delete(key: _loginKey);
await _storage.delete(key: _passwortKey);
}
return token;
}
Future<String> _fuehreLoginAus(String username, String password) async {
final response = await _http.post(
Uri.parse('$baseUrl/auth/login'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {'username': username, 'password': password},
);
if (response.statusCode != 200) {
throw ApiException(response.statusCode, _tryDecode(response.body));
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final token = data['access_token'] as String;
await setzeToken(token);
return token;
}
/// Versucht, mit den zuletzt gespeicherten Zugangsdaten neu einzuloggen -
/// z.B. wenn der Token beim App-Start bereits abgelaufen ist oder eine
/// Anfrage mit 401 fehlschlägt, während wieder eine Verbindung besteht.
/// Gibt false zurück statt zu werfen, wenn keine Zugangsdaten gespeichert
/// sind oder der erneute Login selbst fehlschlägt (z.B. Passwort inzwischen
/// geändert) - Aufrufer fällt dann auf den normalen Login-Screen zurück.
Future<bool> versucheAutoRelogin() async {
final login = await _storage.read(key: _loginKey);
final passwort = await _storage.read(key: _passwortKey);
if (login == null || passwort == null) return false;
try {
await _fuehreLoginAus(login, passwort);
return true;
} catch (_) {
return false;
}
}
/// Löscht Token UND gespeicherte Zugangsdaten - echter Logout, nicht nur
/// Token-Verwerfen (sonst würde die App sich beim nächsten Start/401 sofort
/// wieder automatisch einloggen).
Future<void> logout() async {
_token = null;
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _loginKey);
await _storage.delete(key: _passwortKey);
}
Future<dynamic> request(
String path, {
String method = 'GET',
Object? body,
}) async {
try {
return await _sendeAnfrage(path, method: method, body: body);
} on ApiException catch (e) {
if (e.status != 401) rethrow;
// Token vermutlich abgelaufen - einmaliger automatischer Neuversuch mit
// den gespeicherten Zugangsdaten, bevor der Nutzer zum Login-Screen muss.
final erfolgreich = await versucheAutoRelogin();
if (!erfolgreich) rethrow;
return await _sendeAnfrage(path, method: method, body: body);
}
}
Future<dynamic> _sendeAnfrage(
String path, {
required String method,
Object? body,
}) async {
final uri = Uri.parse('$baseUrl$path');
final headers = {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
final encodedBody = body != null ? jsonEncode(body) : null;
late http.Response response;
switch (method) {
case 'POST':
response = await _http.post(uri, headers: headers, body: encodedBody);
break;
case 'PUT':
response = await _http.put(uri, headers: headers, body: encodedBody);
break;
case 'PATCH':
response = await _http.patch(uri, headers: headers, body: encodedBody);
break;
case 'DELETE':
response = await _http.delete(uri, headers: headers);
break;
default:
response = await _http.get(uri, headers: headers);
}
if (response.statusCode >= 400) {
throw ApiException(response.statusCode, _tryDecode(response.body));
}
if (response.body.isEmpty) return null;
return jsonDecode(response.body);
}
/// Multipart-Upload wie POST /dokumente (Backend: app/api/v1/endpoints/
/// dokument.py) - eigener Pfad statt request(), da JSON-Body dort nicht
/// passt (multipart/form-data mit Datei-Anhang).
Future<Map<String, dynamic>> ladeDokumentHoch({
required String entitaetTyp,
required String entitaetId,
required String dokumenttyp,
required bool istOriginal,
required File datei,
String? beschreibung,
}) async {
final uri = Uri.parse('$baseUrl/dokumente');
final anfrage = http.MultipartRequest('POST', uri)
..headers.addAll({if (_token != null) 'Authorization': 'Bearer $_token'})
..fields['entitaet_typ'] = entitaetTyp
..fields['entitaet_id'] = entitaetId
..fields['dokumenttyp'] = dokumenttyp
..fields['ist_original'] = istOriginal.toString()
..files.add(await http.MultipartFile.fromPath('datei', datei.path));
if (beschreibung != null) anfrage.fields['beschreibung'] = beschreibung;
final gestreamt = await _http.send(anfrage);
final antwort = await http.Response.fromStream(gestreamt);
if (antwort.statusCode >= 400) {
throw ApiException(antwort.statusCode, _tryDecode(antwort.body));
}
return jsonDecode(antwort.body) as Map<String, dynamic>;
}
dynamic _tryDecode(String body) {
try {
return jsonDecode(body);
} catch (_) {
return null;
}
}
}