fix: Soft-Logout darf nie mehr destruktiv enden (onSoftLogout-Guard)

Qualitäts-Review-Befund (b): Der Refresh-Token-Fix (9dc83f7) war richtig
analysiert, aber unvollständig – drei Restlücken gegen das matrix-SDK
6.2.0 verifiziert und geschlossen:

1. Client._handleSoftLogout ruft bei JEDEM Refresh-Fehler logout() auf,
   und logout() macht 'finally clear()' – die lokale Session inkl.
   Krypto-Schlüssel wird selbst bei einem transienten Netzwerkfehler
   zerstört. Mit refreshToken:true laufen Access-Tokens künftig ab,
   Refreshes werden Routine – das Restrisiko wäre also GESTIEGEN.
   Fix: eigener onSoftLogout-Handler (soft_logout_guard.dart) mit
   3 Versuchen, der nie wirft → SDK ruft nie logout() auf; bei
   Dauerfehler bleibt der Client soft-logged-out und der nächste
   Sync versucht es erneut (CLAUDE.md: Fehlerpfade dürfen NIE in
   einem stillen Logout enden).

2. Das Push-Hintergrund-Isolate (background_push.dart) teilt sich die
   pyramid.sqlite mit der Haupt-App; getEventByPushNotification()
   ruft ensureNotSoftLoggedOut() auf. Ohne Guard hätte ein
   Refresh-Fehler im Isolate (inkl. Token-Rotations-Race mit der
   Haupt-App) die Session der GESAMTEN App gelöscht. Guard auch dort
   gesetzt; das Race heilt der Retry, weil refreshAccessToken() den
   Token bei jedem Versuch frisch aus der DB liest.

3. isLoggedInProvider behandelte softLoggedOut wie ausgeloggt – die UI
   wäre bei jedem Routine-Token-Refresh kurz auf den Login-Screen
   gesprungen (für Nutzer nicht von einem 'Random Logout' zu
   unterscheiden). Jetzt zählt nur echtes loggedOut.

