22-06-2026+00-00

This commit is contained in:
Artur 2026-06-22 00:00:20 +05:00
parent 680771f75e
commit c3999db9eb
17 changed files with 1189 additions and 113 deletions

View File

@ -742,4 +742,32 @@ class ApiService extends ChangeNotifier {
);
return response.statusCode == 200;
}
Future<bool> approveQrLogin({
required String roomId,
required String phoneTempPub,
required String encPrivateKeyPayload,
required String deviceName,
}) async {
try {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/qr/approve'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'room_id': roomId,
'phone_temp_pub': phoneTempPub,
'enc_private_key_payload': encPrivateKeyPayload,
'device_name': deviceName,
}),
);
return response.statusCode == 200;
} catch (e) {
print('Ошибка при подтверждении QR входа: $e');
return false;
}
}
}

View File

@ -581,4 +581,64 @@ class CryptoService {
}
return _currentSharedKey!;
}
Future<SimpleKeyPair> generateTempKeyPair() async {
return await algorithm.newKeyPair();
}
Future<String> encryptPrivateKeyWithEcdh({
required SimpleKeyPair tempKeyPair,
required String tempRemotePublicKeyBase64,
required String privateKeyToEncrypt,
}) async {
final remotePublicKey = SimplePublicKey(
base64Decode(tempRemotePublicKeyBase64),
type: KeyPairType.x25519,
);
final sharedSecret = await algorithm.sharedSecretKey(
keyPair: tempKeyPair,
remotePublicKey: remotePublicKey,
);
final privateKeyBytes = base64Decode(privateKeyToEncrypt);
final nonce = aesGcm.newNonce();
final encrypted = await aesGcm.encrypt(
privateKeyBytes,
secretKey: sharedSecret,
nonce: nonce,
);
final encryptedData = nonce + encrypted.mac.bytes + encrypted.cipherText;
return base64Encode(encryptedData);
}
Future<String> decryptPrivateKeyWithEcdh({
required SimpleKeyPair tempKeyPair,
required String tempRemotePublicKeyBase64,
required String encryptedPayloadBase64,
}) async {
final remotePublicKey = SimplePublicKey(
base64Decode(tempRemotePublicKeyBase64),
type: KeyPairType.x25519,
);
final sharedSecret = await algorithm.sharedSecretKey(
keyPair: tempKeyPair,
remotePublicKey: remotePublicKey,
);
final encryptedData = base64Decode(encryptedPayloadBase64);
final nonce = encryptedData.sublist(0, 12);
final macBytes = encryptedData.sublist(12, 28);
final cipherText = encryptedData.sublist(28);
final decrypted = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)),
secretKey: sharedSecret,
);
return base64Encode(decrypted);
}
}

View File

