refactor: implement single-flight JWT renewal with rate-limiting cooldown and fix storage token handling in auth controllers
This commit is contained in:
@@ -82,26 +82,55 @@ class LoginController extends GetxController {
|
||||
// • firstTimeLoadKey != false ← أول مرة يفتح التطبيق → loginFirstTime
|
||||
// • firstTimeLoadKey == false ← مستخدم موجود → loginJwtRider
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
Future<void> getJWT({bool force = false}) async {
|
||||
// إذا كان التوكن الحالي لا يزال صالحاً، لا داعي لطلب واحد جديد
|
||||
static Future<bool>? _jwtFuture;
|
||||
static DateTime _jwtCooldownUntil = DateTime(2000);
|
||||
static int _jwtFailures = 0;
|
||||
|
||||
static Duration get jwtCooldownRemaining {
|
||||
final d = _jwtCooldownUntil.difference(DateTime.now());
|
||||
return d.isNegative ? Duration.zero : d;
|
||||
}
|
||||
|
||||
Future<bool> getJWT({bool force = false}) async {
|
||||
if (_jwtFuture != null) {
|
||||
Log.print('⏳ getJWT: تجديد قيد التنفيذ — إعادة استخدام نفس الـ future.');
|
||||
return _jwtFuture!;
|
||||
}
|
||||
_jwtFuture = _getJwtInternal(force: force).catchError((e) {
|
||||
return _onJwtFailure('exception: $e');
|
||||
});
|
||||
try {
|
||||
return await _jwtFuture!;
|
||||
} finally {
|
||||
_jwtFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _getJwtInternal({bool force = false}) async {
|
||||
if (!force && isTokenValid()) {
|
||||
Log.print("JWT is still valid. Skipping request.");
|
||||
return;
|
||||
_jwtFailures = 0;
|
||||
_jwtCooldownUntil = DateTime(2000);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.now().isBefore(_jwtCooldownUntil)) {
|
||||
Log.print(
|
||||
'🛑 getJWT: بـ cooldown لمدة ${jwtCooldownRemaining.inSeconds}ث — تخطّي التجديد.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dev = Platform.isAndroid ? 'android' : 'ios';
|
||||
|
||||
// تأكد إن البصمة محدّثة قبل أي طلب
|
||||
await DeviceHelper.getDeviceFingerprint();
|
||||
final String fp = box.read(BoxName.deviceFpEncrypted) ?? '';
|
||||
|
||||
final passengerId = box.read(BoxName.passengerID);
|
||||
final isRegistering = passengerId == null || passengerId.toString().isEmpty;
|
||||
|
||||
if (box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
||||
// ── أول تسجيل ─────────────────────────────────────────
|
||||
// نرسل البصمة المشفرة مع باقي البيانات
|
||||
// السيرفر سيعمل hash لها ويخزنها في JWT payload
|
||||
if (isRegistering && box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
||||
var payload = {
|
||||
'id': box.read(BoxName.passengerID) ?? AK.newId,
|
||||
'id': passengerId ?? AK.newId,
|
||||
'password': AK.passnpassenger,
|
||||
'aud': '${AK.allowed}$dev',
|
||||
'fingerPrint': fp,
|
||||
@@ -110,12 +139,14 @@ class LoginController extends GetxController {
|
||||
var response = await http.post(
|
||||
Uri.parse(AppLink.loginFirstTime),
|
||||
body: payload,
|
||||
);
|
||||
Log.print('AppLink.loginFirstTime: ${AppLink.loginFirstTime}');
|
||||
|
||||
Log.print('payload: $payload');
|
||||
Log.print('response code: ${response.statusCode}');
|
||||
Log.print('response body: ${response.body}');
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
|
||||
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
|
||||
Log.print('🛑 getJWT(firstTime): 429 — cooldown ${retryAfter}s');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
@@ -126,18 +157,20 @@ class LoginController extends GetxController {
|
||||
: decoded['jwt']);
|
||||
|
||||
if (jwt != null) {
|
||||
// نشفر الـ JWT بالتشفير الثلاثي قبل التخزين في GetStorage
|
||||
box.write(BoxName.jwt, c(jwt));
|
||||
storage.write(key: BoxName.jwt, value: c(jwt));
|
||||
await storage.write(key: BoxName.jwt, value: jwt);
|
||||
await EncryptionHelper.initialize();
|
||||
return _onJwtSuccess();
|
||||
}
|
||||
|
||||
await EncryptionHelper.initialize();
|
||||
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
|
||||
}
|
||||
return _onJwtFailure('firstTime: HTTP ${response.statusCode}');
|
||||
} else {
|
||||
// ── مستخدم موجود: تجديد التوكن
|
||||
if (isRegistering) {
|
||||
return _onJwtFailure('renew: لا يوجد passengerID');
|
||||
}
|
||||
|
||||
var payload = {
|
||||
'id': box.read(BoxName.passengerID),
|
||||
'id': passengerId,
|
||||
'fingerPrint': fp,
|
||||
'aud': '${AK.allowed}$dev',
|
||||
};
|
||||
@@ -145,11 +178,15 @@ class LoginController extends GetxController {
|
||||
var response = await http.post(
|
||||
Uri.parse(AppLink.loginJwtRider),
|
||||
body: payload,
|
||||
);
|
||||
Log.print('AppLink.loginJwtRider: ${AppLink.loginJwtRider}');
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
|
||||
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
|
||||
Log.print('🛑 getJWT: 429 — cooldown ${retryAfter}ث');
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.print('payload: $payload');
|
||||
Log.print('response: ${response.body}');
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
final String? jwt = decoded['data'] != null
|
||||
@@ -159,16 +196,33 @@ class LoginController extends GetxController {
|
||||
: decoded['jwt']);
|
||||
|
||||
if (jwt != null) {
|
||||
box.write(BoxName.jwt, c(jwt));
|
||||
storage.write(key: BoxName.jwt, value: c(jwt));
|
||||
await storage.write(key: BoxName.jwt, value: jwt);
|
||||
return _onJwtSuccess();
|
||||
}
|
||||
return _onJwtFailure('renew: لا يوجد jwt بالرد');
|
||||
}
|
||||
return _onJwtFailure('renew: HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print('Error in getJWT: $e');
|
||||
return _onJwtFailure('Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _onJwtSuccess() {
|
||||
_jwtFailures = 0;
|
||||
_jwtCooldownUntil = DateTime(2000);
|
||||
Log.print('✅ getJWT: تم توليد توكن جديد بنجاح.');
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _onJwtFailure(String reason) {
|
||||
_jwtFailures++;
|
||||
final seconds = _jwtFailures >= 6 ? 60 : (1 << _jwtFailures);
|
||||
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: seconds));
|
||||
Log.print('❌ getJWT فشل ($reason) — محاولة #$_jwtFailures، cooldown ${seconds}ث');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// التحقق من صلاحية التوكن يدوياً (بدون مكاتب خارجية)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,7 +24,6 @@ class CRUD {
|
||||
final NetGuard _netGuard = NetGuard();
|
||||
final _client = SslPinning.createPinnedClient();
|
||||
|
||||
static bool _isRefreshingJWT = false;
|
||||
static String _lastErrorSignature = '';
|
||||
static DateTime _lastErrorTimestamp = DateTime(2000);
|
||||
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
|
||||
@@ -98,31 +97,50 @@ class CRUD {
|
||||
|
||||
Future<String> _getJwt() async {
|
||||
try {
|
||||
final String? encryptedJwt = await storage.read(key: BoxName.jwt);
|
||||
if (encryptedJwt == null || encryptedJwt.isEmpty) {
|
||||
final String? fallback = box.read(BoxName.jwt);
|
||||
final jwt = await storage.read(key: BoxName.jwt);
|
||||
if (jwt == null || jwt.toString().isEmpty) {
|
||||
// إذا كان التخزين الآمن فارغاً، نحاول استخراج التوكن القديم من GetStorage للركاب القدامى
|
||||
final fallback = box.read(BoxName.jwt);
|
||||
if (fallback != null) {
|
||||
return r(fallback).toString().split(Env.addd)[0];
|
||||
try {
|
||||
return r(fallback).toString().split(Env.addd)[0]; // فك تشفير القديم
|
||||
} catch (_) {
|
||||
return fallback.toString(); // ربما تم تخزينه بدون تشفير
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return r(encryptedJwt).toString().split(Env.addd)[0];
|
||||
} catch (e) {
|
||||
Log.print('Error reading JWT from SecureStorage: $e');
|
||||
final String? fallback = box.read(BoxName.jwt);
|
||||
if (fallback != null) {
|
||||
return r(fallback).toString().split(Env.addd)[0];
|
||||
// التحقق السريع إذا كان التوكن لا يزال مشفراً (يبدأ برموز غريبة وليس ey)
|
||||
if (!jwt.startsWith('ey')) {
|
||||
try {
|
||||
return r(jwt).toString().split(Env.addd)[0];
|
||||
} catch (_) {}
|
||||
}
|
||||
return jwt;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// _ensureJwt — يضمن وجود توكن صالح قبل الإرسال
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
Future<String> _ensureJwt() async {
|
||||
String token = await _getJwt();
|
||||
if (_isJwtValid(token)) return token;
|
||||
|
||||
final ok = await Get.put(LoginController()).getJWT();
|
||||
if (!ok) return '';
|
||||
return await _getJwt();
|
||||
}
|
||||
|
||||
/// Centralized request handler with retry for weak networks.
|
||||
/// For Syria (3G): 60s total timeout, 3 retries, exponential backoff.
|
||||
Future<dynamic> _makeRequest({
|
||||
required String link,
|
||||
Map<String, dynamic>? payload,
|
||||
required Map<String, String> headers,
|
||||
bool allowRefresh = true,
|
||||
}) async {
|
||||
const totalTimeout = Duration(seconds: 60);
|
||||
|
||||
@@ -180,17 +198,33 @@ class CRUD {
|
||||
}
|
||||
}
|
||||
|
||||
// 429 → السيرفر رافض بسبب الضغط؛ ممنوع نجدّد التوكن أو نعيد المحاولة
|
||||
if (sc == 429) {
|
||||
Log.print('🛑 [RES] 429 rate limited — $link');
|
||||
return 'rate_limited';
|
||||
}
|
||||
|
||||
// 401 → تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
|
||||
if (sc == 401) {
|
||||
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
||||
final isNonCritical = link.contains('errorApp.php');
|
||||
if (!_isRefreshingJWT && !isNonCritical) {
|
||||
_isRefreshingJWT = true;
|
||||
try {
|
||||
await Get.put(LoginController()).getJWT();
|
||||
} finally {
|
||||
_isRefreshingJWT = false;
|
||||
}
|
||||
}
|
||||
return 'token_expired';
|
||||
if (isNonCritical || !allowRefresh) return 'token_expired';
|
||||
|
||||
final refreshed = await Get.put(LoginController()).getJWT();
|
||||
if (!refreshed) return 'token_expired';
|
||||
|
||||
final newToken = await _getJwt();
|
||||
if (newToken.isEmpty) return 'token_expired';
|
||||
|
||||
// إعادة الطلب بالتوكن الجديد — allowRefresh: false يمنع أي تكرار إضافي
|
||||
final retryHeaders = Map<String, String>.from(headers)
|
||||
..['Authorization'] = 'Bearer $newToken';
|
||||
return await _makeRequest(
|
||||
link: link,
|
||||
payload: payload,
|
||||
headers: retryHeaders,
|
||||
allowRefresh: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (sc >= 500) {
|
||||
@@ -206,7 +240,14 @@ class CRUD {
|
||||
required String link,
|
||||
Map<String, dynamic>? payload,
|
||||
}) async {
|
||||
String token = await _getJwt();
|
||||
String token = await _ensureJwt();
|
||||
if (token.isEmpty) {
|
||||
// إذا فشل الحصول على توكن، لا ترسل الطلب للباك إند لأنّه سيرفض حتماً.
|
||||
// باستثناء تسجيل الدخول لأنه لا يحتاج توكن
|
||||
if (!link.contains('login') && !link.contains('errorApp.php')) {
|
||||
return 'token_expired';
|
||||
}
|
||||
}
|
||||
|
||||
final headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -221,7 +262,12 @@ class CRUD {
|
||||
required String link,
|
||||
Map<String, dynamic>? payload,
|
||||
}) async {
|
||||
String token = await _getJwt();
|
||||
String token = await _ensureJwt();
|
||||
if (token.isEmpty) {
|
||||
if (!link.contains('login') && !link.contains('errorApp.php')) {
|
||||
return 'token_expired';
|
||||
}
|
||||
}
|
||||
|
||||
final headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
|
||||
Reference in New Issue
Block a user