diff --git a/lib/data/datasources/ws_client.dart b/lib/data/datasources/ws_client.dart index a45c75f..48409b8 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; import 'package:chepuhagram/core/constants.dart'; @@ -12,7 +13,8 @@ class SocketService { // Поток, который будут слушать провайдеры Stream> get messages => _messageController.stream; - void connect(String token) { + Future connect(ApiService apiService) async { + final token = await apiService.getAccessToken(); if (_channel != null) return; // Уже подключены // В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке @@ -25,14 +27,14 @@ class SocketService { final decoded = jsonDecode(data); _messageController.add(decoded); }, - onError: (error) => _reconnect(token), - onDone: () => _reconnect(token), + onError: (error) => _reconnect(apiService), + onDone: () => _reconnect(apiService), ); } - void _reconnect(String token) { + Future _reconnect(ApiService apiService) async { _channel = null; - Future.delayed(const Duration(seconds: 5), () => connect(token)); + Future.delayed(const Duration(seconds: 5), () => connect(apiService)); } void sendMessage(Map data) { diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index 8d60f81..ada0dc8 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -8,7 +8,6 @@ import 'dart:convert'; class ApiService extends ChangeNotifier { final _client = http.Client(); final _storage = const FlutterSecureStorage(); - Future refreshToken() async { notifyListeners(); @@ -49,7 +48,8 @@ class ApiService extends ChangeNotifier { String? token = await _storage.read(key: 'access_token'); if (token != null) { - bool isExpiredSoon = JwtDecoder.isExpired(token) || + bool isExpiredSoon = + JwtDecoder.isExpired(token) || JwtDecoder.getRemainingTime(token).inMinutes < 2; if (isExpiredSoon) { @@ -63,4 +63,31 @@ class ApiService extends ChangeNotifier { } return token; } -} \ No newline at end of file + + Future setPublicKey(String publicKey) async { + notifyListeners(); + + try { + final token = await getAccessToken(); + final response = await _client.post( + Uri.http(AppConstants.baseUrl, 'auth/set-public-key'), + body: jsonEncode({'public_key': publicKey}), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + return true; + } else { + print("Ошибка установки ключа: ${response.statusCode}"); + return false; + } + } catch (e) { + rethrow; + } finally { + notifyListeners(); + } + } +} diff --git a/lib/domain/services/crypto_service.dart b/lib/domain/services/crypto_service.dart index e69de29..d0aae98 100644 --- a/lib/domain/services/crypto_service.dart +++ b/lib/domain/services/crypto_service.dart @@ -0,0 +1,137 @@ +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'dart:convert'; + +class CryptoService { + final _storage = const FlutterSecureStorage(); + final algorithm = X25519(); + final aesGcm = AesGcm.with256bits(); + + Future> initAccountSecurity(String masterPassword) async { + // Генерируем пару X25519 ключей + final keyPair = await algorithm.newKeyPair(); + final publicKey = await keyPair.extractPublicKey(); + final privateKeyBytes = await keyPair.extractPrivateKeyBytes(); + + // Сохраняем приватный ключ в Secure Storage + await _storage.write( + key: 'private_key', + value: base64Encode(privateKeyBytes), + ); + + // Шифруем приватный ключ с мастер-паролем (AES-GCM) + final masterKey = await _deriveKeyFromPassword(masterPassword); + final nonce = aesGcm.newNonce(); + final encrypted = await aesGcm.encrypt( + privateKeyBytes, + secretKey: masterKey, + nonce: nonce, + ); + + // Комбинируем nonce и зашифрованные данные + final encryptedData = nonce + encrypted.mac.bytes + encrypted.cipherText; + final encryptedPrivateKey = base64Encode(encryptedData); + + final publicKeyBase64 = base64Encode(publicKey.bytes); + + return { + 'public_key': publicKeyBase64, + 'encrypted_private_key': encryptedPrivateKey, + }; + } + + Future decryptPrivateKey( + String encryptedPrivateKey, + String masterPassword, + ) async { + try { + final encryptedData = base64Decode(encryptedPrivateKey); + + // Разделяем nonce и зашифрованные данные + final nonce = encryptedData.sublist(0, 12); // GCM nonce = 12 bytes + final macBytes = encryptedData.sublist(12, 28); + final cipherText = encryptedData.sublist(28); + + final masterKey = await _deriveKeyFromPassword(masterPassword); + + final decrypted = await aesGcm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)), + secretKey: masterKey, + ); + + return base64Encode(decrypted); + } catch (e) { + throw Exception('Неверный мастер-пароль или поврежденные данные'); + } + } + + Future _deriveKeyFromPassword(String password) async { + final pbkdf2 = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: 10000, + bits: 256, + ); + + final salt = utf8.encode('chepuhagram_salt'); + return await pbkdf2.deriveKeyFromPassword(password: password, nonce: salt); + } + + Future deriveSharedSecret( + String myPrivateKeyBase64, + String theirPublicKeyBase64, + ) async { + final myKeyPair = await algorithm.newKeyPairFromSeed(base64Decode(myPrivateKeyBase64)); + final theirPublicKey = SimplePublicKey( + base64Decode(theirPublicKeyBase64), + type: KeyPairType.x25519, + ); + + return await algorithm.sharedSecretKey( + keyPair: myKeyPair, + remotePublicKey: theirPublicKey, + ); + } + + Future encryptMessage(String text, SecretKey sharedKey) async { + final nonce = aesGcm.newNonce(); + final encrypted = await aesGcm.encrypt( + utf8.encode(text), + secretKey: sharedKey, + nonce: nonce, + ); + // Сохраняем Nonce + MAC + CipherText для передачи + return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText); + } + + Future decryptMessage(String base64Data, SecretKey sharedKey) async { + final data = base64Decode(base64Data); + + final nonce = data.sublist(0, 12); + final mac = data.sublist(12, 28); + final cipherText = data.sublist(28); + + final decrypted = await aesGcm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: Mac(mac)), + secretKey: sharedKey, + ); + + return utf8.decode(decrypted); + } + + Future getPrivateKey() async { + return await _storage.read(key: 'private_key'); + } + + Future hasPrivateKey() async { + final key = await _storage.read(key: 'private_key'); + return key != null; + } + + Future savePrivateKey(String privateKey) async { + await _storage.write(key: 'private_key', value: privateKey); + } + + Future deletePrivateKey() async { + await _storage.delete(key: 'private_key'); + } +} diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 97bb602..2ce3513 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -5,6 +5,7 @@ import '/core/constants.dart'; import 'package:http/http.dart' as http; import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart'; +import 'package:chepuhagram/domain/services/crypto_service.dart'; class AuthProvider extends ChangeNotifier { bool _isLoading = false; @@ -13,21 +14,30 @@ class AuthProvider extends ChangeNotifier { int? _currentUserId; int? get currentUserId => _currentUserId; + // Флаги для определения пути пользователя + bool _needsSetup = false; + bool get needsSetup => _needsSetup; + + bool _needsKeyRecovery = false; + bool get needsKeyRecovery => _needsKeyRecovery; + + bool _hasPublicKeyOnServer = false; + bool get hasPublicKeyOnServer => _hasPublicKeyOnServer; + final _storage = const FlutterSecureStorage(); final _client = http.Client(); final ApiService _apiService = ApiService(); final SocketService _socketService = SocketService(); + final CryptoService _cryptoService = CryptoService(); Future initRealtime() async { - final token = await _apiService.getAccessToken(); - if (token != null) { - _socketService.connect(token); - } + await _socketService.connect(_apiService); } void closeRealtime() { _socketService.disconnect(); } + SocketService get socketService => _socketService; Future login(String username, String password) async { @@ -58,6 +68,9 @@ class AuthProvider extends ChangeNotifier { ); _currentUserId = decodedResponse['user_id']; + // Проверяем статус аккаунта (нужна ли настройка или восстановление) + await _checkAccountStatus(); + _isLoading = false; notifyListeners(); return true; @@ -91,6 +104,12 @@ class AuthProvider extends ChangeNotifier { final token = await _apiService.getAccessToken(); if (token == null) return false; + // Загружаем currentUserId из хранилища + final userIdStr = await _storage.read(key: 'user_id'); + if (userIdStr != null) { + _currentUserId = int.tryParse(userIdStr); + } + try { final response = await _client .get( @@ -100,20 +119,112 @@ class AuthProvider extends ChangeNotifier { .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { + // Проверяем статус аккаунта для определения дальнейшего пути + await _checkAccountStatus(); return true; } else if (response.statusCode == 401) { bool isUpdated = await _apiService.refreshToken(); + if (isUpdated) { + // После обновления токена проверяем статус + await _checkAccountStatus(); + } return isUpdated; } else { - // Если токен протух (401), чистим память - //await logout(); return false; } } catch (e) { - // Если сервер недоступен (ошибка сети), - // в мессенджерах обычно всё равно пускают в приложение (offline mode), - // но для простоты сейчас вернем true, если токен физически есть. + // Если сервер недоступен, позволяем offline mode return true; } } + + Future updateProfileAndSecurity({ + required String firstName, + String? lastName, + required String masterPassword, + }) async { + notifyListeners(); + + try { + final token = await _apiService.getAccessToken(); + + // Генерируем ключи и шифруем приватный + final keys = await _cryptoService.initAccountSecurity(masterPassword); + + final response = await _client.post( + Uri.http(AppConstants.baseUrl, 'auth/setup-account'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'first_name': firstName, + 'last_name': lastName, + 'public_key': keys['public_key'], + 'encrypted_private_key': keys['encrypted_private_key'], + }), + ); + + if (response.statusCode == 200) { + _needsSetup = false; + notifyListeners(); + return true; + } else { + print("Ошибка настройки профиля: ${response.body}"); + return false; + } + } catch (e) { + print("Ошибка сети: $e"); + return false; + } finally { + notifyListeners(); + } + } + + // Приватный метод для проверки статуса аккаунта + Future _checkAccountStatus() async { + try { + final token = await _apiService.getAccessToken(); + final response = await _client.get( + Uri.http(AppConstants.baseUrl, 'users/me'), + headers: {'Authorization': 'Bearer $token'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + + // Проверяем наличие публичного ключа на сервере + _hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty; + + // Проверяем наличие приватного ключа локально + final hasLocalPrivateKey = await _storage.read(key: 'private_key') != null; + + if (!_hasPublicKeyOnServer) { + // Путь А: Первая настройка - нужно создать ключи и профиль + _needsSetup = true; + _needsKeyRecovery = false; + } else if (!hasLocalPrivateKey) { + // Путь В: Переустановка - ключ на сервере, но его нет локально + _needsKeyRecovery = true; + _needsSetup = false; + } else { + // Путь Б: Нормальный вход - все в порядке + _needsSetup = false; + _needsKeyRecovery = false; + } + } + } catch (e) { + print("Ошибка проверки статуса: $e"); + _needsSetup = false; + _needsKeyRecovery = false; + } + notifyListeners(); + } + + // Метод для начала с чистого листа (новые ключи) + Future resetKeys() async { + await _storage.delete(key: 'private_key'); + _needsKeyRecovery = false; + notifyListeners(); + } } diff --git a/lib/logic/contact_provider.dart b/lib/logic/contact_provider.dart index ba5d38e..23ad63e 100644 --- a/lib/logic/contact_provider.dart +++ b/lib/logic/contact_provider.dart @@ -5,11 +5,13 @@ import '/data/repositories/contact_repository.dart'; class ContactProvider extends ChangeNotifier { final ContactRepository _repository = ContactRepository(); List _contacts = []; + List _allContacts = []; bool _isLoading = false; String? _error; int? _currentUserId; List get contacts => _contacts; + List get allContacts => _allContacts; bool get isLoading => _isLoading; String? get error => _error; @@ -25,7 +27,27 @@ class ContactProvider extends ChangeNotifier { try { final allContacts = await _repository.fetchContacts(); + // Фильтруем: исключаем себя (для основного списка - только чаты) _contacts = allContacts.where((contact) => contact.id != _currentUserId).toList(); + _allContacts = _contacts; + } catch (e) { + _error = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // Метод для получения всех контактов (исключая себя) для нового чата + Future loadAllContactsForNewChat() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final allContacts = await _repository.fetchContacts(); + // Фильтруем только исключение самого себя + _allContacts = allContacts.where((contact) => contact.id != _currentUserId).toList(); } catch (e) { _error = e.toString(); } finally { diff --git a/lib/presentation/screens/account_setup_screen.dart b/lib/presentation/screens/account_setup_screen.dart new file mode 100644 index 0000000..c56c521 --- /dev/null +++ b/lib/presentation/screens/account_setup_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../logic/auth_provider.dart'; +import 'contacts_screen.dart'; + +class AccountSetupScreen extends StatefulWidget { + const AccountSetupScreen({super.key}); + + @override + State createState() => _AccountSetupScreenState(); +} + +class _AccountSetupScreenState extends State { + final _formKey = GlobalKey(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _masterPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _masterPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _setupAccount() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final authProvider = context.read(); + + // Отправляем данные на сервер с мастер-паролем + final success = await authProvider.updateProfileAndSecurity( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + masterPassword: _masterPasswordController.text, + ); + + if (success && mounted) { + // Переходим на экран контактов + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } else if (mounted) { + setState(() { + _errorMessage = 'Ошибка при сохранении профиля. Попробуйте еще раз.'; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Ошибка: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Завершение настройки'), + centerTitle: true, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + Text( + 'Завершите настройку вашего профиля', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Введите ваше имя, фамилию и создайте мастер-пароль. Мастер-пароль будет использоваться для защиты ваших ключей шифрования.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + const SizedBox(height: 32), + + // Поле Имя + TextFormField( + controller: _firstNameController, + decoration: InputDecoration( + labelText: 'Имя *', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + hintText: 'Введите ваше имя', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Имя не может быть пустым'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Поле Фамилия + TextFormField( + controller: _lastNameController, + decoration: InputDecoration( + labelText: 'Фамилия', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + hintText: 'Введите вашу фамилию (опционально)', + ), + ), + const SizedBox(height: 16), + + // Поле Мастер-пароль + TextFormField( + controller: _masterPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: 'Мастер-пароль *', + prefixIcon: const Icon(Icons.lock_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + hintText: 'Создайте надежный пароль', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Мастер-пароль не может быть пустым'; + } + if (value.length < 8) { + return 'Пароль должен содержать минимум 8 символов'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Поле Подтверждение пароля + TextFormField( + controller: _confirmPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: 'Подтвердите пароль *', + prefixIcon: const Icon(Icons.lock_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + hintText: 'Повторите пароль', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Подтвердите пароль'; + } + if (value != _masterPasswordController.text) { + return 'Пароли не совпадают'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Сообщение об ошибке + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: 24), + + // Кнопка подтверждения + ElevatedButton( + onPressed: _isLoading ? null : _setupAccount, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + 'Завершить настройку', + style: TextStyle(fontSize: 16), + ), + ), + + const SizedBox(height: 24), + Text( + 'Сохраните мастер-пароль в надежном месте. Он потребуется для восстановления ключей шифрования при переустановке приложения.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 805720b..30eae83 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../widgets/contact_tile.dart'; -import '/data/models/contact_model.dart'; import '../screens/settings_screen.dart'; import '../screens/new_chat_screen.dart'; import '../screens/chat_screen.dart'; @@ -22,6 +21,8 @@ class _ContactsScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { final authProvider = context.read(); final contactProvider = context.read(); + + // Установить текущего пользователя и загрузить контакты с сообщениями contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.loadContacts(); }); diff --git a/lib/presentation/screens/key_recovery_screen.dart b/lib/presentation/screens/key_recovery_screen.dart new file mode 100644 index 0000000..83d83a9 --- /dev/null +++ b/lib/presentation/screens/key_recovery_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../logic/auth_provider.dart'; +import '../../domain/services/crypto_service.dart'; +import '../../domain/services/api_service.dart'; +import 'account_setup_screen.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../../core/constants.dart'; + +class KeyRecoveryScreen extends StatefulWidget { + const KeyRecoveryScreen({super.key}); + + @override + State createState() => _KeyRecoveryScreenState(); +} + +class _KeyRecoveryScreenState extends State { + bool _isLoading = false; + String? _errorMessage; + final _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + + Future _startFresh() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final authProvider = context.read(); + + // Удаляем старые ключи и создаем новые + await authProvider.resetKeys(); + + // Переходим на экран настройки для создания новых ключей + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const AccountSetupScreen()), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Ошибка: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + Future _recoverKeys() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final authProvider = context.read(); + final apiService = ApiService(); + final cryptoService = CryptoService(); + + // Получаем токен + final token = await apiService.getAccessToken(); + if (token == null) throw Exception('Не авторизован'); + + // Скачиваем зашифрованный приватный ключ с сервера + final response = await http.get( + Uri.http(AppConstants.baseUrl, 'users/me'), + headers: {'Authorization': 'Bearer $token'}, + ); + + if (response.statusCode != 200) { + throw Exception('Не удалось получить данные пользователя'); + } + + final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + final encryptedPrivateKey = data['encrypted_private_key']; + + if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) { + throw Exception('Зашифрованный ключ не найден на сервере'); + } + + // Расшифровываем приватный ключ + final decryptedPrivateKey = await cryptoService.decryptPrivateKey( + encryptedPrivateKey, + _passwordController.text, + ); + + // Сохраняем расшифрованный ключ локально + await cryptoService.savePrivateKey(decryptedPrivateKey); + + // Обновляем статус в AuthProvider + await authProvider.tryAutoLogin(); + + if (mounted) { + // Возвращаемся на главный экран + Navigator.of(context).popUntil((route) => route.isFirst); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Ошибка восстановления: ${e.toString().replaceAll('Exception: ', '')}'; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Восстановление ключей'), + centerTitle: true, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + Icon( + Icons.security_outlined, + size: 80, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Восстановление ключей шифрования', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Text( + 'Вы переустановили приложение или используете новый девайс. У вас есть два варианта:', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 32), + + // Вариант 1: Начать с чистого листа + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.restart_alt_outlined, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Начать с чистого листа', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Создаются новые ключи шифрования. Старые сообщения не будут расшифрованы.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _startFresh, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Продолжить'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Вариант 2: Восстановить из облака + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.cloud_download_outlined, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Восстановить из облака', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Введите мастер-пароль для восстановления ключей шифрования', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Мастер-пароль', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Введите мастер-пароль'; + } + return null; + }, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _recoverKeys, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Восстановить'), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 24), + + // Сообщение об ошибке + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } +} diff --git a/lib/presentation/screens/login_screen.dart b/lib/presentation/screens/login_screen.dart index e944ab9..0e300a5 100644 --- a/lib/presentation/screens/login_screen.dart +++ b/lib/presentation/screens/login_screen.dart @@ -1,4 +1,6 @@ import 'package:chepuhagram/presentation/screens/contacts_screen.dart'; +import 'package:chepuhagram/presentation/screens/account_setup_screen.dart'; +import 'package:chepuhagram/presentation/screens/key_recovery_screen.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../logic/auth_provider.dart'; @@ -121,10 +123,27 @@ class _LoginScreenState extends State { ); if (success && mounted) { await authProvider.initRealtime(); - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const ContactsScreen()), - ); + + // Определяем путь пользователя после входа + if (authProvider.needsSetup) { + // Путь А: Первичная настройка + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const AccountSetupScreen()), + ); + } else if (authProvider.needsKeyRecovery) { + // Путь В: Восстановление ключей + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const KeyRecoveryScreen()), + ); + } else { + // Путь Б: Нормальный вход + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/presentation/screens/new_chat_screen.dart b/lib/presentation/screens/new_chat_screen.dart index b8524ec..7489e20 100644 --- a/lib/presentation/screens/new_chat_screen.dart +++ b/lib/presentation/screens/new_chat_screen.dart @@ -5,17 +5,30 @@ import '/logic/contact_provider.dart'; import '/logic/auth_provider.dart'; import 'chat_screen.dart'; -class NewChatScreen extends StatelessWidget { +class NewChatScreen extends StatefulWidget { const NewChatScreen({super.key}); @override - Widget build(BuildContext context) { - final authProvider = context.watch(); - final contactProvider = context.watch(); + State createState() => _NewChatScreenState(); +} - final filteredContacts = contactProvider.contacts - .where((contact) => contact.id != authProvider.currentUserId) - .toList(); +class _NewChatScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final authProvider = context.read(); + final contactProvider = context.read(); + + // Установить текущего пользователя и загрузить все контакты + contactProvider.setCurrentUserId(authProvider.currentUserId); + contactProvider.loadAllContactsForNewChat(); + }); + } + + @override + Widget build(BuildContext context) { + final contactProvider = context.watch(); return Scaffold( appBar: AppBar( @@ -26,9 +39,9 @@ class NewChatScreen extends StatelessWidget { : contactProvider.error != null ? Center(child: Text('Error: ${contactProvider.error}')) : ListView.builder( - itemCount: filteredContacts.length, + itemCount: contactProvider.allContacts.length, itemBuilder: (context, index) { - final contact = filteredContacts[index]; + final contact = contactProvider.allContacts[index]; return ListTile( leading: CircleAvatar( child: Text(contact.name[0]), diff --git a/lib/presentation/screens/splash_screen.dart b/lib/presentation/screens/splash_screen.dart index 0d33c35..9d534f1 100644 --- a/lib/presentation/screens/splash_screen.dart +++ b/lib/presentation/screens/splash_screen.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import '../../logic/auth_provider.dart'; import 'login_screen.dart'; import 'contacts_screen.dart'; +import 'account_setup_screen.dart'; +import 'key_recovery_screen.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -19,7 +21,7 @@ class _SplashScreenState extends State { } Future _initializeApp() async { - // 1. Искусственная задержка в 2 секунды + // 1. Искусственная задержка в 2 секунды для демонстрации splash await Future.delayed(const Duration(seconds: 2)); if (!mounted) return; @@ -30,14 +32,32 @@ class _SplashScreenState extends State { if (!mounted) return; - // 3. Навигация в зависимости от результата + // 3. Навигация в зависимости от результата и статуса аккаунта if (isLoggedIn) { - await authProvider.initRealtime(); // Запускаем сокет сразу - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const ContactsScreen()), - ); + await authProvider.initRealtime(); // Запускаем WebSocket сразу + + // Определяем путь пользователя + if (authProvider.needsSetup) { + // Путь А: Первичная настройка + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const AccountSetupScreen()), + ); + } else if (authProvider.needsKeyRecovery) { + // Путь В: Восстановление ключей + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const KeyRecoveryScreen()), + ); + } else { + // Путь Б: Нормальный вход в контакты + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } } else { + // Нет токена - переходим на экран входа Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const LoginScreen()), diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart index 84c2ee7..6ccb0f9 100644 --- a/lib/presentation/widgets/contact_tile.dart +++ b/lib/presentation/widgets/contact_tile.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import '../../core/app_colors.dart'; -import '../screens/chat_screen.dart'; import '/data/models/contact_model.dart'; class ContactTile extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index b4ae71f..d9bdf0b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" cupertino_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4920b65..4b7842d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: flutter_secure_storage: ^10.0.0 jwt_decoder: ^2.0.1 web_socket_channel: ^3.0.3 + cryptography: ^2.5.0 dev_dependencies: flutter_test: diff --git a/srv/alembic/env.py b/srv/alembic/env.py index 0706744..339c5b3 100644 --- a/srv/alembic/env.py +++ b/srv/alembic/env.py @@ -1,3 +1,4 @@ +from app.db.models import Base from logging.config import fileConfig from sqlalchemy import engine_from_config @@ -16,7 +17,6 @@ if config.config_file_name is not None: # add your model's MetaData object here # for 'autogenerate' support -from app.db.models import Base target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, @@ -64,7 +64,8 @@ def run_migrations_online() -> None: with connectable.connect() as connection: context.configure( - connection=connection, target_metadata=target_metadata + connection=connection, target_metadata=target_metadata, + render_as_batch=True ) with context.begin_transaction(): diff --git a/srv/alembic/versions/4228f07bd5ad_initial_with_new_columns.py b/srv/alembic/versions/4228f07bd5ad_initial_with_new_columns.py deleted file mode 100644 index b06a9e6..0000000 --- a/srv/alembic/versions/4228f07bd5ad_initial_with_new_columns.py +++ /dev/null @@ -1,42 +0,0 @@ -"""initial_with_new_columns - -Revision ID: 4228f07bd5ad -Revises: -Create Date: 2026-04-19 01:58:07.533812 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '4228f07bd5ad' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('first_name', sa.String(length=50), server_default='User', nullable=False)) - op.add_column('users', sa.Column('last_name', sa.String(length=50), nullable=True)) - op.add_column('users', sa.Column('about', sa.String(), nullable=True)) - op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=False)) - op.add_column('users', sa.Column('totp_secret', sa.String(length=32), nullable=True)) - op.create_unique_constraint(None, 'users', ['phone']) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'users', type_='unique') - op.drop_column('users', 'totp_secret') - op.drop_column('users', 'phone') - op.drop_column('users', 'about') - op.drop_column('users', 'last_name') - op.drop_column('users', 'first_name') - # ### end Alembic commands ### diff --git a/srv/alembic/versions/8eed4a873add_initial_with_new_columns.py b/srv/alembic/versions/4e1aa78f81c6_add_encrypted_private_key.py similarity index 55% rename from srv/alembic/versions/8eed4a873add_initial_with_new_columns.py rename to srv/alembic/versions/4e1aa78f81c6_add_encrypted_private_key.py index f8f5962..bb0801d 100644 --- a/srv/alembic/versions/8eed4a873add_initial_with_new_columns.py +++ b/srv/alembic/versions/4e1aa78f81c6_add_encrypted_private_key.py @@ -1,8 +1,8 @@ -"""initial_with_new_columns +"""add encrypted private key -Revision ID: 8eed4a873add +Revision ID: 4e1aa78f81c6 Revises: -Create Date: 2026-04-19 01:59:16.030461 +Create Date: 2026-04-24 18:17:13.010993 """ from typing import Sequence, Union @@ -12,7 +12,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '8eed4a873add' +revision: str = '4e1aa78f81c6' down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,16 +21,16 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=True)) - op.add_column('users', sa.Column('totp_secret', sa.String(length=32), nullable=True)) - op.create_unique_constraint(None, 'users', ['phone']) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_unique_constraint('uq_user_phone', ['phone']) + # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'users', type_='unique') - op.drop_column('users', 'totp_secret') - op.drop_column('users', 'phone') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + # ### end Alembic commands ### diff --git a/srv/app/api/endpoints/auth.py b/srv/app/api/endpoints/auth.py index 3c79ea2..5369be4 100644 --- a/srv/app/api/endpoints/auth.py +++ b/srv/app/api/endpoints/auth.py @@ -2,17 +2,21 @@ from fastapi import FastAPI, Depends, HTTPException, status, APIRouter from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.core import security +from app.api import schemas from app.db import models from jose import JWTError, jwt +from app.core.security import get_current_user # бд + + def get_db(): db = models.SessionLocal() try: yield db finally: db.close() - + authRouter = APIRouter( prefix="/auth", @@ -20,53 +24,85 @@ authRouter = APIRouter( ) # регистрация + + @authRouter.post("/register") async def register(username: str, password: str, db: Session = Depends(get_db)): if len(password.encode('utf-8')) > 72: - raise HTTPException(status_code=400, detail="Пароль слишком длинный (макс. 72 байта)") - - db_user = db.query(models.User).filter(models.User.username == username).first() + raise HTTPException( + status_code=400, detail="Пароль слишком длинный (макс. 72 байта)") + + db_user = db.query(models.User).filter( + models.User.username == username).first() if db_user: - raise HTTPException(status_code=400, detail="Пользователь уже существует") - + raise HTTPException( + status_code=400, detail="Пользователь уже существует") + hashed_pwd = security.get_password_hash(password) new_user = models.User(username=username, hashed_password=hashed_pwd) db.add(new_user) db.commit() return {"status": "ok", "message": "User created"} + +@authRouter.post("/hash") +async def register(password: str): + if len(password.encode('utf-8')) > 72: + raise HTTPException( + status_code=400, detail="Пароль слишком длинный (макс. 72 байта)") + + hashed_pwd = security.get_password_hash(password) + return {"password": hashed_pwd} + # вход + + @authRouter.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): - user = db.query(models.User).filter(models.User.username == form_data.username).first() - + user = db.query(models.User).filter( + models.User.username == form_data.username).first() + if not user or not security.verify_password(form_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль", headers={"WWW-Authenticate": "Bearer"}, ) - + access_token = security.create_access_token(data={"sub": str(user.id)}) refresh_token = security.create_refresh_token(data={"sub": str(user.id)}) return { - "access_token": access_token, - "refresh_token": refresh_token, + "access_token": access_token, + "refresh_token": refresh_token, "token_type": "bearer", "user_id": user.id } - + + @authRouter.post("/refresh") -async def refresh_token(data: models.RefreshRequest, db: Session = Depends(get_db)): +async def refresh_token(data: schemas.RefreshRequest): try: - payload = jwt.decode(data.refresh_token, security.SECRET_KEY, algorithms=[security.ALGORITHM]) - user_id = payload.get("sub") + payload = jwt.decode(data.refresh_token, security.SECRET_KEY, algorithms=[ + security.ALGORITHM]) + user_id = str(payload.get("sub")) if user_id is None: raise HTTPException(status_code=401) - - + new_access_token = security.create_access_token(data={"sub": user_id}) - new_refresh_token = security.create_refresh_token(data={"sub": user_id}) + new_refresh_token = security.create_refresh_token( + data={"sub": user_id}) return {"refresh_token": new_refresh_token, "access_token": new_access_token, "token_type": "bearer"} except JWTError: - raise HTTPException(status_code=401, detail="Refresh token expired") \ No newline at end of file + raise HTTPException(status_code=401, detail="Refresh token expired") + + +@authRouter.post("/setup-account") +async def setup_account(data: schemas.SetupAccount, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): + user_to_update = db.merge(current_user) + user_to_update.first_name = data.first_name + user_to_update.last_name = data.last_name + user_to_update.public_key = data.public_key + user_to_update.encrypted_private_key = data.encrypted_private_key + db.commit() + db.refresh(user_to_update) + return {"status": "ok", "message": "Account setup completed"} \ No newline at end of file diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py index 7c2c45d..51e87f9 100644 --- a/srv/app/api/endpoints/users.py +++ b/srv/app/api/endpoints/users.py @@ -21,9 +21,9 @@ usersRouter = APIRouter( # Пример защищенного роута @usersRouter.get("/me") async def read_users_me(current_user: models.User = Depends(get_current_user)): - return {"id": current_user.id, "username": current_user.username} + return {"id": current_user.id, "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name, "public_key": current_user.public_key, "encrypted_private_key": current_user.encrypted_private_key} @usersRouter.get("/all") async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): users = db.query(models.User).all() - return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip()} for user in users] \ No newline at end of file + return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users] \ No newline at end of file diff --git a/srv/app/api/schemas.py b/srv/app/api/schemas.py new file mode 100644 index 0000000..c51496a --- /dev/null +++ b/srv/app/api/schemas.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + +class SetPublicKey(BaseModel): + public_key: str + +class RefreshRequest(BaseModel): + refresh_token: str + +class SetupAccount(BaseModel): + first_name: str + last_name: str + public_key: str + encrypted_private_key: str \ No newline at end of file diff --git a/srv/app/core/security.py b/srv/app/core/security.py index b78d399..db039a2 100644 --- a/srv/app/core/security.py +++ b/srv/app/core/security.py @@ -24,7 +24,7 @@ def get_db(): db = models.SessionLocal() try: yield db - finally: + finally: db.close() def verify_password(plain_password, hashed_password): diff --git a/srv/app/db/models.py b/srv/app/db/models.py index 55e40b7..d9d36d1 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -1,7 +1,6 @@ from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -from pydantic import BaseModel SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db" engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) @@ -20,8 +19,6 @@ class User(Base): totp_secret = Column(String(32), nullable=True) hashed_password = Column(String) public_key = Column(String, nullable=True) - -class RefreshRequest(BaseModel): - refresh_token: str + encrypted_private_key = Column(String, nullable=True) Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/srv/app/services/crypto_verify.py b/srv/app/services/crypto_service.py similarity index 100% rename from srv/app/services/crypto_verify.py rename to srv/app/services/crypto_service.py