feat: secure home credentials
This commit is contained in:
@@ -6,22 +6,36 @@ class IgnisApi {
|
||||
final Dio _dio = Dio();
|
||||
Dio get dioInstance => _dio;
|
||||
|
||||
/// Инициализация базового URL и API-ключа
|
||||
void init(String baseUrl, String apiKey) {
|
||||
String url = baseUrl.trim();
|
||||
if (!url.startsWith('http')) {
|
||||
static String normalizeBaseUrl(String baseUrl) {
|
||||
var url = baseUrl.trim();
|
||||
final lowerUrl = url.toLowerCase();
|
||||
if (!lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) {
|
||||
url = 'https://$url';
|
||||
}
|
||||
// Убираем trailing slash
|
||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||
while (url.endsWith('/')) {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
_dio.options.baseUrl = url;
|
||||
/// Инициализация базового URL и API-ключа
|
||||
void init(String baseUrl, String apiKey) {
|
||||
_dio.options.baseUrl = normalizeBaseUrl(baseUrl);
|
||||
_dio.options.headers['X-API-Key'] = apiKey;
|
||||
// Бэкенд WiZ ламп тормозит -- даём запас
|
||||
_dio.options.connectTimeout = const Duration(seconds: 15);
|
||||
_dio.options.receiveTimeout = const Duration(seconds: 15);
|
||||
}
|
||||
|
||||
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
||||
final probe = IgnisApi()..init(baseUrl, apiKey);
|
||||
try {
|
||||
await probe.getAuthMe();
|
||||
} finally {
|
||||
probe.dioInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Авторизация ───────────────────────────────────────────
|
||||
|
||||
/// Проверка текущего ключа: возвращает {is_admin, name}
|
||||
@@ -40,7 +54,10 @@ class IgnisApi {
|
||||
|
||||
/// Создать группу
|
||||
Future<Response> createGroup(String id, String name, List<String> macs) =>
|
||||
_dio.post('/devices/groups', data: {'id': id, 'name': name, 'macs': macs});
|
||||
_dio.post(
|
||||
'/devices/groups',
|
||||
data: {'id': id, 'name': name, 'macs': macs},
|
||||
);
|
||||
|
||||
/// Удалить группу
|
||||
Future<Response> deleteGroup(String groupId) =>
|
||||
@@ -85,8 +102,7 @@ class IgnisApi {
|
||||
Future<Response> getTasks() => _dio.get('/schedules/tasks');
|
||||
|
||||
/// Отменить задачу расписания
|
||||
Future<Response> cancelTask(String jobId) =>
|
||||
_dio.delete('/schedules/$jobId');
|
||||
Future<Response> cancelTask(String jobId) => _dio.delete('/schedules/$jobId');
|
||||
|
||||
// ─── API-ключи ─────────────────────────────────────────────
|
||||
|
||||
@@ -94,11 +110,8 @@ class IgnisApi {
|
||||
Future<Response> getApiKeys() => _dio.get('/api-keys');
|
||||
|
||||
/// Создать гостевой ключ
|
||||
Future<Response> createApiKey(String name, {bool isAdmin = false}) =>
|
||||
_dio.post('/api-keys', queryParameters: {
|
||||
'name': name,
|
||||
'is_admin': isAdmin,
|
||||
});
|
||||
Future<Response> createApiKey(String name, {bool isAdmin = false}) => _dio
|
||||
.post('/api-keys', queryParameters: {'name': name, 'is_admin': isAdmin});
|
||||
|
||||
/// Отозвать ключ (body: {key: ...})
|
||||
Future<Response> revokeApiKey(String key) =>
|
||||
|
||||
28
lib/services/credentials_storage.dart
Normal file
28
lib/services/credentials_storage.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
abstract class CredentialsStorage {
|
||||
Future<String?> getApiKey(String homeId);
|
||||
|
||||
Future<void> setApiKey(String homeId, String apiKey);
|
||||
|
||||
Future<void> deleteApiKey(String homeId);
|
||||
}
|
||||
|
||||
class SecureCredentialsStorage implements CredentialsStorage {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
static String _apiKeyStorageKey(String homeId) =>
|
||||
'ignis_home_api_key_$homeId';
|
||||
|
||||
@override
|
||||
Future<String?> getApiKey(String homeId) =>
|
||||
_storage.read(key: _apiKeyStorageKey(homeId));
|
||||
|
||||
@override
|
||||
Future<void> setApiKey(String homeId, String apiKey) =>
|
||||
_storage.write(key: _apiKeyStorageKey(homeId), value: apiKey);
|
||||
|
||||
@override
|
||||
Future<void> deleteApiKey(String homeId) =>
|
||||
_storage.delete(key: _apiKeyStorageKey(homeId));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -10,6 +11,7 @@ const double geofenceThresholdMeters = 500.0;
|
||||
|
||||
/// Ключ в SharedPreferences: геофенс уже сработал, таск можно не запускать
|
||||
const String _firedKey = 'ignis_geofence_fired';
|
||||
const String _apiKeyPrefix = 'ignis_home_api_key_';
|
||||
|
||||
/// Имя задачи в workmanager
|
||||
const String geofenceTaskName = 'ignis_geofence_check';
|
||||
@@ -93,8 +95,12 @@ Future<bool> executeGeofenceCheck() async {
|
||||
// 4. Считаем расстояние
|
||||
final homeLat = (targetHome['latitude'] as num).toDouble();
|
||||
final homeLon = (targetHome['longitude'] as num).toDouble();
|
||||
final distMeters =
|
||||
_haversineMeters(pos.latitude, pos.longitude, homeLat, homeLon);
|
||||
final distMeters = _haversineMeters(
|
||||
pos.latitude,
|
||||
pos.longitude,
|
||||
homeLat,
|
||||
homeLon,
|
||||
);
|
||||
|
||||
if (distMeters <= geofenceThresholdMeters) {
|
||||
return true; // всё ещё рядом с домом
|
||||
@@ -102,7 +108,8 @@ Future<bool> executeGeofenceCheck() async {
|
||||
|
||||
// 5. Ушли за порог -- выключаем все группы
|
||||
final url = _normalizeUrl(targetHome['url'] as String);
|
||||
final apiKey = targetHome['apiKey'] as String;
|
||||
final apiKey = await _getHomeApiKey(targetHome);
|
||||
if (apiKey == null || apiKey.isEmpty) return true;
|
||||
final homeName = (targetHome['name'] ?? 'Дом') as String;
|
||||
|
||||
int groupCount = 0;
|
||||
@@ -122,7 +129,8 @@ Future<bool> executeGeofenceCheck() async {
|
||||
: '${(distMeters / 1000).toStringAsFixed(1)} км';
|
||||
await _showNotification(
|
||||
title: 'Свет выключен',
|
||||
body: '$homeName -- вы ушли на $distText. '
|
||||
body:
|
||||
'$homeName -- вы ушли на $distText. '
|
||||
'Выключено групп: $groupCount.',
|
||||
);
|
||||
|
||||
@@ -133,6 +141,19 @@ Future<bool> executeGeofenceCheck() async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHomeApiKey(Map<String, dynamic> home) async {
|
||||
final id = home['id']?.toString();
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
const secureStorage = FlutterSecureStorage();
|
||||
final secureKey = await secureStorage.read(key: '$_apiKeyPrefix$id');
|
||||
if (secureKey != null && secureKey.isNotEmpty) return secureKey;
|
||||
|
||||
// Backward compatibility: if the app has not run after migration yet,
|
||||
// old background tasks can still read the legacy key once.
|
||||
return home['apiKey']?.toString();
|
||||
}
|
||||
|
||||
/// Сбросить флаг "сработал" -- вызывать при включении геофенса
|
||||
/// или при возврате в приложение.
|
||||
Future<void> resetGeofenceFired() async {
|
||||
@@ -183,12 +204,14 @@ Future<void> _showNotification({
|
||||
|
||||
/// Выключить все группы на сервере. Возвращает кол-во выключенных.
|
||||
Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
headers: {'X-API-Key': apiKey},
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
headers: {'X-API-Key': apiKey},
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Получаем список групп
|
||||
@@ -212,14 +235,19 @@ Future<int> _turnOffAllGroups(String baseUrl, String apiKey) async {
|
||||
|
||||
// Выключаем каждую группу
|
||||
int success = 0;
|
||||
await Future.wait(groupIds.map((id) async {
|
||||
try {
|
||||
await dio.post('/control/group/$id', queryParameters: {'state': false});
|
||||
success++;
|
||||
} catch (_) {
|
||||
// Одна группа упала -- не останавливаем остальные
|
||||
}
|
||||
}));
|
||||
await Future.wait(
|
||||
groupIds.map((id) async {
|
||||
try {
|
||||
await dio.post(
|
||||
'/control/group/$id',
|
||||
queryParameters: {'state': false},
|
||||
);
|
||||
success++;
|
||||
} catch (_) {
|
||||
// Одна группа упала -- не останавливаем остальные
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return success;
|
||||
} finally {
|
||||
@@ -236,12 +264,12 @@ String _normalizeUrl(String url) {
|
||||
}
|
||||
|
||||
/// Расстояние в метрах (Haversine)
|
||||
double _haversineMeters(
|
||||
double lat1, double lon1, double lat2, double lon2) {
|
||||
double _haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||
const earthRadiusM = 6371000.0;
|
||||
final dLat = _degToRad(lat2 - lat1);
|
||||
final dLon = _degToRad(lon2 - lon1);
|
||||
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
final a =
|
||||
math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_degToRad(lat1)) *
|
||||
math.cos(_degToRad(lat2)) *
|
||||
math.sin(dLon / 2) *
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/home_config.dart';
|
||||
import 'credentials_storage.dart';
|
||||
|
||||
/// Сервис для хранения списка "домов" и текущего выбранного.
|
||||
/// Данные лежат в SharedPreferences как JSON-массив.
|
||||
/// Несекретные данные лежат в SharedPreferences, API-ключи -- отдельно.
|
||||
class SettingsService {
|
||||
static const String _homesKey = 'ignis_homes';
|
||||
static const String _currentHomeKey = 'ignis_current_home_id';
|
||||
|
||||
final CredentialsStorage _credentialsStorage;
|
||||
|
||||
SettingsService({CredentialsStorage? credentialsStorage})
|
||||
: _credentialsStorage = credentialsStorage ?? SecureCredentialsStorage();
|
||||
|
||||
/// Загрузить все дома
|
||||
Future<List<HomeConfig>> getHomes() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_homesKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list.map((e) => HomeConfig.fromJson(e as Map<String, dynamic>)).toList();
|
||||
final migrated = await _migrateApiKeysIfNeeded(prefs, list);
|
||||
return migrated.map(HomeConfig.fromJson).toList();
|
||||
}
|
||||
|
||||
/// Сохранить весь список домов
|
||||
Future<void> saveHomes(List<HomeConfig> homes) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_homesKey, jsonEncode(homes.map((h) => h.toJson()).toList()));
|
||||
await prefs.setString(
|
||||
_homesKey,
|
||||
jsonEncode(homes.map((h) => h.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Добавить или обновить дом
|
||||
Future<void> upsertHome(HomeConfig home) async {
|
||||
Future<void> upsertHome(HomeConfig home, {String? apiKey}) async {
|
||||
final homes = await getHomes();
|
||||
final idx = homes.indexWhere((h) => h.id == home.id);
|
||||
if (idx >= 0) {
|
||||
@@ -32,6 +42,9 @@ class SettingsService {
|
||||
} else {
|
||||
homes.add(home);
|
||||
}
|
||||
if (apiKey != null) {
|
||||
await setHomeApiKey(home.id, apiKey);
|
||||
}
|
||||
await saveHomes(homes);
|
||||
}
|
||||
|
||||
@@ -40,6 +53,7 @@ class SettingsService {
|
||||
final homes = await getHomes();
|
||||
homes.removeWhere((h) => h.id == id);
|
||||
await saveHomes(homes);
|
||||
await deleteHomeApiKey(id);
|
||||
|
||||
// Если удалили текущий -- сбрасываем выбор
|
||||
final currentId = await getCurrentHomeId();
|
||||
@@ -75,4 +89,53 @@ class SettingsService {
|
||||
return homes.isNotEmpty ? homes.first : null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getHomeApiKey(String homeId) =>
|
||||
_credentialsStorage.getApiKey(homeId);
|
||||
|
||||
Future<String> requireHomeApiKey(String homeId) async {
|
||||
final key = await getHomeApiKey(homeId);
|
||||
if (key == null || key.isEmpty) {
|
||||
throw StateError('API key is missing for home $homeId');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
Future<void> setHomeApiKey(String homeId, String apiKey) =>
|
||||
_credentialsStorage.setApiKey(homeId, apiKey);
|
||||
|
||||
Future<void> deleteHomeApiKey(String homeId) =>
|
||||
_credentialsStorage.deleteApiKey(homeId);
|
||||
|
||||
Future<List<Map<String, dynamic>>> _migrateApiKeysIfNeeded(
|
||||
SharedPreferences prefs,
|
||||
List<dynamic> rawList,
|
||||
) async {
|
||||
var changed = false;
|
||||
final result = <Map<String, dynamic>>[];
|
||||
|
||||
for (final item in rawList) {
|
||||
final map = Map<String, dynamic>.from(item as Map);
|
||||
final id = map['id']?.toString();
|
||||
final legacyApiKey = map['apiKey']?.toString();
|
||||
|
||||
if (id != null && legacyApiKey != null && legacyApiKey.isNotEmpty) {
|
||||
final existingKey = await getHomeApiKey(id);
|
||||
if (existingKey == null || existingKey.isEmpty) {
|
||||
await setHomeApiKey(id, legacyApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (map.remove('apiKey') != null) {
|
||||
changed = true;
|
||||
}
|
||||
result.add(map);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await prefs.setString(_homesKey, jsonEncode(result));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user