fixed stale state after logout and replayed or lost share intents

This commit is contained in:
2026-09-24 21:43:21 +02:00
parent e4e2b1a4fb
commit 498d195138
32 changed files with 743 additions and 188 deletions
@@ -0,0 +1,11 @@
import 'app_exception.dart';
/// A request finished after the account it was started for signed out. Its
/// result must not reach the state or cache of whoever is signed in now.
class StaleSessionException extends AppException {
const StaleSessionException()
: super(
userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.',
technicalDetails: 'result of a previous session discarded',
);
}
@@ -17,6 +17,10 @@ Future<void>? _shareFolderReady;
Future<void> ensureTalkShareFolder() =>
_shareFolderReady ??= _createTalkShareFolder();
/// The folder is per user; after a sign-out the next account has to check
/// again.
void resetTalkShareFolderCache() => _shareFolderReady = null;
Future<void> _createTalkShareFolder() async {
try {
final webdav = await WebdavApi.webdav;
+4 -1
View File
@@ -18,7 +18,10 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> get webdav {
final secret = AccountData().getNextcloudSecret();
// Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name.
final secret =
'${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}';
if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret;
_webdav = establishWebdavConnection();
@@ -88,17 +88,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false;
final username = AccountData().getUsername();
// A background engine (widget task) keeps the account it loaded. When
// the app signed that account out, its revoked token answers 401 — a
// re-login would mint a fresh token for it into the shared keystore.
if (await AccountData().readStoredUsername() != username) return false;
try {
await _loginClient.run(
username: AccountData().getUsername(),
username: username,
password: AccountData().getPassword(),
tokenName: await DeviceTokenName.resolve(),
);
return true;
} catch (_) {
await _tokenStorage.clear();
if (await AccountData().readStoredUsername() == username) {
await _tokenStorage.clear();
}
return false;
}
final stored = await AccountData().readStoredUsername();
if (stored == username) return true;
// Signed out during the login: drop the orphaned token, unless another
// account already stored its own.
if (stored == null) await _tokenStorage.clear();
return false;
}
Future<Response<dynamic>> _retryWithFreshToken(
@@ -19,10 +19,14 @@ class SessionValidator {
if (AccountData().isDemo) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
final epoch = AccountData().sessionEpoch;
try {
await AuthVerify().run(username: username, password: password);
} on AuthException catch (e) {
if (e.statusCode != 401) return;
// The probed account already signed out; the 401 must not sign out
// whoever logged in meanwhile.
if (!AccountData().isCurrentSession(epoch)) return;
log('MC: stored credentials rejected — forcing re-login');
await AuthLogout().run();
await const MarianumConnectTokenStorage().clear();
@@ -31,18 +31,23 @@ class CustomEventsMigration {
if (DemoMode.active) return;
if (await _isDone()) return;
final epoch = AccountData().sessionEpoch;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
).run();
for (final event in response.events) {
// The POST authenticates with whoever is signed in now; after a
// sign-out the previous account's events would land in the next one.
if (!AccountData().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
if (!AccountData().isCurrentSession(epoch)) return;
await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
} catch (e) {
+12 -1
View File
@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../model/account_data.dart';
import 'api_response.dart';
import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0;
@@ -48,6 +50,7 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {}
Future<void> start(String document) async {
final epoch = AccountData().sessionEpoch;
try {
final tableData = await Localstore.instance
.collection(collection)
@@ -67,6 +70,12 @@ abstract class RequestCache<T extends ApiResponse?> {
try {
final newValue = await onLoad();
// The collection is shared, so a late response of a signed-out
// account would otherwise be cached for the next one.
if (!AccountData().isCurrentSession(epoch)) {
onError(const StaleSessionException());
return;
}
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(
@@ -141,8 +150,10 @@ Future<T> resolveFromCache<T extends ApiResponse?>(
onError?.call(e);
});
await cache.ready;
if (latest != null) return latest as T;
final err = capturedError;
// `latest` may still hold the cache hit read before the sign-out.
if (err is StaleSessionException) throw err;
if (latest != null) return latest as T;
if (err != null) throw err;
throw ParseException(
technicalDetails: operationName != null