Добавлен пароль расшифровки сообщений
This commit is contained in:
parent
275dd2e024
commit
8a0a237e18
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import 'dart:convert';
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LoginScreen> {
|
|||
);
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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(); // Запускаем сокет сразу
|
||||
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()),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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")
|
||||
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"}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -24,7 +24,7 @@ def get_db():
|
|||
db = models.SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue