From 288e6d8a519376487c8a734fde610c62e03784c4 Mon Sep 17 00:00:00 2001 From: Hongkuan Zhou <6771308+tedzhouhk@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:15:49 -0700 Subject: [PATCH 1/2] fix BeeCount Cloud 2FA session lifecycle --- lib/pages/cloud/cloud_service_page.dart | 10 +- lib/pages/cloud/devices_page.dart | 22 +-- lib/providers/sync_providers.dart | 25 +-- .../providers/beecount_cloud_provider.dart | 90 +++++++--- .../beecount_cloud_auth_service_test.dart | 160 ++++++++++++++++++ .../beecount_cloud_auth_provider_test.dart | 36 ++++ 6 files changed, 293 insertions(+), 50 deletions(-) create mode 100644 packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart create mode 100644 test/providers/beecount_cloud_auth_provider_test.dart diff --git a/lib/pages/cloud/cloud_service_page.dart b/lib/pages/cloud/cloud_service_page.dart index 41eb28423..da8134b83 100644 --- a/lib/pages/cloud/cloud_service_page.dart +++ b/lib/pages/cloud/cloud_service_page.dart @@ -1543,14 +1543,18 @@ class _CloudServicePageState extends ConsumerState { await ref.read(cloudServiceStoreProvider).saveOnly(cfg); ref.invalidate(beecountCloudConfigProvider); ref.invalidate(activeCloudConfigProvider); + ref.invalidate(beecountCloudProviderInstance); if (mounted) showToast(context, AppLocalizations.of(context).cloudConfigSaved); // 如果提供了邮箱和密码,尝试登录(恢复旧行为) if (email.isNotEmpty && password.isNotEmpty) { try { - final services = await createCloudServices(cfg); - if (services.auth != null) { - await services.auth!.signInWithEmail( + // 在全 App 唯一的 provider 上登录,避免 2FA session 只写入临时 + // auth 实例、SyncEngine 仍持有未登录实例。 + final provider = + await ref.read(beecountCloudProviderInstance.future); + if (provider != null) { + await provider.auth.signInWithEmail( email: email, password: password, ); diff --git a/lib/pages/cloud/devices_page.dart b/lib/pages/cloud/devices_page.dart index 7d1923949..9db47439a 100644 --- a/lib/pages/cloud/devices_page.dart +++ b/lib/pages/cloud/devices_page.dart @@ -82,17 +82,11 @@ class _DevicesPageState extends ConsumerState { /// 获取 BeeCountCloudProvider 实例(仅 beecountCloud 后端可用) Future _getCloudProvider() async { - final config = await ref.read(activeCloudConfigProvider.future); - if (!config.valid || config.type != CloudBackendType.beecountCloud) { - throw StateError( - AppLocalizations.of(context).cloudCollabUnavailableMessage); - } - final services = await createCloudServices(config); - if (services.provider == null || services.provider is! BeeCountCloudProvider) { - throw StateError( - AppLocalizations.of(context).cloudCollabUnavailableMessage); - } - return services.provider as BeeCountCloudProvider; + final unavailableMessage = + AppLocalizations.of(context).cloudCollabUnavailableMessage; + final provider = await ref.read(beecountCloudProviderInstance.future); + if (provider == null) throw StateError(unavailableMessage); + return provider; } Future _reload({bool keepLoadingState = true}) async { @@ -104,11 +98,9 @@ class _DevicesPageState extends ConsumerState { _scopeDenied = false; }); try { - final auth = await ref.read(authServiceProvider.future); - final user = await auth.currentUser; - final currentDeviceId = user?.metadata?['deviceId']?.toString(); - final provider = await _getCloudProvider(); + final user = await provider.auth.currentUser; + final currentDeviceId = user?.metadata?['deviceId']?.toString(); final devices = await provider.listDevices( view: _showAllSessions ? 'sessions' : 'deduped', activeWithinDays: 30, diff --git a/lib/providers/sync_providers.dart b/lib/providers/sync_providers.dart index 7950d531c..d72ccb20f 100644 --- a/lib/providers/sync_providers.dart +++ b/lib/providers/sync_providers.dart @@ -141,19 +141,26 @@ final s3ConfigProvider = FutureProvider((ref) async { }); final authServiceProvider = FutureProvider((ref) async { - final activeAsync = ref.watch(activeCloudConfigProvider); - if (!activeAsync.hasValue) { - return NoopAuthService(); - } - - final config = activeAsync.value!; + final config = await ref.watch(activeCloudConfigProvider.future); if (!config.valid || config.type == CloudBackendType.local) { return NoopAuthService(); } try { + // BeeCount Cloud 必须复用同步引擎持有的唯一 provider/auth 实例。 + // 多个实例虽然共用 SharedPreferences,却各自缓存 session;这会让 2FA + // 登录成功后同步实例仍停留在未登录状态。 + if (config.type == CloudBackendType.beecountCloud) { + final provider = await ref.watch(beecountCloudProviderInstance.future); + return provider?.auth ?? NoopAuthService(); + } + final services = await createCloudServices(config); if (services.auth != null) { + final provider = services.provider; + if (provider != null) { + ref.onDispose(() => unawaited(provider.dispose())); + } return services.auth!; } } catch (e) { @@ -488,10 +495,7 @@ final syncServiceProvider = Provider((ref) { /// 用于 SyncEngine 和其他需要直接访问 BeeCount Cloud API 的场景 final beecountCloudProviderInstance = FutureProvider((ref) async { - final configAsync = ref.watch(activeCloudConfigProvider); - if (!configAsync.hasValue) return null; - - final config = configAsync.value!; + final config = await ref.watch(activeCloudConfigProvider.future); if (!config.valid || config.type != CloudBackendType.beecountCloud) { return null; } @@ -500,6 +504,7 @@ final beecountCloudProviderInstance = final services = await createCloudServices(config); if (services.provider is! BeeCountCloudProvider) return null; final provider = services.provider as BeeCountCloudProvider; + ref.onDispose(() => unawaited(provider.dispose())); final email = config.beecountCloudEmail; final password = config.beecountCloudPassword; diff --git a/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart b/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart index 8856906cf..2808c9bef 100644 --- a/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart +++ b/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart @@ -1175,22 +1175,12 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future initialize() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_sessionStorageKey); - if (raw == null || raw.isEmpty) { - return; - } - - try { - final json = jsonDecode(raw) as Map; - _session = _BeeCountCloudSession.fromJson(json); - if (_isAccessTokenExpired(_session!)) { - await _refreshSessionOrClear(); - } else { - _emitCurrentUser(); - } - } catch (_) { - await _clearSession(); + final session = await _restorePersistedSession(); + if (session == null) return; + if (_isAccessTokenExpired(session)) { + await _refreshSessionOrClear(); + } else { + _emitCurrentUser(); } } @@ -1199,7 +1189,9 @@ class BeeCountCloudAuthService implements CloudAuthService { @override Future get currentUser async { - final session = _session; + // 先接住其他实例已经完成 2FA 后持久化的 session,不要直接走 silent + // password recovery;后者遇到 2FA 会按设计取消。 + final session = _session ?? await _restorePersistedSession(emit: true); if (session == null) { // 完全没 session(从没登过 / session 被清了):只有带了恢复凭证才尝试 // 自动重登,否则按未登录返回 null 让 UI 显示登录入口。 @@ -1218,7 +1210,7 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future requireAccessToken() async { - final session = _session; + final session = _session ?? await _restorePersistedSession(emit: true); if (session == null) { final recovered = await _tryRecoveryLogin(); if (recovered == null || _session == null) { @@ -1312,12 +1304,16 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future _doRefreshSession() async { + final refreshToken = _session?.refreshToken; try { await _refreshSession(); return true; } catch (_) { - await _clearSession(); - return false; + // 只清除本次失败所使用的旧 rotating token。若另一实例已经刷新并 + // 持久化了新 token,则载入新 session 继续使用。 + final cleared = + await _clearSession(expectedRefreshToken: refreshToken); + return !cleared && _session != null; } } @@ -1766,11 +1762,61 @@ class BeeCountCloudAuthService implements CloudAuthService { _emitCurrentUser(); } - Future _clearSession() async { - _session = null; + Future<_BeeCountCloudSession?> _restorePersistedSession({ + bool emit = false, + }) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_sessionStorageKey); + if (raw == null || raw.isEmpty) return null; + + try { + final session = _BeeCountCloudSession.fromJson( + jsonDecode(raw) as Map, + ); + _session = session; + if (emit) _emitCurrentUser(); + return session; + } catch (_) { + // 不要让旧值的解析失败删除并发写入的新 session。 + if (prefs.getString(_sessionStorageKey) == raw) { + await prefs.remove(_sessionStorageKey); + } + return null; + } + } + + /// 返回 true 表示 session 已清除;false 表示发现并载入了更新的 session。 + Future _clearSession({String? expectedRefreshToken}) async { final prefs = await SharedPreferences.getInstance(); + if (expectedRefreshToken != null) { + final raw = prefs.getString(_sessionStorageKey); + if (raw != null && raw.isNotEmpty) { + try { + final persisted = _BeeCountCloudSession.fromJson( + jsonDecode(raw) as Map, + ); + if (persisted.refreshToken != expectedRefreshToken) { + // 同一用户说明是 rotating refresh 的新版本,可以安全接管;不同 + // 用户说明账号已被另一个登录切换,只保护磁盘值而不越权接管。 + if (_session?.userId == persisted.userId) { + _session = persisted; + _emitCurrentUser(); + } else { + _session = null; + _authStateController.add(null); + } + return false; + } + } catch (_) { + // 无法解析的旧值继续按清除处理。 + } + } + } + + _session = null; await prefs.remove(_sessionStorageKey); _authStateController.add(null); + return true; } void _emitCurrentUser() { diff --git a/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart b/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart new file mode 100644 index 000000000..5c77155ff --- /dev/null +++ b/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Map _sessionPayload({ + required String accessToken, + required String refreshToken, +}) { + return { + 'user': {'id': 'user-1', 'email': 'user@example.com'}, + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'expires_in': 3600, + 'device_id': 'device-1', + }; +} + +http.Response _jsonResponse(Map body, {int status = 200}) { + return http.Response( + jsonEncode(body), + status, + headers: {'content-type': 'application/json'}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('reloads a 2FA session persisted by another auth instance', () async { + var silentLoginRequests = 0; + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + silentLoginRequests++; + return _jsonResponse({ + 'requires_2fa': true, + 'challenge_token': 'challenge-stale', + 'available_methods': ['totp'], + }); + } + return _jsonResponse({'error': 'unexpected request'}, status: 500); + }), + ); + await stale.initialize(); + stale.setRecoveryCredentials( + email: 'user@example.com', + password: 'secret', + ); + + final interactive = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/auth/login')) { + return _jsonResponse({ + 'requires_2fa': true, + 'challenge_token': 'challenge-1', + 'available_methods': ['totp'], + }); + } + if (request.url.path.endsWith('/auth/2fa/verify')) { + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + } + return _jsonResponse({'error': 'unexpected request'}, status: 500); + }), + twoFactorHandler: (challenge) async { + final error = await challenge.verify('totp', '123456'); + expect(error, isNull); + return true; + }, + ); + await interactive.initialize(); + + addTearDown(stale.dispose); + addTearDown(interactive.dispose); + + await interactive.signInWithEmail( + email: 'user@example.com', + password: 'secret', + ); + + expect(await stale.requireAccessToken(), 'access-1'); + expect(silentLoginRequests, 0, + reason: 'persisted 2FA session should win over silent password login'); + }); + + test('stale refresh failure cannot delete a newer rotated session', () async { + final seed = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + return _jsonResponse(_sessionPayload( + accessToken: 'access-0', + refreshToken: 'refresh-0', + )); + }), + ); + await seed.initialize(); + await seed.signInWithEmail( + email: 'user@example.com', + password: 'secret', + ); + + final winner = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + expect(request.url.path, endsWith('/auth/refresh')); + return _jsonResponse(_sessionPayload( + accessToken: 'access-1', + refreshToken: 'refresh-1', + )); + }), + ); + final stale = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + expect(request.url.path, endsWith('/auth/refresh')); + return _jsonResponse({'error': 'refresh token revoked'}, status: 401); + }), + ); + await winner.initialize(); + await stale.initialize(); + + addTearDown(seed.dispose); + addTearDown(winner.dispose); + addTearDown(stale.dispose); + + expect(await winner.tryRefreshSession(), isTrue); + expect(await stale.tryRefreshSession(), isTrue, + reason: 'stale instance should adopt the newer persisted session'); + expect(await stale.requireAccessToken(), 'access-1'); + + final restored = BeeCountCloudAuthService( + baseUrl: 'https://cloud.example.com', + apiPrefix: '/api/v1', + httpClient: MockClient((request) async { + fail('restoring a valid session must not call the server'); + }), + ); + await restored.initialize(); + addTearDown(restored.dispose); + + expect(await restored.requireAccessToken(), 'access-1'); + }); +} diff --git a/test/providers/beecount_cloud_auth_provider_test.dart b/test/providers/beecount_cloud_auth_provider_test.dart new file mode 100644 index 000000000..3a43ea036 --- /dev/null +++ b/test/providers/beecount_cloud_auth_provider_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:beecount/providers/sync_providers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('BeeCount Cloud UI auth and SyncEngine share one auth instance', + () async { + const config = CloudServiceConfig( + type: CloudBackendType.beecountCloud, + name: 'BeeCount Cloud', + beecountCloudBaseUrl: 'https://cloud.example.com', + beecountCloudApiPrefix: '/api/v1', + ); + final container = ProviderContainer( + overrides: [ + activeCloudConfigProvider.overrideWith((ref) async => config), + ], + ); + addTearDown(container.dispose); + + final provider = await container.read(beecountCloudProviderInstance.future); + final auth = await container.read(authServiceProvider.future); + + expect(provider, isNotNull); + expect(identical(auth, provider!.auth), isTrue); + }); +} From aceef8a9a8fc7a0d679a35f09dab5c71ad4728e3 Mon Sep 17 00:00:00 2001 From: Hongkuan Zhou <6771308+tedzhouhk@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:14:22 -0700 Subject: [PATCH 2/2] refactor: narrow BeeCount Cloud 2FA session fix --- lib/pages/cloud/devices_page.dart | 22 ++- lib/providers/sync_providers.dart | 25 ++- .../providers/beecount_cloud_provider.dart | 90 +++------- .../beecount_cloud_auth_service_test.dart | 160 ------------------ .../beecount_cloud_auth_provider_test.dart | 36 ---- 5 files changed, 47 insertions(+), 286 deletions(-) delete mode 100644 packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart delete mode 100644 test/providers/beecount_cloud_auth_provider_test.dart diff --git a/lib/pages/cloud/devices_page.dart b/lib/pages/cloud/devices_page.dart index 9db47439a..7d1923949 100644 --- a/lib/pages/cloud/devices_page.dart +++ b/lib/pages/cloud/devices_page.dart @@ -82,11 +82,17 @@ class _DevicesPageState extends ConsumerState { /// 获取 BeeCountCloudProvider 实例(仅 beecountCloud 后端可用) Future _getCloudProvider() async { - final unavailableMessage = - AppLocalizations.of(context).cloudCollabUnavailableMessage; - final provider = await ref.read(beecountCloudProviderInstance.future); - if (provider == null) throw StateError(unavailableMessage); - return provider; + final config = await ref.read(activeCloudConfigProvider.future); + if (!config.valid || config.type != CloudBackendType.beecountCloud) { + throw StateError( + AppLocalizations.of(context).cloudCollabUnavailableMessage); + } + final services = await createCloudServices(config); + if (services.provider == null || services.provider is! BeeCountCloudProvider) { + throw StateError( + AppLocalizations.of(context).cloudCollabUnavailableMessage); + } + return services.provider as BeeCountCloudProvider; } Future _reload({bool keepLoadingState = true}) async { @@ -98,9 +104,11 @@ class _DevicesPageState extends ConsumerState { _scopeDenied = false; }); try { - final provider = await _getCloudProvider(); - final user = await provider.auth.currentUser; + final auth = await ref.read(authServiceProvider.future); + final user = await auth.currentUser; final currentDeviceId = user?.metadata?['deviceId']?.toString(); + + final provider = await _getCloudProvider(); final devices = await provider.listDevices( view: _showAllSessions ? 'sessions' : 'deduped', activeWithinDays: 30, diff --git a/lib/providers/sync_providers.dart b/lib/providers/sync_providers.dart index d72ccb20f..7950d531c 100644 --- a/lib/providers/sync_providers.dart +++ b/lib/providers/sync_providers.dart @@ -141,26 +141,19 @@ final s3ConfigProvider = FutureProvider((ref) async { }); final authServiceProvider = FutureProvider((ref) async { - final config = await ref.watch(activeCloudConfigProvider.future); + final activeAsync = ref.watch(activeCloudConfigProvider); + if (!activeAsync.hasValue) { + return NoopAuthService(); + } + + final config = activeAsync.value!; if (!config.valid || config.type == CloudBackendType.local) { return NoopAuthService(); } try { - // BeeCount Cloud 必须复用同步引擎持有的唯一 provider/auth 实例。 - // 多个实例虽然共用 SharedPreferences,却各自缓存 session;这会让 2FA - // 登录成功后同步实例仍停留在未登录状态。 - if (config.type == CloudBackendType.beecountCloud) { - final provider = await ref.watch(beecountCloudProviderInstance.future); - return provider?.auth ?? NoopAuthService(); - } - final services = await createCloudServices(config); if (services.auth != null) { - final provider = services.provider; - if (provider != null) { - ref.onDispose(() => unawaited(provider.dispose())); - } return services.auth!; } } catch (e) { @@ -495,7 +488,10 @@ final syncServiceProvider = Provider((ref) { /// 用于 SyncEngine 和其他需要直接访问 BeeCount Cloud API 的场景 final beecountCloudProviderInstance = FutureProvider((ref) async { - final config = await ref.watch(activeCloudConfigProvider.future); + final configAsync = ref.watch(activeCloudConfigProvider); + if (!configAsync.hasValue) return null; + + final config = configAsync.value!; if (!config.valid || config.type != CloudBackendType.beecountCloud) { return null; } @@ -504,7 +500,6 @@ final beecountCloudProviderInstance = final services = await createCloudServices(config); if (services.provider is! BeeCountCloudProvider) return null; final provider = services.provider as BeeCountCloudProvider; - ref.onDispose(() => unawaited(provider.dispose())); final email = config.beecountCloudEmail; final password = config.beecountCloudPassword; diff --git a/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart b/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart index 2808c9bef..8856906cf 100644 --- a/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart +++ b/packages/flutter_cloud_sync/lib/src/providers/beecount_cloud_provider.dart @@ -1175,12 +1175,22 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future initialize() async { - final session = await _restorePersistedSession(); - if (session == null) return; - if (_isAccessTokenExpired(session)) { - await _refreshSessionOrClear(); - } else { - _emitCurrentUser(); + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_sessionStorageKey); + if (raw == null || raw.isEmpty) { + return; + } + + try { + final json = jsonDecode(raw) as Map; + _session = _BeeCountCloudSession.fromJson(json); + if (_isAccessTokenExpired(_session!)) { + await _refreshSessionOrClear(); + } else { + _emitCurrentUser(); + } + } catch (_) { + await _clearSession(); } } @@ -1189,9 +1199,7 @@ class BeeCountCloudAuthService implements CloudAuthService { @override Future get currentUser async { - // 先接住其他实例已经完成 2FA 后持久化的 session,不要直接走 silent - // password recovery;后者遇到 2FA 会按设计取消。 - final session = _session ?? await _restorePersistedSession(emit: true); + final session = _session; if (session == null) { // 完全没 session(从没登过 / session 被清了):只有带了恢复凭证才尝试 // 自动重登,否则按未登录返回 null 让 UI 显示登录入口。 @@ -1210,7 +1218,7 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future requireAccessToken() async { - final session = _session ?? await _restorePersistedSession(emit: true); + final session = _session; if (session == null) { final recovered = await _tryRecoveryLogin(); if (recovered == null || _session == null) { @@ -1304,16 +1312,12 @@ class BeeCountCloudAuthService implements CloudAuthService { } Future _doRefreshSession() async { - final refreshToken = _session?.refreshToken; try { await _refreshSession(); return true; } catch (_) { - // 只清除本次失败所使用的旧 rotating token。若另一实例已经刷新并 - // 持久化了新 token,则载入新 session 继续使用。 - final cleared = - await _clearSession(expectedRefreshToken: refreshToken); - return !cleared && _session != null; + await _clearSession(); + return false; } } @@ -1762,61 +1766,11 @@ class BeeCountCloudAuthService implements CloudAuthService { _emitCurrentUser(); } - Future<_BeeCountCloudSession?> _restorePersistedSession({ - bool emit = false, - }) async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_sessionStorageKey); - if (raw == null || raw.isEmpty) return null; - - try { - final session = _BeeCountCloudSession.fromJson( - jsonDecode(raw) as Map, - ); - _session = session; - if (emit) _emitCurrentUser(); - return session; - } catch (_) { - // 不要让旧值的解析失败删除并发写入的新 session。 - if (prefs.getString(_sessionStorageKey) == raw) { - await prefs.remove(_sessionStorageKey); - } - return null; - } - } - - /// 返回 true 表示 session 已清除;false 表示发现并载入了更新的 session。 - Future _clearSession({String? expectedRefreshToken}) async { - final prefs = await SharedPreferences.getInstance(); - if (expectedRefreshToken != null) { - final raw = prefs.getString(_sessionStorageKey); - if (raw != null && raw.isNotEmpty) { - try { - final persisted = _BeeCountCloudSession.fromJson( - jsonDecode(raw) as Map, - ); - if (persisted.refreshToken != expectedRefreshToken) { - // 同一用户说明是 rotating refresh 的新版本,可以安全接管;不同 - // 用户说明账号已被另一个登录切换,只保护磁盘值而不越权接管。 - if (_session?.userId == persisted.userId) { - _session = persisted; - _emitCurrentUser(); - } else { - _session = null; - _authStateController.add(null); - } - return false; - } - } catch (_) { - // 无法解析的旧值继续按清除处理。 - } - } - } - + Future _clearSession() async { _session = null; + final prefs = await SharedPreferences.getInstance(); await prefs.remove(_sessionStorageKey); _authStateController.add(null); - return true; } void _emitCurrentUser() { diff --git a/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart b/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart deleted file mode 100644 index 5c77155ff..000000000 --- a/packages/flutter_cloud_sync/test/beecount_cloud_auth_service_test.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -Map _sessionPayload({ - required String accessToken, - required String refreshToken, -}) { - return { - 'user': {'id': 'user-1', 'email': 'user@example.com'}, - 'access_token': accessToken, - 'refresh_token': refreshToken, - 'expires_in': 3600, - 'device_id': 'device-1', - }; -} - -http.Response _jsonResponse(Map body, {int status = 200}) { - return http.Response( - jsonEncode(body), - status, - headers: {'content-type': 'application/json'}, - ); -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - test('reloads a 2FA session persisted by another auth instance', () async { - var silentLoginRequests = 0; - final stale = BeeCountCloudAuthService( - baseUrl: 'https://cloud.example.com', - apiPrefix: '/api/v1', - httpClient: MockClient((request) async { - if (request.url.path.endsWith('/auth/login')) { - silentLoginRequests++; - return _jsonResponse({ - 'requires_2fa': true, - 'challenge_token': 'challenge-stale', - 'available_methods': ['totp'], - }); - } - return _jsonResponse({'error': 'unexpected request'}, status: 500); - }), - ); - await stale.initialize(); - stale.setRecoveryCredentials( - email: 'user@example.com', - password: 'secret', - ); - - final interactive = BeeCountCloudAuthService( - baseUrl: 'https://cloud.example.com', - apiPrefix: '/api/v1', - httpClient: MockClient((request) async { - if (request.url.path.endsWith('/auth/login')) { - return _jsonResponse({ - 'requires_2fa': true, - 'challenge_token': 'challenge-1', - 'available_methods': ['totp'], - }); - } - if (request.url.path.endsWith('/auth/2fa/verify')) { - return _jsonResponse(_sessionPayload( - accessToken: 'access-1', - refreshToken: 'refresh-1', - )); - } - return _jsonResponse({'error': 'unexpected request'}, status: 500); - }), - twoFactorHandler: (challenge) async { - final error = await challenge.verify('totp', '123456'); - expect(error, isNull); - return true; - }, - ); - await interactive.initialize(); - - addTearDown(stale.dispose); - addTearDown(interactive.dispose); - - await interactive.signInWithEmail( - email: 'user@example.com', - password: 'secret', - ); - - expect(await stale.requireAccessToken(), 'access-1'); - expect(silentLoginRequests, 0, - reason: 'persisted 2FA session should win over silent password login'); - }); - - test('stale refresh failure cannot delete a newer rotated session', () async { - final seed = BeeCountCloudAuthService( - baseUrl: 'https://cloud.example.com', - apiPrefix: '/api/v1', - httpClient: MockClient((request) async { - return _jsonResponse(_sessionPayload( - accessToken: 'access-0', - refreshToken: 'refresh-0', - )); - }), - ); - await seed.initialize(); - await seed.signInWithEmail( - email: 'user@example.com', - password: 'secret', - ); - - final winner = BeeCountCloudAuthService( - baseUrl: 'https://cloud.example.com', - apiPrefix: '/api/v1', - httpClient: MockClient((request) async { - expect(request.url.path, endsWith('/auth/refresh')); - return _jsonResponse(_sessionPayload( - accessToken: 'access-1', - refreshToken: 'refresh-1', - )); - }), - ); - final stale = BeeCountCloudAuthService( - baseUrl: 'https://cloud.example.com', - apiPrefix: '/api/v1', - httpClient: MockClient((request) async { - expect(request.url.path, endsWith('/auth/refresh')); - return _jsonResponse({'error': 'refresh token revoked'}, status: 401); - }), - ); - await winner.initialize(); - await stale.initialize(); - - addTearDown(seed.dispose); - addTearDown(winner.dispose); - addTearDown(stale.dispose); - - expect(await winner.tryRefreshSession(), isTrue); - expect(await stale.tryRefreshSession(), isTrue, - reason: 'stale instance should adopt the newer persisted session'); - expect(await stale.requireAccessToken(), 'access-1'); - - final restored = BeeCountCloudAuthService( - baseUrl: 'https://cloud.example.com', - apiPrefix: '/api/v1', - httpClient: MockClient((request) async { - fail('restoring a valid session must not call the server'); - }), - ); - await restored.initialize(); - addTearDown(restored.dispose); - - expect(await restored.requireAccessToken(), 'access-1'); - }); -} diff --git a/test/providers/beecount_cloud_auth_provider_test.dart b/test/providers/beecount_cloud_auth_provider_test.dart deleted file mode 100644 index 3a43ea036..000000000 --- a/test/providers/beecount_cloud_auth_provider_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:beecount/providers/sync_providers.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - }); - - test('BeeCount Cloud UI auth and SyncEngine share one auth instance', - () async { - const config = CloudServiceConfig( - type: CloudBackendType.beecountCloud, - name: 'BeeCount Cloud', - beecountCloudBaseUrl: 'https://cloud.example.com', - beecountCloudApiPrefix: '/api/v1', - ); - final container = ProviderContainer( - overrides: [ - activeCloudConfigProvider.overrideWith((ref) async => config), - ], - ); - addTearDown(container.dispose); - - final provider = await container.read(beecountCloudProviderInstance.future); - final auth = await container.read(authServiceProvider.future); - - expect(provider, isNotNull); - expect(identical(auth, provider!.auth), isTrue); - }); -}