22-06-2026+00-00
This commit is contained in:
parent
680771f75e
commit
c3999db9eb
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
24
pubspec.lock
24
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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-код успешно одобрен"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue