Добавлен пароль расшифровки сообщений

This commit is contained in:
Artur 2026-04-24 20:51:08 +05:00
parent 275dd2e024
commit 8a0a237e18
23 changed files with 1014 additions and 119 deletions

View File

@ -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<Map<String, dynamic>> get messages => _messageController.stream;
void connect(String token) {
Future<void> 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<void> _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<String, dynamic> data) {

View File

@ -9,7 +9,6 @@ class ApiService extends ChangeNotifier {
final _client = http.Client();
final _storage = const FlutterSecureStorage();
Future<bool> 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;
}
Future<bool> 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();
}
}
}

View File

@ -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<Map<String, String>> 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<String> 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<SecretKey> _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<SecretKey> 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<String> 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<String> 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<String?> getPrivateKey() async {
return await _storage.read(key: 'private_key');
}
Future<bool> hasPrivateKey() async {
final key = await _storage.read(key: 'private_key');
return key != null;
}
Future<void> savePrivateKey(String privateKey) async {
await _storage.write(key: 'private_key', value: privateKey);
}
Future<void> deletePrivateKey() async {
await _storage.delete(key: 'private_key');
}
}

View File

@ -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<void> 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<bool> 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<bool> 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<void> _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<void> resetKeys() async {
await _storage.delete(key: 'private_key');
_needsKeyRecovery = false;
notifyListeners();
}
}

View File

@ -5,11 +5,13 @@ import '/data/repositories/contact_repository.dart';
class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository();
List<Contact> _contacts = [];
List<Contact> _allContacts = [];
bool _isLoading = false;
String? _error;
int? _currentUserId;
List<Contact> get contacts => _contacts;
List<Contact> 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<void> 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 {

View File

@ -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<AccountSetupScreen> createState() => _AccountSetupScreenState();
}
class _AccountSetupScreenState extends State<AccountSetupScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _setupAccount() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final authProvider = context.read<AuthProvider>();
// Отправляем данные на сервер с мастер-паролем
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,
),
],
),
),
),
);
}
}

View File

@ -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<ContactsScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>();
// Установить текущего пользователя и загрузить контакты с сообщениями
contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts();
});

View File

@ -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<KeyRecoveryScreen> createState() => _KeyRecoveryScreenState();
}
class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
bool _isLoading = false;
String? _errorMessage;
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
Future<void> _startFresh() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final authProvider = context.read<AuthProvider>();
// Удаляем старые ключи и создаем новые
await authProvider.resetKeys();
// Переходим на экран настройки для создания новых ключей
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const AccountSetupScreen()),
);
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Ошибка: ${e.toString()}';
_isLoading = false;
});
}
}
}
Future<void> _recoverKeys() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final authProvider = context.read<AuthProvider>();
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();
}
}

View File

@ -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,11 +123,28 @@ class _LoginScreenState extends State<LoginScreen> {
);
if (success && mounted) {
await authProvider.initRealtime();
// Определяем путь пользователя после входа
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(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),

View File

@ -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<AuthProvider>();
final contactProvider = context.watch<ContactProvider>();
State<NewChatScreen> createState() => _NewChatScreenState();
}
final filteredContacts = contactProvider.contacts
.where((contact) => contact.id != authProvider.currentUserId)
.toList();
class _NewChatScreenState extends State<NewChatScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>();
// Установить текущего пользователя и загрузить все контакты
contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadAllContactsForNewChat();
});
}
@override
Widget build(BuildContext context) {
final contactProvider = context.watch<ContactProvider>();
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]),

View File

@ -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<SplashScreen> {
}
Future<void> _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<SplashScreen> {
if (!mounted) return;
// 3. Навигация в зависимости от результата
// 3. Навигация в зависимости от результата и статуса аккаунта
if (isLoggedIn) {
await authProvider.initRealtime(); // Запускаем сокет сразу
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()),

View File

@ -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 {

View File

@ -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:

View File

@ -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:

View File

@ -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():

View File

@ -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 ###

View File

@ -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 ###

View File

@ -2,10 +2,14 @@ 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:
@ -20,14 +24,19 @@ 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 байта)")
raise HTTPException(
status_code=400, detail="Пароль слишком длинный (макс. 72 байта)")
db_user = db.query(models.User).filter(models.User.username == username).first()
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)
@ -35,10 +44,23 @@ async def register(username: str, password: str, db: Session = Depends(get_db)):
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(
@ -56,17 +78,31 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
"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")
@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"}

View File

@ -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]
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]

13
srv/app/api/schemas.py Normal file
View File

@ -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

View File

@ -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)