582 lines
20 KiB
Dart
582 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:local_auth/local_auth.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||
import 'dart:convert';
|
||
|
||
class SecuritySettingsScreen extends StatefulWidget {
|
||
const SecuritySettingsScreen({super.key});
|
||
|
||
@override
|
||
State<SecuritySettingsScreen> createState() => _SecuritySettingsScreenState();
|
||
}
|
||
|
||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||
final _passwordFormKey = GlobalKey<FormState>();
|
||
final _encryptionFormKey = GlobalKey<FormState>();
|
||
//final _totpFormKey = GlobalKey<FormState>();
|
||
|
||
final _currentPasswordController = TextEditingController();
|
||
final _newPasswordController = TextEditingController();
|
||
final _confirmPasswordController = TextEditingController();
|
||
|
||
final _currentEncryptPasswordController = TextEditingController();
|
||
final _newEncryptPasswordController = TextEditingController();
|
||
final _confirmEncryptPasswordController = TextEditingController();
|
||
|
||
final LocalAuthentication _localAuth = LocalAuthentication();
|
||
bool _isBiometricAvailable = false;
|
||
bool _isSavingPassword = false;
|
||
bool _isSavingEncryption = false;
|
||
bool _isSavingTotp = false;
|
||
bool _isTotpEnabled = false;
|
||
String? _totpSecret;
|
||
String? _totpQrCode;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_checkBiometricSupport();
|
||
_loadTotpStatus();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_currentPasswordController.dispose();
|
||
_newPasswordController.dispose();
|
||
_confirmPasswordController.dispose();
|
||
_currentEncryptPasswordController.dispose();
|
||
_newEncryptPasswordController.dispose();
|
||
_confirmEncryptPasswordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _checkBiometricSupport() async {
|
||
try {
|
||
final canCheckBiometrics = await _localAuth.canCheckBiometrics;
|
||
final isSupported = await _localAuth.isDeviceSupported();
|
||
final availableBiometrics = await _localAuth.getAvailableBiometrics();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isBiometricAvailable =
|
||
canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
|
||
});
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isBiometricAvailable = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _loadTotpStatus() async {
|
||
try {
|
||
final api = ApiService();
|
||
final userData = await api.getMe();
|
||
print('TOTP status from getMe: ${userData['totp_enabled']}');
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isTotpEnabled = userData['totp_enabled'] ?? false;
|
||
});
|
||
print('TOTP status set to: $_isTotpEnabled');
|
||
} catch (e) {
|
||
print('Error loading TOTP status: $e');
|
||
// Ignore errors, assume TOTP is disabled
|
||
if (!mounted) return;
|
||
setState(() => _isTotpEnabled = false);
|
||
}
|
||
}
|
||
|
||
Future<bool> _authenticateBiometric() async {
|
||
try {
|
||
return await _localAuth.authenticate(
|
||
localizedReason: 'Подтвердите личность для смены пароля шифрования',
|
||
options: const AuthenticationOptions(
|
||
biometricOnly: false,
|
||
stickyAuth: false,
|
||
useErrorDialogs: true,
|
||
sensitiveTransaction: true,
|
||
),
|
||
);
|
||
} catch (error) {
|
||
debugPrint('Biometric authentication error: $error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<void> _savePassword() async {
|
||
if (!_passwordFormKey.currentState!.validate()) return;
|
||
setState(() => _isSavingPassword = true);
|
||
|
||
try {
|
||
final api = ApiService();
|
||
final success = await api.changePassword(
|
||
_currentPasswordController.text.trim(),
|
||
_newPasswordController.text.trim(),
|
||
);
|
||
|
||
if (!success) {
|
||
throw Exception('Не удалось изменить пароль');
|
||
}
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
|
||
_currentPasswordController.clear();
|
||
_newPasswordController.clear();
|
||
_confirmPasswordController.clear();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||
);
|
||
} finally {
|
||
if (!mounted) return;
|
||
setState(() => _isSavingPassword = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _saveEncryptionPassword() async {
|
||
await _checkBiometricSupport();
|
||
|
||
if (!_encryptionFormKey.currentState!.validate()) return;
|
||
setState(() => _isSavingEncryption = true);
|
||
|
||
try {
|
||
final newPassword = _newEncryptPasswordController.text.trim();
|
||
final currentPassword = _currentEncryptPasswordController.text.trim();
|
||
final cryptoService = CryptoService();
|
||
String privateKeyBase64;
|
||
|
||
if (currentPassword.isEmpty) {
|
||
if (!_isBiometricAvailable) {
|
||
throw Exception('Биометрия не настроена. Введите текущий пароль.');
|
||
}
|
||
|
||
final authenticated = await _authenticateBiometric();
|
||
if (!authenticated) {
|
||
throw Exception('Биометрическая аутентификация не пройдена.');
|
||
}
|
||
|
||
final localPrivateKey = await cryptoService.getPrivateKey();
|
||
if (localPrivateKey == null || localPrivateKey.isEmpty) {
|
||
throw Exception('Локальный приватный ключ не найден.');
|
||
}
|
||
privateKeyBase64 = localPrivateKey;
|
||
} else {
|
||
final api = ApiService();
|
||
final userData = await api.getMe();
|
||
final encryptedPrivateKey = userData['encrypted_private_key']
|
||
?.toString();
|
||
|
||
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
|
||
throw Exception('Зашифрованный ключ не найден на сервере.');
|
||
}
|
||
|
||
privateKeyBase64 = await cryptoService.decryptPrivateKey(
|
||
encryptedPrivateKey,
|
||
currentPassword,
|
||
);
|
||
await cryptoService.savePrivateKey(privateKeyBase64);
|
||
}
|
||
|
||
final updatedEncryptedPrivateKey = await cryptoService
|
||
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
|
||
|
||
final success = await ApiService().updateEncryptedPrivateKey(
|
||
updatedEncryptedPrivateKey,
|
||
);
|
||
if (!success) {
|
||
throw Exception('Не удалось обновить пароль шифрования на сервере.');
|
||
}
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Пароль шифрования успешно обновлён')),
|
||
);
|
||
_currentEncryptPasswordController.clear();
|
||
_newEncryptPasswordController.clear();
|
||
_confirmEncryptPasswordController.clear();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||
);
|
||
} finally {
|
||
if (!mounted) return;
|
||
setState(() => _isSavingEncryption = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _setupTotp() async {
|
||
if (_isTotpEnabled) {
|
||
// Показываем диалог с опциями
|
||
_showTotpOptionsDialog();
|
||
} else {
|
||
// Enable TOTP
|
||
setState(() => _isSavingTotp = true);
|
||
try {
|
||
final api = ApiService();
|
||
final data = await api.enableTotp();
|
||
setState(() {
|
||
_totpSecret = data['secret'];
|
||
_totpQrCode = data['qr_code'];
|
||
});
|
||
// Show dialog to scan QR and enter code
|
||
_showTotpSetupDialog();
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||
);
|
||
} finally {
|
||
setState(() => _isSavingTotp = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showTotpOptionsDialog() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('TOTP'),
|
||
content: const Text('TOTP включён. Выберите действие:'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
_reissueTotp();
|
||
},
|
||
child: const Text('Перевыпустить ключ'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
_disableTotp();
|
||
},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.red,
|
||
),
|
||
child: const Text('Отключить TOTP'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _reissueTotp() async {
|
||
setState(() => _isSavingTotp = true);
|
||
try {
|
||
final api = ApiService();
|
||
final data = await api.enableTotp();
|
||
setState(() {
|
||
_totpSecret = data['secret'];
|
||
_totpQrCode = data['qr_code'];
|
||
});
|
||
// Show dialog to scan QR and enter code
|
||
_showTotpSetupDialog(isReissue: true);
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||
);
|
||
} finally {
|
||
setState(() => _isSavingTotp = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _disableTotp() async {
|
||
setState(() => _isSavingTotp = true);
|
||
try {
|
||
final api = ApiService();
|
||
final success = await api.disableTotp();
|
||
if (success) {
|
||
setState(() {
|
||
_isTotpEnabled = false;
|
||
_totpSecret = null;
|
||
_totpQrCode = null;
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('TOTP отключён')),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||
);
|
||
} finally {
|
||
setState(() => _isSavingTotp = false);
|
||
}
|
||
}
|
||
|
||
void _showTotpSetupDialog({bool isReissue = false}) {
|
||
final codeController = TextEditingController();
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => AlertDialog(
|
||
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
|
||
content: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(isReissue
|
||
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
|
||
: 'Отсканируйте QR-код в приложении аутентификатора:'),
|
||
const SizedBox(height: 16),
|
||
if (_totpQrCode != null)
|
||
Builder(
|
||
builder: (context) {
|
||
final base64String = _totpQrCode!.split(',').last;
|
||
final bytes = base64Decode(base64String);
|
||
return Image.memory(bytes, width: 200, height: 200);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Ключ: ${_totpSecret ?? ''}',
|
||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||
overflow: TextOverflow.ellipsis,
|
||
maxLines: 1,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton(
|
||
icon: const Icon(Icons.copy, size: 18),
|
||
onPressed: () {
|
||
if (_totpSecret != null) {
|
||
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Ключ скопирован')),
|
||
);
|
||
}
|
||
},
|
||
tooltip: 'Скопировать ключ',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: codeController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Введите код из приложения',
|
||
helperText: 'Обычно это 6 цифр',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
setState(() {
|
||
_totpSecret = null;
|
||
_totpQrCode = null;
|
||
});
|
||
},
|
||
child: const Text('Отмена'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () async {
|
||
final code = codeController.text.trim();
|
||
if (code.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Введите код')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final api = ApiService();
|
||
final success = await api.verifyTotp(code);
|
||
if (success) {
|
||
Navigator.of(context).pop();
|
||
setState(() {
|
||
_isTotpEnabled = true;
|
||
_totpSecret = null;
|
||
_totpQrCode = null;
|
||
});
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
|
||
);
|
||
} else {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Неверный код')),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('Подтвердить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String? _currentEncryptionPasswordValidator(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
if (!_isBiometricAvailable) {
|
||
return 'Введите текущий пароль';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Безопасность')),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
const Text(
|
||
'Смена пароля аккаунта',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Form(
|
||
key: _passwordFormKey,
|
||
child: Column(
|
||
children: [
|
||
TextFormField(
|
||
controller: _currentPasswordController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Текущий пароль',
|
||
),
|
||
obscureText: true,
|
||
validator: (value) {
|
||
if (value == null || value.isEmpty)
|
||
return 'Введите текущий пароль';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _newPasswordController,
|
||
decoration: const InputDecoration(labelText: 'Новый пароль'),
|
||
obscureText: true,
|
||
validator: (value) {
|
||
if (value == null || value.isEmpty)
|
||
return 'Введите новый пароль';
|
||
if (value.length < 6) return 'Пароль слишком короткий';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _confirmPasswordController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Повторите пароль',
|
||
),
|
||
obscureText: true,
|
||
validator: (value) {
|
||
if (value != _newPasswordController.text)
|
||
return 'Пароли не совпадают';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 14),
|
||
ElevatedButton(
|
||
onPressed: _isSavingPassword ? null : _savePassword,
|
||
child: _isSavingPassword
|
||
? const CircularProgressIndicator(color: Colors.white)
|
||
: const Text('Сохранить пароль'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
const Text(
|
||
'Пароль шифрования сообщений',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Form(
|
||
key: _encryptionFormKey,
|
||
child: Column(
|
||
children: [
|
||
TextFormField(
|
||
controller: _currentEncryptPasswordController,
|
||
decoration: InputDecoration(
|
||
labelText: 'Текущий пароль шифрования',
|
||
helperText: _isBiometricAvailable
|
||
? 'Оставьте поле пустым и подтвердите биометрией'
|
||
: 'Требуется текущий пароль',
|
||
),
|
||
obscureText: true,
|
||
validator: _currentEncryptionPasswordValidator,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _newEncryptPasswordController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Новый пароль шифрования',
|
||
),
|
||
obscureText: true,
|
||
validator: (value) {
|
||
if (value == null || value.length < 6)
|
||
return 'Пароль слишком короткий';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _confirmEncryptPasswordController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Повторите новый пароль',
|
||
),
|
||
obscureText: true,
|
||
validator: (value) {
|
||
if (value != _newEncryptPasswordController.text)
|
||
return 'Пароли не совпадают';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 14),
|
||
ElevatedButton(
|
||
onPressed: _isSavingEncryption
|
||
? null
|
||
: _saveEncryptionPassword,
|
||
child: _isSavingEncryption
|
||
? const CircularProgressIndicator(color: Colors.white)
|
||
: const Text('Сохранить пароль шифрования'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
const Text(
|
||
'TOTP',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
|
||
const SizedBox(height: 12),
|
||
ElevatedButton(
|
||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||
child: _isSavingTotp
|
||
? const CircularProgressIndicator(color: Colors.white)
|
||
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|