Add Baron Safe app features through session block skeleton
ci / flutter-check (push) Successful in 4s
ci / flutter-check (push) Successful in 4s
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
|
import 'features/session/session_screens.dart';
|
||||||
import 'features/webview/baron_safe_webview_screen.dart';
|
import 'features/webview/baron_safe_webview_screen.dart';
|
||||||
|
|
||||||
final _router = GoRouter(
|
final _router = GoRouter(
|
||||||
@@ -16,6 +17,22 @@ final _router = GoRouter(
|
|||||||
name: BaronSafeWebViewScreen.routeName,
|
name: BaronSafeWebViewScreen.routeName,
|
||||||
builder: (context, state) => const BaronSafeWebViewScreen(),
|
builder: (context, state) => const BaronSafeWebViewScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/sessions',
|
||||||
|
name: SessionListScreen.routeName,
|
||||||
|
builder: (context, state) => const SessionListScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':sessionId',
|
||||||
|
name: SessionDetailScreen.routeName,
|
||||||
|
builder: (context, state) {
|
||||||
|
return SessionDetailScreen(
|
||||||
|
sessionId: state.pathParameters['sessionId']!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'baron_safe_bridge_command.dart';
|
||||||
|
import 'baron_safe_bridge_models.dart';
|
||||||
|
import 'baron_safe_bridge_service.dart';
|
||||||
|
|
||||||
|
class BaronSafeBridgeMessageHandler {
|
||||||
|
const BaronSafeBridgeMessageHandler(this._bridgeService);
|
||||||
|
|
||||||
|
final BaronSafeBridgeService _bridgeService;
|
||||||
|
|
||||||
|
Future<String> handle(String rawMessage) async {
|
||||||
|
final decoded = _decode(rawMessage);
|
||||||
|
if (decoded == null) {
|
||||||
|
return _failure(
|
||||||
|
requestId: null,
|
||||||
|
code: 'invalid_json',
|
||||||
|
message: 'Bridge message must be a JSON object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final requestId = decoded['requestId'] as String?;
|
||||||
|
final commandName = decoded['command'] as String?;
|
||||||
|
if (commandName == null) {
|
||||||
|
return _failure(
|
||||||
|
requestId: requestId,
|
||||||
|
code: 'missing_command',
|
||||||
|
message: 'Bridge command is required.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final command = BaronSafeBridgeCommand.tryParse(commandName);
|
||||||
|
if (command == null) {
|
||||||
|
return _failure(
|
||||||
|
requestId: requestId,
|
||||||
|
code: 'unsupported_command',
|
||||||
|
message: '$commandName is not an allowed bridge command.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = decoded['payload'];
|
||||||
|
if (payload != null && payload is! Map<String, Object?>) {
|
||||||
|
return _failure(
|
||||||
|
requestId: requestId,
|
||||||
|
code: 'invalid_payload',
|
||||||
|
message: 'Bridge payload must be an object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _bridgeService.handle(
|
||||||
|
BaronSafeBridgeRequest(
|
||||||
|
command: command,
|
||||||
|
payload: payload as Map<String, Object?>? ?? const <String, Object?>{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.isSuccess) {
|
||||||
|
return _failure(
|
||||||
|
requestId: requestId,
|
||||||
|
code: response.error!.code,
|
||||||
|
message: response.error!.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonEncode({
|
||||||
|
'requestId': requestId,
|
||||||
|
'ok': true,
|
||||||
|
'data': response.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?>? _decode(String rawMessage) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(rawMessage);
|
||||||
|
if (decoded is Map<String, Object?>) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _failure({
|
||||||
|
required String? requestId,
|
||||||
|
required String code,
|
||||||
|
required String message,
|
||||||
|
}) {
|
||||||
|
return jsonEncode({
|
||||||
|
'requestId': requestId,
|
||||||
|
'ok': false,
|
||||||
|
'error': {'code': code, 'message': message},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'baron_safe_bridge_command.dart';
|
import 'baron_safe_bridge_command.dart';
|
||||||
import 'baron_safe_bridge_models.dart';
|
import 'baron_safe_bridge_models.dart';
|
||||||
import '../push/baron_safe_push_service.dart';
|
import '../push/baron_safe_push_service.dart';
|
||||||
|
import '../session/baron_safe_session_block_service.dart';
|
||||||
|
|
||||||
abstract interface class BaronSafeBridgeService {
|
abstract interface class BaronSafeBridgeService {
|
||||||
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request);
|
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request);
|
||||||
@@ -19,11 +20,13 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
|
|||||||
this.deviceInfoProvider = const PlaceholderBaronSafeDeviceInfoProvider(),
|
this.deviceInfoProvider = const PlaceholderBaronSafeDeviceInfoProvider(),
|
||||||
this.biometricPrompt = const PlaceholderBaronSafeBiometricPrompt(),
|
this.biometricPrompt = const PlaceholderBaronSafeBiometricPrompt(),
|
||||||
this.pushService = const PlaceholderBaronSafePushService(),
|
this.pushService = const PlaceholderBaronSafePushService(),
|
||||||
|
this.sessionBlockService,
|
||||||
});
|
});
|
||||||
|
|
||||||
final BaronSafeDeviceInfoProvider deviceInfoProvider;
|
final BaronSafeDeviceInfoProvider deviceInfoProvider;
|
||||||
final BaronSafeBiometricPrompt biometricPrompt;
|
final BaronSafeBiometricPrompt biometricPrompt;
|
||||||
final BaronSafePushService pushService;
|
final BaronSafePushService pushService;
|
||||||
|
final BaronSafeSessionBlockService? sessionBlockService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request) async {
|
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request) async {
|
||||||
@@ -33,7 +36,7 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
|
|||||||
request.payload,
|
request.payload,
|
||||||
),
|
),
|
||||||
BaronSafeBridgeCommand.registerPushToken => _registerPushToken(),
|
BaronSafeBridgeCommand.registerPushToken => _registerPushToken(),
|
||||||
BaronSafeBridgeCommand.blockSession => _notImplemented(request.command),
|
BaronSafeBridgeCommand.blockSession => _blockSession(request.payload),
|
||||||
BaronSafeBridgeCommand.blockLinkedApp => _notImplemented(request.command),
|
BaronSafeBridgeCommand.blockLinkedApp => _notImplemented(request.command),
|
||||||
BaronSafeBridgeCommand.openSettings => _notImplemented(request.command),
|
BaronSafeBridgeCommand.openSettings => _notImplemented(request.command),
|
||||||
};
|
};
|
||||||
@@ -67,6 +70,31 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
|
|||||||
final result = await pushService.registerCurrentToken();
|
final result = await pushService.registerCurrentToken();
|
||||||
return BaronSafeBridgeResponse.success(data: result.toJson());
|
return BaronSafeBridgeResponse.success(data: result.toJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<BaronSafeBridgeResponse> _blockSession(
|
||||||
|
Map<String, Object?> payload,
|
||||||
|
) async {
|
||||||
|
final service = sessionBlockService;
|
||||||
|
if (service == null) {
|
||||||
|
return _notImplemented(BaronSafeBridgeCommand.blockSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sessionId = payload['sessionId'] as String?;
|
||||||
|
if (sessionId == null || sessionId.isEmpty) {
|
||||||
|
return const BaronSafeBridgeResponse.failure(
|
||||||
|
BaronSafeBridgeError(
|
||||||
|
code: 'invalid_payload',
|
||||||
|
message: 'blockSession requires a sessionId.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await service.blockSession(
|
||||||
|
sessionId: sessionId,
|
||||||
|
reason: payload['reason'] as String? ?? 'Confirm session block',
|
||||||
|
);
|
||||||
|
return BaronSafeBridgeResponse.success(data: result.toJson());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlaceholderBaronSafeDeviceInfoProvider
|
class PlaceholderBaronSafeDeviceInfoProvider
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import '../push/baron_safe_push_models.dart';
|
||||||
|
|
||||||
|
class BaronSafeDeviceRegistrationRequest {
|
||||||
|
const BaronSafeDeviceRegistrationRequest({
|
||||||
|
required this.deviceId,
|
||||||
|
required this.platform,
|
||||||
|
required this.appVersion,
|
||||||
|
this.pushToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String deviceId;
|
||||||
|
final BaronSafePushPlatform platform;
|
||||||
|
final String appVersion;
|
||||||
|
final String? pushToken;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'platform': platform.value,
|
||||||
|
'appVersion': appVersion,
|
||||||
|
'pushToken': pushToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeDeviceRegistrationResult {
|
||||||
|
const BaronSafeDeviceRegistrationResult({
|
||||||
|
required this.deviceId,
|
||||||
|
required this.registered,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String deviceId;
|
||||||
|
final bool registered;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'baron_safe_device_models.dart';
|
||||||
|
|
||||||
|
abstract interface class BaronSafeDeviceRegistrationClient {
|
||||||
|
Future<BaronSafeDeviceRegistrationResult> registerDevice(
|
||||||
|
BaronSafeDeviceRegistrationRequest request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoopBaronSafeDeviceRegistrationClient
|
||||||
|
implements BaronSafeDeviceRegistrationClient {
|
||||||
|
const NoopBaronSafeDeviceRegistrationClient();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeDeviceRegistrationResult> registerDevice(
|
||||||
|
BaronSafeDeviceRegistrationRequest request,
|
||||||
|
) async {
|
||||||
|
return BaronSafeDeviceRegistrationResult(
|
||||||
|
deviceId: request.deviceId,
|
||||||
|
registered: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import '../push/baron_safe_push_models.dart';
|
||||||
|
import '../push/baron_safe_push_service.dart';
|
||||||
|
import '../secure_storage/baron_safe_secure_storage_service.dart';
|
||||||
|
import 'baron_safe_device_models.dart';
|
||||||
|
import 'baron_safe_device_registration_client.dart';
|
||||||
|
|
||||||
|
typedef BaronSafeDeviceIdGenerator = String Function();
|
||||||
|
|
||||||
|
class BaronSafeDeviceRegistrationService {
|
||||||
|
const BaronSafeDeviceRegistrationService({
|
||||||
|
required BaronSafeSecureStorageService secureStorage,
|
||||||
|
required BaronSafeDeviceRegistrationClient registrationClient,
|
||||||
|
BaronSafePushTokenProvider pushTokenProvider =
|
||||||
|
const PlaceholderBaronSafePushTokenProvider(),
|
||||||
|
BaronSafeDeviceIdGenerator deviceIdGenerator = _defaultDeviceIdGenerator,
|
||||||
|
BaronSafePushPlatform platform = BaronSafePushPlatform.unknown,
|
||||||
|
String appVersion = '0.1.0',
|
||||||
|
}) : _secureStorage = secureStorage,
|
||||||
|
_registrationClient = registrationClient,
|
||||||
|
_pushTokenProvider = pushTokenProvider,
|
||||||
|
_deviceIdGenerator = deviceIdGenerator,
|
||||||
|
_platform = platform,
|
||||||
|
_appVersion = appVersion;
|
||||||
|
|
||||||
|
final BaronSafeSecureStorageService _secureStorage;
|
||||||
|
final BaronSafeDeviceRegistrationClient _registrationClient;
|
||||||
|
final BaronSafePushTokenProvider _pushTokenProvider;
|
||||||
|
final BaronSafeDeviceIdGenerator _deviceIdGenerator;
|
||||||
|
final BaronSafePushPlatform _platform;
|
||||||
|
final String _appVersion;
|
||||||
|
|
||||||
|
Future<BaronSafeDeviceRegistrationResult> registerCurrentDevice() async {
|
||||||
|
final deviceId = await _readOrCreateDeviceId();
|
||||||
|
final pushToken = await _pushTokenProvider.readCurrentToken();
|
||||||
|
|
||||||
|
return _registrationClient.registerDevice(
|
||||||
|
BaronSafeDeviceRegistrationRequest(
|
||||||
|
deviceId: deviceId,
|
||||||
|
platform: pushToken?.platform ?? _platform,
|
||||||
|
appVersion: pushToken?.appVersion ?? _appVersion,
|
||||||
|
pushToken: pushToken?.token,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _readOrCreateDeviceId() async {
|
||||||
|
final existingDeviceId = await _secureStorage.readDeviceId();
|
||||||
|
if (existingDeviceId != null && existingDeviceId.isNotEmpty) {
|
||||||
|
return existingDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newDeviceId = _deviceIdGenerator();
|
||||||
|
await _secureStorage.saveDeviceId(newDeviceId);
|
||||||
|
return newDeviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _defaultDeviceIdGenerator() {
|
||||||
|
return 'baron-safe-${DateTime.now().microsecondsSinceEpoch}';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../session/session_screens.dart';
|
||||||
import '../webview/baron_safe_webview_screen.dart';
|
import '../webview/baron_safe_webview_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
@@ -38,6 +39,12 @@ class HomeScreen extends StatelessWidget {
|
|||||||
subtitle: 'Route container placeholder',
|
subtitle: 'Route container placeholder',
|
||||||
onTap: () => context.goNamed(BaronSafeWebViewScreen.routeName),
|
onTap: () => context.goNamed(BaronSafeWebViewScreen.routeName),
|
||||||
),
|
),
|
||||||
|
_HomeActionTile(
|
||||||
|
icon: Icons.history,
|
||||||
|
title: 'Recent sessions',
|
||||||
|
subtitle: 'Mock session list and detail',
|
||||||
|
onTap: () => context.goNamed(SessionListScreen.routeName),
|
||||||
|
),
|
||||||
const _HomeActionTile(
|
const _HomeActionTile(
|
||||||
icon: Icons.fingerprint,
|
icon: Icons.fingerprint,
|
||||||
title: 'Biometric bridge',
|
title: 'Biometric bridge',
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import '../bridge/baron_safe_bridge_service.dart';
|
||||||
|
import 'baron_safe_session_models.dart';
|
||||||
|
import 'baron_safe_session_repository.dart';
|
||||||
|
|
||||||
|
abstract interface class BaronSafeSessionBlockService {
|
||||||
|
Future<BaronSafeSessionBlockResult> blockSession({
|
||||||
|
required String sessionId,
|
||||||
|
required String reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class BiometricBaronSafeSessionBlockService
|
||||||
|
implements BaronSafeSessionBlockService {
|
||||||
|
const BiometricBaronSafeSessionBlockService({
|
||||||
|
required BaronSafeSessionRepository repository,
|
||||||
|
required BaronSafeBiometricPrompt biometricPrompt,
|
||||||
|
}) : _repository = repository,
|
||||||
|
_biometricPrompt = biometricPrompt;
|
||||||
|
|
||||||
|
final BaronSafeSessionRepository _repository;
|
||||||
|
final BaronSafeBiometricPrompt _biometricPrompt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionBlockResult> blockSession({
|
||||||
|
required String sessionId,
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
|
final biometricResult = await _biometricPrompt.verify(reason: reason);
|
||||||
|
if (!biometricResult.verified) {
|
||||||
|
return BaronSafeSessionBlockResult(
|
||||||
|
sessionId: sessionId,
|
||||||
|
blocked: false,
|
||||||
|
biometricVerified: false,
|
||||||
|
message: biometricResult.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _repository.blockSession(
|
||||||
|
BaronSafeSessionBlockRequest(sessionId: sessionId, reason: reason),
|
||||||
|
);
|
||||||
|
|
||||||
|
return BaronSafeSessionBlockResult(
|
||||||
|
sessionId: response.sessionId,
|
||||||
|
blocked: response.blocked,
|
||||||
|
biometricVerified: true,
|
||||||
|
message: response.blocked ? 'Session blocked' : 'Session block failed',
|
||||||
|
blockedAt: response.blockedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'baron_safe_session_models.dart';
|
||||||
|
|
||||||
|
abstract interface class BaronSafeSessionClient {
|
||||||
|
Future<List<BaronSafeSessionSummary>> listSessions();
|
||||||
|
|
||||||
|
Future<BaronSafeSessionDetail> getSession(String sessionId);
|
||||||
|
|
||||||
|
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||||
|
BaronSafeSessionBlockRequest request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockBaronSafeSessionClient implements BaronSafeSessionClient {
|
||||||
|
MockBaronSafeSessionClient({List<BaronSafeSessionDetail>? sessions})
|
||||||
|
: _sessions = sessions ?? _defaultSessions;
|
||||||
|
|
||||||
|
final List<BaronSafeSessionDetail> _sessions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BaronSafeSessionSummary>> listSessions() async {
|
||||||
|
return List<BaronSafeSessionSummary>.unmodifiable(_sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionDetail> getSession(String sessionId) async {
|
||||||
|
return _sessions.firstWhere(
|
||||||
|
(session) => session.id == sessionId,
|
||||||
|
orElse: () => throw BaronSafeSessionNotFoundException(sessionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||||
|
BaronSafeSessionBlockRequest request,
|
||||||
|
) async {
|
||||||
|
await getSession(request.sessionId);
|
||||||
|
return BaronSafeSessionBlockResponse(
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
blocked: true,
|
||||||
|
blockedAt: DateTime.utc(2026, 6, 30, 10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeSessionNotFoundException implements Exception {
|
||||||
|
const BaronSafeSessionNotFoundException(this.sessionId);
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'BaronSafeSessionNotFoundException: $sessionId';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _defaultSessions = [
|
||||||
|
BaronSafeSessionDetail(
|
||||||
|
id: 'session-1',
|
||||||
|
serviceName: 'Baron Portal',
|
||||||
|
status: BaronSafeSessionStatus.active,
|
||||||
|
riskLevel: BaronSafeSessionRiskLevel.low,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 30, 9),
|
||||||
|
ipAddress: '203.0.113.10',
|
||||||
|
deviceLabel: 'Chrome on Windows',
|
||||||
|
locationLabel: 'Seoul',
|
||||||
|
userAgent: 'Chrome',
|
||||||
|
authMethod: 'phone',
|
||||||
|
),
|
||||||
|
BaronSafeSessionDetail(
|
||||||
|
id: 'session-2',
|
||||||
|
serviceName: 'Admin Console',
|
||||||
|
status: BaronSafeSessionStatus.active,
|
||||||
|
riskLevel: BaronSafeSessionRiskLevel.high,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 30, 9, 15),
|
||||||
|
ipAddress: '198.51.100.22',
|
||||||
|
deviceLabel: 'Unknown browser',
|
||||||
|
locationLabel: 'Unknown',
|
||||||
|
userAgent: 'Unknown',
|
||||||
|
authMethod: 'phone',
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
enum BaronSafeSessionStatus {
|
||||||
|
active('active'),
|
||||||
|
blocked('blocked'),
|
||||||
|
expired('expired'),
|
||||||
|
unknown('unknown');
|
||||||
|
|
||||||
|
const BaronSafeSessionStatus(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
static BaronSafeSessionStatus fromValue(String value) {
|
||||||
|
return values.firstWhere(
|
||||||
|
(status) => status.value == value,
|
||||||
|
orElse: () => unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BaronSafeSessionRiskLevel {
|
||||||
|
low('low'),
|
||||||
|
medium('medium'),
|
||||||
|
high('high'),
|
||||||
|
unknown('unknown');
|
||||||
|
|
||||||
|
const BaronSafeSessionRiskLevel(this.value);
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
static BaronSafeSessionRiskLevel fromValue(String value) {
|
||||||
|
return values.firstWhere(
|
||||||
|
(riskLevel) => riskLevel.value == value,
|
||||||
|
orElse: () => unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeSessionSummary {
|
||||||
|
const BaronSafeSessionSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.serviceName,
|
||||||
|
required this.status,
|
||||||
|
required this.riskLevel,
|
||||||
|
required this.createdAt,
|
||||||
|
this.ipAddress,
|
||||||
|
this.deviceLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String serviceName;
|
||||||
|
final BaronSafeSessionStatus status;
|
||||||
|
final BaronSafeSessionRiskLevel riskLevel;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? ipAddress;
|
||||||
|
final String? deviceLabel;
|
||||||
|
|
||||||
|
factory BaronSafeSessionSummary.fromJson(Map<String, Object?> json) {
|
||||||
|
return BaronSafeSessionSummary(
|
||||||
|
id: json['id'] as String,
|
||||||
|
serviceName: json['serviceName'] as String? ?? 'Unknown service',
|
||||||
|
status: BaronSafeSessionStatus.fromValue(
|
||||||
|
json['status'] as String? ?? 'unknown',
|
||||||
|
),
|
||||||
|
riskLevel: BaronSafeSessionRiskLevel.fromValue(
|
||||||
|
json['riskLevel'] as String? ?? 'unknown',
|
||||||
|
),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
ipAddress: json['ipAddress'] as String?,
|
||||||
|
deviceLabel: json['deviceLabel'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'serviceName': serviceName,
|
||||||
|
'status': status.value,
|
||||||
|
'riskLevel': riskLevel.value,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'ipAddress': ipAddress,
|
||||||
|
'deviceLabel': deviceLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeSessionDetail extends BaronSafeSessionSummary {
|
||||||
|
const BaronSafeSessionDetail({
|
||||||
|
required super.id,
|
||||||
|
required super.serviceName,
|
||||||
|
required super.status,
|
||||||
|
required super.riskLevel,
|
||||||
|
required super.createdAt,
|
||||||
|
super.ipAddress,
|
||||||
|
super.deviceLabel,
|
||||||
|
this.locationLabel,
|
||||||
|
this.userAgent,
|
||||||
|
this.authMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? locationLabel;
|
||||||
|
final String? userAgent;
|
||||||
|
final String? authMethod;
|
||||||
|
|
||||||
|
factory BaronSafeSessionDetail.fromJson(Map<String, Object?> json) {
|
||||||
|
final summary = BaronSafeSessionSummary.fromJson(json);
|
||||||
|
return BaronSafeSessionDetail(
|
||||||
|
id: summary.id,
|
||||||
|
serviceName: summary.serviceName,
|
||||||
|
status: summary.status,
|
||||||
|
riskLevel: summary.riskLevel,
|
||||||
|
createdAt: summary.createdAt,
|
||||||
|
ipAddress: summary.ipAddress,
|
||||||
|
deviceLabel: summary.deviceLabel,
|
||||||
|
locationLabel: json['locationLabel'] as String?,
|
||||||
|
userAgent: json['userAgent'] as String?,
|
||||||
|
authMethod: json['authMethod'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
...super.toJson(),
|
||||||
|
'locationLabel': locationLabel,
|
||||||
|
'userAgent': userAgent,
|
||||||
|
'authMethod': authMethod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeSessionBlockRequest {
|
||||||
|
const BaronSafeSessionBlockRequest({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final String reason;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {'sessionId': sessionId, 'reason': reason};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeSessionBlockResponse {
|
||||||
|
const BaronSafeSessionBlockResponse({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.blocked,
|
||||||
|
this.blockedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final bool blocked;
|
||||||
|
final DateTime? blockedAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'blocked': blocked,
|
||||||
|
'blockedAt': blockedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaronSafeSessionBlockResult {
|
||||||
|
const BaronSafeSessionBlockResult({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.blocked,
|
||||||
|
required this.biometricVerified,
|
||||||
|
required this.message,
|
||||||
|
this.blockedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
final bool blocked;
|
||||||
|
final bool biometricVerified;
|
||||||
|
final String message;
|
||||||
|
final DateTime? blockedAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'blocked': blocked,
|
||||||
|
'biometricVerified': biometricVerified,
|
||||||
|
'message': message,
|
||||||
|
'blockedAt': blockedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'baron_safe_session_client.dart';
|
||||||
|
import 'baron_safe_session_models.dart';
|
||||||
|
|
||||||
|
abstract interface class BaronSafeSessionRepository {
|
||||||
|
Future<List<BaronSafeSessionSummary>> listRecentSessions();
|
||||||
|
|
||||||
|
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId);
|
||||||
|
|
||||||
|
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||||
|
BaronSafeSessionBlockRequest request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClientBaronSafeSessionRepository implements BaronSafeSessionRepository {
|
||||||
|
const ClientBaronSafeSessionRepository(this._client);
|
||||||
|
|
||||||
|
final BaronSafeSessionClient _client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BaronSafeSessionSummary>> listRecentSessions() {
|
||||||
|
return _client.listSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId) {
|
||||||
|
return _client.getSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||||
|
BaronSafeSessionBlockRequest request,
|
||||||
|
) {
|
||||||
|
return _client.blockSession(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../bridge/baron_safe_bridge_service.dart';
|
||||||
|
import 'baron_safe_session_block_service.dart';
|
||||||
|
import 'baron_safe_session_client.dart';
|
||||||
|
import 'baron_safe_session_models.dart';
|
||||||
|
import 'baron_safe_session_repository.dart';
|
||||||
|
|
||||||
|
final _sessionRepository = ClientBaronSafeSessionRepository(
|
||||||
|
MockBaronSafeSessionClient(),
|
||||||
|
);
|
||||||
|
final _sessionBlockService = BiometricBaronSafeSessionBlockService(
|
||||||
|
repository: _sessionRepository,
|
||||||
|
biometricPrompt: const PlaceholderBaronSafeBiometricPrompt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class SessionListScreen extends StatelessWidget {
|
||||||
|
const SessionListScreen({super.key});
|
||||||
|
|
||||||
|
static const routeName = 'sessions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Recent sessions')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: FutureBuilder<List<BaronSafeSessionSummary>>(
|
||||||
|
future: _sessionRepository.listRecentSessions(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final sessions = snapshot.data ?? const <BaronSafeSessionSummary>[];
|
||||||
|
if (sessions.isEmpty) {
|
||||||
|
return const Center(child: Text('No recent sessions'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final session = sessions[index];
|
||||||
|
return _SessionSummaryTile(session: session);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||||||
|
itemCount: sessions.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionDetailScreen extends StatefulWidget {
|
||||||
|
const SessionDetailScreen({required this.sessionId, super.key});
|
||||||
|
|
||||||
|
static const routeName = 'session-detail';
|
||||||
|
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionDetailScreenState extends State<SessionDetailScreen> {
|
||||||
|
bool _isBlocking = false;
|
||||||
|
|
||||||
|
Future<void> _blockSession(BaronSafeSessionDetail session) async {
|
||||||
|
setState(() {
|
||||||
|
_isBlocking = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _sessionBlockService.blockSession(
|
||||||
|
sessionId: session.id,
|
||||||
|
reason: 'Confirm blocking ${session.serviceName}',
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(result.message)));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isBlocking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Session detail')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: FutureBuilder<BaronSafeSessionDetail>(
|
||||||
|
future: _sessionRepository.getSessionDetail(widget.sessionId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError || !snapshot.hasData) {
|
||||||
|
return const Center(child: Text('Session not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = snapshot.data!;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
session.serviceName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_StatusRow(
|
||||||
|
status: session.status,
|
||||||
|
riskLevel: session.riskLevel,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_DetailItem(label: 'Session ID', value: session.id),
|
||||||
|
_DetailItem(
|
||||||
|
label: 'Created',
|
||||||
|
value: session.createdAt.toUtc().toIso8601String(),
|
||||||
|
),
|
||||||
|
_DetailItem(label: 'IP address', value: session.ipAddress),
|
||||||
|
_DetailItem(label: 'Device', value: session.deviceLabel),
|
||||||
|
_DetailItem(label: 'Location', value: session.locationLabel),
|
||||||
|
_DetailItem(label: 'User agent', value: session.userAgent),
|
||||||
|
_DetailItem(label: 'Auth method', value: session.authMethod),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isBlocking ? null : () => _blockSession(session),
|
||||||
|
icon: const Icon(Icons.block),
|
||||||
|
label: Text(_isBlocking ? 'Blocking...' : 'Block session'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionSummaryTile extends StatelessWidget {
|
||||||
|
const _SessionSummaryTile({required this.session});
|
||||||
|
|
||||||
|
final BaronSafeSessionSummary session;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(session.serviceName),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
session.deviceLabel,
|
||||||
|
session.ipAddress,
|
||||||
|
].whereType<String>().join(' · '),
|
||||||
|
),
|
||||||
|
leading: Icon(
|
||||||
|
_riskIcon(session.riskLevel),
|
||||||
|
color: _riskColor(colorScheme, session.riskLevel),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.goNamed(
|
||||||
|
SessionDetailScreen.routeName,
|
||||||
|
pathParameters: {'sessionId': session.id},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusRow extends StatelessWidget {
|
||||||
|
const _StatusRow({required this.status, required this.riskLevel});
|
||||||
|
|
||||||
|
final BaronSafeSessionStatus status;
|
||||||
|
final BaronSafeSessionRiskLevel riskLevel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Status: ${status.value}')),
|
||||||
|
Chip(label: Text('Risk: ${riskLevel.value}')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailItem extends StatelessWidget {
|
||||||
|
const _DetailItem({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String? value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(value == null || value!.isEmpty ? '-' : value!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _riskIcon(BaronSafeSessionRiskLevel riskLevel) {
|
||||||
|
return switch (riskLevel) {
|
||||||
|
BaronSafeSessionRiskLevel.high => Icons.warning_amber,
|
||||||
|
BaronSafeSessionRiskLevel.medium => Icons.info_outline,
|
||||||
|
BaronSafeSessionRiskLevel.low => Icons.check_circle_outline,
|
||||||
|
BaronSafeSessionRiskLevel.unknown => Icons.help_outline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _riskColor(ColorScheme colorScheme, BaronSafeSessionRiskLevel riskLevel) {
|
||||||
|
return switch (riskLevel) {
|
||||||
|
BaronSafeSessionRiskLevel.high => colorScheme.error,
|
||||||
|
BaronSafeSessionRiskLevel.medium => colorScheme.tertiary,
|
||||||
|
BaronSafeSessionRiskLevel.low => colorScheme.primary,
|
||||||
|
BaronSafeSessionRiskLevel.unknown => colorScheme.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
class BaronSafeWebViewPolicy {
|
||||||
|
const BaronSafeWebViewPolicy({this.allowedHosts = defaultAllowedHosts});
|
||||||
|
|
||||||
|
static const defaultAllowedHosts = {
|
||||||
|
'safe.baron.hmac.kr',
|
||||||
|
'safe-staging.baron.hmac.kr',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'10.0.2.2',
|
||||||
|
};
|
||||||
|
|
||||||
|
final Set<String> allowedHosts;
|
||||||
|
|
||||||
|
bool allows(Uri uri) {
|
||||||
|
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.scheme != 'https' && uri.scheme != 'http') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.scheme == 'http' && !_isLocalHost(uri.host)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedHosts.contains(uri.host.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLocalHost(String host) {
|
||||||
|
final normalizedHost = host.toLowerCase();
|
||||||
|
return normalizedHost == 'localhost' ||
|
||||||
|
normalizedHost == '127.0.0.1' ||
|
||||||
|
normalizedHost == '10.0.2.2';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
import '../bridge/baron_safe_bridge_message_handler.dart';
|
||||||
|
import '../bridge/baron_safe_bridge_service.dart';
|
||||||
import 'baron_safe_web_config.dart';
|
import 'baron_safe_web_config.dart';
|
||||||
|
import 'baron_safe_webview_policy.dart';
|
||||||
|
|
||||||
class BaronSafeWebViewScreen extends StatefulWidget {
|
class BaronSafeWebViewScreen extends StatefulWidget {
|
||||||
const BaronSafeWebViewScreen({super.key});
|
const BaronSafeWebViewScreen({super.key});
|
||||||
@@ -14,8 +17,13 @@ class BaronSafeWebViewScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
||||||
late final WebViewController _controller;
|
late final WebViewController _controller;
|
||||||
|
final _policy = const BaronSafeWebViewPolicy();
|
||||||
|
final _bridgeMessageHandler = const BaronSafeBridgeMessageHandler(
|
||||||
|
PlaceholderBaronSafeBridgeService(),
|
||||||
|
);
|
||||||
var _loadingProgress = 0;
|
var _loadingProgress = 0;
|
||||||
WebResourceError? _lastError;
|
WebResourceError? _lastError;
|
||||||
|
Uri? _blockedUri;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -23,14 +31,30 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
|||||||
|
|
||||||
_controller = WebViewController()
|
_controller = WebViewController()
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..addJavaScriptChannel(
|
||||||
|
'BaronSafeBridge',
|
||||||
|
onMessageReceived: (message) {
|
||||||
|
_handleBridgeMessage(message.message);
|
||||||
|
},
|
||||||
|
)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
NavigationDelegate(
|
||||||
onPageStarted: (_) {
|
onPageStarted: (_) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingProgress = 0;
|
_loadingProgress = 0;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
|
_blockedUri = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onNavigationRequest: (request) {
|
||||||
|
final uri = Uri.tryParse(request.url);
|
||||||
|
if (uri == null || !_policy.allows(uri)) {
|
||||||
|
setState(() => _blockedUri = uri);
|
||||||
|
return NavigationDecision.prevent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NavigationDecision.navigate;
|
||||||
|
},
|
||||||
onProgress: (progress) {
|
onProgress: (progress) {
|
||||||
setState(() => _loadingProgress = progress);
|
setState(() => _loadingProgress = progress);
|
||||||
},
|
},
|
||||||
@@ -45,6 +69,14 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
|||||||
..loadRequest(Uri.parse(BaronSafeWebConfig.initialUrl));
|
..loadRequest(Uri.parse(BaronSafeWebConfig.initialUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleBridgeMessage(String message) async {
|
||||||
|
final response = await _bridgeMessageHandler.handle(message);
|
||||||
|
await _controller.runJavaScript(
|
||||||
|
'window.dispatchEvent(new CustomEvent("BaronSafeBridgeResponse", '
|
||||||
|
'{ detail: $response }));',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -69,6 +101,11 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
|||||||
description: _lastError!.description,
|
description: _lastError!.description,
|
||||||
onRetry: _controller.reload,
|
onRetry: _controller.reload,
|
||||||
),
|
),
|
||||||
|
if (_blockedUri != null)
|
||||||
|
_BlockedNavigationState(
|
||||||
|
uri: _blockedUri!,
|
||||||
|
onDismiss: () => setState(() => _blockedUri = null),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -76,6 +113,57 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BlockedNavigationState extends StatelessWidget {
|
||||||
|
const _BlockedNavigationState({required this.uri, required this.onDismiss});
|
||||||
|
|
||||||
|
final Uri uri;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return ColoredBox(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.gpp_maybe_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Blocked navigation',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
uri.toString(),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onDismiss,
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _WebViewErrorState extends StatelessWidget {
|
class _WebViewErrorState extends StatelessWidget {
|
||||||
const _WebViewErrorState({required this.description, required this.onRetry});
|
const _WebViewErrorState({required this.description, required this.onRetry});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_message_handler.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BaronSafeBridgeMessageHandler', () {
|
||||||
|
const handler = BaronSafeBridgeMessageHandler(
|
||||||
|
PlaceholderBaronSafeBridgeService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('handles an allowed command and returns response envelope', () async {
|
||||||
|
final rawResponse = await handler.handle(
|
||||||
|
jsonEncode({'requestId': 'req-1', 'command': 'getDeviceInfo'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = jsonDecode(rawResponse) as Map<String, Object?>;
|
||||||
|
|
||||||
|
expect(response['requestId'], 'req-1');
|
||||||
|
expect(response['ok'], isTrue);
|
||||||
|
expect(response['data'], isA<Map<String, Object?>>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown commands before service handling', () async {
|
||||||
|
final rawResponse = await handler.handle(
|
||||||
|
jsonEncode({'requestId': 'req-2', 'command': 'stealPrivateKey'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = jsonDecode(rawResponse) as Map<String, Object?>;
|
||||||
|
final error = response['error'] as Map<String, Object?>;
|
||||||
|
|
||||||
|
expect(response['ok'], isFalse);
|
||||||
|
expect(error['code'], 'unsupported_command');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid JSON messages', () async {
|
||||||
|
final rawResponse = await handler.handle('not-json');
|
||||||
|
|
||||||
|
final response = jsonDecode(rawResponse) as Map<String, Object?>;
|
||||||
|
final error = response['error'] as Map<String, Object?>;
|
||||||
|
|
||||||
|
expect(response['ok'], isFalse);
|
||||||
|
expect(error['code'], 'invalid_json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-object payloads', () async {
|
||||||
|
final rawResponse = await handler.handle(
|
||||||
|
jsonEncode({
|
||||||
|
'requestId': 'req-3',
|
||||||
|
'command': 'openBiometricPrompt',
|
||||||
|
'payload': 'bad-payload',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = jsonDecode(rawResponse) as Map<String, Object?>;
|
||||||
|
final error = response['error'] as Map<String, Object?>;
|
||||||
|
|
||||||
|
expect(response['ok'], isFalse);
|
||||||
|
expect(error['code'], 'invalid_payload');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_command.dart';
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_command.dart';
|
||||||
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_models.dart';
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_models.dart';
|
||||||
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_block_service.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_models.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -77,5 +79,55 @@ void main() {
|
|||||||
expect(response.isSuccess, isFalse);
|
expect(response.isSuccess, isFalse);
|
||||||
expect(response.error?.code, 'not_implemented');
|
expect(response.error?.code, 'not_implemented');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects blockSession when sessionId is missing', () async {
|
||||||
|
const service = PlaceholderBaronSafeBridgeService(
|
||||||
|
sessionBlockService: _FakeSessionBlockService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await service.handle(
|
||||||
|
const BaronSafeBridgeRequest(
|
||||||
|
command: BaronSafeBridgeCommand.blockSession,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.isSuccess, isFalse);
|
||||||
|
expect(response.error?.code, 'invalid_payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs injected session block service', () async {
|
||||||
|
const service = PlaceholderBaronSafeBridgeService(
|
||||||
|
sessionBlockService: _FakeSessionBlockService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await service.handle(
|
||||||
|
const BaronSafeBridgeRequest(
|
||||||
|
command: BaronSafeBridgeCommand.blockSession,
|
||||||
|
payload: {'sessionId': 'session-1', 'reason': 'Suspicious login'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.isSuccess, isTrue);
|
||||||
|
expect(response.data['sessionId'], 'session-1');
|
||||||
|
expect(response.data['blocked'], isTrue);
|
||||||
|
expect(response.data['biometricVerified'], isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FakeSessionBlockService implements BaronSafeSessionBlockService {
|
||||||
|
const _FakeSessionBlockService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionBlockResult> blockSession({
|
||||||
|
required String sessionId,
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
|
return BaronSafeSessionBlockResult(
|
||||||
|
sessionId: sessionId,
|
||||||
|
blocked: true,
|
||||||
|
biometricVerified: true,
|
||||||
|
message: reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:baron_safe_app/src/features/device/baron_safe_device_models.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/device/baron_safe_device_registration_client.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/device/baron_safe_device_registration_service.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/push/baron_safe_push_models.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/push/baron_safe_push_service.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/secure_storage/baron_safe_secure_storage.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/secure_storage/baron_safe_secure_storage_service.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BaronSafeDeviceRegistrationService', () {
|
||||||
|
test('creates, stores, and registers a new device id', () async {
|
||||||
|
final secureStorage = DefaultBaronSafeSecureStorageService(
|
||||||
|
MemoryBaronSafeKeyValueStore(),
|
||||||
|
);
|
||||||
|
final client = _RecordingDeviceRegistrationClient();
|
||||||
|
final service = BaronSafeDeviceRegistrationService(
|
||||||
|
secureStorage: secureStorage,
|
||||||
|
registrationClient: client,
|
||||||
|
pushTokenProvider: const _FixedPushTokenProvider(
|
||||||
|
BaronSafePushToken(
|
||||||
|
token: 'push-token-1',
|
||||||
|
platform: BaronSafePushPlatform.android,
|
||||||
|
deviceId: 'ignored-device-id',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
deviceIdGenerator: () => 'device-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.registerCurrentDevice();
|
||||||
|
|
||||||
|
expect(result.registered, isTrue);
|
||||||
|
expect(result.deviceId, 'device-1');
|
||||||
|
expect(await secureStorage.readDeviceId(), 'device-1');
|
||||||
|
expect(client.lastRequest?.pushToken, 'push-token-1');
|
||||||
|
expect(client.lastRequest?.platform, BaronSafePushPlatform.android);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses an existing device id', () async {
|
||||||
|
final secureStorage = DefaultBaronSafeSecureStorageService(
|
||||||
|
MemoryBaronSafeKeyValueStore(),
|
||||||
|
);
|
||||||
|
await secureStorage.saveDeviceId('existing-device');
|
||||||
|
final client = _RecordingDeviceRegistrationClient();
|
||||||
|
final service = BaronSafeDeviceRegistrationService(
|
||||||
|
secureStorage: secureStorage,
|
||||||
|
registrationClient: client,
|
||||||
|
pushTokenProvider: const _FixedPushTokenProvider(null),
|
||||||
|
deviceIdGenerator: () => 'new-device',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.registerCurrentDevice();
|
||||||
|
|
||||||
|
expect(result.deviceId, 'existing-device');
|
||||||
|
expect(client.lastRequest?.deviceId, 'existing-device');
|
||||||
|
expect(client.lastRequest?.pushToken, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingDeviceRegistrationClient
|
||||||
|
implements BaronSafeDeviceRegistrationClient {
|
||||||
|
BaronSafeDeviceRegistrationRequest? lastRequest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeDeviceRegistrationResult> registerDevice(
|
||||||
|
BaronSafeDeviceRegistrationRequest request,
|
||||||
|
) async {
|
||||||
|
lastRequest = request;
|
||||||
|
return BaronSafeDeviceRegistrationResult(
|
||||||
|
deviceId: request.deviceId,
|
||||||
|
registered: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FixedPushTokenProvider implements BaronSafePushTokenProvider {
|
||||||
|
const _FixedPushTokenProvider(this._token);
|
||||||
|
|
||||||
|
final BaronSafePushToken? _token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafePushToken?> readCurrentToken() async {
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_models.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_block_service.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_client.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_models.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BiometricBaronSafeSessionBlockService', () {
|
||||||
|
test('blocks a session after biometric verification succeeds', () async {
|
||||||
|
final repository = ClientBaronSafeSessionRepository(
|
||||||
|
MockBaronSafeSessionClient(),
|
||||||
|
);
|
||||||
|
final service = BiometricBaronSafeSessionBlockService(
|
||||||
|
repository: repository,
|
||||||
|
biometricPrompt: const _FakeBiometricPrompt(verified: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.blockSession(
|
||||||
|
sessionId: 'session-1',
|
||||||
|
reason: 'Suspicious login',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sessionId, 'session-1');
|
||||||
|
expect(result.biometricVerified, isTrue);
|
||||||
|
expect(result.blocked, isTrue);
|
||||||
|
expect(result.message, 'Session blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not call block API when biometric verification fails', () async {
|
||||||
|
final repository = _RecordingSessionRepository();
|
||||||
|
final service = BiometricBaronSafeSessionBlockService(
|
||||||
|
repository: repository,
|
||||||
|
biometricPrompt: const _FakeBiometricPrompt(
|
||||||
|
verified: false,
|
||||||
|
reason: 'User canceled biometric prompt',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.blockSession(
|
||||||
|
sessionId: 'session-1',
|
||||||
|
reason: 'Suspicious login',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.biometricVerified, isFalse);
|
||||||
|
expect(result.blocked, isFalse);
|
||||||
|
expect(result.message, 'User canceled biometric prompt');
|
||||||
|
expect(repository.blockCallCount, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeBiometricPrompt implements BaronSafeBiometricPrompt {
|
||||||
|
const _FakeBiometricPrompt({required this.verified, this.reason = 'OK'});
|
||||||
|
|
||||||
|
final bool verified;
|
||||||
|
final String reason;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeBiometricResult> verify({required String reason}) async {
|
||||||
|
return BaronSafeBiometricResult(verified: verified, reason: this.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordingSessionRepository implements BaronSafeSessionRepository {
|
||||||
|
int blockCallCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionBlockResponse> blockSession(
|
||||||
|
BaronSafeSessionBlockRequest request,
|
||||||
|
) async {
|
||||||
|
blockCallCount += 1;
|
||||||
|
return BaronSafeSessionBlockResponse(
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
blocked: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId) {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BaronSafeSessionSummary>> listRecentSessions() {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_client.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_models.dart';
|
||||||
|
import 'package:baron_safe_app/src/features/session/baron_safe_session_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ClientBaronSafeSessionRepository', () {
|
||||||
|
test('lists recent sessions from the client', () async {
|
||||||
|
final repository = ClientBaronSafeSessionRepository(
|
||||||
|
MockBaronSafeSessionClient(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final sessions = await repository.listRecentSessions();
|
||||||
|
|
||||||
|
expect(sessions, hasLength(2));
|
||||||
|
expect(sessions.first.id, 'session-1');
|
||||||
|
expect(sessions.last.riskLevel, BaronSafeSessionRiskLevel.high);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads a session detail by id', () async {
|
||||||
|
final repository = ClientBaronSafeSessionRepository(
|
||||||
|
MockBaronSafeSessionClient(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final session = await repository.getSessionDetail('session-2');
|
||||||
|
|
||||||
|
expect(session.serviceName, 'Admin Console');
|
||||||
|
expect(session.locationLabel, 'Unknown');
|
||||||
|
expect(session.status, BaronSafeSessionStatus.active);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when a session id is unknown', () async {
|
||||||
|
final repository = ClientBaronSafeSessionRepository(
|
||||||
|
MockBaronSafeSessionClient(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => repository.getSessionDetail('missing-session'),
|
||||||
|
throwsA(isA<BaronSafeSessionNotFoundException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks a known session through the client', () async {
|
||||||
|
final repository = ClientBaronSafeSessionRepository(
|
||||||
|
MockBaronSafeSessionClient(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await repository.blockSession(
|
||||||
|
const BaronSafeSessionBlockRequest(
|
||||||
|
sessionId: 'session-1',
|
||||||
|
reason: 'Suspicious login',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sessionId, 'session-1');
|
||||||
|
expect(result.blocked, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BaronSafeSessionDetail', () {
|
||||||
|
test('round trips from JSON to model', () {
|
||||||
|
final session = BaronSafeSessionDetail.fromJson({
|
||||||
|
'id': 'session-json',
|
||||||
|
'serviceName': 'JSON Service',
|
||||||
|
'status': 'blocked',
|
||||||
|
'riskLevel': 'medium',
|
||||||
|
'createdAt': '2026-06-30T10:00:00.000Z',
|
||||||
|
'ipAddress': '203.0.113.11',
|
||||||
|
'deviceLabel': 'Safari on iOS',
|
||||||
|
'locationLabel': 'Busan',
|
||||||
|
'userAgent': 'Safari',
|
||||||
|
'authMethod': 'phone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.status, BaronSafeSessionStatus.blocked);
|
||||||
|
expect(session.riskLevel, BaronSafeSessionRiskLevel.medium);
|
||||||
|
expect(session.toJson()['locationLabel'], 'Busan');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:baron_safe_app/src/features/webview/baron_safe_webview_policy.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BaronSafeWebViewPolicy', () {
|
||||||
|
const policy = BaronSafeWebViewPolicy();
|
||||||
|
|
||||||
|
test('allows production and staging HTTPS hosts', () {
|
||||||
|
expect(policy.allows(Uri.parse('https://safe.baron.hmac.kr')), isTrue);
|
||||||
|
expect(
|
||||||
|
policy.allows(Uri.parse('https://safe-staging.baron.hmac.kr/path')),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows local HTTP development hosts', () {
|
||||||
|
expect(policy.allows(Uri.parse('http://localhost:8080')), isTrue);
|
||||||
|
expect(policy.allows(Uri.parse('http://127.0.0.1:8080')), isTrue);
|
||||||
|
expect(policy.allows(Uri.parse('http://10.0.2.2:8080')), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks unknown hosts and insecure remote HTTP', () {
|
||||||
|
expect(policy.allows(Uri.parse('https://example.com')), isFalse);
|
||||||
|
expect(policy.allows(Uri.parse('http://safe.baron.hmac.kr')), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks non-web schemes and relative URLs', () {
|
||||||
|
expect(policy.allows(Uri.parse('javascript:alert(1)')), isFalse);
|
||||||
|
expect(policy.allows(Uri.parse('/relative/path')), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 금일 작업내역 보고
|
||||||
|
|
||||||
|
일자: 2026-06-30
|
||||||
|
프로젝트: Baron Safe 앱 개발
|
||||||
|
|
||||||
|
## 금일 내용
|
||||||
|
|
||||||
|
| 작업 꼭지 | 주요 내용 |
|
||||||
|
| --- | --- |
|
||||||
|
| 앱 개발 방향 및 Flutter 프로젝트 확장 계획 정리 | Baron Safe 앱 개발 단계, 진행 원칙, Docker Flutter 사용 방식, 단계별 타임테이블 정리 |
|
||||||
|
| Flutter Android/iOS 정식 프로젝트 구조 병합 | 임시 Flutter 프로젝트 생성 후 Android/iOS 기본 scaffold를 기존 repository 구조에 병합 |
|
||||||
|
| 앱 기본 실행 구조 및 의존성 정리 | `pubspec.yaml` SDK/의존성 정리, `go_router` 기반 앱 entrypoint와 기본 홈 화면 구성 |
|
||||||
|
| WebView 및 Native bridge skeleton 구성 | Baron Safe WebView route, URL 주입 방식, WebView 보안 정책, JavaScript bridge 메시지 구조 구성 |
|
||||||
|
| Push, Secure Storage, Device registration skeleton 구성 | push token service, secure storage abstraction, device id 생성/저장 및 기기 등록 service skeleton 구성 |
|
||||||
|
| 세션 API 및 사용자 확인 화면 skeleton 구성 | 최근 세션 목록/상세 DTO, mock repository, 세션 목록/상세 화면 route와 홈 진입 타일 구성 |
|
||||||
|
| 검증 및 원격 반영 | Docker Flutter 컨테이너에서 `flutter pub get`, `flutter analyze`, `flutter test` 검증 및 0-12단계 변경분 Gitea push |
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
|
||||||
|
- Flutter Android/iOS 앱 기본 구조 생성
|
||||||
|
- Baron Safe 앱 기본 홈, WebView, bridge, push, secure storage, device registration, session skeleton 추가
|
||||||
|
- 세션 목록/상세 mock 화면 확인 가능
|
||||||
|
- 진행 문서 및 후속 개발 단계표 작성
|
||||||
|
- Gitea 원격 반영: `d2f6af7 Expand Flutter app scaffold`
|
||||||
|
|
||||||
|
## 다음 예정 작업
|
||||||
|
|
||||||
|
| 우선순위 | 작업 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | 세션 차단 workflow skeleton 구성 |
|
||||||
|
| 2 | 생체 인증 placeholder와 세션 차단 요청 흐름 연결 |
|
||||||
|
| 3 | 연결 앱 목록/차단 skeleton 구성 |
|
||||||
|
| 4 | Android FCM 및 iOS APNs 설정 방식 확정 |
|
||||||
@@ -180,13 +180,39 @@ flutter create --project-name baron_safe_app --org kr.hmac.baron.safe /tmp/baron
|
|||||||
| 2026-06-30 | 10단계 | Secure storage skeleton | 완료 | device id, app lock, biometric 설정 저장 abstraction과 `flutter_secure_storage` adapter, memory test store 구성 완료. Docker Flutter 컨테이너에서 `dart format lib test`, `flutter analyze`, secure storage 단위 테스트 통과 |
|
| 2026-06-30 | 10단계 | Secure storage skeleton | 완료 | device id, app lock, biometric 설정 저장 abstraction과 `flutter_secure_storage` adapter, memory test store 구성 완료. Docker Flutter 컨테이너에서 `dart format lib test`, `flutter analyze`, secure storage 단위 테스트 통과 |
|
||||||
| 2026-06-30 | 11단계 | 검증 | 완료 | Docker Flutter 컨테이너에서 `flutter pub get`, `flutter analyze`, 전체 `flutter test` 통과. 전체 테스트 10건 성공 |
|
| 2026-06-30 | 11단계 | 검증 | 완료 | Docker Flutter 컨테이너에서 `flutter pub get`, `flutter analyze`, 전체 `flutter test` 통과. 전체 테스트 10건 성공 |
|
||||||
| 2026-06-30 | 12단계 | 커밋 및 push | 완료 | Flutter Android/iOS 프로젝트 병합 및 앱 skeleton 변경분을 커밋하고 Gitea `origin/main` push 진행 |
|
| 2026-06-30 | 12단계 | 커밋 및 push | 완료 | Flutter Android/iOS 프로젝트 병합 및 앱 skeleton 변경분을 커밋하고 Gitea `origin/main` push 진행 |
|
||||||
|
| 2026-06-30 | 13단계 | WebView URL 및 실행 환경 분리 | 완료 | `BARON_SAFE_WEB_URL` dart-define 기반 WebView URL 주입 방식과 local/staging/production 예시, 검증 방법을 `docs/runtime-config.md`에 정리 |
|
||||||
|
| 2026-06-30 | 14단계 | WebView 보안 정책 보강 | 완료 | 허용 host 기반 WebView navigation policy와 차단 UI를 구성하고 URL 허용/차단 단위 테스트 추가 |
|
||||||
|
| 2026-06-30 | 15단계 | Bridge 메시지 연결 | 완료 | WebView `BaronSafeBridge` JavaScript channel, bridge JSON request/response envelope, command parsing 및 허용 command 검증 테스트 추가 |
|
||||||
|
| 2026-06-30 | 16단계 | Device registration skeleton | 완료 | device id 생성/저장 service, device register API client interface, push token 포함 등록 request skeleton 및 단위 테스트 추가 |
|
||||||
|
| 2026-06-30 | 17단계 | Session API skeleton | 완료 | 세션 목록/상세 DTO, session client/repository interface, mock session client 및 목록/상세 단위 테스트 추가 |
|
||||||
|
| 2026-06-30 | 18단계 | Session 화면 skeleton | 완료 | 최근 세션 목록 route, 세션 상세 route, mock repository 기반 세션 목록/상세 화면과 홈 진입 타일 구성 |
|
||||||
|
| 2026-06-30 | 19단계 | Session block skeleton | 완료 | 세션 차단 request/response/result 모델, 생체 인증 placeholder 기반 block workflow, bridge `blockSession` service 주입 경로, 상세 화면 차단 버튼 연결 및 관련 단위 테스트 추가. Docker Flutter 컨테이너에서 `dart format lib test`, `flutter analyze`, 관련 테스트 15건 통과 |
|
||||||
|
|
||||||
## 7. 다음 승인 요청 예정
|
## 7. 다음 승인 요청 예정
|
||||||
|
|
||||||
다음 단계는 후속 개발 범위 확정 후 별도 승인으로 진행한다.
|
다음 단계는 `20단계: Linked apps skeleton`이다.
|
||||||
|
|
||||||
승인 후 결정할 작업:
|
승인 후 결정할 작업:
|
||||||
|
|
||||||
1. Baron Safe WebView 실제 URL과 환경 분리 방식을 확정한다.
|
1. 연결 앱 목록/차단 DTO와 service interface를 정의한다.
|
||||||
2. Firebase/FCM Android 설정과 iOS APNs 설정 방식을 확정한다.
|
2. mock 기반 연결 앱 목록/상세 또는 차단 진입 화면 placeholder를 구성한다.
|
||||||
3. native bridge와 backend API 연동 순서를 확정한다.
|
3. 연결 앱 목록/차단 workflow 단위 테스트를 추가한다.
|
||||||
|
|
||||||
|
## 8. 후속 개발 단계
|
||||||
|
|
||||||
|
정식 Flutter Android/iOS scaffold와 앱 skeleton이 원격 `main`에 반영된 이후의 후속 개발 단계이다. 각 단계는 기존 원칙과 동일하게 사용자 승인 후 진행하며, 검증 가능한 단위마다 Docker Flutter 컨테이너에서 `flutter analyze`와 필요한 테스트를 수행한다.
|
||||||
|
|
||||||
|
| 단계 | 작업명 | 주요 작업 | 산출물 | 완료 기준 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 13단계 | WebView URL 및 실행 환경 분리 | `BARON_SAFE_WEB_URL` 사용법 문서화, local/staging/production URL 관리 방식 정리 | 실행 환경 설정 문서 | 환경별 WebView URL 주입 방식 확인 |
|
||||||
|
| 14단계 | WebView 보안 정책 보강 | 허용 origin 목록, navigation delegate 차단 정책, 외부 링크 처리 기준 정리 | WebView policy 코드/문서 | 허용되지 않은 URL 이동 차단 테스트 가능 |
|
||||||
|
| 15단계 | Bridge 메시지 연결 | WebView JavaScript channel, bridge request parsing, response envelope 구성 | WebView-native bridge 연결 skeleton | 허용 command만 처리되는 테스트 통과 |
|
||||||
|
| 16단계 | Device registration skeleton | device id 생성/저장, device register API client interface 구성 | 기기 등록 service skeleton | device id 저장 및 등록 요청 테스트 통과 |
|
||||||
|
| 17단계 | Session API skeleton | 세션 목록/상세 DTO, API client interface, mock repository 구성 | session service skeleton | mock 기반 목록/상세 테스트 통과 |
|
||||||
|
| 18단계 | Session 화면 skeleton | 최근 로그인/세션 목록, 상세, 차단 진입 UI 구성 | Flutter session 화면 | analyze 통과 및 화면 route 동작 |
|
||||||
|
| 19단계 | Session block skeleton | 생체 인증 placeholder와 세션 차단 service 연결 | block workflow skeleton | 생체 인증 후 차단 요청 흐름 테스트 통과 |
|
||||||
|
| 20단계 | Linked apps skeleton | 연결 앱 목록/차단 DTO, service, 화면 placeholder 구성 | linked apps feature skeleton | mock 기반 목록/차단 테스트 통과 |
|
||||||
|
| 21단계 | Firebase/FCM Android 준비 | Android Firebase 설정 파일 반영 정책, Gradle 설정, 알림 권한 확인 | Android push 준비 변경 | secret 제외 여부 확인 및 analyze 통과 |
|
||||||
|
| 22단계 | iOS APNs 준비 | bundle id, entitlement, APNs/FCM 설정 방식 문서화 및 iOS 설정 skeleton | iOS push 준비 변경 | secret 제외 여부 확인 및 analyze 통과 |
|
||||||
|
| 23단계 | 앱 실행/빌드 검증 | Docker Flutter 기반 Android debug build 가능성 확인, 필요 시 iOS 설정 정합성 점검 | 빌드 검증 결과 | Android debug build 또는 명확한 차단 사유 기록 |
|
||||||
|
| 24단계 | 후속 커밋 및 push | 13-23단계 변경 목록 확인, 검증, 커밋, Gitea push | Git commit | 원격 `main` 반영 |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Runtime Configuration
|
||||||
|
|
||||||
|
Baron Safe runtime values are injected at Flutter build/run time with `--dart-define`.
|
||||||
|
|
||||||
|
## WebView URL
|
||||||
|
|
||||||
|
The app reads the initial WebView URL from `BARON_SAFE_WEB_URL`.
|
||||||
|
|
||||||
|
| Environment | Example value | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| local | `http://10.0.2.2:8080` | Android emulator access to a host preview server |
|
||||||
|
| staging | `https://safe-staging.baron.hmac.kr` | Internal QA and SSO integration testing |
|
||||||
|
| production | `https://safe.baron.hmac.kr` | Production Baron Safe web route |
|
||||||
|
|
||||||
|
Default value in code:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://safe.baron.hmac.kr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run Examples
|
||||||
|
|
||||||
|
Android emulator local preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run --dart-define=BARON_SAFE_WEB_URL=http://10.0.2.2:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Staging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run --dart-define=BARON_SAFE_WEB_URL=https://safe-staging.baron.hmac.kr
|
||||||
|
```
|
||||||
|
|
||||||
|
Release build example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build apk --dart-define=BARON_SAFE_WEB_URL=https://safe.baron.hmac.kr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Flutter Examples
|
||||||
|
|
||||||
|
From repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v /home/ubuntu/workspace/baron-safe-app/app:/app \
|
||||||
|
-w /app \
|
||||||
|
ghcr.io/cirruslabs/flutter:3.38.0 \
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
For build or run commands, pass the same `--dart-define` argument to Flutter inside the container.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Do not hardcode staging-only or developer-only URLs in feature code.
|
||||||
|
- Do not put API keys, APNs keys, FCM server keys, signing keys, or production secrets in `--dart-define`.
|
||||||
|
- Keep WebView URL configuration separate from future native API base URL configuration.
|
||||||
|
- Keep the WebView allowed host list in app code small and explicit.
|
||||||
|
- Allow remote WebView traffic only over HTTPS.
|
||||||
|
- Allow HTTP only for local development hosts such as `localhost`, `127.0.0.1`, and Android emulator host `10.0.2.2`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Confirm [baron_safe_web_config.dart](../app/lib/src/features/webview/baron_safe_web_config.dart) still reads `BARON_SAFE_WEB_URL`.
|
||||||
|
2. Confirm [baron_safe_webview_policy.dart](../app/lib/src/features/webview/baron_safe_webview_policy.dart) allows only approved WebView hosts.
|
||||||
|
3. Run `flutter analyze`.
|
||||||
|
4. Run WebView policy tests.
|
||||||
|
5. For device/emulator checks, launch the app with the environment-specific `--dart-define` and verify the `/safe` route loads the expected WebView target.
|
||||||
Reference in New Issue
Block a user