384 lines
11 KiB
Dart
384 lines
11 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:crypto/crypto.dart' as crypto;
|
|
import 'package:cryptography/cryptography.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:notas/data/sync_models.dart';
|
|
|
|
class ApiConfig {
|
|
ApiConfig._();
|
|
|
|
static const String _endpointKey = 'api_endpoint_v1';
|
|
|
|
/// Default endpoint for local development. Can be overridden by user.
|
|
static const String defaultEndpoint = 'http://localhost:3000/api';
|
|
|
|
static Future<String> getEndpoint() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
return prefs.getString(_endpointKey) ?? defaultEndpoint;
|
|
}
|
|
|
|
static Future<void> setEndpoint(String endpoint) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_endpointKey, endpoint);
|
|
}
|
|
|
|
static Future<void> clearEndpoint() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_endpointKey);
|
|
}
|
|
}
|
|
|
|
class AuthApi {
|
|
AuthApi._();
|
|
|
|
static final AuthApi instance = AuthApi._();
|
|
|
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
|
|
|
static const String _accessTokenKey = 'api_access_token_v1';
|
|
static const String _refreshTokenKey = 'api_refresh_token_v1';
|
|
static const int _passwordHashVersion = 1;
|
|
static const int _kdfIterations = 100000;
|
|
static final Pbkdf2 _kdf = Pbkdf2(
|
|
macAlgorithm: Hmac.sha256(),
|
|
iterations: _kdfIterations,
|
|
bits: 256,
|
|
);
|
|
static final AesGcm _aes = AesGcm.with256bits();
|
|
|
|
Future<String?> get accessToken async =>
|
|
await _secureStorage.read(key: _accessTokenKey);
|
|
|
|
Future<String?> get refreshToken async =>
|
|
await _secureStorage.read(key: _refreshTokenKey);
|
|
|
|
Future<void> clearTokens() async {
|
|
await _secureStorage.delete(key: _accessTokenKey);
|
|
await _secureStorage.delete(key: _refreshTokenKey);
|
|
}
|
|
|
|
String hashPassword(String password) {
|
|
return crypto.sha256.convert(utf8.encode(password)).toString();
|
|
}
|
|
|
|
Future<String> encryptWithPassword(
|
|
String plaintext,
|
|
String password,
|
|
) async {
|
|
final List<int> salt = _randomBytes(16);
|
|
final SecretKey secretKey = await _kdf.deriveKey(
|
|
secretKey: SecretKey(utf8.encode(password)),
|
|
nonce: salt,
|
|
);
|
|
|
|
final List<int> nonce = _randomBytes(12);
|
|
final SecretBox box = await _aes.encrypt(
|
|
utf8.encode(plaintext),
|
|
secretKey: secretKey,
|
|
nonce: nonce,
|
|
);
|
|
|
|
return jsonEncode(<String, dynamic>{
|
|
'v': _passwordHashVersion,
|
|
'salt': base64Encode(salt),
|
|
'nonce': base64Encode(box.nonce),
|
|
'cipherText': base64Encode(box.cipherText),
|
|
'mac': base64Encode(box.mac.bytes),
|
|
});
|
|
}
|
|
|
|
Future<String> decryptWithPassword(
|
|
String encodedBox,
|
|
String password,
|
|
) async {
|
|
final Map<String, dynamic> payload = jsonDecode(encodedBox) as Map<String, dynamic>;
|
|
final List<int> salt = base64Decode(payload['salt'] as String);
|
|
final List<int> nonce = base64Decode(payload['nonce'] as String);
|
|
final List<int> cipherText = base64Decode(payload['cipherText'] as String);
|
|
final List<int> macBytes = base64Decode(payload['mac'] as String);
|
|
|
|
final SecretKey secretKey = await _kdf.deriveKey(
|
|
secretKey: SecretKey(utf8.encode(password)),
|
|
nonce: salt,
|
|
);
|
|
|
|
final SecretBox box = SecretBox(
|
|
cipherText,
|
|
nonce: nonce,
|
|
mac: Mac(macBytes),
|
|
);
|
|
|
|
final List<int> clearText = await _aes.decrypt(
|
|
box,
|
|
secretKey: secretKey,
|
|
);
|
|
|
|
return utf8.decode(clearText);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> login(
|
|
String username,
|
|
String password, {
|
|
String? deviceName,
|
|
String? endpoint,
|
|
}) async {
|
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
|
final Uri url = Uri.parse('$base/auth/login');
|
|
|
|
final Map<String, dynamic> body = {
|
|
'username': username,
|
|
'password': hashPassword(password),
|
|
};
|
|
|
|
if (deviceName != null) body['deviceName'] = deviceName;
|
|
|
|
final http.Response res = await http.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode(body),
|
|
);
|
|
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
final Map<String, dynamic> json =
|
|
jsonDecode(res.body) as Map<String, dynamic>;
|
|
|
|
final String? access = json['accessToken'] as String?;
|
|
final String? refresh = json['refreshToken'] as String?;
|
|
|
|
if (access != null) {
|
|
await _secureStorage.write(key: _accessTokenKey, value: access);
|
|
}
|
|
|
|
if (refresh != null) {
|
|
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
// Try to decode error body for better diagnostics.
|
|
try {
|
|
final dynamic decoded = jsonDecode(res.body);
|
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
|
} catch (_) {
|
|
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> register(
|
|
String username,
|
|
String password, {
|
|
String? encryptedMasterKey,
|
|
String? endpoint,
|
|
}) async {
|
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
|
final Uri url = Uri.parse('$base/auth/register');
|
|
|
|
final Map<String, dynamic> body = {
|
|
'username': username,
|
|
'password': hashPassword(password),
|
|
'encrypted_master_key': encryptedMasterKey,
|
|
};
|
|
|
|
final http.Response res = await http.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode(body),
|
|
);
|
|
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
final Map<String, dynamic> json =
|
|
jsonDecode(res.body) as Map<String, dynamic>;
|
|
|
|
final String? access = json['accessToken'] as String?;
|
|
final String? refresh = json['refreshToken'] as String?;
|
|
|
|
if (access != null) {
|
|
await _secureStorage.write(key: _accessTokenKey, value: access);
|
|
}
|
|
|
|
if (refresh != null) {
|
|
await _secureStorage.write(key: _refreshTokenKey, value: refresh);
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
try {
|
|
final dynamic decoded = jsonDecode(res.body);
|
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
|
} catch (_) {
|
|
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> deleteAllServerData({String? endpoint}) async {
|
|
final String? token = await accessToken;
|
|
if (token == null) {
|
|
return {'error': true, 'message': 'No access token available'};
|
|
}
|
|
|
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
|
final Uri url = Uri.parse('$base/auth/delete-all-data');
|
|
|
|
final http.Response res = await http.post(
|
|
url,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $token',
|
|
},
|
|
);
|
|
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
return {'error': false, 'status': res.statusCode, 'body': res.body};
|
|
}
|
|
|
|
try {
|
|
final dynamic decoded = jsonDecode(res.body);
|
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
|
} catch (_) {
|
|
return {'error': true, 'status': res.statusCode, 'body': res.body};
|
|
}
|
|
}
|
|
|
|
List<int> _randomBytes(int length) {
|
|
final Random random = Random.secure();
|
|
return List<int>.generate(length, (_) => random.nextInt(256));
|
|
}
|
|
|
|
// ========== Sync API ==========
|
|
|
|
static const String _lastSyncAtKey = 'api_last_sync_at_v1';
|
|
|
|
Future<DateTime?> getLastSyncAt() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final String? timestamp = prefs.getString(_lastSyncAtKey);
|
|
if (timestamp == null) return null;
|
|
return DateTime.tryParse(timestamp);
|
|
}
|
|
|
|
Future<void> setLastSyncAt(DateTime timestamp) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_lastSyncAtKey, timestamp.toIso8601String());
|
|
}
|
|
|
|
Future<Map<String, dynamic>> sync(
|
|
SyncRequest syncRequest, {
|
|
String? endpoint,
|
|
}) async {
|
|
final String? token = await accessToken;
|
|
if (token == null) {
|
|
return {'error': true, 'message': 'No access token available'};
|
|
}
|
|
|
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
|
final Uri url = Uri.parse('$base/sync');
|
|
|
|
final Map<String, String> headers = {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $token',
|
|
};
|
|
|
|
final String bodyJson = jsonEncode(syncRequest.toJson());
|
|
|
|
// Log request (mask authorization header)
|
|
final Map<String, String> logHeaders = Map.from(headers);
|
|
if (logHeaders.containsKey('Authorization')) {
|
|
logHeaders['Authorization'] = 'REDACTED';
|
|
}
|
|
debugPrint('SYNC REQUEST -> POST $url');
|
|
debugPrint('Headers: $logHeaders');
|
|
debugPrint('Body: $bodyJson');
|
|
|
|
final http.Response res = await http.post(
|
|
url,
|
|
headers: headers,
|
|
body: bodyJson,
|
|
);
|
|
|
|
// Log response
|
|
debugPrint('SYNC RESPONSE <- ${res.statusCode}');
|
|
debugPrint('Response body: ${res.body}');
|
|
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
try {
|
|
final Map<String, dynamic> json =
|
|
jsonDecode(res.body) as Map<String, dynamic>;
|
|
return {'error': false, 'data': SyncResponse.fromJson(json)};
|
|
} catch (e, st) {
|
|
debugPrint('SYNC PARSE ERROR -> $e');
|
|
debugPrint(st.toString());
|
|
debugPrint('SYNC PARSE RAW BODY -> ${res.body}');
|
|
return {
|
|
'error': true,
|
|
'message': 'Error parseando respuesta de sync: $e',
|
|
'exception': e.toString(),
|
|
'stackTrace': st.toString(),
|
|
'body': res.body,
|
|
'status': res.statusCode,
|
|
};
|
|
}
|
|
}
|
|
|
|
// If token expired (401), try to refresh
|
|
if (res.statusCode == 401) {
|
|
final String? refreshTok = await refreshToken;
|
|
if (refreshTok != null) {
|
|
final bool refreshed = await _refreshAccessToken(refreshTok, endpoint: endpoint);
|
|
if (refreshed) {
|
|
// Retry sync with new token
|
|
return sync(syncRequest, endpoint: endpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to decode error body for better diagnostics.
|
|
try {
|
|
final dynamic decoded = jsonDecode(res.body);
|
|
return {'error': true, 'status': res.statusCode, 'body': decoded};
|
|
} catch (e, st) {
|
|
debugPrint('SYNC HTTP ERROR PARSE FAILED -> $e');
|
|
debugPrint(st.toString());
|
|
return {
|
|
'error': true,
|
|
'status': res.statusCode,
|
|
'body': res.body,
|
|
'exception': e.toString(),
|
|
'stackTrace': st.toString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
Future<bool> _refreshAccessToken(
|
|
String refreshToken, {
|
|
String? endpoint,
|
|
}) async {
|
|
final String base = endpoint ?? await ApiConfig.getEndpoint();
|
|
final Uri url = Uri.parse('$base/auth/refresh');
|
|
|
|
final http.Response res = await http.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({'refreshToken': refreshToken}),
|
|
);
|
|
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
final Map<String, dynamic> json =
|
|
jsonDecode(res.body) as Map<String, dynamic>;
|
|
final String? newAccess = json['accessToken'] as String?;
|
|
|
|
if (newAccess != null) {
|
|
await _secureStorage.write(key: _accessTokenKey, value: newAccess);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|