@ -15,6 +15,7 @@ import 'package:provider/provider.dart';
import 'package:path/path.dart' as p;
import 'package:chepuhagram/data/models/session_model.dart';
import 'package:chepuhagram/presentation/screens/splash_screen.dart';
import 'package:chepuhagram/presentation/screens/login_screen.dart';
import 'package:chepuhagram/main.dart';
class AuthProvider extends ChangeNotifier {
@ -115,18 +116,53 @@ class AuthProvider extends ChangeNotifier {
}
Future<void> logout() async {
if (_isLoading) return;
_isLoading = true;
notifyListeners();
await ApiService().logoutCurrentUser();
// 1. Отзываем токен на сервере
try {
await ApiService().logoutCurrentUser();
} catch (e) {
print("Сервер не ответил на logout: $e");
}
// 1. Закрываем WebSocket connection
SocketService().disconnect();
// 2. Отключаем WebSocket connection
try {
SocketService().disconnect();
} catch (e) {
print("Ошибка при отключении сокета: $e");
}
String? mode;
String? color;
try {
mode = await _storage.read(key: 'theme_mode');
color = await _storage.read(key: 'accent_color');
} catch (e) {
print("Ошибка чтения настроек темы: $e");
}
try {
await _storage.deleteAll();
} catch (e) {
print("Ошибка очистки SecureStorage: $e");
// Пытаемся удалить хотя бы авторизационные токены
try {
await _storage.delete(key: 'access_token');
await _storage.delete(key: 'refresh_token');
await _storage.delete(key: 'private_key');
} catch (ex) {
print("Ошибка удаления отдельных токенов: $ex");
}
}
try {
CryptoService.clearCache();
} catch (e) {
print("Ошибка очистки кэша криптографии: $e");
}
final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color');
await _storage.deleteAll();
CryptoService.clearCache();
final context = navigatorKey.currentContext;
if (context != null) {
try {
@ -135,9 +171,20 @@ class AuthProvider extends ChangeNotifier {
print("Error clearing contact provider cache: $e");
}
}
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
await LocalDbService().clearDatabase();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
} catch (e) {
print("Ошибка очистки SharedPreferences: $e");
}
try {
await LocalDbService().clearDatabase();
} catch (e) {
print("Ошибка очистки локальной базы данных: $e");
}
_currentUserId = null;
_username = null;
_firstName = null;
@ -147,16 +194,19 @@ class AuthProvider extends ChangeNotifier {
_about = null;
_avatarPath = null;
_avatarUrl = null;
if (mode != null) {
await _storage.write(key: 'theme_mode', value: mode);
}
if (color != null) {
await _storage.write(key: 'accent_color', value: color);
try {
if (mode != null) {
await _storage.write(key: 'theme_mode', value: mode);
}
if (color != null) {
await _storage.write(key: 'accent_color', value: color);
}
} catch (e) {
print("Ошибка записи настроек темы: $e");
}
// 3. Уничтожаем локальную базу данных переписки (Drift)
// Поскольку у вас Drift открывает SqfliteQueryExecutor (local_db_service.dart),
// самый надежный способ физически удалить файл базы данных чепухаграма.
try {
final dbFolder = await databaseFactory.getDatabasesPath();
final dbFile = File(p.join(dbFolder, 'chat_app.db'));
@ -177,18 +227,17 @@ class AuthProvider extends ChangeNotifier {
await shmFile.delete();
}
} catch (e) {
print("Ошибка удаления файла БД: $e");
print("Ошибка удаления файлов БД: $e");
}
_currentUserId = null;
_isLoading = false;
notifyListeners();
// Перенаправляем напрямую на LoginScreen во избежание циклического автологина
navigatorKey.currentState?.pushAndRemoveUntil(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SplashScreen(),
const LoginScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
@ -197,19 +246,26 @@ class AuthProvider extends ChangeNotifier {
);
}
Future<void> fetchSessions() async {
_isLoading = true;
_error = null;
notifyListeners();
Future<void> fetchSessions({bool silent = false}) async {
if (!silent) {
_isLoading = true;
_error = null;
notifyListeners();
}
try {
final data = await _apiService.getActiveSessions();
print('Fetched sessions: $data');
_sessions = data.map((json) => Session.fromJson(json)).toList();
_error = null;
} catch (e) {
_error = 'Произошла ошибка при загрузке сессий: $e';
print(_error);
if (!silent) {
_error = 'Произошла ошибка при загрузке сессий: $e';
}
print('Ошибка при загрузке сессий: $e');
} finally {
_isLoading = false;
if (!silent) {
_isLoading = false;
}
notifyListeners();
}
}
@ -321,6 +377,33 @@ class AuthProvider extends ChangeNotifier {
}
}
Future<void> loginWithQrData({
required String accessToken,
required String refreshToken,
required String userId,
required String privateKey,
}) async {
_isLoading = true;
notifyListeners();
try {
await _storage.write(key: 'access_token', value: accessToken);
await _storage.write(key: 'refresh_token', value: refreshToken);
await _storage.write(key: 'user_id', value: userId);
await _storage.write(key: 'private_key', value: privateKey);
_currentUserId = int.tryParse(userId);
await _checkAccountStatus();
} catch (e) {
print("Ошибка авторизации по QR: $e");
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<bool> tryAutoLogin() async {
String? token;
try {

View File

@ -830,9 +830,10 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
final double bottomPadding = MediaQuery.of(
context,
).padding.bottom;
final double effectiveInputHeight = _currentContact.id == 0 ? 0.0 : inputBarHeight;
return ScrollablePositionedList.builder(
padding: EdgeInsets.only(
bottom: bottomPadding + inputBarHeight + 20.0,
bottom: bottomPadding + effectiveInputHeight + 20.0,
left: 8,
right: 8,
top: 8,
@ -4275,10 +4276,18 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
final positions = _itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
// 1. Логика подгрузки истории через индексы
// В reverse: true списке: индекс 0 это низ (новые), последний индекс верх (старые)
final firstVisible = positions.first.index;
final lastVisible = positions.last.index;
// Так как позиции в itemPositions приходят не отсортированными,
// находим фактический первый и последний видимый элемент
int firstVisible = positions.first.index;
int lastVisible = positions.first.index;
for (final pos in positions) {
if (pos.index < firstVisible) {
firstVisible = pos.index;
}
if (pos.index > lastVisible) {
lastVisible = pos.index;
}
}
// Если прокрутили вверх к старым сообщениям
if (lastVisible >= messages.length - 5) {
@ -4290,8 +4299,29 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
_loadNewerMessages();
}
// 2. Логика кнопки скролла вниз
_showScrollButtonNotifier.value = firstVisible > 5;
// 2. Логика кнопки скролла вниз:
// Показываем кнопку сразу, как только первый элемент (индекс 0) полностью ушел с экрана,
// либо если он виден, но его нижний край опустился ниже границы видимости (leadingEdge < -0.001)
bool showScrollButton = false;
ItemPosition? pos0;
for (final p in positions) {
if (p.index == 0) {
pos0 = p;
break;
}
}
if (pos0 == null) {
// Нижний элемент (индекс 0) больше не виден вообще
showScrollButton = true;
} else {
// Нижний элемент виден, но прокручен вниз за нижнюю границу (leadingEdge отрицательный)
if (pos0.itemLeadingEdge < -0.005) {
showScrollButton = true;
}
}
_showScrollButtonNotifier.value = showScrollButton;
}
Future<void> _scrollToBottom() async {

View File

@ -1107,46 +1107,50 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
curve: Curves.fastOutSlowIn,
width: railWidth,
color: colorScheme.surfaceVariant.withOpacity(0.12),
child: Column(
children: [
const SizedBox(height: 12),
IconButton(
icon: Icon(
_isLeftRailExpanded
? Icons.menu_open_rounded
: Icons.menu_rounded,
child: SafeArea(
top: true,
bottom: true,
child: Column(
children: [
const SizedBox(height: 12),
IconButton(
icon: Icon(
_isLeftRailExpanded
? Icons.menu_open_rounded
: Icons.menu_rounded,
),
onPressed: () =>
setState(() => _isLeftRailExpanded = !_isLeftRailExpanded),
),
onPressed: () =>
setState(() => _isLeftRailExpanded = !_isLeftRailExpanded),
),
const Divider(height: 24, indent: 12, endIndent: 12),
const Divider(height: 24, indent: 12, endIndent: 12),
_buildRailItem(
Icons.chat_bubble_outline_rounded,
Icons.chat_bubble_rounded,
"Чаты",
0,
onTap: () => setState(() => _currentIndex = 0),
),
_buildRailItem(
Icons.chat_bubble_outline_rounded,
Icons.chat_bubble_rounded,
"Чаты",
0,
onTap: () => setState(() => _currentIndex = 0),
),
const SizedBox(height: 8),
const Divider(height: 12, indent: 12, endIndent: 12),
const SizedBox(height: 8),
const Divider(height: 12, indent: 12, endIndent: 12),
_buildRailItem(
Icons.settings_outlined,
Icons.settings_rounded,
"Настройки",
1,
onTap: () => _showSettingsDialog(),
),
_buildRailItem(
Icons.person_outline_rounded,
Icons.person_rounded,
"Профиль",
2,
onTap: () => _showProfileDialog(),
),
],
_buildRailItem(
Icons.settings_outlined,
Icons.settings_rounded,
"Настройки",
1,
onTap: () => _showSettingsDialog(),
),
_buildRailItem(
Icons.person_outline_rounded,
Icons.person_rounded,
"Профиль",
2,
onTap: () => _showProfileDialog(),
),
],
),
),
);
}

View File

@ -2,6 +2,7 @@ import 'dart:ui';
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:chepuhagram/presentation/screens/qr_login_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../logic/auth_provider.dart';
@ -183,6 +184,28 @@ class _LoginScreenState extends State<LoginScreen>
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const QrLoginScreen(),
),
);
},
icon: const Icon(Icons.qr_code_scanner),
label: const Text(
"Войти по QR-коду",
style: TextStyle(fontWeight: FontWeight.w600),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
],
),
),

View File

@ -0,0 +1,283 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:cryptography/cryptography.dart';
import '../../core/constants.dart';
import '../../domain/services/crypto_service.dart';
import '../../domain/services/api_service.dart';
import '../../logic/auth_provider.dart';
import 'contacts_screen.dart';
class QrLoginScreen extends StatefulWidget {
const QrLoginScreen({super.key});
@override
State<QrLoginScreen> createState() => _QrLoginScreenState();
}
class _QrLoginScreenState extends State<QrLoginScreen> {
final _cryptoService = CryptoService();
String? _roomId;
SimpleKeyPair? _tempKeyPair;
String? _publicKeyBase64;
String? _deviceName;
WebSocketChannel? _channel;
bool _isInitializing = true;
String? _errorMessage;
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_initQrSession();
}
String _generateRandomRoomId() {
final rand = Random.secure();
final bytes = List<int>.generate(16, (i) => rand.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _initQrSession() async {
try {
setState(() {
_isInitializing = true;
_errorMessage = null;
});
// 1. Генерируем ID комнаты сопряжения
final roomId = _generateRandomRoomId();
// 2. Генерируем временные ключи X25519 для шифрования канала
final tempKeyPair = await _cryptoService.generateTempKeyPair();
final publicKey = await tempKeyPair.extractPublicKey();
final publicKeyBase64 = base64Encode(publicKey.bytes);
// Получаем имя текущего устройства
final apiService = ApiService();
final deviceName = await apiService.getDeviceName();
setState(() {
_roomId = roomId;
_tempKeyPair = tempKeyPair;
_publicKeyBase64 = publicKeyBase64;
_deviceName = deviceName;
_isInitializing = false;
});
// 3. Подключаемся к WebSocket серверу сопряжения
_connectWebSocket(roomId);
} catch (e) {
setState(() {
_isInitializing = false;
_errorMessage = 'Ошибка инициализации: $e';
});
}
}
void _connectWebSocket(String roomId) {
try {
final wsUri = Uri.parse('${AppConstants.wsUrl}/ws/qr?room_id=$roomId');
_channel = WebSocketChannel.connect(wsUri);
_subscription = _channel!.stream.listen(
(data) {
_handleWsMessage(data);
},
onError: (error) {
setState(() {
_errorMessage = 'Сбой подключения: $error. Переподключение...';
});
// Попытка переподключения через 3 секунды
Future.delayed(const Duration(seconds: 3), () {
if (mounted && _roomId == roomId) {
_connectWebSocket(roomId);
}
});
},
onDone: () {
print('QR WS соединение закрыто');
},
);
} catch (e) {
setState(() {
_errorMessage = 'Ошибка WebSocket: $e';
});
}
}
Future<void> _handleWsMessage(dynamic rawData) async {
try {
final data = jsonDecode(rawData) as Map<String, dynamic>;
if (data['type'] == 'qr_authorized') {
final accessToken = data['access_token'] as String;
final refreshToken = data['refresh_token'] as String;
final userId = data['user_id'].toString();
final phoneTempPub = data['phone_temp_pub'] as String;
final encPrivateKeyPayload = data['enc_private_key_payload'] as String;
if (_tempKeyPair == null) return;
// Расшифровываем основной E2EE private_key
final decryptedPrivateKey = await _cryptoService.decryptPrivateKeyWithEcdh(
tempKeyPair: _tempKeyPair!,
tempRemotePublicKeyBase64: phoneTempPub,
encryptedPayloadBase64: encPrivateKeyPayload,
);
// Завершаем авторизацию и сохраняем все данные
final authProvider = context.read<AuthProvider>();
await authProvider.loginWithQrData(
accessToken: accessToken,
refreshToken: refreshToken,
userId: userId,
privateKey: decryptedPrivateKey,
);
if (mounted) {
// Запускаем WebSocket клиент мессенджера
await authProvider.initRealtime();
// Перенаправляем на главный экран
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const ContactsScreen()),
(route) => false,
);
}
}
} catch (e) {
setState(() {
_errorMessage = 'Не удалось завершить авторизацию: $e';
});
}
}
@override
void dispose() {
_subscription?.cancel();
_channel?.sink.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Содержимое QR-кода
final encodedPublicKey = Uri.encodeComponent(_publicKeyBase64 ?? '');
final encodedDeviceName = Uri.encodeComponent(_deviceName ?? 'Новое устройство');
final qrData = 'chepuhagram:qr_login?room_id=$_roomId&public_key=$encodedPublicKey&device_name=$encodedDeviceName';
return Scaffold(
appBar: AppBar(
title: Text('Вход по QR-коду', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isInitializing)
const Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Генерация сессии авторизации...'),
],
)
else if (_errorMessage != null && _roomId == null)
Column(
children: [
Icon(Icons.error_outline, size: 60, color: colorScheme.error),
const SizedBox(height: 16),
Text(_errorMessage!, textAlign: TextAlign.center),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _initQrSession,
child: const Text('Повторить попытку'),
),
],
)
else
Column(
children: [
Text(
'Войти в Чепухаграм',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(height: 12),
const Text(
'Откройте Чепухаграм на вашем телефоне, зайдите в Настройки -> Устройства -> Подключить устройство и отсканируйте код.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 36),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: QrImageView(
data: qrData,
version: QrVersions.auto,
size: 260.0,
gapless: false,
),
),
const SizedBox(height: 36),
if (_errorMessage != null)
Text(
_errorMessage!,
style: TextStyle(color: colorScheme.error, fontSize: 13),
textAlign: TextAlign.center,
)
else
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text(
'Ожидание сканирования...',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,263 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:local_auth/local_auth.dart';
import '../../domain/services/crypto_service.dart';
import '../../domain/services/api_service.dart';
class QrScanScreen extends StatefulWidget {
const QrScanScreen({super.key});
@override
State<QrScanScreen> createState() => _QrScanScreenState();
}
class _QrScanScreenState extends State<QrScanScreen> {
final _mobileScannerController = MobileScannerController();
final _localAuth = LocalAuthentication();
final _cryptoService = CryptoService();
bool _isProcessing = false;
String? _statusMessage;
@override
void dispose() {
_mobileScannerController.dispose();
super.dispose();
}
Future<void> _handleQrDetected(BarcodeCapture capture) async {
if (_isProcessing) return;
final barcode = capture.barcodes.firstOrNull;
if (barcode == null || barcode.rawValue == null) return;
final scannedValue = barcode.rawValue!;
print('Сканирован QR: $scannedValue');
try {
final uri = Uri.parse(scannedValue);
if (uri.scheme != 'chepuhagram' || !scannedValue.contains('qr_login')) {
setState(() {
_statusMessage = 'Неверный формат QR-кода Чепухаграм';
});
return;
}
final roomId = uri.queryParameters['room_id'];
final tempPublicKeyBase64 = uri.queryParameters['public_key']?.replaceAll(' ', '+');
final connectingDeviceName = uri.queryParameters['device_name'];
if (roomId == null || tempPublicKeyBase64 == null) {
setState(() {
_statusMessage = 'Отсутствуют параметры сопряжения';
});
return;
}
// Нашли валидный QR, останавливаем сканер и начинаем обработку
setState(() {
_isProcessing = true;
_statusMessage = 'Обработка запроса сопряжения...';
});
_mobileScannerController.stop();
// Запрашиваем подтверждение
final confirmed = await _showConfirmDialog(context);
if (!confirmed) {
_resetScanner();
return;
}
// Биометрическая аутентификация с ручным фолбеком при ошибке/отсутствии настроек
bool authenticated = false;
try {
authenticated = await _localAuth.authenticate(
localizedReason: 'Подтвердите личность для сопряжения нового устройства',
options: const AuthenticationOptions(
biometricOnly: false,
useErrorDialogs: true,
stickyAuth: false,
),
);
} catch (e) {
print('Исключение local_auth, пробуем ручное подтверждение: $e');
authenticated = await _showManualConfirmationDialog(context);
}
if (!authenticated) {
_resetScanner();
return;
}
// 1. Извлекаем локальный закрытый E2EE-ключ
final privateKey = await _cryptoService.getPrivateKey();
if (privateKey == null) {
throw Exception('Локальный закрытый ключ шифрования не найден');
}
// 2. Генерируем свои временные ключи сопряжения
final phoneKeyPair = await _cryptoService.generateTempKeyPair();
final phonePublicKey = await phoneKeyPair.extractPublicKey();
final phonePublicKeyBase64 = base64Encode(phonePublicKey.bytes);
// 3. Шифруем приватный ключ на временном общем секрете
final encPayload = await _cryptoService.encryptPrivateKeyWithEcdh(
tempKeyPair: phoneKeyPair,
tempRemotePublicKeyBase64: tempPublicKeyBase64,
privateKeyToEncrypt: privateKey,
);
// 4. Отправляем одобрение на сервер
final apiService = ApiService();
final success = await apiService.approveQrLogin(
roomId: roomId,
phoneTempPub: phonePublicKeyBase64,
encPrivateKeyPayload: encPayload,
deviceName: connectingDeviceName ?? 'Новое устройство',
);
if (success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Новое устройство успешно авторизовано')),
);
Navigator.pop(context);
}
} else {
throw Exception('Сервер отклонил авторизацию сопряжения');
}
} catch (e) {
print(' Ошибка: $e');
setState(() {
_statusMessage = 'Ошибка: $e';
});
_resetScanner();
}
}
void _resetScanner() {
setState(() {
_isProcessing = false;
});
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_mobileScannerController.start();
}
});
}
Future<bool> _showConfirmDialog(BuildContext context) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Подтверждение входа'),
content: const Text(
'Вы действительно хотите войти на новом устройстве? Это перенесет ваши зашифрованные ключи для доступа к чатам.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Войти'),
),
],
);
},
) ??
false;
}
Future<bool> _showManualConfirmationDialog(BuildContext context) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Подтверждение сопряжения'),
content: const Text(
'Биометрическая защита или блокировка экрана не настроены на этом телефоне.\n\nВы действительно подтверждаете вход на новом устройстве?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Подтвердить'),
),
],
);
},
) ??
false;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Сканировать QR-код'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Stack(
children: [
// Сканер камеры
MobileScanner(
controller: _mobileScannerController,
onDetect: _handleQrDetected,
),
// Полупрозрачная рамка фокуса по центру
Center(
child: Container(
width: 250,
height: 250,
decoration: BoxDecoration(
border: Border.all(color: colorScheme.primary, width: 3),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.5),
spreadRadius: 2000,
),
],
),
),
),
// Текстовая подсказка
Positioned(
bottom: 60,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Text(
_statusMessage ?? 'Наведите камеру на QR-код на новом устройстве',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
),
),
],
),
);
}
}

