feat(flutter): sicherer Zugangsdaten-Speicher + Auto-Relogin
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:
1 parent
78b07d671f
commit
11e2e5e399
9 files changed
+210
-25
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user