317 lines
12 KiB
Dart
317 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:local_auth/local_auth.dart';
|
||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||
|
||
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;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_checkBiometricSupport();
|
||
}
|
||
|
||
@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<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 {
|
||
setState(() => _isSavingTotp = true);
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
if (!mounted) return;
|
||
setState(() => _isSavingTotp = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('TOTP пока не подключён на сервере')),
|
||
);
|
||
}
|
||
|
||
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),
|
||
const Text('Настройка одноразового кода (TOTP) пока не подключена на сервере.'),
|
||
const SizedBox(height: 12),
|
||
ElevatedButton(
|
||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||
child: _isSavingTotp ? const CircularProgressIndicator(color: Colors.white) : const Text('Установить TOTP код'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|