feat(flutter): sicherer Zugangsdaten-Speicher + Auto-Relogin
CI / backend-tests (push) Successful in 3m15s
CI / frontend-build (push) Successful in 21s

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
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 20:24:33 +02:00
1 parent 78b07d671f
commit 11e2e5e399
9 files changed
+210 -25

No files matched your search

+21 -3
View File
@@ -77,9 +77,27 @@ Build-Host, kein Dauerbetrieb der App dort (App läuft auf Endgeräten).
nicht ausgewertet** — dafür bräuchte es einen bewussten Vergleichstest
unter Feldbedingungen, nicht nur "die App existiert und funktioniert".
Diese Bewertung steht weiterhin aus, unabhängig vom App-Reifegrad.
- Code lebt vollständig auf dem separaten Build-Server
(`/opt/flutter-projects/mabea_scan_prototyp/`), **nicht** im MABEA-Git-Repo
— dieser Kachel-Eintrag ist die einzige Dokumentation davon.
- **Korrektur 2026-09-10:** entgegen der ursprünglichen Notiz hier liegt der
Code inzwischen DOCH im MABEA-Git-Repo (`flutter_app/`, getrackt) — der
separate Build-Server (`/opt/flutter-projects/mabea_scan_prototyp/`) wird
weiterhin fürs eigentliche APK-Bauen genutzt, ist aber nicht mehr die
einzige Quelle. Kein lokales Flutter/Dart-SDK in dieser Entwicklungs-
umgebung verfügbar — Änderungen hier werden per `ast`-artiger manueller
Code-Review statt automatisiertem `flutter analyze`/`flutter test`
verifiziert, echte CI-Prüfung fehlt für dieses Verzeichnis noch.
- **Zugangsdaten-Persistenz (Nutzer-Vorgabe 2026-09-10):** Login-Token +
optional Benutzername/Passwort jetzt über `flutter_secure_storage`
(verschlüsselter Android-Keystore/iOS-Keychain) statt der bisherigen
`shared_preferences` (Klartext) — löst zwei Dinge: (1) sicherer Speicher
für das ohnehin schon persistierte Token, (2) NEU: automatischer
Re-Login mit gespeicherten Zugangsdaten bei abgelaufenem Token (`ApiClient
.versucheAutoRelogin()`, greift transparent bei jeder 401-Antwort in
`request()`), damit die App auch nach Ablauf der 8h-Token-Gültigkeit ohne
erneute manuelle Eingabe weiterläuft, sobald wieder eine Verbindung
besteht. Speichern der Zugangsdaten ist eine bewusste Checkbox auf dem
Login-Screen (Default an, abwählbar) — kein stiller Zwang, Passwort auf
dem Gerät abzulegen. Ein Abwählen löscht auch bereits gespeicherte
Zugangsdaten (kein wirkungsloses nachträgliches Opt-out).
## FLUT-002 — voller Kontroll-Workflow in der App (nicht beauftragt, nur Frage beantwortet)
+85 -8
View File
@@ -1,12 +1,21 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
/// 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;
@@ -17,30 +26,53 @@ class ApiException implements Exception {
}
class ApiClient {
ApiClient({required this.baseUrl, http.Client? httpClient}) : _http = httpClient ?? http.Client();
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 {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString('mabea_token');
_token = await _storage.read(key: _tokenKey);
}
Future<void> setzeToken(String? token) async {
_token = token;
final prefs = await SharedPreferences.getInstance();
if (token == null) {
await prefs.remove('mabea_token');
await _storage.delete(key: _tokenKey);
} else {
await prefs.setString('mabea_token', token);
await _storage.write(key: _tokenKey, value: token);
}
}
bool get istEingeloggt => _token != null;
Future<String> login(String username, String password) async {
/// [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'},
@@ -55,10 +87,55 @@ class ApiClient {
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 = {
+17 -1
View File
@@ -16,6 +16,11 @@ class _LoginScreenState extends State<LoginScreen> {
final _passwortController = TextEditingController();
String? _fehler;
bool _ladeVorgang = false;
// Nutzer-Vorgabe 2026-09-10: Speichern der Zugangsdaten (für automatischen
// Neu-Login nach Token-Ablauf, auch offline nutzbar) ist bewusst eine
// aktive Wahl auf dem Login-Screen, kein stiller Standard - Default an,
// damit der Offline-Nutzen ohne Zutun greift, aber jederzeit abwählbar.
bool _zugangsdatenSpeichern = true;
Future<void> _anmelden() async {
setState(() {
@@ -23,7 +28,11 @@ class _LoginScreenState extends State<LoginScreen> {
_ladeVorgang = true;
});
try {
await widget.apiClient.login(_loginController.text, _passwortController.text);
await widget.apiClient.login(
_loginController.text,
_passwortController.text,
zugangsdatenSpeichern: _zugangsdatenSpeichern,
);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => ObjektListScreen(apiClient: widget.apiClient)),
@@ -57,6 +66,13 @@ class _LoginScreenState extends State<LoginScreen> {
decoration: const InputDecoration(labelText: 'Passwort'),
obscureText: true,
),
CheckboxListTile(
value: _zugangsdatenSpeichern,
onChanged: (wert) => setState(() => _zugangsdatenSpeichern = wert ?? true),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: const Text('Zugangsdaten speichern (auch offline nutzbar)'),
),
if (_fehler != null) ...[
const SizedBox(height: 12),
Text(_fehler!, style: const TextStyle(color: Colors.red)),
+1 -1
View File
@@ -10,7 +10,7 @@ dependencies:
flutter:
sdk: flutter
http: ^1.2.2
shared_preferences: ^2.3.2
flutter_secure_storage: ^9.2.2
mobile_scanner: ^5.2.3
sqflite: ^2.3.3
path: ^1.9.0
+8 -3
View File
@@ -3,10 +3,11 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mabea_app/api/api_client.dart';
import 'fakes/fake_secure_storage.dart';
class _MockClient extends Mock implements http.Client {}
void main() {
@@ -14,9 +15,13 @@ void main() {
late ApiClient apiClient;
setUp(() {
SharedPreferences.setMockInitialValues({});
FakeSecureStorage.zuruecksetzen();
httpClient = _MockClient();
apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient);
apiClient = ApiClient(
baseUrl: 'http://test.local/api/v1',
httpClient: httpClient,
storage: const FakeSecureStorage(),
);
});
group('login', () {
@@ -0,0 +1,54 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// In-Memory-Ersatz für FlutterSecureStorage in Tests - kein Plattform-Kanal
/// nötig (Android Keystore/iOS Keychain existieren im Test-Runner nicht).
class FakeSecureStorage extends FlutterSecureStorage {
const FakeSecureStorage();
static final Map<String, String> _werte = {};
static void zuruecksetzen() => _werte.clear();
@override
Future<String?> read({
required String key,
IOSOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
MacOsOptions? mOptions,
WindowsOptions? wOptions,
}) async =>
_werte[key];
@override
Future<void> write({
required String key,
required String? value,
IOSOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
MacOsOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (value == null) {
_werte.remove(key);
} else {
_werte[key] = value;
}
}
@override
Future<void> delete({
required String key,
IOSOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
MacOsOptions? mOptions,
WindowsOptions? wOptions,
}) async {
_werte.remove(key);
}
}
+8 -3
View File
@@ -4,12 +4,13 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:mabea_app/api/api_client.dart';
import 'package:mabea_app/offline/queue.dart';
import 'fakes/fake_secure_storage.dart';
class _MockClient extends Mock implements http.Client {}
/// OfflineQueue ist ein Singleton mit eigenem Datenbank-Handle (analog zur
@@ -27,9 +28,13 @@ void main() {
late ApiClient apiClient;
setUp(() {
SharedPreferences.setMockInitialValues({});
FakeSecureStorage.zuruecksetzen();
httpClient = _MockClient();
apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient);
apiClient = ApiClient(
baseUrl: 'http://test.local/api/v1',
httpClient: httpClient,
storage: const FakeSecureStorage(),
);
});
test('enqueue speichert Eintrag, verschwindet nach erfolgreicher Übertragung', () async {
@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mabea_app/api/api_client.dart';
import 'package:mabea_app/screens/login_screen.dart';
import '../fakes/fake_secure_storage.dart';
class _MockClient extends Mock implements http.Client {}
void main() {
@@ -20,9 +21,13 @@ void main() {
late ApiClient apiClient;
setUp(() {
SharedPreferences.setMockInitialValues({});
FakeSecureStorage.zuruecksetzen();
httpClient = _MockClient();
apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient);
apiClient = ApiClient(
baseUrl: 'http://test.local/api/v1',
httpClient: httpClient,
storage: const FakeSecureStorage(),
);
});
testWidgets('erfolgreicher Login navigiert zur Objektliste', (tester) async {
@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mabea_app/api/api_client.dart';
import 'package:mabea_app/screens/objekt_list_screen.dart';
import '../fakes/fake_secure_storage.dart';
class _MockClient extends Mock implements http.Client {}
void main() {
@@ -20,9 +21,13 @@ void main() {
late ApiClient apiClient;
setUp(() {
SharedPreferences.setMockInitialValues({});
FakeSecureStorage.zuruecksetzen();
httpClient = _MockClient();
apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient);
apiClient = ApiClient(
baseUrl: 'http://test.local/api/v1',
httpClient: httpClient,
storage: const FakeSecureStorage(),
);
});
testWidgets('Suche filtert nach Name und Code', (tester) async {