Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
@@ -0,0 +1,9 @@
/// Single source of truth for the API base URL.
///
/// It used to be duplicated as a literal in every place that needed a bare Dio,
/// which meant a URL change had to be found in several files.
class ApiConfig {
const ApiConfig._();
static const String baseUrl = 'https://musadaq.intaleqapp.com/api/v1/';
}
+2 -1
View File
@@ -1,9 +1,10 @@
import 'package:dio/dio.dart';
import 'api_config.dart';
import 'hmac_interceptor.dart';
import '../storage/secure_storage.dart';
class DioClient {
static const String baseUrl = 'https://musadaq.intaleqapp.com/api/v1/'; // Update with actual URL
static const String baseUrl = ApiConfig.baseUrl;
late final Dio dio;
DioClient() {
@@ -1,15 +1,32 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../storage/secure_storage.dart';
import 'package:get/get.dart';
import '../storage/secure_storage.dart';
import '../utils/logger.dart';
import 'api_config.dart';
/// Signs outgoing requests with the per-device secret and attaches the JWT.
///
/// The signature format must match HmacMiddleware on the server exactly:
/// payload = "METHOD:path:timestampMs[:jsonBody]"
/// signature = HMAC-SHA256(payload, device_secret) (lowercase hex)
/// headers = X-Signature, X-Timestamp
///
/// Bodies that are not JSON maps (FormData uploads) are signed without a body
/// segment, because there is no canonical string form to hash.
class HmacInterceptor extends Interceptor {
final SecureStorage secureStorage;
HmacInterceptor(this.secureStorage);
/// Guards against several parallel 401s all firing a refresh at once.
static Future<bool>? _inFlightRefresh;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
final token = await secureStorage.getToken();
final deviceSecret = await secureStorage.getDeviceSecret();
@@ -20,35 +37,123 @@ class HmacInterceptor extends Interceptor {
// Only sign if we have a device secret (after login)
if (deviceSecret != null) {
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
// Create signature payload
String payload = '${options.method}:${options.path}:$timestamp';
// Include body in signature if present
if (options.data != null && options.data is Map) {
final method = options.method.toUpperCase();
final path = canonicalPath(options.path);
var payload = '$method:$path:$timestamp';
if (options.data is Map) {
payload += ':${jsonEncode(options.data)}';
}
// Generate HMAC-SHA256
final key = utf8.encode(deviceSecret);
final bytes = utf8.encode(payload);
final hmac = Hmac(sha256, key);
final digest = hmac.convert(bytes);
final hmac = Hmac(sha256, utf8.encode(deviceSecret));
final digest = hmac.convert(utf8.encode(payload));
// Attach headers
options.headers['X-Timestamp'] = timestamp;
options.headers['X-Signature'] = digest.toString();
}
super.onRequest(options, handler);
handler.next(options);
}
/// Strips a leading slash and any api/v1 prefix so client and server hash the
/// same string regardless of how the base URL is configured.
static String canonicalPath(String path) {
var p = path;
if (p.startsWith('http')) {
p = Uri.parse(p).path;
}
p = p.split('?').first;
p = p.replaceFirst(RegExp(r'^/+'), '');
for (final prefix in ['api/v1/', 'api/', 'v1/']) {
if (p.startsWith(prefix)) {
p = p.substring(prefix.length);
break;
}
}
return p;
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
// Handle Token Expiry / Unauthorized
// TODO: Trigger logout or token refresh
void onError(DioException err, ErrorInterceptorHandler handler) async {
final status = err.response?.statusCode;
final path = err.requestOptions.path;
// Only try to recover from an expired access token, and never for the auth
// endpoints themselves (that would recurse).
final isAuthCall = path.contains('auth/');
if (status != 401 || isAuthCall) {
return handler.next(err);
}
final refreshed = await _refreshSession();
if (!refreshed) {
// The session is genuinely gone. Clear it and send the user to login once.
// Previously this branch was an empty TODO, so the app just surfaced
// unexplained errors forever once the 30-day JWT expired.
await secureStorage.clearAll();
if (Get.currentRoute != '/login') {
Get.offAllNamed('/login');
}
return handler.next(err);
}
// Retry the original request once with the fresh token.
try {
final retryDio = Dio(BaseOptions(baseUrl: ApiConfig.baseUrl));
retryDio.interceptors.add(HmacInterceptor(secureStorage));
final response = await retryDio.fetch(err.requestOptions);
return handler.resolve(response);
} catch (e) {
AppLogger.error('Retry after token refresh failed', e);
return handler.next(err);
}
}
/// Exchanges the stored refresh token for a new access token.
/// Concurrent callers share a single in-flight request.
Future<bool> _refreshSession() {
return _inFlightRefresh ??= _doRefresh().whenComplete(() {
_inFlightRefresh = null;
});
}
Future<bool> _doRefresh() async {
try {
final refreshToken = await secureStorage.getRefreshToken();
final deviceId = await secureStorage.getDeviceId();
if (refreshToken == null || deviceId == null) return false;
// A bare Dio: this request must not pass back through this interceptor.
final dio = Dio(BaseOptions(
baseUrl: ApiConfig.baseUrl,
headers: {'Content-Type': 'application/json'},
));
final res = await dio.post('auth/refresh', data: {
'refresh_token': refreshToken,
'device_id': deviceId,
});
final data = res.data is Map ? res.data['data'] : null;
final newAccess = data is Map ? data['access_token'] as String? : null;
if (newAccess == null) return false;
await secureStorage.saveToken(newAccess);
final newRefresh = data is Map ? data['refresh_token'] as String? : null;
if (newRefresh != null && newRefresh.isNotEmpty) {
await secureStorage.saveRefreshToken(newRefresh);
}
AppLogger.print('Access token refreshed');
return true;
} catch (e) {
AppLogger.error('Token refresh failed', e);
return false;
}
super.onError(err, handler);
}
}