Chepuhagram/lib/presentation/screens/security_settings_screen.dart

582 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'),
),
],
),
);
}
}