View File

@ -1,9 +1,11 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:chepuhagram/logic/auth_provider.dart';
import 'package:chepuhagram/data/models/session_model.dart';
import 'package:chepuhagram/presentation/screens/qr_scan_screen.dart';
import 'package:timeago/timeago.dart' as timeago;
class SessionsScreen extends StatefulWidget {
@ -14,6 +16,8 @@ class SessionsScreen extends StatefulWidget {
}
class _SessionsScreenState extends State<SessionsScreen> {
Timer? _refreshTimer;
@override
void initState() {
super.initState();
@ -22,6 +26,18 @@ class _SessionsScreenState extends State<SessionsScreen> {
Provider.of<AuthProvider>(context, listen: false).fetchSessions();
}
});
// Периодическое обновление списка сессий каждые 5 секунд (фоновое)
_refreshTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (mounted) {
Provider.of<AuthProvider>(context, listen: false).fetchSessions(silent: true);
}
});
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
@override
@ -32,9 +48,14 @@ class _SessionsScreenState extends State<SessionsScreen> {
appBar: AppBar(
title: Text(
'Активные сеансы',
style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
iconTheme: IconThemeData(
color: Theme.of(context).colorScheme.onSurface,
),
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
elevation: 0,
backgroundColor: Colors.transparent,
),
@ -169,9 +190,83 @@ class _SessionsList extends StatelessWidget {
.where((s) => s.id != currentSession?.id)
.toList();
final colorScheme = Theme.of(context).colorScheme;
return ListView(
padding: EdgeInsets.zero,
children: [
if (Platform.isAndroid) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.15),
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const QrScanScreen()),
);
},
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.qr_code_scanner,
color: colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Подключить устройство',
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'Сканируйте QR-код для входа на ПК или планшете',
style: TextStyle(
color: colorScheme.outline,
fontSize: 12,
),
),
],
),
),
Icon(Icons.chevron_right, color: colorScheme.outline),
],
),
),
),
),
),
),
],
const SizedBox(height: 16),
if (currentSession != null) ...[
_buildSectionHeader(context, 'Текущий сеанс'),
Padding(
@ -264,22 +359,48 @@ class _SessionsList extends StatelessWidget {
}) {
final colorScheme = Theme.of(context).colorScheme;
DateTime now = DateTime.now();
// Считаем устройство онлайн, если оно текущее или последняя активность была менее 90 секунд назад
final difference = DateTime.now().toUtc().difference(session.lastActive.toUtc());
final isOnline = isCurrent || difference.inSeconds < 90;
final statusText = isOnline
? 'В сети'
: 'Активность: ${timeago.format(session.lastActive.toLocal(), locale: 'ru')}';
Duration offset = now.timeZoneOffset;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_getIconForDevice(session.deviceName),
color: colorScheme.primary,
size: 24,
),
leading: Stack(
clipBehavior: Clip.none,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_getIconForDevice(session.deviceName),
color: colorScheme.primary,
size: 24,
),
),
Positioned(
bottom: -2,
right: -2,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: isOnline ? Colors.green : Colors.grey,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 2,
),
),
),
),
],
),
title: Text(
session.deviceName,
@ -288,9 +409,20 @@ class _SessionsList extends StatelessWidget {
fontSize: 16,
),
),
subtitle: Text(
'${session.ipAddress}\nАктивность: ${timeago.format(session.lastActive.toLocal(), locale: 'ru')}',
style: TextStyle(color: colorScheme.outline, fontSize: 13),
subtitle: Text.rich(
TextSpan(
children: [
TextSpan(text: '${session.ipAddress}\n'),
TextSpan(
text: statusText,
style: TextStyle(
color: isOnline ? Colors.green : colorScheme.outline,
fontWeight: isOnline ? FontWeight.w500 : FontWeight.normal,
),
),
],
),
style: const TextStyle(fontSize: 13),
),
trailing: isCurrent
? null

View File

@ -368,28 +368,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
horizontal: 20,
vertical: 4,
),
leading: Icon(Icons.logout_rounded, color: colorScheme.error),
leading: authProv.isLoading
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.error),
),
)
: Icon(Icons.logout_rounded, color: colorScheme.error),
title: Text(
"Выйти из аккаунта",
authProv.isLoading ? "Выход из аккаунта..." : "Выйти из аккаунта",
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
),
trailing: Icon(
Icons.chevron_right_rounded,
color: colorScheme.error,
),
onTap: () async {
await authProv.logout();
if (context.mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
(r) => false,
);
}
},
trailing: authProv.isLoading
? null
: Icon(
Icons.chevron_right_rounded,
color: colorScheme.error,
),
onTap: authProv.isLoading
? null
: () async {
await authProv.logout();
},
),
),
),

