refactor: implement single-flight JWT renewal with rate-limiting cooldown and fix storage token handling in auth controllers

This commit is contained in:
Hamza-Ayed
2026-08-02 01:39:21 +03:00
parent c5f8c6cd8e
commit cc9c5885b3
4 changed files with 325 additions and 121 deletions
@@ -141,7 +141,9 @@ class LoginDriverController extends GetxController {
}
var dev = '';
Future<String>? _walletJwtFuture;
// static: لأن CRUD ينشئ LoginDriverController() جديد بكل طلب،
// فلو كانت instance field ما بيشتغل الـ single-flight أبداً.
static Future<String>? _walletJwtFuture;
getJwtWallet() async {
if (_walletJwtFuture != null) {
@@ -228,7 +230,41 @@ class LoginDriverController extends GetxController {
return '';
}
getJWT() async {
// ═══════════════════════════════════════════════════════════════
// getJWT — تجديد توكن السائق
// • single-flight: طلب تجديد واحد فقط بنفس اللحظة (static)
// • بيرجّع true/false حتى المستدعي يعرف نجح ولا لأ
// • cooldown بعد الفشل: يمنع ضرب /loginJwtDriver.php (حده 5/دقيقة)
// بحلقة لا نهائية تنتهي بـ 429 "Please slow down"
// ═══════════════════════════════════════════════════════════════
static Future<bool>? _jwtFuture;
static DateTime _jwtCooldownUntil = DateTime(2000);
static int _jwtFailures = 0;
/// الوقت المتبقّي على انتهاء الـ cooldown (0 يعني مسموح التجديد)
static Duration get jwtCooldownRemaining {
final d = _jwtCooldownUntil.difference(DateTime.now());
return d.isNegative ? Duration.zero : d;
}
Future<bool> getJWT() async {
if (_jwtFuture != null) {
Log.print('⏳ getJWT: تجديد قيد التنفيذ — إعادة استخدام نفس الـ future.');
return _jwtFuture!;
}
_jwtFuture = _getJwtInternal().catchError((e) {
// أي استثناء (timeout/socket) لازم يتحوّل لـ false + cooldown،
// مش يطلع للمستدعي ويكسر الطلب اللي فوق
return _onJwtFailure('exception: $e');
});
try {
return await _jwtFuture!;
} finally {
_jwtFuture = null;
}
}
Future<bool> _getJwtInternal() async {
await EncryptionHelper.initialize();
// 1. Check secure storage first to avoid redundant API calls
@@ -259,14 +295,31 @@ class LoginDriverController extends GetxController {
if (isTokenValid) {
Log.print('🔑 Valid JWT found in secure storage. Skipping generation.');
return;
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
return true;
}
}
// التوكن منتهي فعلاً — بس لو آخر محاولة فشلت لازم ننتظر قبل ما نعيد
if (DateTime.now().isBefore(_jwtCooldownUntil)) {
Log.print(
'🛑 getJWT: بـ cooldown لمدة ${jwtCooldownRemaining.inSeconds}ث — تخطّي التجديد.');
return false;
}
dev = Platform.isAndroid ? 'android' : 'ios';
Log.print(
'box.read(BoxName.firstTimeLoadKey): ${box.read(BoxName.firstTimeLoadKey)}');
if (box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
// ⚠️ loginFirstTimeDriver.php بيرجّع توكن نوعه "registration" (صالح ساعة
// وone-time)، وباقي الـ endpoints بترفضه بـ 401. فما بنستخدمه إلا لما ما
// يكون عنا driverID أصلاً (سائق لسا ما تسجّل). أي سائق عنده ID → تجديد عادي
// عبر loginJwtDriver.php حتى لو firstTimeLoadKey ما انكتب.
final driverId = box.read(BoxName.driverID);
final isRegistering =
driverId == null || driverId.toString().isEmpty;
if (isRegistering && box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
var payload = {
'id': box.read(BoxName.driverID) ?? AK.newId,
'password': AK.passnpassenger,
@@ -276,12 +329,21 @@ class LoginDriverController extends GetxController {
};
// Log.print('payload: ${payload}');
var response0 = await http.post(
Uri.parse(AppLink.loginFirstTimeDriver),
body: payload,
);
var response0 = await http
.post(
Uri.parse(AppLink.loginFirstTimeDriver),
body: payload,
)
.timeout(const Duration(seconds: 30));
Log.print('response0: ${response0.body}');
Log.print('request: ${response0.request}');
if (response0.statusCode == 429) {
final retryAfter =
int.tryParse(response0.headers['retry-after'] ?? '') ?? 60;
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
Log.print('🛑 getJWT(firstTime): 429 — cooldown ${retryAfter}s');
return false;
}
if (response0.statusCode == 200) {
final decodedResponse1 = jsonDecode(response0.body);
Log.print('decodedResponse1: ${decodedResponse1}');
@@ -297,31 +359,47 @@ class LoginDriverController extends GetxController {
if (jwt != null) {
// box.write(BoxName.jwt, c(jwt));
await storage.write(key: BoxName.jwt, value: jwt);
// ✅ بعد التأكد أن كل المفاتيح موجودة
await EncryptionHelper.initialize();
return _onJwtSuccess();
}
// ✅ بعد التأكد أن كل المفاتيح موجودة
await EncryptionHelper.initialize();
// await AppInitializer().getKey();
} else {}
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
}
return _onJwtFailure('firstTime: HTTP ${response0.statusCode}');
} else {
await EncryptionHelper.initialize();
// بدون driverID التجديد مضمون الفشل — نوقف بدون ما نضرب السيرفر
if (isRegistering) {
return _onJwtFailure('renew: لا يوجد driverID');
}
var payload = {
'id': box.read(BoxName.driverID),
'id': driverId,
'password': box.read(BoxName.emailDriver),
'aud': '${AK.allowed}$dev',
'fingerPrint': box.read(BoxName.deviceFingerprint) ??
await DeviceHelper.getDeviceFingerprint(),
};
// print(payload);
var response1 = await http.post(
Uri.parse(AppLink.loginJwtDriver),
body: payload,
);
var response1 = await http
.post(
Uri.parse(AppLink.loginJwtDriver),
body: payload,
)
.timeout(const Duration(seconds: 30));
Log.print('response1.request: ${response1.request}');
Log.print('response1.body: ${response1.body}');
// 429 → السيرفر عامل rate limit؛ نحترم Retry-After ولا نعيد المحاولة
if (response1.statusCode == 429) {
final retryAfter =
int.tryParse(response1.headers['retry-after'] ?? '') ?? 60;
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
Log.print('🛑 getJWT: 429 — cooldown ${retryAfter}ث');
return false;
}
if (response1.statusCode == 200) {
final decodedResponse1 = jsonDecode(response1.body);
// Log.print('decodedResponse1: ${decodedResponse1}');
@@ -337,13 +415,32 @@ class LoginDriverController extends GetxController {
if (jwt != null) {
// await box.write(BoxName.jwt, c(jwt));
await storage.write(key: BoxName.jwt, value: jwt);
return _onJwtSuccess();
}
// await AppInitializer().getKey();
return _onJwtFailure('renew: لا يوجد jwt بالرد');
}
return _onJwtFailure('renew: HTTP ${response1.statusCode}');
}
}
// نجاح التجديد → تصفير العدّاد والـ cooldown
bool _onJwtSuccess() {
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
Log.print('✅ getJWT: تم توليد توكن جديد.');
return true;
}
// فشل التجديد → backoff تصاعدي (2,4,8,16,32,60ث كحد أقصى)
// هذا هو اللي بيمنع الحلقة اللانهائية اللي بتوصل لـ rate limit
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;
}
Future<void> getLocationPermission() async {
var status = await Permission.locationAlways.status;
if (!status.isGranted) {
@@ -654,9 +751,11 @@ class LoginDriverController extends GetxController {
var jwt = jsonDecoeded['jwt'];
// حفظ التوكن أولاً
// ⚠️ لازم يُخزَّن خام (بدون c()) — CRUD._getJwt() بيقرأه مباشرة
// ويحطه بـ Authorization، ولو كان مشفّر بصير كل طلب 401 → حلقة تجديد
if (jwt != null) {
box.write(BoxName.jwt, c(jwt));
await storage.write(key: BoxName.jwt, value: c(jwt));
box.write(BoxName.jwt, jwt);
await storage.write(key: BoxName.jwt, value: jwt.toString());
}
box.write(BoxName.emailDriver, (d['email']));
@@ -22,7 +22,6 @@ import 'ssl_pinning.dart';
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);
@@ -107,6 +106,21 @@ class CRUD {
}
}
// ═══════════════════════════════════════════════════════════════
// _ensureJwt — يضمن وجود توكن صالح قبل الإرسال
// • getJWT() نفسها single-flight + فيها cooldown بعد الفشل،
// فما في داعي لأي flag هون — ولا في خطر حلقة لا نهائية.
// • بترجع التوكن الصالح أو '' لو التجديد فشل/بـ cooldown.
// ═══════════════════════════════════════════════════════════════
Future<String> _ensureJwt() async {
String token = await _getJwt();
if (_isJwtValid(token)) return token;
final ok = await Get.put(LoginDriverController()).getJWT();
if (!ok) return '';
return await _getJwt();
}
// ═══════════════════════════════════════════════════════════════
// _makeRequest — دالة مركزية لكل الطلبات
// ───────────────────────────────────────────────────────────────
@@ -119,6 +133,7 @@ class CRUD {
required String link,
Map<String, dynamic>? payload,
required Map<String, String> headers,
bool allowRefresh = true,
}) async {
// timeouts مرتفعة مناسبة للإنترنت الضعيف في سوريا
const totalTimeout = Duration(seconds: 60);
@@ -186,19 +201,33 @@ class CRUD {
}
}
// 401 → تجديد التوكن (مع حماية من الحلقة اللانهائية)
// 429 → السيرفر رافض بسبب الضغط؛ ممنوع نجدّد التوكن أو نعيد المحاولة
if (sc == 429) {
Log.print('🛑 [RES-$requestId] 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(LoginDriverController()).getJWT();
} finally {
_isRefreshingJWT = false;
}
}
return 'token_expired';
if (isNonCritical || !allowRefresh) return 'token_expired';
final refreshed = await Get.put(LoginDriverController()).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,
);
}
// 5xx
@@ -218,18 +247,9 @@ class CRUD {
required String link,
Map<String, dynamic>? payload,
}) async {
String token = await _getJwt();
// فحص صلاحية التوكن قبل الإرسال — تجنب طلب مضمون الرفض
if (!_isJwtValid(token) && !_isRefreshingJWT) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
token = await _getJwt();
} finally {
_isRefreshingJWT = false;
}
}
final String token = await _ensureJwt();
if (token.isEmpty) return 'token_expired';
final headers = {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -250,16 +270,8 @@ class CRUD {
}) async {
try {
// فحص صلاحية التوكن قبل الإرسال
String token = await _getJwt();
if (!_isJwtValid(token) && !_isRefreshingJWT) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
token = await _getJwt();
} finally {
_isRefreshingJWT = false;
}
}
final String token = await _ensureJwt();
if (token.isEmpty) return 'token_expired';
var url = Uri.parse(link);
var response = await _client.post(
@@ -279,15 +291,12 @@ class CRUD {
var jsonData = jsonDecode(response.body);
if (jsonData['status'] == 'success') return response.body;
return jsonData['status'];
} else if (response.statusCode == 429) {
Log.print('🛑 get: 429 rate limited — $link');
return 'rate_limited';
} else if (response.statusCode == 401) {
if (!_isRefreshingJWT) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
} finally {
_isRefreshingJWT = false;
}
}
// تجديد واحد فقط؛ getJWT فيها single-flight + cooldown
await Get.put(LoginDriverController()).getJWT();
return 'token_expired';
} else if (response.statusCode >= 500) {
addError('Non-200: ${response.statusCode}', 'crud().get - Other',
@@ -572,12 +581,8 @@ class CRUD {
// ── sendEmail — إصلاح: استخدام r() بدل X.r() القديم ─────────
Future<void> sendEmail(String link, Map<String, String>? payload) async {
// r() هي نفس دالة فك التشفير الثلاثي المختصرة
String token = await _getJwt();
if (!_isJwtValid(token)) {
await LoginDriverController().getJWT();
token = await _getJwt();
}
final String token = await _ensureJwt();
if (token.isEmpty) return;
final headers = {
'Content-Type': 'application/x-www-form-urlencoded',