refactor: implement single-flight JWT renewal with rate-limiting cooldown and fix storage token handling in auth controllers
This commit is contained in:
@@ -141,7 +141,9 @@ class LoginDriverController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dev = '';
|
var dev = '';
|
||||||
Future<String>? _walletJwtFuture;
|
// static: لأن CRUD ينشئ LoginDriverController() جديد بكل طلب،
|
||||||
|
// فلو كانت instance field ما بيشتغل الـ single-flight أبداً.
|
||||||
|
static Future<String>? _walletJwtFuture;
|
||||||
|
|
||||||
getJwtWallet() async {
|
getJwtWallet() async {
|
||||||
if (_walletJwtFuture != null) {
|
if (_walletJwtFuture != null) {
|
||||||
@@ -228,7 +230,41 @@ class LoginDriverController extends GetxController {
|
|||||||
return '';
|
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();
|
await EncryptionHelper.initialize();
|
||||||
|
|
||||||
// 1. Check secure storage first to avoid redundant API calls
|
// 1. Check secure storage first to avoid redundant API calls
|
||||||
@@ -259,14 +295,31 @@ class LoginDriverController extends GetxController {
|
|||||||
|
|
||||||
if (isTokenValid) {
|
if (isTokenValid) {
|
||||||
Log.print('🔑 Valid JWT found in secure storage. Skipping generation.');
|
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';
|
dev = Platform.isAndroid ? 'android' : 'ios';
|
||||||
Log.print(
|
Log.print(
|
||||||
'box.read(BoxName.firstTimeLoadKey): ${box.read(BoxName.firstTimeLoadKey)}');
|
'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 = {
|
var payload = {
|
||||||
'id': box.read(BoxName.driverID) ?? AK.newId,
|
'id': box.read(BoxName.driverID) ?? AK.newId,
|
||||||
'password': AK.passnpassenger,
|
'password': AK.passnpassenger,
|
||||||
@@ -276,12 +329,21 @@ class LoginDriverController extends GetxController {
|
|||||||
};
|
};
|
||||||
// Log.print('payload: ${payload}');
|
// Log.print('payload: ${payload}');
|
||||||
|
|
||||||
var response0 = await http.post(
|
var response0 = await http
|
||||||
Uri.parse(AppLink.loginFirstTimeDriver),
|
.post(
|
||||||
body: payload,
|
Uri.parse(AppLink.loginFirstTimeDriver),
|
||||||
);
|
body: payload,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
Log.print('response0: ${response0.body}');
|
Log.print('response0: ${response0.body}');
|
||||||
Log.print('request: ${response0.request}');
|
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) {
|
if (response0.statusCode == 200) {
|
||||||
final decodedResponse1 = jsonDecode(response0.body);
|
final decodedResponse1 = jsonDecode(response0.body);
|
||||||
Log.print('decodedResponse1: ${decodedResponse1}');
|
Log.print('decodedResponse1: ${decodedResponse1}');
|
||||||
@@ -297,31 +359,47 @@ class LoginDriverController extends GetxController {
|
|||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
// box.write(BoxName.jwt, c(jwt));
|
// box.write(BoxName.jwt, c(jwt));
|
||||||
await storage.write(key: BoxName.jwt, value: jwt);
|
await storage.write(key: BoxName.jwt, value: jwt);
|
||||||
|
// ✅ بعد التأكد أن كل المفاتيح موجودة
|
||||||
|
await EncryptionHelper.initialize();
|
||||||
|
return _onJwtSuccess();
|
||||||
}
|
}
|
||||||
|
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
|
||||||
// ✅ بعد التأكد أن كل المفاتيح موجودة
|
}
|
||||||
await EncryptionHelper.initialize();
|
return _onJwtFailure('firstTime: HTTP ${response0.statusCode}');
|
||||||
|
|
||||||
// await AppInitializer().getKey();
|
|
||||||
} else {}
|
|
||||||
} else {
|
} else {
|
||||||
await EncryptionHelper.initialize();
|
await EncryptionHelper.initialize();
|
||||||
|
|
||||||
|
// بدون driverID التجديد مضمون الفشل — نوقف بدون ما نضرب السيرفر
|
||||||
|
if (isRegistering) {
|
||||||
|
return _onJwtFailure('renew: لا يوجد driverID');
|
||||||
|
}
|
||||||
|
|
||||||
var payload = {
|
var payload = {
|
||||||
'id': box.read(BoxName.driverID),
|
'id': driverId,
|
||||||
'password': box.read(BoxName.emailDriver),
|
'password': box.read(BoxName.emailDriver),
|
||||||
'aud': '${AK.allowed}$dev',
|
'aud': '${AK.allowed}$dev',
|
||||||
'fingerPrint': box.read(BoxName.deviceFingerprint) ??
|
'fingerPrint': box.read(BoxName.deviceFingerprint) ??
|
||||||
await DeviceHelper.getDeviceFingerprint(),
|
await DeviceHelper.getDeviceFingerprint(),
|
||||||
};
|
};
|
||||||
// print(payload);
|
// print(payload);
|
||||||
var response1 = await http.post(
|
var response1 = await http
|
||||||
Uri.parse(AppLink.loginJwtDriver),
|
.post(
|
||||||
body: payload,
|
Uri.parse(AppLink.loginJwtDriver),
|
||||||
);
|
body: payload,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
Log.print('response1.request: ${response1.request}');
|
Log.print('response1.request: ${response1.request}');
|
||||||
Log.print('response1.body: ${response1.body}');
|
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) {
|
if (response1.statusCode == 200) {
|
||||||
final decodedResponse1 = jsonDecode(response1.body);
|
final decodedResponse1 = jsonDecode(response1.body);
|
||||||
// Log.print('decodedResponse1: ${decodedResponse1}');
|
// Log.print('decodedResponse1: ${decodedResponse1}');
|
||||||
@@ -337,13 +415,32 @@ class LoginDriverController extends GetxController {
|
|||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
// await box.write(BoxName.jwt, c(jwt));
|
// await box.write(BoxName.jwt, c(jwt));
|
||||||
await storage.write(key: BoxName.jwt, value: jwt);
|
await storage.write(key: BoxName.jwt, value: jwt);
|
||||||
|
return _onJwtSuccess();
|
||||||
}
|
}
|
||||||
|
return _onJwtFailure('renew: لا يوجد jwt بالرد');
|
||||||
// await AppInitializer().getKey();
|
|
||||||
}
|
}
|
||||||
|
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 {
|
Future<void> getLocationPermission() async {
|
||||||
var status = await Permission.locationAlways.status;
|
var status = await Permission.locationAlways.status;
|
||||||
if (!status.isGranted) {
|
if (!status.isGranted) {
|
||||||
@@ -654,9 +751,11 @@ class LoginDriverController extends GetxController {
|
|||||||
var jwt = jsonDecoeded['jwt'];
|
var jwt = jsonDecoeded['jwt'];
|
||||||
|
|
||||||
// حفظ التوكن أولاً
|
// حفظ التوكن أولاً
|
||||||
|
// ⚠️ لازم يُخزَّن خام (بدون c()) — CRUD._getJwt() بيقرأه مباشرة
|
||||||
|
// ويحطه بـ Authorization، ولو كان مشفّر بصير كل طلب 401 → حلقة تجديد
|
||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
box.write(BoxName.jwt, c(jwt));
|
box.write(BoxName.jwt, jwt);
|
||||||
await storage.write(key: BoxName.jwt, value: c(jwt));
|
await storage.write(key: BoxName.jwt, value: jwt.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
box.write(BoxName.emailDriver, (d['email']));
|
box.write(BoxName.emailDriver, (d['email']));
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import 'ssl_pinning.dart';
|
|||||||
class CRUD {
|
class CRUD {
|
||||||
final NetGuard _netGuard = NetGuard();
|
final NetGuard _netGuard = NetGuard();
|
||||||
final _client = SslPinning.createPinnedClient();
|
final _client = SslPinning.createPinnedClient();
|
||||||
static bool _isRefreshingJWT = false;
|
|
||||||
static String _lastErrorSignature = '';
|
static String _lastErrorSignature = '';
|
||||||
static DateTime _lastErrorTimestamp = DateTime(2000);
|
static DateTime _lastErrorTimestamp = DateTime(2000);
|
||||||
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
|
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 — دالة مركزية لكل الطلبات
|
// _makeRequest — دالة مركزية لكل الطلبات
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
@@ -119,6 +133,7 @@ class CRUD {
|
|||||||
required String link,
|
required String link,
|
||||||
Map<String, dynamic>? payload,
|
Map<String, dynamic>? payload,
|
||||||
required Map<String, String> headers,
|
required Map<String, String> headers,
|
||||||
|
bool allowRefresh = true,
|
||||||
}) async {
|
}) async {
|
||||||
// timeouts مرتفعة مناسبة للإنترنت الضعيف في سوريا
|
// timeouts مرتفعة مناسبة للإنترنت الضعيف في سوريا
|
||||||
const totalTimeout = Duration(seconds: 60);
|
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) {
|
if (sc == 401) {
|
||||||
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
||||||
final isNonCritical = link.contains('errorApp.php');
|
final isNonCritical = link.contains('errorApp.php');
|
||||||
if (!_isRefreshingJWT && !isNonCritical) {
|
if (isNonCritical || !allowRefresh) return 'token_expired';
|
||||||
_isRefreshingJWT = true;
|
|
||||||
try {
|
final refreshed = await Get.put(LoginDriverController()).getJWT();
|
||||||
await Get.put(LoginDriverController()).getJWT();
|
if (!refreshed) return 'token_expired';
|
||||||
} finally {
|
|
||||||
_isRefreshingJWT = false;
|
final newToken = await _getJwt();
|
||||||
}
|
if (newToken.isEmpty) return 'token_expired';
|
||||||
}
|
|
||||||
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
|
// 5xx
|
||||||
@@ -218,18 +247,9 @@ class CRUD {
|
|||||||
required String link,
|
required String link,
|
||||||
Map<String, dynamic>? payload,
|
Map<String, dynamic>? payload,
|
||||||
}) async {
|
}) async {
|
||||||
String token = await _getJwt();
|
|
||||||
|
|
||||||
// فحص صلاحية التوكن قبل الإرسال — تجنب طلب مضمون الرفض
|
// فحص صلاحية التوكن قبل الإرسال — تجنب طلب مضمون الرفض
|
||||||
if (!_isJwtValid(token) && !_isRefreshingJWT) {
|
final String token = await _ensureJwt();
|
||||||
_isRefreshingJWT = true;
|
if (token.isEmpty) return 'token_expired';
|
||||||
try {
|
|
||||||
await Get.put(LoginDriverController()).getJWT();
|
|
||||||
token = await _getJwt();
|
|
||||||
} finally {
|
|
||||||
_isRefreshingJWT = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final headers = {
|
final headers = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -250,16 +270,8 @@ class CRUD {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// فحص صلاحية التوكن قبل الإرسال
|
// فحص صلاحية التوكن قبل الإرسال
|
||||||
String token = await _getJwt();
|
final String token = await _ensureJwt();
|
||||||
if (!_isJwtValid(token) && !_isRefreshingJWT) {
|
if (token.isEmpty) return 'token_expired';
|
||||||
_isRefreshingJWT = true;
|
|
||||||
try {
|
|
||||||
await Get.put(LoginDriverController()).getJWT();
|
|
||||||
token = await _getJwt();
|
|
||||||
} finally {
|
|
||||||
_isRefreshingJWT = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = Uri.parse(link);
|
var url = Uri.parse(link);
|
||||||
var response = await _client.post(
|
var response = await _client.post(
|
||||||
@@ -279,15 +291,12 @@ class CRUD {
|
|||||||
var jsonData = jsonDecode(response.body);
|
var jsonData = jsonDecode(response.body);
|
||||||
if (jsonData['status'] == 'success') return response.body;
|
if (jsonData['status'] == 'success') return response.body;
|
||||||
return jsonData['status'];
|
return jsonData['status'];
|
||||||
|
} else if (response.statusCode == 429) {
|
||||||
|
Log.print('🛑 get: 429 rate limited — $link');
|
||||||
|
return 'rate_limited';
|
||||||
} else if (response.statusCode == 401) {
|
} else if (response.statusCode == 401) {
|
||||||
if (!_isRefreshingJWT) {
|
// تجديد واحد فقط؛ getJWT فيها single-flight + cooldown
|
||||||
_isRefreshingJWT = true;
|
await Get.put(LoginDriverController()).getJWT();
|
||||||
try {
|
|
||||||
await Get.put(LoginDriverController()).getJWT();
|
|
||||||
} finally {
|
|
||||||
_isRefreshingJWT = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'token_expired';
|
return 'token_expired';
|
||||||
} else if (response.statusCode >= 500) {
|
} else if (response.statusCode >= 500) {
|
||||||
addError('Non-200: ${response.statusCode}', 'crud().get - Other',
|
addError('Non-200: ${response.statusCode}', 'crud().get - Other',
|
||||||
@@ -572,12 +581,8 @@ class CRUD {
|
|||||||
// ── sendEmail — إصلاح: استخدام r() بدل X.r() القديم ─────────
|
// ── sendEmail — إصلاح: استخدام r() بدل X.r() القديم ─────────
|
||||||
Future<void> sendEmail(String link, Map<String, String>? payload) async {
|
Future<void> sendEmail(String link, Map<String, String>? payload) async {
|
||||||
// r() هي نفس دالة فك التشفير الثلاثي المختصرة
|
// r() هي نفس دالة فك التشفير الثلاثي المختصرة
|
||||||
String token = await _getJwt();
|
final String token = await _ensureJwt();
|
||||||
|
if (token.isEmpty) return;
|
||||||
if (!_isJwtValid(token)) {
|
|
||||||
await LoginDriverController().getJWT();
|
|
||||||
token = await _getJwt();
|
|
||||||
}
|
|
||||||
|
|
||||||
final headers = {
|
final headers = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
|||||||
@@ -82,26 +82,55 @@ class LoginController extends GetxController {
|
|||||||
// • firstTimeLoadKey != false ← أول مرة يفتح التطبيق → loginFirstTime
|
// • firstTimeLoadKey != false ← أول مرة يفتح التطبيق → loginFirstTime
|
||||||
// • firstTimeLoadKey == false ← مستخدم موجود → loginJwtRider
|
// • 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()) {
|
if (!force && isTokenValid()) {
|
||||||
Log.print("JWT is still valid. Skipping request.");
|
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 {
|
try {
|
||||||
dev = Platform.isAndroid ? 'android' : 'ios';
|
dev = Platform.isAndroid ? 'android' : 'ios';
|
||||||
|
|
||||||
// تأكد إن البصمة محدّثة قبل أي طلب
|
|
||||||
await DeviceHelper.getDeviceFingerprint();
|
await DeviceHelper.getDeviceFingerprint();
|
||||||
final String fp = box.read(BoxName.deviceFpEncrypted) ?? '';
|
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') {
|
if (isRegistering && box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
||||||
// ── أول تسجيل ─────────────────────────────────────────
|
|
||||||
// نرسل البصمة المشفرة مع باقي البيانات
|
|
||||||
// السيرفر سيعمل hash لها ويخزنها في JWT payload
|
|
||||||
var payload = {
|
var payload = {
|
||||||
'id': box.read(BoxName.passengerID) ?? AK.newId,
|
'id': passengerId ?? AK.newId,
|
||||||
'password': AK.passnpassenger,
|
'password': AK.passnpassenger,
|
||||||
'aud': '${AK.allowed}$dev',
|
'aud': '${AK.allowed}$dev',
|
||||||
'fingerPrint': fp,
|
'fingerPrint': fp,
|
||||||
@@ -110,12 +139,14 @@ class LoginController extends GetxController {
|
|||||||
var response = await http.post(
|
var response = await http.post(
|
||||||
Uri.parse(AppLink.loginFirstTime),
|
Uri.parse(AppLink.loginFirstTime),
|
||||||
body: payload,
|
body: payload,
|
||||||
);
|
).timeout(const Duration(seconds: 30));
|
||||||
Log.print('AppLink.loginFirstTime: ${AppLink.loginFirstTime}');
|
|
||||||
|
if (response.statusCode == 429) {
|
||||||
Log.print('payload: $payload');
|
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
|
||||||
Log.print('response code: ${response.statusCode}');
|
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
|
||||||
Log.print('response body: ${response.body}');
|
Log.print('🛑 getJWT(firstTime): 429 — cooldown ${retryAfter}s');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
@@ -126,18 +157,20 @@ class LoginController extends GetxController {
|
|||||||
: decoded['jwt']);
|
: decoded['jwt']);
|
||||||
|
|
||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
// نشفر الـ JWT بالتشفير الثلاثي قبل التخزين في GetStorage
|
await storage.write(key: BoxName.jwt, value: jwt);
|
||||||
box.write(BoxName.jwt, c(jwt));
|
await EncryptionHelper.initialize();
|
||||||
storage.write(key: BoxName.jwt, value: c(jwt));
|
return _onJwtSuccess();
|
||||||
}
|
}
|
||||||
|
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
|
||||||
await EncryptionHelper.initialize();
|
|
||||||
}
|
}
|
||||||
|
return _onJwtFailure('firstTime: HTTP ${response.statusCode}');
|
||||||
} else {
|
} else {
|
||||||
// ── مستخدم موجود: تجديد التوكن
|
if (isRegistering) {
|
||||||
|
return _onJwtFailure('renew: لا يوجد passengerID');
|
||||||
|
}
|
||||||
|
|
||||||
var payload = {
|
var payload = {
|
||||||
'id': box.read(BoxName.passengerID),
|
'id': passengerId,
|
||||||
'fingerPrint': fp,
|
'fingerPrint': fp,
|
||||||
'aud': '${AK.allowed}$dev',
|
'aud': '${AK.allowed}$dev',
|
||||||
};
|
};
|
||||||
@@ -145,11 +178,15 @@ class LoginController extends GetxController {
|
|||||||
var response = await http.post(
|
var response = await http.post(
|
||||||
Uri.parse(AppLink.loginJwtRider),
|
Uri.parse(AppLink.loginJwtRider),
|
||||||
body: payload,
|
body: payload,
|
||||||
);
|
).timeout(const Duration(seconds: 30));
|
||||||
Log.print('AppLink.loginJwtRider: ${AppLink.loginJwtRider}');
|
|
||||||
|
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) {
|
if (response.statusCode == 200) {
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
final String? jwt = decoded['data'] != null
|
final String? jwt = decoded['data'] != null
|
||||||
@@ -159,16 +196,33 @@ class LoginController extends GetxController {
|
|||||||
: decoded['jwt']);
|
: decoded['jwt']);
|
||||||
|
|
||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
box.write(BoxName.jwt, c(jwt));
|
await storage.write(key: BoxName.jwt, value: jwt);
|
||||||
storage.write(key: BoxName.jwt, value: c(jwt));
|
return _onJwtSuccess();
|
||||||
}
|
}
|
||||||
|
return _onJwtFailure('renew: لا يوجد jwt بالرد');
|
||||||
}
|
}
|
||||||
|
return _onJwtFailure('renew: HTTP ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 NetGuard _netGuard = NetGuard();
|
||||||
final _client = SslPinning.createPinnedClient();
|
final _client = SslPinning.createPinnedClient();
|
||||||
|
|
||||||
static bool _isRefreshingJWT = false;
|
|
||||||
static String _lastErrorSignature = '';
|
static String _lastErrorSignature = '';
|
||||||
static DateTime _lastErrorTimestamp = DateTime(2000);
|
static DateTime _lastErrorTimestamp = DateTime(2000);
|
||||||
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
|
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
|
||||||
@@ -98,31 +97,50 @@ class CRUD {
|
|||||||
|
|
||||||
Future<String> _getJwt() async {
|
Future<String> _getJwt() async {
|
||||||
try {
|
try {
|
||||||
final String? encryptedJwt = await storage.read(key: BoxName.jwt);
|
final jwt = await storage.read(key: BoxName.jwt);
|
||||||
if (encryptedJwt == null || encryptedJwt.isEmpty) {
|
if (jwt == null || jwt.toString().isEmpty) {
|
||||||
final String? fallback = box.read(BoxName.jwt);
|
// إذا كان التخزين الآمن فارغاً، نحاول استخراج التوكن القديم من GetStorage للركاب القدامى
|
||||||
|
final fallback = box.read(BoxName.jwt);
|
||||||
if (fallback != null) {
|
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 '';
|
||||||
}
|
}
|
||||||
return r(encryptedJwt).toString().split(Env.addd)[0];
|
// التحقق السريع إذا كان التوكن لا يزال مشفراً (يبدأ برموز غريبة وليس ey)
|
||||||
} catch (e) {
|
if (!jwt.startsWith('ey')) {
|
||||||
Log.print('Error reading JWT from SecureStorage: $e');
|
try {
|
||||||
final String? fallback = box.read(BoxName.jwt);
|
return r(jwt).toString().split(Env.addd)[0];
|
||||||
if (fallback != null) {
|
} catch (_) {}
|
||||||
return r(fallback).toString().split(Env.addd)[0];
|
|
||||||
}
|
}
|
||||||
|
return jwt;
|
||||||
|
} catch (_) {
|
||||||
return '';
|
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.
|
/// Centralized request handler with retry for weak networks.
|
||||||
/// For Syria (3G): 60s total timeout, 3 retries, exponential backoff.
|
/// For Syria (3G): 60s total timeout, 3 retries, exponential backoff.
|
||||||
Future<dynamic> _makeRequest({
|
Future<dynamic> _makeRequest({
|
||||||
required String link,
|
required String link,
|
||||||
Map<String, dynamic>? payload,
|
Map<String, dynamic>? payload,
|
||||||
required Map<String, String> headers,
|
required Map<String, String> headers,
|
||||||
|
bool allowRefresh = true,
|
||||||
}) async {
|
}) async {
|
||||||
const totalTimeout = Duration(seconds: 60);
|
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) {
|
if (sc == 401) {
|
||||||
|
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
||||||
final isNonCritical = link.contains('errorApp.php');
|
final isNonCritical = link.contains('errorApp.php');
|
||||||
if (!_isRefreshingJWT && !isNonCritical) {
|
if (isNonCritical || !allowRefresh) return 'token_expired';
|
||||||
_isRefreshingJWT = true;
|
|
||||||
try {
|
final refreshed = await Get.put(LoginController()).getJWT();
|
||||||
await Get.put(LoginController()).getJWT();
|
if (!refreshed) return 'token_expired';
|
||||||
} finally {
|
|
||||||
_isRefreshingJWT = false;
|
final newToken = await _getJwt();
|
||||||
}
|
if (newToken.isEmpty) return 'token_expired';
|
||||||
}
|
|
||||||
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) {
|
if (sc >= 500) {
|
||||||
@@ -206,7 +240,14 @@ class CRUD {
|
|||||||
required String link,
|
required String link,
|
||||||
Map<String, dynamic>? payload,
|
Map<String, dynamic>? payload,
|
||||||
}) async {
|
}) 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 = {
|
final headers = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -221,7 +262,12 @@ class CRUD {
|
|||||||
required String link,
|
required String link,
|
||||||
Map<String, dynamic>? payload,
|
Map<String, dynamic>? payload,
|
||||||
}) async {
|
}) 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 = {
|
final headers = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
|||||||
Reference in New Issue
Block a user