UNGETESTET (Pi) – gehört zum selben ausstehenden PC-Praxistest wie
9dc83f7: Login → Nachrichten → Logout → Neu-Login → alte Nachrichten
lesbar; zusätzlich auth_log.txt auf die neuen Guard-Einträge prüfen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-03 14:42:15 +02:00
parent f9979e4f32
commit 891f3489bd
5 changed files with 73 additions and 1 deletions
+14
View File
@@ -1173,3 +1173,17 @@
2026-07-03 12:57 [Write] /home/steggi/pyramid/lib/features/chat/document_viewer.dart 2026-07-03 12:57 [Write] /home/steggi/pyramid/lib/features/chat/document_viewer.dart
2026-07-03 12:58 [Edit] /home/steggi/pyramid/ROADMAP.md 2026-07-03 12:58 [Edit] /home/steggi/pyramid/ROADMAP.md
2026-07-03 12:58 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-03 12:58 [Edit] /home/steggi/pyramid/PROGRESS.md
2026-07-03 14:33 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_voice.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_pdf.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_pdf.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_pdf.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_archive.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/document/document_viewer_archive.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/widgets/settings/settings_encryption.dart
2026-07-03 14:36 [Edit] /home/steggi/pyramid/lib/features/chat/message/message_text.dart
2026-07-03 14:41 [Write] /home/steggi/pyramid/lib/core/soft_logout_guard.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/matrix_client.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/core/background_push.dart
2026-07-03 14:41 [Edit] /home/steggi/pyramid/lib/features/auth/login_notifier.dart
+7
View File
@@ -9,6 +9,7 @@ import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native; import 'package:sqflite/sqflite.dart' as sqflite_native;
@@ -201,6 +202,12 @@ Future<Client?> _buildClient() async {
database: sdkDb, database: sdkDb,
nativeImplementations: NativeImplementationsDummy(), nativeImplementations: NativeImplementationsDummy(),
logLevel: Level.error, logLevel: Level.error,
// Kritisch: getEventByPushNotification() ruft als Erstes
// ensureNotSoftLoggedOut() auf. Ohne eigenen Handler würde ein
// fehlgeschlagener Token-Refresh AUS DEM HINTERGRUND-ISOLATE heraus
// logout()+clear() auf der geteilten pyramid.sqlite auslösen und die
// Session der gesamten App zerstören. Siehe soft_logout_guard.dart.
onSoftLogout: guardedSoftLogoutRefresh,
); );
await client await client
+5
View File
@@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native; import 'package:sqflite/sqflite.dart' as sqflite_native;
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:pyramid/core/auth_log.dart'; import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:pyramid/core/voip_manager.dart'; import 'package:pyramid/core/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async { final matrixClientProvider = FutureProvider<Client>((ref) async {
@@ -59,6 +60,10 @@ final matrixClientProvider = FutureProvider<Client>((ref) async {
KeyVerificationMethod.qrShow, KeyVerificationMethod.qrShow,
}, },
logLevel: kReleaseMode ? Level.warning : Level.verbose, logLevel: kReleaseMode ? Level.warning : Level.verbose,
// Ohne eigenen Handler eskaliert das SDK jeden fehlgeschlagenen
// Token-Refresh (auch bloße Netzwerkfehler!) zu logout()+clear() —
// Session und Krypto-Schlüssel wären weg. Siehe soft_logout_guard.dart.
onSoftLogout: guardedSoftLogoutRefresh,
nativeImplementations: NativeImplementationsIsolate( nativeImplementations: NativeImplementationsIsolate(
compute, compute,
vodozemacInit: () => vod.init(), vodozemacInit: () => vod.init(),
+41
View File
@@ -0,0 +1,41 @@
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/auth_log.dart';
/// Wendet einen Soft-Logout durch Token-Refresh ab, ohne JE in einen
/// destruktiven Logout zu eskalieren.
///
/// Hintergrund: Ohne gesetztes `Client.onSoftLogout` behandelt das matrix-SDK
/// einen fehlgeschlagenen Refresh so: catch → `logout()` → `finally clear()` —
/// die lokale Session inkl. Krypto-Schlüssel wird also selbst dann zerstört,
/// wenn der Refresh nur an einem transienten Netzwerkfehler scheiterte
/// (Gerät offline im Moment des Token-Ablaufs) oder am Rotations-Race mit dem
/// Push-Hintergrund-Isolate (beide teilen sich die DB; refreshAccessToken()
/// liest den Refresh-Token bei jedem Versuch frisch aus der DB, deshalb heilt
/// ein Retry dieses Race). Wirft dieser Handler nicht, ruft das SDK auch kein
/// `logout()` auf (Client._handleSoftLogout, matrix 6.2.0).
///
/// Regel aus CLAUDE.md: Fehlerpfade dürfen NIE in einem stillen Logout enden.
/// Schlägt der Refresh dauerhaft fehl, bleibt der Client im Zustand
/// soft-logged-out; der nächste Sync/API-Aufruf stößt den Refresh erneut an.
Future<void> guardedSoftLogoutRefresh(Client client) async {
for (var attempt = 1; attempt <= 3; attempt++) {
try {
await client.refreshAccessToken();
await AuthLog.write(
'Soft-Logout durch Token-Refresh abgewendet (Versuch $attempt)',
);
return;
} catch (e) {
await AuthLog.write(
'Soft-Logout: Token-Refresh fehlgeschlagen (Versuch $attempt/3): $e',
);
if (attempt < 3) {
await Future.delayed(Duration(seconds: 2 * attempt));
}
}
}
await AuthLog.write(
'Soft-Logout: Refresh dauerhaft fehlgeschlagen Session wird NICHT '
'zerstört, nächster Sync/API-Aufruf versucht es erneut.',
);
}
+6 -1
View File
@@ -110,6 +110,11 @@ final isLoggedInProvider = StreamProvider<bool>((ref) async* {
final client = await ref.watch(matrixClientProvider.future); final client = await ref.watch(matrixClientProvider.future);
yield client.isLogged(); yield client.isLogged();
await for (final loginState in client.onLoginStateChanged.stream) { await for (final loginState in client.onLoginStateChanged.stream) {
yield loginState == mx.LoginState.loggedIn; // softLoggedOut ist KEIN Logout: Die Session existiert lokal weiter, das
// SDK versucht gerade einen stillen Token-Refresh. Würde hier `false`
// geliefert, spränge die UI bei jedem Routine-Refresh kurz auf den
// Login-Screen für Nutzer nicht von einem „Random Logout" zu
// unterscheiden. Nur ein echtes loggedOut zählt.
yield loginState != mx.LoginState.loggedOut;
} }
}); });