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

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:async';
import 'dart:convert'; 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/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status; import 'package:web_socket_channel/status.dart' as status;
import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/core/constants.dart';
@ -12,7 +13,8 @@ class SocketService {
// Поток, который будут слушать провайдеры // Поток, который будут слушать провайдеры
Stream<Map<String, dynamic>> get messages => _messageController.stream; 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; // Уже подключены if (_channel != null) return; // Уже подключены
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке // В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
@ -25,14 +27,14 @@ class SocketService {
final decoded = jsonDecode(data); final decoded = jsonDecode(data);
_messageController.add(decoded); _messageController.add(decoded);
}, },
onError: (error) => _reconnect(token), onError: (error) => _reconnect(apiService),
onDone: () => _reconnect(token), onDone: () => _reconnect(apiService),
); );
} }
void _reconnect(String token) { Future<void> _reconnect(ApiService apiService) async {
_channel = null; _channel = null;
Future.delayed(const Duration(seconds: 5), () => connect(token)); Future.delayed(const Duration(seconds: 5), () => connect(apiService));
} }
void sendMessage(Map<String, dynamic> data) { void sendMessage(Map<String, dynamic> data) {

View File

@ -8,7 +8,6 @@ import 'dart:convert';
class ApiService extends ChangeNotifier { class ApiService extends ChangeNotifier {
final _client = http.Client(); final _client = http.Client();
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
Future<bool> refreshToken() async { Future<bool> refreshToken() async {
notifyListeners(); notifyListeners();
@ -49,7 +48,8 @@ class ApiService extends ChangeNotifier {
String? token = await _storage.read(key: 'access_token'); String? token = await _storage.read(key: 'access_token');
if (token != null) { if (token != null) {
bool isExpiredSoon = JwtDecoder.isExpired(token) || bool isExpiredSoon =
JwtDecoder.isExpired(token) ||
JwtDecoder.getRemainingTime(token).inMinutes < 2; JwtDecoder.getRemainingTime(token).inMinutes < 2;
if (isExpiredSoon) { if (isExpiredSoon) {
@ -63,4 +63,31 @@ class ApiService extends ChangeNotifier {
} }
return token; 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:http/http.dart' as http;
import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
class AuthProvider extends ChangeNotifier { class AuthProvider extends ChangeNotifier {
bool _isLoading = false; bool _isLoading = false;
@ -13,21 +14,30 @@ class AuthProvider extends ChangeNotifier {
int? _currentUserId; int? _currentUserId;
int? get currentUserId => _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 _storage = const FlutterSecureStorage();
final _client = http.Client(); final _client = http.Client();
final ApiService _apiService = ApiService(); final ApiService _apiService = ApiService();
final SocketService _socketService = SocketService(); final SocketService _socketService = SocketService();
final CryptoService _cryptoService = CryptoService();
Future<void> initRealtime() async { Future<void> initRealtime() async {
final token = await _apiService.getAccessToken(); await _socketService.connect(_apiService);
if (token != null) {
_socketService.connect(token);
}
} }
void closeRealtime() { void closeRealtime() {
_socketService.disconnect(); _socketService.disconnect();
} }
SocketService get socketService => _socketService; SocketService get socketService => _socketService;
Future<bool> login(String username, String password) async { Future<bool> login(String username, String password) async {
@ -58,6 +68,9 @@ class AuthProvider extends ChangeNotifier {
); );
_currentUserId = decodedResponse['user_id']; _currentUserId = decodedResponse['user_id'];
// Проверяем статус аккаунта (нужна ли настройка или восстановление)
await _checkAccountStatus();
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return true; return true;
@ -91,6 +104,12 @@ class AuthProvider extends ChangeNotifier {
final token = await _apiService.getAccessToken(); final token = await _apiService.getAccessToken();
if (token == null) return false; if (token == null) return false;
// Загружаем currentUserId из хранилища
final userIdStr = await _storage.read(key: 'user_id');
if (userIdStr != null) {
_currentUserId = int.tryParse(userIdStr);
}
try { try {
final response = await _client final response = await _client
.get( .get(
@ -100,20 +119,112 @@ class AuthProvider extends ChangeNotifier {
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
if (response.statusCode == 200) { if (response.statusCode == 200) {
// Проверяем статус аккаунта для определения дальнейшего пути
await _checkAccountStatus();
return true; return true;
} else if (response.statusCode == 401) { } else if (response.statusCode == 401) {
bool isUpdated = await _apiService.refreshToken(); bool isUpdated = await _apiService.refreshToken();
if (isUpdated) {
// После обновления токена проверяем статус
await _checkAccountStatus();
}
return isUpdated; return isUpdated;
} else { } else {
// Если токен протух (401), чистим память
//await logout();
return false; return false;
} }
} catch (e) { } catch (e) {
// Если сервер недоступен (ошибка сети), // Если сервер недоступен, позволяем offline mode
// в мессенджерах обычно всё равно пускают в приложение (offline mode),
// но для простоты сейчас вернем true, если токен физически есть.
return true; 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 { class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository(); final ContactRepository _repository = ContactRepository();
List<Contact> _contacts = []; List<Contact> _contacts = [];
List<Contact> _allContacts = [];
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
int? _currentUserId; int? _currentUserId;
List<Contact> get contacts => _contacts; List<Contact> get contacts => _contacts;
List<Contact> get allContacts => _allContacts;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get error => _error; String? get error => _error;
@ -25,7 +27,27 @@ class ContactProvider extends ChangeNotifier {
try { try {
final allContacts = await _repository.fetchContacts(); final allContacts = await _repository.fetchContacts();
// Фильтруем: исключаем себя (для основного списка - только чаты)
_contacts = allContacts.where((contact) => contact.id != _currentUserId).toList(); _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) { } catch (e) {
_error = e.toString(); _error = e.toString();
} finally { } 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../widgets/contact_tile.dart'; import '../widgets/contact_tile.dart';
import '/data/models/contact_model.dart';
import '../screens/settings_screen.dart'; import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart'; import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart'; import '../screens/chat_screen.dart';
@ -22,6 +21,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
// Установить текущего пользователя и загрузить контакты с сообщениями
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts(); 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/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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart'; import '../../logic/auth_provider.dart';
@ -121,10 +123,27 @@ class _LoginScreenState extends State<LoginScreen> {
); );
if (success && mounted) { if (success && mounted) {
await authProvider.initRealtime(); 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) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View File

@ -5,17 +5,30 @@ import '/logic/contact_provider.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
class NewChatScreen extends StatelessWidget { class NewChatScreen extends StatefulWidget {
const NewChatScreen({super.key}); const NewChatScreen({super.key});
@override @override
Widget build(BuildContext context) { State<NewChatScreen> createState() => _NewChatScreenState();
final authProvider = context.watch<AuthProvider>(); }
final contactProvider = context.watch<ContactProvider>();
final filteredContacts = contactProvider.contacts class _NewChatScreenState extends State<NewChatScreen> {
.where((contact) => contact.id != authProvider.currentUserId) @override
.toList(); 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -26,9 +39,9 @@ class NewChatScreen extends StatelessWidget {
: contactProvider.error != null : contactProvider.error != null
? Center(child: Text('Error: ${contactProvider.error}')) ? Center(child: Text('Error: ${contactProvider.error}'))
: ListView.builder( : ListView.builder(
itemCount: filteredContacts.length, itemCount: contactProvider.allContacts.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final contact = filteredContacts[index]; final contact = contactProvider.allContacts[index];
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
child: Text(contact.name[0]), child: Text(contact.name[0]),

View File

@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart'; import '../../logic/auth_provider.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'account_setup_screen.dart';
import 'key_recovery_screen.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -19,7 +21,7 @@ class _SplashScreenState extends State<SplashScreen> {
} }
Future<void> _initializeApp() async { Future<void> _initializeApp() async {
// 1. Искусственная задержка в 2 секунды // 1. Искусственная задержка в 2 секунды для демонстрации splash
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(seconds: 2));
if (!mounted) return; if (!mounted) return;
@ -30,14 +32,32 @@ class _SplashScreenState extends State<SplashScreen> {
if (!mounted) return; if (!mounted) return;
// 3. Навигация в зависимости от результата // 3. Навигация в зависимости от результата и статуса аккаунта
if (isLoggedIn) { if (isLoggedIn) {
await authProvider.initRealtime(); // Запускаем сокет сразу await authProvider.initRealtime(); // Запускаем WebSocket сразу
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()),
);
}
} else { } else {
// Нет токена - переходим на экран входа
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (_) => const LoginScreen()), MaterialPageRoute(builder: (_) => const LoginScreen()),

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../screens/chat_screen.dart';
import '/data/models/contact_model.dart'; import '/data/models/contact_model.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatelessWidget {

View File

@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -39,6 +39,7 @@ dependencies:
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
jwt_decoder: ^2.0.1 jwt_decoder: ^2.0.1
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
cryptography: ^2.5.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,3 +1,4 @@
from app.db.models import Base
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config 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 # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
from app.db.models import Base
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # 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: with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, target_metadata=target_metadata connection=connection, target_metadata=target_metadata,
render_as_batch=True
) )
with context.begin_transaction(): 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: Revises:
Create Date: 2026-04-19 01:59:16.030461 Create Date: 2026-04-24 18:17:13.010993
""" """
from typing import Sequence, Union from typing import Sequence, Union
@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '8eed4a873add' revision: str = '4e1aa78f81c6'
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: 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: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=True)) with op.batch_alter_table('users', schema=None) as batch_op:
op.add_column('users', sa.Column('totp_secret', sa.String(length=32), nullable=True)) batch_op.create_unique_constraint('uq_user_phone', ['phone'])
op.create_unique_constraint(None, 'users', ['phone'])
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='unique') with op.batch_alter_table('users', schema=None) as batch_op:
op.drop_column('users', 'totp_secret') batch_op.drop_constraint(None, type_='unique')
op.drop_column('users', 'phone')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -2,17 +2,21 @@ from fastapi import FastAPI, Depends, HTTPException, status, APIRouter
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import security from app.core import security
from app.api import schemas
from app.db import models from app.db import models
from jose import JWTError, jwt from jose import JWTError, jwt
from app.core.security import get_current_user
# бд # бд
def get_db(): def get_db():
db = models.SessionLocal() db = models.SessionLocal()
try: try:
yield db yield db
finally: finally:
db.close() db.close()
authRouter = APIRouter( authRouter = APIRouter(
prefix="/auth", prefix="/auth",
@ -20,53 +24,85 @@ authRouter = APIRouter(
) )
# регистрация # регистрация
@authRouter.post("/register") @authRouter.post("/register")
async def register(username: str, password: str, db: Session = Depends(get_db)): async def register(username: str, password: str, db: Session = Depends(get_db)):
if len(password.encode('utf-8')) > 72: 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: if db_user:
raise HTTPException(status_code=400, detail="Пользователь уже существует") raise HTTPException(
status_code=400, detail="Пользователь уже существует")
hashed_pwd = security.get_password_hash(password) hashed_pwd = security.get_password_hash(password)
new_user = models.User(username=username, hashed_password=hashed_pwd) new_user = models.User(username=username, hashed_password=hashed_pwd)
db.add(new_user) db.add(new_user)
db.commit() db.commit()
return {"status": "ok", "message": "User created"} 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") @authRouter.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): 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): if not user or not security.verify_password(form_data.password, user.hashed_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль", detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
access_token = security.create_access_token(data={"sub": str(user.id)}) access_token = security.create_access_token(data={"sub": str(user.id)})
refresh_token = security.create_refresh_token(data={"sub": str(user.id)}) refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
"token_type": "bearer", "token_type": "bearer",
"user_id": user.id "user_id": user.id
} }
@authRouter.post("/refresh") @authRouter.post("/refresh")
async def refresh_token(data: models.RefreshRequest, db: Session = Depends(get_db)): async def refresh_token(data: schemas.RefreshRequest):
try: try:
payload = jwt.decode(data.refresh_token, security.SECRET_KEY, algorithms=[security.ALGORITHM]) payload = jwt.decode(data.refresh_token, security.SECRET_KEY, algorithms=[
user_id = payload.get("sub") security.ALGORITHM])
user_id = str(payload.get("sub"))
if user_id is None: if user_id is None:
raise HTTPException(status_code=401) raise HTTPException(status_code=401)
new_access_token = security.create_access_token(data={"sub": user_id}) 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"} return {"refresh_token": new_refresh_token, "access_token": new_access_token, "token_type": "bearer"}
except JWTError: 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"}

View File

@ -21,9 +21,9 @@ usersRouter = APIRouter(
# Пример защищенного роута # Пример защищенного роута
@usersRouter.get("/me") @usersRouter.get("/me")
async def read_users_me(current_user: models.User = Depends(get_current_user)): 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") @usersRouter.get("/all")
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(models.User).all() 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

@ -24,7 +24,7 @@ def get_db():
db = models.SessionLocal() db = models.SessionLocal()
try: try:
yield db yield db
finally: finally:
db.close() db.close()
def verify_password(plain_password, hashed_password): def verify_password(plain_password, hashed_password):

View File

@ -1,7 +1,6 @@
from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from pydantic import BaseModel
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db" SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) 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) totp_secret = Column(String(32), nullable=True)
hashed_password = Column(String) hashed_password = Column(String)
public_key = Column(String, nullable=True) public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True)
class RefreshRequest(BaseModel):
refresh_token: str
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)