diff --git a/app/lib/src/app.dart b/app/lib/src/app.dart index b90834f..b67aeda 100644 --- a/app/lib/src/app.dart +++ b/app/lib/src/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'features/home/home_screen.dart'; +import 'features/session/session_screens.dart'; import 'features/webview/baron_safe_webview_screen.dart'; final _router = GoRouter( @@ -16,6 +17,22 @@ final _router = GoRouter( name: BaronSafeWebViewScreen.routeName, 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']!, + ); + }, + ), + ], + ), ], ); diff --git a/app/lib/src/features/bridge/baron_safe_bridge_message_handler.dart b/app/lib/src/features/bridge/baron_safe_bridge_message_handler.dart new file mode 100644 index 0000000..5609923 --- /dev/null +++ b/app/lib/src/features/bridge/baron_safe_bridge_message_handler.dart @@ -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 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) { + 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? ?? const {}, + ), + ); + + 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? _decode(String rawMessage) { + try { + final decoded = jsonDecode(rawMessage); + if (decoded is Map) { + 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}, + }); + } +} diff --git a/app/lib/src/features/bridge/baron_safe_bridge_service.dart b/app/lib/src/features/bridge/baron_safe_bridge_service.dart index a4d5235..931965b 100644 --- a/app/lib/src/features/bridge/baron_safe_bridge_service.dart +++ b/app/lib/src/features/bridge/baron_safe_bridge_service.dart @@ -1,6 +1,7 @@ import 'baron_safe_bridge_command.dart'; import 'baron_safe_bridge_models.dart'; import '../push/baron_safe_push_service.dart'; +import '../session/baron_safe_session_block_service.dart'; abstract interface class BaronSafeBridgeService { Future handle(BaronSafeBridgeRequest request); @@ -19,11 +20,13 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService { this.deviceInfoProvider = const PlaceholderBaronSafeDeviceInfoProvider(), this.biometricPrompt = const PlaceholderBaronSafeBiometricPrompt(), this.pushService = const PlaceholderBaronSafePushService(), + this.sessionBlockService, }); final BaronSafeDeviceInfoProvider deviceInfoProvider; final BaronSafeBiometricPrompt biometricPrompt; final BaronSafePushService pushService; + final BaronSafeSessionBlockService? sessionBlockService; @override Future handle(BaronSafeBridgeRequest request) async { @@ -33,7 +36,7 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService { request.payload, ), BaronSafeBridgeCommand.registerPushToken => _registerPushToken(), - BaronSafeBridgeCommand.blockSession => _notImplemented(request.command), + BaronSafeBridgeCommand.blockSession => _blockSession(request.payload), BaronSafeBridgeCommand.blockLinkedApp => _notImplemented(request.command), BaronSafeBridgeCommand.openSettings => _notImplemented(request.command), }; @@ -67,6 +70,31 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService { final result = await pushService.registerCurrentToken(); return BaronSafeBridgeResponse.success(data: result.toJson()); } + + Future _blockSession( + Map 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 diff --git a/app/lib/src/features/device/baron_safe_device_models.dart b/app/lib/src/features/device/baron_safe_device_models.dart new file mode 100644 index 0000000..3a77860 --- /dev/null +++ b/app/lib/src/features/device/baron_safe_device_models.dart @@ -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 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; +} diff --git a/app/lib/src/features/device/baron_safe_device_registration_client.dart b/app/lib/src/features/device/baron_safe_device_registration_client.dart new file mode 100644 index 0000000..8d06aab --- /dev/null +++ b/app/lib/src/features/device/baron_safe_device_registration_client.dart @@ -0,0 +1,22 @@ +import 'baron_safe_device_models.dart'; + +abstract interface class BaronSafeDeviceRegistrationClient { + Future registerDevice( + BaronSafeDeviceRegistrationRequest request, + ); +} + +class NoopBaronSafeDeviceRegistrationClient + implements BaronSafeDeviceRegistrationClient { + const NoopBaronSafeDeviceRegistrationClient(); + + @override + Future registerDevice( + BaronSafeDeviceRegistrationRequest request, + ) async { + return BaronSafeDeviceRegistrationResult( + deviceId: request.deviceId, + registered: true, + ); + } +} diff --git a/app/lib/src/features/device/baron_safe_device_registration_service.dart b/app/lib/src/features/device/baron_safe_device_registration_service.dart new file mode 100644 index 0000000..634533f --- /dev/null +++ b/app/lib/src/features/device/baron_safe_device_registration_service.dart @@ -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 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 _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}'; +} diff --git a/app/lib/src/features/home/home_screen.dart b/app/lib/src/features/home/home_screen.dart index 747e2a2..ae06472 100644 --- a/app/lib/src/features/home/home_screen.dart +++ b/app/lib/src/features/home/home_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../session/session_screens.dart'; import '../webview/baron_safe_webview_screen.dart'; class HomeScreen extends StatelessWidget { @@ -38,6 +39,12 @@ class HomeScreen extends StatelessWidget { subtitle: 'Route container placeholder', 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( icon: Icons.fingerprint, title: 'Biometric bridge', diff --git a/app/lib/src/features/session/baron_safe_session_block_service.dart b/app/lib/src/features/session/baron_safe_session_block_service.dart new file mode 100644 index 0000000..06c5271 --- /dev/null +++ b/app/lib/src/features/session/baron_safe_session_block_service.dart @@ -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 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 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, + ); + } +} diff --git a/app/lib/src/features/session/baron_safe_session_client.dart b/app/lib/src/features/session/baron_safe_session_client.dart new file mode 100644 index 0000000..40f190d --- /dev/null +++ b/app/lib/src/features/session/baron_safe_session_client.dart @@ -0,0 +1,81 @@ +import 'baron_safe_session_models.dart'; + +abstract interface class BaronSafeSessionClient { + Future> listSessions(); + + Future getSession(String sessionId); + + Future blockSession( + BaronSafeSessionBlockRequest request, + ); +} + +class MockBaronSafeSessionClient implements BaronSafeSessionClient { + MockBaronSafeSessionClient({List? sessions}) + : _sessions = sessions ?? _defaultSessions; + + final List _sessions; + + @override + Future> listSessions() async { + return List.unmodifiable(_sessions); + } + + @override + Future getSession(String sessionId) async { + return _sessions.firstWhere( + (session) => session.id == sessionId, + orElse: () => throw BaronSafeSessionNotFoundException(sessionId), + ); + } + + @override + Future 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', + ), +]; diff --git a/app/lib/src/features/session/baron_safe_session_models.dart b/app/lib/src/features/session/baron_safe_session_models.dart new file mode 100644 index 0000000..582f87e --- /dev/null +++ b/app/lib/src/features/session/baron_safe_session_models.dart @@ -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 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 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 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 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 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 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 toJson() { + return { + 'sessionId': sessionId, + 'blocked': blocked, + 'biometricVerified': biometricVerified, + 'message': message, + 'blockedAt': blockedAt?.toIso8601String(), + }; + } +} diff --git a/app/lib/src/features/session/baron_safe_session_repository.dart b/app/lib/src/features/session/baron_safe_session_repository.dart new file mode 100644 index 0000000..ce972e0 --- /dev/null +++ b/app/lib/src/features/session/baron_safe_session_repository.dart @@ -0,0 +1,35 @@ +import 'baron_safe_session_client.dart'; +import 'baron_safe_session_models.dart'; + +abstract interface class BaronSafeSessionRepository { + Future> listRecentSessions(); + + Future getSessionDetail(String sessionId); + + Future blockSession( + BaronSafeSessionBlockRequest request, + ); +} + +class ClientBaronSafeSessionRepository implements BaronSafeSessionRepository { + const ClientBaronSafeSessionRepository(this._client); + + final BaronSafeSessionClient _client; + + @override + Future> listRecentSessions() { + return _client.listSessions(); + } + + @override + Future getSessionDetail(String sessionId) { + return _client.getSession(sessionId); + } + + @override + Future blockSession( + BaronSafeSessionBlockRequest request, + ) { + return _client.blockSession(request); + } +} diff --git a/app/lib/src/features/session/session_screens.dart b/app/lib/src/features/session/session_screens.dart new file mode 100644 index 0000000..25035b3 --- /dev/null +++ b/app/lib/src/features/session/session_screens.dart @@ -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>( + future: _sessionRepository.listRecentSessions(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + + final sessions = snapshot.data ?? const []; + 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 createState() => _SessionDetailScreenState(); +} + +class _SessionDetailScreenState extends State { + bool _isBlocking = false; + + Future _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( + 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().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, + }; +} diff --git a/app/lib/src/features/webview/baron_safe_webview_policy.dart b/app/lib/src/features/webview/baron_safe_webview_policy.dart new file mode 100644 index 0000000..ddcc378 --- /dev/null +++ b/app/lib/src/features/webview/baron_safe_webview_policy.dart @@ -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 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'; + } +} diff --git a/app/lib/src/features/webview/baron_safe_webview_screen.dart b/app/lib/src/features/webview/baron_safe_webview_screen.dart index 99da2ed..74d6549 100644 --- a/app/lib/src/features/webview/baron_safe_webview_screen.dart +++ b/app/lib/src/features/webview/baron_safe_webview_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.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_webview_policy.dart'; class BaronSafeWebViewScreen extends StatefulWidget { const BaronSafeWebViewScreen({super.key}); @@ -14,8 +17,13 @@ class BaronSafeWebViewScreen extends StatefulWidget { class _BaronSafeWebViewScreenState extends State { late final WebViewController _controller; + final _policy = const BaronSafeWebViewPolicy(); + final _bridgeMessageHandler = const BaronSafeBridgeMessageHandler( + PlaceholderBaronSafeBridgeService(), + ); var _loadingProgress = 0; WebResourceError? _lastError; + Uri? _blockedUri; @override void initState() { @@ -23,14 +31,30 @@ class _BaronSafeWebViewScreenState extends State { _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'BaronSafeBridge', + onMessageReceived: (message) { + _handleBridgeMessage(message.message); + }, + ) ..setNavigationDelegate( NavigationDelegate( onPageStarted: (_) { setState(() { _loadingProgress = 0; _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) { setState(() => _loadingProgress = progress); }, @@ -45,6 +69,14 @@ class _BaronSafeWebViewScreenState extends State { ..loadRequest(Uri.parse(BaronSafeWebConfig.initialUrl)); } + Future _handleBridgeMessage(String message) async { + final response = await _bridgeMessageHandler.handle(message); + await _controller.runJavaScript( + 'window.dispatchEvent(new CustomEvent("BaronSafeBridgeResponse", ' + '{ detail: $response }));', + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -69,6 +101,11 @@ class _BaronSafeWebViewScreenState extends State { description: _lastError!.description, onRetry: _controller.reload, ), + if (_blockedUri != null) + _BlockedNavigationState( + uri: _blockedUri!, + onDismiss: () => setState(() => _blockedUri = null), + ), ], ), ), @@ -76,6 +113,57 @@ class _BaronSafeWebViewScreenState extends State { } } +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 { const _WebViewErrorState({required this.description, required this.onRetry}); diff --git a/app/test/features/bridge/baron_safe_bridge_message_handler_test.dart b/app/test/features/bridge/baron_safe_bridge_message_handler_test.dart new file mode 100644 index 0000000..b31d346 --- /dev/null +++ b/app/test/features/bridge/baron_safe_bridge_message_handler_test.dart @@ -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; + + expect(response['requestId'], 'req-1'); + expect(response['ok'], isTrue); + expect(response['data'], isA>()); + }); + + 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; + final error = response['error'] as Map; + + 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; + final error = response['error'] as Map; + + 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; + final error = response['error'] as Map; + + expect(response['ok'], isFalse); + expect(error['code'], 'invalid_payload'); + }); + }); +} diff --git a/app/test/features/bridge/baron_safe_bridge_service_test.dart b/app/test/features/bridge/baron_safe_bridge_service_test.dart index f950e67..4acbb86 100644 --- a/app/test/features/bridge/baron_safe_bridge_service_test.dart +++ b/app/test/features/bridge/baron_safe_bridge_service_test.dart @@ -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_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_models.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -77,5 +79,55 @@ void main() { expect(response.isSuccess, isFalse); 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 blockSession({ + required String sessionId, + required String reason, + }) async { + return BaronSafeSessionBlockResult( + sessionId: sessionId, + blocked: true, + biometricVerified: true, + message: reason, + ); + } +} diff --git a/app/test/features/device/baron_safe_device_registration_service_test.dart b/app/test/features/device/baron_safe_device_registration_service_test.dart new file mode 100644 index 0000000..964ce7d --- /dev/null +++ b/app/test/features/device/baron_safe_device_registration_service_test.dart @@ -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 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 readCurrentToken() async { + return _token; + } +} diff --git a/app/test/features/session/baron_safe_session_block_service_test.dart b/app/test/features/session/baron_safe_session_block_service_test.dart new file mode 100644 index 0000000..cbccd5b --- /dev/null +++ b/app/test/features/session/baron_safe_session_block_service_test.dart @@ -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 verify({required String reason}) async { + return BaronSafeBiometricResult(verified: verified, reason: this.reason); + } +} + +class _RecordingSessionRepository implements BaronSafeSessionRepository { + int blockCallCount = 0; + + @override + Future blockSession( + BaronSafeSessionBlockRequest request, + ) async { + blockCallCount += 1; + return BaronSafeSessionBlockResponse( + sessionId: request.sessionId, + blocked: true, + ); + } + + @override + Future getSessionDetail(String sessionId) { + throw UnimplementedError(); + } + + @override + Future> listRecentSessions() { + throw UnimplementedError(); + } +} diff --git a/app/test/features/session/baron_safe_session_repository_test.dart b/app/test/features/session/baron_safe_session_repository_test.dart new file mode 100644 index 0000000..2a80efb --- /dev/null +++ b/app/test/features/session/baron_safe_session_repository_test.dart @@ -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()), + ); + }); + + 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'); + }); + }); +} diff --git a/app/test/features/webview/baron_safe_webview_policy_test.dart b/app/test/features/webview/baron_safe_webview_policy_test.dart new file mode 100644 index 0000000..d7c6180 --- /dev/null +++ b/app/test/features/webview/baron_safe_webview_policy_test.dart @@ -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); + }); + }); +} diff --git a/docs/daily-work-report-2026-06-30.md b/docs/daily-work-report-2026-06-30.md new file mode 100644 index 0000000..c2f4ea4 --- /dev/null +++ b/docs/daily-work-report-2026-06-30.md @@ -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 설정 방식 확정 | diff --git a/docs/flutter-project-expansion-timetable-2026-06-30.md b/docs/flutter-project-expansion-timetable-2026-06-30.md index 466d137..7984d00 100644 --- a/docs/flutter-project-expansion-timetable-2026-06-30.md +++ b/docs/flutter-project-expansion-timetable-2026-06-30.md @@ -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 | 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 | 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. 다음 승인 요청 예정 -다음 단계는 후속 개발 범위 확정 후 별도 승인으로 진행한다. +다음 단계는 `20단계: Linked apps skeleton`이다. 승인 후 결정할 작업: -1. Baron Safe WebView 실제 URL과 환경 분리 방식을 확정한다. -2. Firebase/FCM Android 설정과 iOS APNs 설정 방식을 확정한다. -3. native bridge와 backend API 연동 순서를 확정한다. +1. 연결 앱 목록/차단 DTO와 service interface를 정의한다. +2. mock 기반 연결 앱 목록/상세 또는 차단 진입 화면 placeholder를 구성한다. +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` 반영 | diff --git a/docs/runtime-config.md b/docs/runtime-config.md new file mode 100644 index 0000000..96c82c4 --- /dev/null +++ b/docs/runtime-config.md @@ -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.