Token-Speicherung von SharedPreferences (Klartext) auf flutter_secure_storage (Android Keystore/iOS Keychain) umgestellt. Optional werden Benutzername/Passwort mitgespeichert (Checkbox auf dem Login-Screen, Default an, abwählbar - löscht dabei auch vorhandene gespeicherte Zugangsdaten), damit die App nach Ablauf des 8h-Tokens automatisch neu einloggt statt den Nutzer zurück auf den Login-Screen zu werfen (ApiClient.versucheAutoRelogin(), greift bei 401 in request()). Tests auf FakeSecureStorage (In-Memory-Testdouble) statt SharedPreferences.setMockInitialValues umgestellt. Kein lokales Flutter-SDK in dieser Umgebung - Änderungen per manuellem Code-Review statt flutter analyze/test verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
180 lines
6.3 KiB
Dart
180 lines
6.3 KiB
Dart
import 'dart:convert';
|
|
|
|
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);
|
|
}
|
|
|
|
dynamic _tryDecode(String body) {
|
|
try {
|
|
return jsonDecode(body);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|