feat: secure home credentials

This commit is contained in:
Artem Kokos
2026-04-22 23:25:48 +07:00
parent 6a961209cc
commit 7c0a2675c6
22 changed files with 1782 additions and 397 deletions

View File

@@ -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) =>

View 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));
}

View File

@@ -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) *

View File

@@ -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;
}
}