Chepuhagram/lib/presentation/screens/security_settings_screen.dart

656 lines
23 KiB
Dart
Raw 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 _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,
),
),
),
);
}
}