View File

@ -20,6 +20,7 @@ import flutter_secure_storage_darwin
import flutter_webrtc
import gal
import local_auth_darwin
import mobile_scanner
import package_info_plus
import path_provider_foundation
import photo_manager
@ -47,6 +48,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin"))

View File

@ -1168,6 +1168,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
url: "https://pub.dev"
source: hosted
version: "7.2.0"
nested:
dependency: transitive
description:
@ -1408,6 +1416,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
recase:
dependency: transitive
description:

View File

@ -85,6 +85,8 @@ dependencies:
timeago: ^3.6.1
cached_network_image: ^3.4.1
scrollable_positioned_list: ^0.3.8
qr_flutter: ^4.1.0
mobile_scanner: ^7.2.0
dev_dependencies:
flutter_test:

View File

@ -33,7 +33,8 @@ authRouter = APIRouter(prefix="/auth", tags=[])
async def _register_new_login_session(user_id: int, request: Request, db: AsyncSession, device_name: Optional[str] = None):
if not device_name:
device_name = request.headers.get("X-Device-Name") or _parse_device_name(request)
device_name = request.headers.get(
"X-Device-Name") or _parse_device_name(request)
ip_address = request.client.host if request.client else "0.0.0.0"
new_session = models.Session(
@ -99,9 +100,10 @@ async def login(data: schemas.LoginRequest, request: Request, db: AsyncSession =
raise HTTPException(status_code=400, detail="Неверный TOTP код")
user_id = user.id
explicit_device_name = getattr(data, "device_name", None) or request.headers.get("X-Device-Name")
explicit_device_name = getattr(
data, "device_name", None) or request.headers.get("X-Device-Name")
new_session = await _register_new_login_session(user_id, request, db, device_name=explicit_device_name)
access_token = security.create_access_token(
@ -121,7 +123,7 @@ async def login(data: schemas.LoginRequest, request: Request, db: AsyncSession =
f"Устройство: {device_name}\n"
f"IP-адрес: {ip_address}\n"
f"Время: {current_time}\n\n"
f"Если это не вы, немедленно перейдите в Настройки -> Активные сеансы и завершите подозрительную сессию."
f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию."
)
await send_system_notification(
@ -160,9 +162,9 @@ async def login_oauth(request: Request, form_data: OAuth2PasswordRequestForm = D
raise HTTPException(status_code=400, detail="Неверный TOTP код")
# Логируем сессию в базу данных
oauth_device_name = request.headers.get("X-Device-Name")
new_session = await _register_new_login_session(user.id, request, db, device_name=oauth_device_name)
access_token = security.create_access_token(
@ -551,3 +553,82 @@ async def update_fcm(token: str, request: Request, current_user: models.User = D
await db.refresh(session)
return {"status": "ok"}
@authRouter.post("/qr/approve")
async def qr_approve(
data: schemas.QRApproveRequest,
request: Request,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# 1. Проверяем, есть ли активное вебсокет-соединение для этой комнаты (room_id)
if data.room_id not in manager.qr_connections:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Сессия сопряжения не найдена или истекла"
)
# 2. Регистрируем новую сессию для сопряженного устройства
device_name = data.device_name or "Новое устройство (QR)"
new_session = await _register_new_login_session(
user_id=current_user.id,
request=request,
db=db,
device_name=device_name
)
# 3. Генерируем JWT-токены авторизации для новой сессии
access_token = security.create_access_token(
data={"sub": str(current_user.id)},
session_id=new_session.id
)
refresh_token = security.create_refresh_token(
data={"sub": str(current_user.id)},
session_id=new_session.id
)
new_session.session_token = access_token
await db.commit()
# 4. Отправляем все необходимые данные новому устройству через WebSocket
payload = {
"type": "qr_authorized",
"access_token": access_token,
"refresh_token": refresh_token,
"phone_temp_pub": data.phone_temp_pub,
"enc_private_key_payload": data.enc_private_key_payload,
"user_id": current_user.id
}
# Отправляем сообщение
sent = await manager.send_qr_message(data.room_id, payload)
# Закрываем сокет комнаты сопряжения
if sent:
ws = manager.qr_connections.get(data.room_id)
if ws:
try:
await ws.close()
except Exception:
pass
manager.disconnect_qr(data.room_id)
# Отправляем системное оповещение на телефон о новом входе
current_time = datetime.now(timezone.utc).strftime("%H:%M:%S")
ip_address = request.client.host if request.client else "0.0.0.0"
security_alert_text = (
f"Обнаружен новый вход в ваш аккаунт через QR-код.\n\n"
f"Устройство: {device_name}\n"
f"IP-адрес: {ip_address}\n"
f"Время: {current_time}\n\n"
f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию."
)
await send_system_notification(
db=db,
receiver_id=current_user.id,
plain_text=security_alert_text
)
return {"status": "ok", "message": "Вход через QR-код успешно одобрен"}

View File

@ -101,4 +101,11 @@ class AdminCreateUser(BaseModel):
username: str
password: str
first_name: str
last_name: Optional[str] = None
last_name: Optional[str] = None
class QRApproveRequest(BaseModel):
room_id: str
phone_temp_pub: str
enc_private_key_payload: str
device_name: Optional[str] = None

View File

@ -17,6 +17,20 @@ elif SQLALCHEMY_DATABASE_URL.startswith("postgresql://"):
connect_args = {"check_same_thread": False} if "sqlite" in SQLALCHEMY_DATABASE_URL else {}
engine = create_async_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
from sqlalchemy import event
@event.listens_for(engine.sync_engine, "connect")
def register_sqlite_functions(dbapi_connection, connection_record):
real_conn = dbapi_connection
if hasattr(real_conn, "_conn"):
real_conn = real_conn._conn
if hasattr(real_conn, "create_function"):
try:
real_conn.create_function("LOWER", 1, lambda s: s.lower() if s is not None else None)
except Exception:
pass
AsyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()

View File

@ -32,6 +32,23 @@ wsRouter = APIRouter(
)
@wsRouter.websocket("/qr")
async def qr_websocket_endpoint(websocket: WebSocket, room_id: str = Query(...)):
print(f"QR сопряжение: новое соединение для комнаты {room_id}")
await manager.connect_qr(websocket, room_id)
try:
while True:
data = await websocket.receive_text()
message_data = json.loads(data)
if message_data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
finally:
manager.disconnect_qr(room_id)
print(f"QR сопряжение: отключено соединение для комнаты {room_id}")
@wsRouter.websocket("")
async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: AsyncSession = Depends(get_db)):
if token is None:
@ -486,6 +503,23 @@ class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, Dict[int, WebSocket]] = {}
self.online_users: Dict[str, datetime] = {}
self.qr_connections: Dict[str, WebSocket] = {}
async def connect_qr(self, websocket: WebSocket, room_id: str):
await websocket.accept()
self.qr_connections[room_id] = websocket
def disconnect_qr(self, room_id: str):
self.qr_connections.pop(room_id, None)
async def send_qr_message(self, room_id: str, message: dict) -> bool:
if room_id in self.qr_connections:
try:
await self.qr_connections[room_id].send_json(message)
return True
except Exception:
self.disconnect_qr(room_id)
return False
async def connect(self, websocket: WebSocket, user_id: int, session_id: int):
await websocket.accept()