refactor(auth): Migrate session storage to flutter_secure_storage

This commit is contained in:
gasaichandesu
2026-06-19 22:58:27 +04:00
parent 8f6ed5504b
commit 07b9e44350
10 changed files with 181 additions and 103 deletions
+4 -4
View File
@@ -23,7 +23,8 @@ Important areas:
and client synchronization. and client synchronization.
- `lib/features/launcher/data` contains API, installation, and log services. - `lib/features/launcher/data` contains API, installation, and log services.
- `lib/features/preferences` stores selected install directory and launcher - `lib/features/preferences` stores selected install directory and launcher
session data. session data. Launcher sessions must use secure storage; non-sensitive
preferences may use `shared_preferences`.
- `lib/service_container.dart` and `lib/service_container.config.dart` provide - `lib/service_container.dart` and `lib/service_container.config.dart` provide
`get_it`/`injectable` dependency wiring. `get_it`/`injectable` dependency wiring.
- `docs/launcher_web_api_spec.md` documents the current launcher Web API and - `docs/launcher_web_api_spec.md` documents the current launcher Web API and
@@ -52,9 +53,6 @@ flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host
See `docs/linting.md` for the required format, analyze, test, and Lefthook See `docs/linting.md` for the required format, analyze, test, and Lefthook
pre-commit workflow. pre-commit workflow.
In the current shell on 2026-06-18, `flutter` was not recognized by
`Get-Command flutter` or `where.exe flutter`.
## Configuration ## Configuration
The launcher API base URL is compile-time configuration: The launcher API base URL is compile-time configuration:
@@ -69,6 +67,8 @@ or local-only API URLs.
## Architecture Notes ## Architecture Notes
- All backend calls should go through `LauncherApiClient`. - All backend calls should go through `LauncherApiClient`.
- Launcher bearer tokens must be persisted through `flutter_secure_storage`.
Do not store sessions or other secrets in `shared_preferences`.
- File sync behavior should stay in `ClientSyncUseCase` and - File sync behavior should stay in `ClientSyncUseCase` and
`GameInstallationService`. `GameInstallationService`.
- The server manifest is the source of truth for client files. - The server manifest is the source of truth for client files.
+3 -2
View File
@@ -18,7 +18,8 @@ The launcher is responsible for:
- `flutter_bloc` for launcher UI state - `flutter_bloc` for launcher UI state
- `get_it` and `injectable` for dependency injection - `get_it` and `injectable` for dependency injection
- `dio` for Web API and file download requests - `dio` for Web API and file download requests
- `shared_preferences` for launcher preferences - `flutter_secure_storage` for launcher sessions
- `shared_preferences` for non-sensitive launcher preferences
## Prerequisites ## Prerequisites
@@ -93,7 +94,7 @@ See `docs/launcher_web_api_spec.md` for the API and sync contract.
## Documentation ## Documentation
- `AGENTS.md`: architecture, commands, and repository rules for contributors - `AGENTS.md`: architecture, commands, and repository rules for contributors
- `TODO.md`: refactoring, verification, and Flutter upgrade tasks - `TODO.md`: remaining verification and maintenance tasks
- `docs/linting.md`: format, analyze, test, and Lefthook workflow - `docs/linting.md`: format, analyze, test, and Lefthook workflow
- `docs/project_review.md`: project review with strengths, weaknesses, and - `docs/project_review.md`: project review with strengths, weaknesses, and
upgrade feasibility notes upgrade feasibility notes
+6
View File
@@ -0,0 +1,6 @@
# TODO
## Verification
- [ ] Run a Windows build smoke test:
`flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`.
+14 -20
View File
@@ -51,8 +51,9 @@ most important file verification behavior.
normalized to UTF-8. normalized to UTF-8.
- At initial review, custom title-bar widgets were duplicated between login and - At initial review, custom title-bar widgets were duplicated between login and
home screens. They have since been extracted into a shared widget. home screens. They have since been extracted into a shared widget.
- Session tokens are persisted through `shared_preferences`. That is simple and - Session tokens were originally persisted through `shared_preferences`. They
testable, but it is not hardened storage for bearer tokens. now use `flutter_secure_storage`, while `shared_preferences` remains limited
to non-sensitive launcher preferences.
- The dependency injection output file `lib/service_container.config.dart` is - The dependency injection output file `lib/service_container.config.dart` is
committed, but the regeneration command was not previously documented. committed, but the regeneration command was not previously documented.
- UI/widget/integration coverage is still light compared with the risk in login, - UI/widget/integration coverage is still light compared with the risk in login,
@@ -69,37 +70,30 @@ Assumption: `3.44.2` means Flutter SDK `3.44.2`.
Current local indicators: Current local indicators:
- `.fvmrc` pins Flutter `3.35.2`. - `.fvmrc` is absent.
- `pubspec.yaml` declares Dart SDK `^3.9.0`. - `pubspec.yaml` declares Dart SDK `^3.9.0`.
- `pubspec.lock` declares Flutter SDK `>=3.38.0`. - `pubspec.lock` declares Flutter SDK `>=3.38.0`.
- The project policy is latest stable Flutter SDK only; FVM-pinned workflows are - The project policy is latest stable Flutter SDK only; FVM-pinned workflows are
not supported. not supported.
- The local shell cannot find `flutter`: both `Get-Command flutter` and - The local shell now finds Flutter `3.44.2` on the stable channel.
`where.exe flutter` fail.
Because `flutter` is not available in this shell, the upgrade cannot be fully Flutter `3.44.2` has been validated for dependency resolution, formatting,
validated here. If Flutter is installed, restart the shell or fix PATH so analysis, and tests. A production-like Windows build smoke test remains open.
`flutter --version` works. The code and lockfile already suggest that the
project has moved beyond the stale `.fvmrc` pin, so the upgrade is plausible,
but it remains a tooling validation task.
Recommended upgrade path: Recommended upgrade path:
1. Install or expose the latest stable Flutter SDK on PATH. 1. Keep the latest stable Flutter SDK on PATH.
2. Confirm whether the latest stable Flutter SDK is `3.44.2` or newer. 2. Run `flutter pub get`.
3. Remove or ignore stale FVM pinning if it conflicts with the latest-SDK 3. Regenerate dependency injection only if dependency resolution or generator
policy.
4. Run `flutter pub get`.
5. Regenerate dependency injection only if dependency resolution or generator
output requires it: output requires it:
`dart run build_runner build --delete-conflicting-outputs`. `dart run build_runner build --delete-conflicting-outputs`.
6. Run `flutter analyze`. 4. Run `flutter analyze`.
7. Run a Windows build smoke test: 5. Run a Windows build smoke test:
`flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`. `flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`.
## Recommended Refactoring Direction ## Recommended Refactoring Direction
- Decide whether bearer tokens can remain in `shared_preferences`; if not, - Keep secure session persistence covered by tests when login or session
migrate session storage to platform-secure storage. restore behavior changes.
- Add CI or a documented local verification workflow that runs analysis and a - Add CI or a documented local verification workflow that runs analysis and a
Windows build smoke test with the latest stable Flutter SDK. Windows build smoke test with the latest stable Flutter SDK.
@@ -2,9 +2,11 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart'; import 'package:moonwell_launcher/config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@lazySingleton
class LauncherLogService { class LauncherLogService {
static Future<void> _pendingWrite = Future<void>.value(); static Future<void> _pendingWrite = Future<void>.value();
@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
@@ -9,12 +10,15 @@ const _outputDirKey = 'output_dir';
const _launcherSessionKey = 'launcher_session'; const _launcherSessionKey = 'launcher_session';
@LazySingleton(as: PreferencesRepository, env: ['flutter']) @LazySingleton(as: PreferencesRepository, env: ['flutter'])
class SharedPrefsPreferencesRepository implements PreferencesRepository { class LocalPreferencesRepository implements PreferencesRepository {
final SharedPreferences _preferences; final SharedPreferences _preferences;
final FlutterSecureStorage _secureStorage;
const SharedPrefsPreferencesRepository({ const LocalPreferencesRepository({
required SharedPreferences preferences, required SharedPreferences preferences,
}) : _preferences = preferences; required FlutterSecureStorage secureStorage,
}) : _preferences = preferences,
_secureStorage = secureStorage;
@override @override
Future<Uri?> getOutputDir() { Future<Uri?> getOutputDir() {
@@ -33,35 +37,45 @@ class SharedPrefsPreferencesRepository implements PreferencesRepository {
} }
@override @override
Future<LauncherSession?> getLauncherSession() { Future<LauncherSession?> getLauncherSession() async {
final rawSession = _preferences.getString(_launcherSessionKey); await _maybeClearLegacyLauncherSession();
final rawSession = await _secureStorage.read(key: _launcherSessionKey);
if (rawSession == null || rawSession.isEmpty) { if (rawSession == null || rawSession.isEmpty) {
return Future.value(null); return null;
} }
try { try {
final decoded = jsonDecode(rawSession); final decoded = jsonDecode(rawSession);
if (decoded is! Map) { if (decoded is! Map) {
return Future.value(null); return null;
} }
final json = decoded.map((key, value) => MapEntry(key.toString(), value)); final json = decoded.map((key, value) => MapEntry(key.toString(), value));
return Future.value(LauncherSession.fromJson(json)); return LauncherSession.fromJson(json);
} catch (_) { } catch (_) {
return Future.value(null); return null;
} }
} }
@override @override
Future<void> setLauncherSession(LauncherSession session) { Future<void> setLauncherSession(LauncherSession session) async {
return _preferences.setString( await _secureStorage.write(
_launcherSessionKey, key: _launcherSessionKey,
jsonEncode(session.toJson()), value: jsonEncode(session.toJson()),
); );
await _maybeClearLegacyLauncherSession();
} }
@override @override
Future<void> clearLauncherSession() { Future<void> clearLauncherSession() async {
return _preferences.remove(_launcherSessionKey); await _secureStorage.delete(key: _launcherSessionKey);
await _maybeClearLegacyLauncherSession();
}
Future<void> _maybeClearLegacyLauncherSession() async {
if (_preferences.containsKey(_launcherSessionKey)) {
await _preferences.remove(_launcherSessionKey);
}
} }
} }
+26 -10
View File
@@ -1,5 +1,5 @@
// dart format width=80
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// dart format width=80
// ************************************************************************** // **************************************************************************
// InjectableConfigGenerator // InjectableConfigGenerator
@@ -9,6 +9,7 @@
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i558;
import 'package:get_it/get_it.dart' as _i174; import 'package:get_it/get_it.dart' as _i174;
import 'package:injectable/injectable.dart' as _i526; import 'package:injectable/injectable.dart' as _i526;
import 'package:shared_preferences/shared_preferences.dart' as _i460; import 'package:shared_preferences/shared_preferences.dart' as _i460;
@@ -16,10 +17,12 @@ import 'package:shared_preferences/shared_preferences.dart' as _i460;
import 'features/launcher/application/client_sync_use_case.dart' as _i757; import 'features/launcher/application/client_sync_use_case.dart' as _i757;
import 'features/launcher/data/game_installation_service.dart' as _i315; import 'features/launcher/data/game_installation_service.dart' as _i315;
import 'features/launcher/data/launcher_api_client.dart' as _i703; import 'features/launcher/data/launcher_api_client.dart' as _i703;
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart' import 'features/launcher/data/launcher_log_service.dart' as _i43;
as _i662; import 'features/preferences/data/repositories/local_preferences_repository.dart'
as _i658;
import 'features/preferences/domain/repositories/preferences_repository.dart' import 'features/preferences/domain/repositories/preferences_repository.dart'
as _i44; as _i44;
import 'third_party/flutter_secure_storage.dart' as _i782;
import 'third_party/shared_preferences.dart' as _i1006; import 'third_party/shared_preferences.dart' as _i1006;
const String _flutter = 'flutter'; const String _flutter = 'flutter';
@@ -32,6 +35,7 @@ extension GetItInjectableX on _i174.GetIt {
}) async { }) async {
final gh = _i526.GetItHelper(this, environment, environmentFilter); final gh = _i526.GetItHelper(this, environment, environmentFilter);
final sharedPreferencesModule = _$SharedPreferencesModule(); final sharedPreferencesModule = _$SharedPreferencesModule();
final flutterSecureStorageModule = _$FlutterSecureStorageModule();
await gh.factoryAsync<_i460.SharedPreferences>( await gh.factoryAsync<_i460.SharedPreferences>(
() => sharedPreferencesModule.prefs, () => sharedPreferencesModule.prefs,
preResolve: true, preResolve: true,
@@ -39,21 +43,33 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i315.GameInstallationService>( gh.lazySingleton<_i315.GameInstallationService>(
() => _i315.GameInstallationService(), () => _i315.GameInstallationService(),
); );
gh.lazySingleton<_i703.LauncherApiClient>(() => _i703.LauncherApiClient()); gh.lazySingleton<_i43.LauncherLogService>(() => _i43.LauncherLogService());
gh.lazySingleton<_i558.FlutterSecureStorage>(
() => flutterSecureStorageModule.storage,
);
gh.lazySingleton<_i703.LauncherApiClient>(
() => _i703.LauncherApiClient(
launcherLogService: gh<_i43.LauncherLogService>(),
),
);
gh.lazySingleton<_i44.PreferencesRepository>(
() => _i658.LocalPreferencesRepository(
preferences: gh<_i460.SharedPreferences>(),
secureStorage: gh<_i558.FlutterSecureStorage>(),
),
registerFor: {_flutter},
);
gh.lazySingleton<_i757.ClientSyncUseCase>( gh.lazySingleton<_i757.ClientSyncUseCase>(
() => _i757.ClientSyncUseCase( () => _i757.ClientSyncUseCase(
launcherApiClient: gh<_i703.LauncherApiClient>(), launcherApiClient: gh<_i703.LauncherApiClient>(),
installationService: gh<_i315.GameInstallationService>(), installationService: gh<_i315.GameInstallationService>(),
launcherLogService: gh<_i43.LauncherLogService>(),
), ),
); );
gh.lazySingleton<_i44.PreferencesRepository>(
() => _i662.SharedPrefsPreferencesRepository(
preferences: gh<_i460.SharedPreferences>(),
),
registerFor: {_flutter},
);
return this; return this;
} }
} }
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {} class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
class _$FlutterSecureStorageModule extends _i782.FlutterSecureStorageModule {}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
@module
abstract class FlutterSecureStorageModule {
@lazySingleton
FlutterSecureStorage get storage => const FlutterSecureStorage();
}
@@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/data/repositories/local_preferences_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
group('LocalPreferencesRepository', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
FlutterSecureStorage.setMockInitialValues(<String, String>{});
});
Future<LocalPreferencesRepository> createRepository() async {
final preferences = await SharedPreferences.getInstance();
return LocalPreferencesRepository(
preferences: preferences,
secureStorage: const FlutterSecureStorage(),
);
}
test('persists output directory through shared preferences', () async {
final repository = await createRepository();
final uri = Uri.file(r'C:\Games\MoonWell');
await repository.setOutputDir(uri);
expect(await repository.getOutputDir(), uri);
});
test('persists launcher session through secure storage', () async {
final repository = await createRepository();
final session = LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: DateTime.parse('2030-01-01T00:00:00Z'),
);
await repository.setLauncherSession(session);
final restoredSession = await repository.getLauncherSession();
expect(restoredSession?.accessToken, 'token');
expect(restoredSession?.tokenType, 'Bearer');
expect(
restoredSession?.expiresAt?.toUtc(),
DateTime.parse('2030-01-01T00:00:00Z'),
);
});
test('clears persisted launcher session', () async {
final repository = await createRepository();
await repository.setLauncherSession(
const LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: null,
),
);
await repository.clearLauncherSession();
expect(await repository.getLauncherSession(), isNull);
});
test(
'removes legacy shared preferences session without restoring it',
() async {
final legacySession = LauncherSession(
accessToken: 'legacy-token',
tokenType: 'Bearer',
expiresAt: DateTime.parse('2030-01-01T00:00:00Z'),
);
SharedPreferences.setMockInitialValues(<String, Object>{
'launcher_session': jsonEncode(legacySession.toJson()),
});
final preferences = await SharedPreferences.getInstance();
final repository = LocalPreferencesRepository(
preferences: preferences,
secureStorage: const FlutterSecureStorage(),
);
expect(await repository.getLauncherSession(), isNull);
expect(preferences.containsKey('launcher_session'), isFalse);
},
);
});
}
@@ -1,52 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/data/repositories/shared_prefs_preferences_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
group('SharedPrefsPreferencesRepository', () {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('persists launcher session between reads', () async {
final preferences = await SharedPreferences.getInstance();
final repository = SharedPrefsPreferencesRepository(
preferences: preferences,
);
final session = LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: DateTime.parse('2030-01-01T00:00:00Z'),
);
await repository.setLauncherSession(session);
final restoredSession = await repository.getLauncherSession();
expect(restoredSession?.accessToken, 'token');
expect(restoredSession?.tokenType, 'Bearer');
expect(
restoredSession?.expiresAt?.toUtc(),
DateTime.parse('2030-01-01T00:00:00Z'),
);
});
test('clears persisted launcher session', () async {
final preferences = await SharedPreferences.getInstance();
final repository = SharedPrefsPreferencesRepository(
preferences: preferences,
);
await repository.setLauncherSession(
LauncherSession(
accessToken: 'token',
tokenType: 'Bearer',
expiresAt: null,
),
);
await repository.clearLauncherSession();
expect(await repository.getLauncherSession(), isNull);
});
});
}