656 lines
23 KiB
Dart
656 lines
23 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 _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();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isTotpEnabled = userData['totp_enabled'] ?? false;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _isTotpEnabled = false);
|
||
}
|
||
}
|
||
|
||
Future<bool> _authenticateBiometric() async {
|
||
try {
|
||
return await _localAuth.authenticate(
|
||
localizedReason: 'Подтвердите личность для изменения крипто-пароля',
|
||
options: const AuthenticationOptions(
|
||
biometricOnly: false,
|
||
useErrorDialogs: true,
|
||
stickyAuth: false,
|
||
),
|
||
);
|
||
} catch (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('Основной пароль успешно обновлен'),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
_currentPasswordController.clear();
|
||
_newPasswordController.clear();
|
||
_confirmPasswordController.clear();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) 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('Крипто-пароль успешно изменен'),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
_currentEncryptPasswordController.clear();
|
||
_newEncryptPasswordController.clear();
|
||
_confirmEncryptPasswordController.clear();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _isSavingEncryption = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _setupTotp() async {
|
||
if (_isTotpEnabled) {
|
||
_showTotpOptionsDialog();
|
||
} else {
|
||
setState(() => _isSavingTotp = true);
|
||
try {
|
||
final api = ApiService();
|
||
final data = await api.enableTotp();
|
||
setState(() {
|
||
_totpSecret = data['secret'];
|
||
_totpQrCode = data['qr_code'];
|
||
});
|
||
_showTotpSetupDialog();
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
} finally {
|
||
setState(() => _isSavingTotp = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showTotpOptionsDialog() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
title: const Text('Защита TOTP активна'),
|
||
content: const Text(
|
||
'Выберите необходимое действие для управления двухфакторной аутентификацией.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Отмена'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () {
|
||
Navigator.pop(context);
|
||
_disableTotp();
|
||
},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
elevation: 0,
|
||
),
|
||
child: const Text(
|
||
'Отключить 2FA',
|
||
style: TextStyle(color: Colors.white),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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('Двухфакторная защита отключена'),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
} finally {
|
||
setState(() => _isSavingTotp = false);
|
||
}
|
||
}
|
||
|
||
void _showTotpSetupDialog() {
|
||
final codeController = TextEditingController();
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||
title: const Text('Активация TOTP 2FA'),
|
||
content: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Text(
|
||
'Сканируйте код приложением аутентификатора (Google Authenticator / Aegis):',
|
||
style: TextStyle(fontSize: 13),
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (_totpQrCode != null)
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Image.memory(
|
||
base64Decode(_totpQrCode!.split(',').last),
|
||
width: 180,
|
||
height: 180,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 8,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surfaceVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
_totpSecret ?? '',
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 12,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.copy_rounded, size: 16),
|
||
onPressed: () {
|
||
if (_totpSecret != null) {
|
||
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Ключ скопирован в буфер'),
|
||
duration: Duration(seconds: 1),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: codeController,
|
||
keyboardType: TextInputType.number,
|
||
decoration: const InputDecoration(
|
||
labelText: '6-значный одноразовый код',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Отмена'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () async {
|
||
if (codeController.text.trim().isEmpty) return;
|
||
try {
|
||
final success = await ApiService().verifyTotp(
|
||
codeController.text.trim(),
|
||
);
|
||
if (success) {
|
||
Navigator.pop(context);
|
||
setState(() => _isTotpEnabled = true);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Двухфакторный ключ успешно привязан'),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('Подтвердить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return Scaffold(
|
||
backgroundColor: colorScheme.background,
|
||
appBar: AppBar(
|
||
title: const Text('Безопасность'),
|
||
elevation: 0,
|
||
backgroundColor: Colors.transparent,
|
||
),
|
||
body: ListView(
|
||
physics: const BouncingScrollPhysics(),
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
// Модуль 1: Основной пароль
|
||
_buildCardSection(
|
||
title: 'Смена пароля аккаунта',
|
||
child: Form(
|
||
key: _passwordFormKey,
|
||
child: Column(
|
||
children: [
|
||
_buildFormInput(
|
||
_currentPasswordController,
|
||
'Текущий пароль',
|
||
true,
|
||
),
|
||
_buildFormInput(_newPasswordController, 'Новый пароль', true),
|
||
_buildFormInput(
|
||
_confirmPasswordController,
|
||
'Повторите новый пароль',
|
||
true,
|
||
validator: (v) {
|
||
if (v != _newPasswordController.text)
|
||
return 'Пароли не совпадают';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: _isSavingPassword ? null : _savePassword,
|
||
style: ElevatedButton.styleFrom(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
child: _isSavingPassword
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Обновить основной пароль'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Модуль 2: Сквозное шифрование
|
||
_buildCardSection(
|
||
title: 'Пароль сквозного шифрования (E2EE)',
|
||
child: Form(
|
||
key: _encryptionFormKey,
|
||
child: Column(
|
||
children: [
|
||
_buildFormInput(
|
||
_currentEncryptPasswordController,
|
||
_isBiometricAvailable
|
||
? 'Оставьте пустым и подтвердите биометрией'
|
||
: 'Текущий крипто-пароль',
|
||
true,
|
||
hint: _isBiometricAvailable
|
||
? 'Подтвердите биометрией'
|
||
: null,
|
||
validator: (v) {
|
||
// Если биометрия доступна на устройстве, поле МОЖЕТ быть пустым
|
||
if (_isBiometricAvailable) return null;
|
||
|
||
// Если биометрии нет, то поле становится строго обязательным
|
||
if (v == null || v.isEmpty)
|
||
return 'Введите текущий пароль';
|
||
return null;
|
||
},
|
||
),
|
||
_buildFormInput(
|
||
_newEncryptPasswordController,
|
||
'Новый крипто-пароль',
|
||
true,
|
||
),
|
||
_buildFormInput(
|
||
_confirmEncryptPasswordController,
|
||
'Повторите новый крипто-пароль',
|
||
true,
|
||
validator: (v) {
|
||
if (v != _newEncryptPasswordController.text)
|
||
return 'Пароли не совпадают';
|
||
return null;
|
||
},
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: _isSavingEncryption
|
||
? null
|
||
: _saveEncryptionPassword,
|
||
style: ElevatedButton.styleFrom(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
child: _isSavingEncryption
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Обновить ключ шифрования'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Модуль 3: Двухфакторная аутентификация
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(
|
||
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.lock_clock_rounded,
|
||
color: colorScheme.primary,
|
||
size: 28,
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'Двухфакторная защита (TOTP)',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
_isTotpEnabled
|
||
? 'Статус: Активна'
|
||
: 'Статус: Отключена',
|
||
style: TextStyle(
|
||
color: _isTotpEnabled
|
||
? Colors.green
|
||
: colorScheme.outline,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||
style: ElevatedButton.styleFrom(
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
child: Text(_isTotpEnabled ? 'Опции' : 'Включить'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCardSection({required String title, required Widget child}) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: -0.2,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFormInput(
|
||
TextEditingController controller,
|
||
String label,
|
||
bool obscure, {
|
||
String? hint,
|
||
String? Function(String?)? validator,
|
||
}) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 12.0),
|
||
child: TextFormField(
|
||
controller: controller,
|
||
obscureText: obscure,
|
||
validator:
|
||
validator ??
|
||
(v) => (v == null || v.isEmpty) ? 'Обязательное поле' : null,
|
||
decoration: InputDecoration(
|
||
labelText: label,
|
||
hintText: hint,
|
||
border: const OutlineInputBorder(
|
||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 14,
|
||
vertical: 12,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|