Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -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/';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user