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;
|
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!;
|
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:path/path.dart' as p;
|
||||||
import 'package:chepuhagram/data/models/session_model.dart';
|
import 'package:chepuhagram/data/models/session_model.dart';
|
||||||
import 'package:chepuhagram/presentation/screens/splash_screen.dart';
|
import 'package:chepuhagram/presentation/screens/splash_screen.dart';
|
||||||
|
import 'package:chepuhagram/presentation/screens/login_screen.dart';
|
||||||
import 'package:chepuhagram/main.dart';
|
import 'package:chepuhagram/main.dart';
|
||||||
|
|
||||||
class AuthProvider extends ChangeNotifier {
|
class AuthProvider extends ChangeNotifier {
|
||||||
|
|
@ -115,18 +116,53 @@ class AuthProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
|
if (_isLoading) return;
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
await ApiService().logoutCurrentUser();
|
// 1. Отзываем токен на сервере
|
||||||
|
try {
|
||||||
|
await ApiService().logoutCurrentUser();
|
||||||
|
} catch (e) {
|
||||||
|
print("Сервер не ответил на logout: $e");
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Закрываем WebSocket connection
|
// 2. Отключаем WebSocket connection
|
||||||
SocketService().disconnect();
|
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;
|
final context = navigatorKey.currentContext;
|
||||||
if (context != null) {
|
if (context != null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -135,9 +171,20 @@ class AuthProvider extends ChangeNotifier {
|
||||||
print("Error clearing contact provider cache: $e");
|
print("Error clearing contact provider cache: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.clear();
|
try {
|
||||||
await LocalDbService().clearDatabase();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.clear();
|
||||||
|
} catch (e) {
|
||||||
|
print("Ошибка очистки SharedPreferences: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await LocalDbService().clearDatabase();
|
||||||
|
} catch (e) {
|
||||||
|
print("Ошибка очистки локальной базы данных: $e");
|
||||||
|
}
|
||||||
|
|
||||||
_currentUserId = null;
|
_currentUserId = null;
|
||||||
_username = null;
|
_username = null;
|
||||||
_firstName = null;
|
_firstName = null;
|
||||||
|
|
@ -147,16 +194,19 @@ class AuthProvider extends ChangeNotifier {
|
||||||
_about = null;
|
_about = null;
|
||||||
_avatarPath = null;
|
_avatarPath = null;
|
||||||
_avatarUrl = null;
|
_avatarUrl = null;
|
||||||
if (mode != null) {
|
|
||||||
await _storage.write(key: 'theme_mode', value: mode);
|
try {
|
||||||
}
|
if (mode != null) {
|
||||||
if (color != null) {
|
await _storage.write(key: 'theme_mode', value: mode);
|
||||||
await _storage.write(key: 'accent_color', value: color);
|
}
|
||||||
|
if (color != null) {
|
||||||
|
await _storage.write(key: 'accent_color', value: color);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Ошибка записи настроек темы: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Уничтожаем локальную базу данных переписки (Drift)
|
// 3. Уничтожаем локальную базу данных переписки (Drift)
|
||||||
// Поскольку у вас Drift открывает SqfliteQueryExecutor (local_db_service.dart),
|
|
||||||
// самый надежный способ — физически удалить файл базы данных чепухаграма.
|
|
||||||
try {
|
try {
|
||||||
final dbFolder = await databaseFactory.getDatabasesPath();
|
final dbFolder = await databaseFactory.getDatabasesPath();
|
||||||
final dbFile = File(p.join(dbFolder, 'chat_app.db'));
|
final dbFile = File(p.join(dbFolder, 'chat_app.db'));
|
||||||
|
|
@ -177,18 +227,17 @@ class AuthProvider extends ChangeNotifier {
|
||||||
await shmFile.delete();
|
await shmFile.delete();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Ошибка удаления файла БД: $e");
|
print("Ошибка удаления файлов БД: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentUserId = null;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Перенаправляем напрямую на LoginScreen во избежание циклического автологина
|
||||||
navigatorKey.currentState?.pushAndRemoveUntil(
|
navigatorKey.currentState?.pushAndRemoveUntil(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||||
const SplashScreen(),
|
const LoginScreen(),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
return FadeTransition(opacity: animation, child: child);
|
return FadeTransition(opacity: animation, child: child);
|
||||||
},
|
},
|
||||||
|
|
@ -197,19 +246,26 @@ class AuthProvider extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchSessions() async {
|
Future<void> fetchSessions({bool silent = false}) async {
|
||||||
_isLoading = true;
|
if (!silent) {
|
||||||
_error = null;
|
_isLoading = true;
|
||||||
notifyListeners();
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final data = await _apiService.getActiveSessions();
|
final data = await _apiService.getActiveSessions();
|
||||||
print('Fetched sessions: $data');
|
print('Fetched sessions: $data');
|
||||||
_sessions = data.map((json) => Session.fromJson(json)).toList();
|
_sessions = data.map((json) => Session.fromJson(json)).toList();
|
||||||
|
_error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Произошла ошибка при загрузке сессий: $e';
|
if (!silent) {
|
||||||
print(_error);
|
_error = 'Произошла ошибка при загрузке сессий: $e';
|
||||||
|
}
|
||||||
|
print('Ошибка при загрузке сессий: $e');
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
if (!silent) {
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
notifyListeners();
|
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 {
|
Future<bool> tryAutoLogin() async {
|
||||||
String? token;
|
String? token;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -830,9 +830,10 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
final double bottomPadding = MediaQuery.of(
|
final double bottomPadding = MediaQuery.of(
|
||||||
context,
|
context,
|
||||||
).padding.bottom;
|
).padding.bottom;
|
||||||
|
final double effectiveInputHeight = _currentContact.id == 0 ? 0.0 : inputBarHeight;
|
||||||
return ScrollablePositionedList.builder(
|
return ScrollablePositionedList.builder(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: bottomPadding + inputBarHeight + 20.0,
|
bottom: bottomPadding + effectiveInputHeight + 20.0,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|
@ -4275,10 +4276,18 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
final positions = _itemPositionsListener.itemPositions.value;
|
final positions = _itemPositionsListener.itemPositions.value;
|
||||||
if (positions.isEmpty) return;
|
if (positions.isEmpty) return;
|
||||||
|
|
||||||
// 1. Логика подгрузки истории через индексы
|
// Так как позиции в itemPositions приходят не отсортированными,
|
||||||
// В reverse: true списке: индекс 0 — это низ (новые), последний индекс — верх (старые)
|
// находим фактический первый и последний видимый элемент
|
||||||
final firstVisible = positions.first.index;
|
int firstVisible = positions.first.index;
|
||||||
final lastVisible = positions.last.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) {
|
if (lastVisible >= messages.length - 5) {
|
||||||
|
|
@ -4290,8 +4299,29 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||||
_loadNewerMessages();
|
_loadNewerMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Логика кнопки скролла вниз
|
// 2. Логика кнопки скролла вниз:
|
||||||
_showScrollButtonNotifier.value = firstVisible > 5;
|
// Показываем кнопку сразу, как только первый элемент (индекс 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 {
|
Future<void> _scrollToBottom() async {
|
||||||
|
|
|
||||||
|
|
@ -1107,46 +1107,50 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
||||||
curve: Curves.fastOutSlowIn,
|
curve: Curves.fastOutSlowIn,
|
||||||
width: railWidth,
|
width: railWidth,
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.12),
|
color: colorScheme.surfaceVariant.withOpacity(0.12),
|
||||||
child: Column(
|
child: SafeArea(
|
||||||
children: [
|
top: true,
|
||||||
const SizedBox(height: 12),
|
bottom: true,
|
||||||
IconButton(
|
child: Column(
|
||||||
icon: Icon(
|
children: [
|
||||||
_isLeftRailExpanded
|
const SizedBox(height: 12),
|
||||||
? Icons.menu_open_rounded
|
IconButton(
|
||||||
: Icons.menu_rounded,
|
icon: Icon(
|
||||||
|
_isLeftRailExpanded
|
||||||
|
? Icons.menu_open_rounded
|
||||||
|
: Icons.menu_rounded,
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _isLeftRailExpanded = !_isLeftRailExpanded),
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
const Divider(height: 24, indent: 12, endIndent: 12),
|
||||||
setState(() => _isLeftRailExpanded = !_isLeftRailExpanded),
|
|
||||||
),
|
|
||||||
const Divider(height: 24, indent: 12, endIndent: 12),
|
|
||||||
|
|
||||||
_buildRailItem(
|
_buildRailItem(
|
||||||
Icons.chat_bubble_outline_rounded,
|
Icons.chat_bubble_outline_rounded,
|
||||||
Icons.chat_bubble_rounded,
|
Icons.chat_bubble_rounded,
|
||||||
"Чаты",
|
"Чаты",
|
||||||
0,
|
0,
|
||||||
onTap: () => setState(() => _currentIndex = 0),
|
onTap: () => setState(() => _currentIndex = 0),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Divider(height: 12, indent: 12, endIndent: 12),
|
const Divider(height: 12, indent: 12, endIndent: 12),
|
||||||
|
|
||||||
_buildRailItem(
|
_buildRailItem(
|
||||||
Icons.settings_outlined,
|
Icons.settings_outlined,
|
||||||
Icons.settings_rounded,
|
Icons.settings_rounded,
|
||||||
"Настройки",
|
"Настройки",
|
||||||
1,
|
1,
|
||||||
onTap: () => _showSettingsDialog(),
|
onTap: () => _showSettingsDialog(),
|
||||||
),
|
),
|
||||||
_buildRailItem(
|
_buildRailItem(
|
||||||
Icons.person_outline_rounded,
|
Icons.person_outline_rounded,
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
"Профиль",
|
"Профиль",
|
||||||
2,
|
2,
|
||||||
onTap: () => _showProfileDialog(),
|
onTap: () => _showProfileDialog(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:ui';
|
||||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||||
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
|
import 'package:chepuhagram/presentation/screens/account_setup_screen.dart';
|
||||||
import 'package:chepuhagram/presentation/screens/key_recovery_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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../logic/auth_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:io';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:chepuhagram/logic/auth_provider.dart';
|
import 'package:chepuhagram/logic/auth_provider.dart';
|
||||||
import 'package:chepuhagram/data/models/session_model.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;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
|
|
||||||
class SessionsScreen extends StatefulWidget {
|
class SessionsScreen extends StatefulWidget {
|
||||||
|
|
@ -14,6 +16,8 @@ class SessionsScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SessionsScreenState extends State<SessionsScreen> {
|
class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -22,6 +26,18 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
Provider.of<AuthProvider>(context, listen: false).fetchSessions();
|
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
|
@override
|
||||||
|
|
@ -32,9 +48,14 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
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,
|
elevation: 0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
|
|
@ -169,9 +190,83 @@ class _SessionsList extends StatelessWidget {
|
||||||
.where((s) => s.id != currentSession?.id)
|
.where((s) => s.id != currentSession?.id)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
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) ...[
|
if (currentSession != null) ...[
|
||||||
_buildSectionHeader(context, 'Текущий сеанс'),
|
_buildSectionHeader(context, 'Текущий сеанс'),
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -264,22 +359,48 @@ class _SessionsList extends StatelessWidget {
|
||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
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(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
leading: Container(
|
leading: Stack(
|
||||||
padding: const EdgeInsets.all(8),
|
clipBehavior: Clip.none,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08),
|
Container(
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.all(8),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Icon(
|
color: colorScheme.primary.withOpacity(isCurrent ? 0.12 : 0.08),
|
||||||
_getIconForDevice(session.deviceName),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: colorScheme.primary,
|
),
|
||||||
size: 24,
|
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(
|
title: Text(
|
||||||
session.deviceName,
|
session.deviceName,
|
||||||
|
|
@ -288,9 +409,20 @@ class _SessionsList extends StatelessWidget {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text.rich(
|
||||||
'${session.ipAddress}\nАктивность: ${timeago.format(session.lastActive.toLocal(), locale: 'ru')}',
|
TextSpan(
|
||||||
style: TextStyle(color: colorScheme.outline, fontSize: 13),
|
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
|
trailing: isCurrent
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -368,28 +368,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
horizontal: 20,
|
horizontal: 20,
|
||||||
vertical: 4,
|
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(
|
title: Text(
|
||||||
"Выйти из аккаунта",
|
authProv.isLoading ? "Выход из аккаунта..." : "Выйти из аккаунта",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.error,
|
color: colorScheme.error,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: Icon(
|
trailing: authProv.isLoading
|
||||||
Icons.chevron_right_rounded,
|
? null
|
||||||
color: colorScheme.error,
|
: Icon(
|
||||||
),
|
Icons.chevron_right_rounded,
|
||||||
onTap: () async {
|
color: colorScheme.error,
|
||||||
await authProv.logout();
|
),
|
||||||
if (context.mounted) {
|
onTap: authProv.isLoading
|
||||||
Navigator.pushAndRemoveUntil(
|
? null
|
||||||
context,
|
: () async {
|
||||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
await authProv.logout();
|
||||||
(r) => false,
|
},
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import flutter_secure_storage_darwin
|
||||||
import flutter_webrtc
|
import flutter_webrtc
|
||||||
import gal
|
import gal
|
||||||
import local_auth_darwin
|
import local_auth_darwin
|
||||||
|
import mobile_scanner
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import photo_manager
|
import photo_manager
|
||||||
|
|
@ -47,6 +48,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin"))
|
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin"))
|
||||||
|
|
|
||||||
24
pubspec.lock
24
pubspec.lock
|
|
@ -1168,6 +1168,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1408,6 +1416,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
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:
|
recase:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ dependencies:
|
||||||
timeago: ^3.6.1
|
timeago: ^3.6.1
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
scrollable_positioned_list: ^0.3.8
|
scrollable_positioned_list: ^0.3.8
|
||||||
|
qr_flutter: ^4.1.0
|
||||||
|
mobile_scanner: ^7.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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):
|
async def _register_new_login_session(user_id: int, request: Request, db: AsyncSession, device_name: Optional[str] = None):
|
||||||
if not device_name:
|
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"
|
ip_address = request.client.host if request.client else "0.0.0.0"
|
||||||
|
|
||||||
new_session = models.Session(
|
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 код")
|
raise HTTPException(status_code=400, detail="Неверный TOTP код")
|
||||||
|
|
||||||
user_id = user.id
|
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)
|
new_session = await _register_new_login_session(user_id, request, db, device_name=explicit_device_name)
|
||||||
|
|
||||||
access_token = security.create_access_token(
|
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"Устройство: {device_name}\n"
|
||||||
f"IP-адрес: {ip_address}\n"
|
f"IP-адрес: {ip_address}\n"
|
||||||
f"Время: {current_time}\n\n"
|
f"Время: {current_time}\n\n"
|
||||||
f"Если это не вы, немедленно перейдите в Настройки -> Активные сеансы и завершите подозрительную сессию."
|
f"Если это не вы, немедленно перейдите в Настройки -> Устройства и завершите подозрительную сессию."
|
||||||
)
|
)
|
||||||
|
|
||||||
await send_system_notification(
|
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 код")
|
raise HTTPException(status_code=400, detail="Неверный TOTP код")
|
||||||
|
|
||||||
# Логируем сессию в базу данных
|
# Логируем сессию в базу данных
|
||||||
|
|
||||||
oauth_device_name = request.headers.get("X-Device-Name")
|
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)
|
new_session = await _register_new_login_session(user.id, request, db, device_name=oauth_device_name)
|
||||||
|
|
||||||
access_token = security.create_access_token(
|
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)
|
await db.refresh(session)
|
||||||
|
|
||||||
return {"status": "ok"}
|
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
|
username: str
|
||||||
password: str
|
password: str
|
||||||
first_name: 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 {}
|
connect_args = {"check_same_thread": False} if "sqlite" in SQLALCHEMY_DATABASE_URL else {}
|
||||||
|
|
||||||
engine = create_async_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
|
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)
|
AsyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
Base = declarative_base()
|
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("")
|
@wsRouter.websocket("")
|
||||||
async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: AsyncSession = Depends(get_db)):
|
async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: AsyncSession = Depends(get_db)):
|
||||||
if token is None:
|
if token is None:
|
||||||
|
|
@ -486,6 +503,23 @@ class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: Dict[str, Dict[int, WebSocket]] = {}
|
self.active_connections: Dict[str, Dict[int, WebSocket]] = {}
|
||||||
self.online_users: Dict[str, datetime] = {}
|
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):
|
async def connect(self, websocket: WebSocket, user_id: int, session_id: int):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue