Update: 2026-08-08 13:33:53

This commit is contained in:
Hamza-Ayed
2026-08-08 13:33:54 +03:00
parent fae0e4a38a
commit c8e8e481a6
7 changed files with 362 additions and 141 deletions
@@ -261,38 +261,75 @@ class LoginDriverController extends GetxController {
}
}
/// يفكّ payload الـ JWT محلياً (بدون التحقق من التوقيع) لفحص نوعه فقط.
static Map<String, dynamic>? decodeJwtPayload(String? token) {
if (token == null || token.isEmpty) return null;
try {
final parts = token.split('.');
if (parts.length != 3) return null;
String payload = parts[1];
switch (payload.length % 4) {
case 2:
payload += '==';
break;
case 3:
payload += '=';
break;
}
final decoded = jsonDecode(utf8.decode(base64Url.decode(payload)));
return decoded is Map ? Map<String, dynamic>.from(decoded) : null;
} catch (_) {
return null;
}
}
/// توكن تسجيل (`registration` / `new`): يصدره loginFirstTimeDriver قبل أن
/// يصير للسائق id. الباك إند يسمح له بمسارات التسجيل فقط، وأي endpoint
/// آخر يردّ 403 "Token not authorized for this action".
static bool isRegistrationToken(Map<String, dynamic> payload) {
final type = payload['token_type']?.toString();
return type == 'registration' ||
type == 'new' ||
payload['user_id']?.toString() == 'new';
}
Future<bool> _getJwtInternal() async {
await EncryptionHelper.initialize();
String? secureJwt = await storage.read(key: BoxName.jwt);
if (secureJwt != null && secureJwt.isNotEmpty) {
bool isTokenValid = false;
try {
final parts = secureJwt.split('.');
if (parts.length == 3) {
String payload = parts[1];
switch (payload.length % 4) {
case 2:
payload += '==';
break;
case 3:
payload += '=';
break;
}
final decoded = jsonDecode(utf8.decode(base64Url.decode(payload)));
final exp = decoded['exp'];
if (exp != null) {
isTokenValid =
DateTime.now().millisecondsSinceEpoch < (exp * 1000 - 30000);
}
}
} catch (_) {}
dev = Platform.isAndroid ? 'android' : 'ios';
final driverId = box.read(BoxName.driverID)?.toString() ?? '';
final isRegistering = driverId.isEmpty;
if (isTokenValid) {
Log.print('🔑 Valid JWT found in secure storage. Skipping generation.');
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
return true;
String? secureJwt = await storage.read(key: BoxName.jwt);
final cached = decodeJwtPayload(secureJwt);
if (cached != null) {
final exp = cached['exp'];
final notExpired = exp is int &&
DateTime.now().millisecondsSinceEpoch < (exp * 1000 - 30000);
if (notExpired) {
// ═══════════════════════════════════════════════════════════
// الصلاحية الزمنية وحدها لا تكفي: توكن التسجيل يبقى صالحاً
// ساعة كاملة لكنه بلا صلاحيات. لو اكتفينا بفحص exp يبقى
// مخزّناً بعد اكتمال الدخول فيردّ كل endpoint محمي 403.
// ═══════════════════════════════════════════════════════════
if (isRegistrationToken(cached) && !isRegistering) {
Log.print(
'♻️ getJWT: توكن registration مع driverID موجود — ترقية إلى توكن driver.',
);
} else if (!isRegistering &&
cached['user_id'] != null &&
cached['user_id'].toString() != driverId) {
// توكن يخص سائقاً/جهازاً سابقاً على نفس التثبيت.
Log.print(
'♻️ getJWT: التوكن مُصدر لـ ${cached['user_id']} لا $driverId — إعادة توليد.',
);
} else {
Log.print('🔑 Valid JWT found in secure storage. Skipping generation.');
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
return true;
}
}
}
@@ -303,51 +340,9 @@ class LoginDriverController extends GetxController {
return false;
}
dev = Platform.isAndroid ? 'android' : 'ios';
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,
'aud': '${AK.allowed}$dev',
'fingerPrint':
box.read(BoxName.deviceFingerprint) ??
await DeviceHelper.getDeviceFingerprint(),
};
var response0 = await http
.post(Uri.parse(AppLink.loginFirstTimeDriver), body: payload)
.timeout(const Duration(seconds: 30));
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);
String? jwt;
if (decodedResponse1['message'] is Map &&
decodedResponse1['message']['jwt'] != null) {
jwt = decodedResponse1['message']['jwt'];
} else {
jwt = decodedResponse1['jwt'];
}
if (jwt != null) {
await storage.write(key: BoxName.jwt, value: jwt);
await EncryptionHelper.initialize();
return _onJwtSuccess();
}
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
}
return _onJwtFailure('firstTime: HTTP ${response0.statusCode}');
return _getRegistrationJwt();
} else {
await EncryptionHelper.initialize();
@@ -387,14 +382,80 @@ class LoginDriverController extends GetxController {
if (jwt != null) {
await storage.write(key: BoxName.jwt, value: jwt);
needsDeviceVerification = false;
return _onJwtSuccess();
}
return _onJwtFailure('renew: لا يوجد jwt بالرد');
}
if (response1.statusCode == 401) {
// سائق لم يُكمل التسجيل بعد: عنده driverID من تحقق الهاتف لكن لا
// صفّ له في جدول driver، فـ loginJwtDriver يردّ 401 دائماً. هنا
// توكن التسجيل هو الصحيح — وبدون هذا الرجوع يعلق في منتصف التسجيل.
if (box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
Log.print('↩️ getJWT: التسجيل لم يكتمل — الرجوع لتوكن التسجيل.');
return _getRegistrationJwt();
}
// سائق مكتمل: البصمة لا تطابق المخزّنة (جهاز جديد). الحل الوحيد
// OTP يعيد ربط البصمة — لا فائدة من إعادة المحاولة، فنرفع العلم
// ليحوّله loginDriver لصفحة التحقق.
needsDeviceVerification = true;
Log.print('🔐 getJWT: 401 من loginJwtDriver — الجهاز يحتاج تحقق OTP.');
}
return _onJwtFailure('renew: HTTP ${response1.statusCode}');
}
}
/// يُرفع عندما يرفض loginJwtDriver بصمة هذا الجهاز (401): السائق مسجّل
/// لكن الجهاز غير مربوط، ولا يمكن إصدار توكن driver قبل تحقق OTP.
static bool needsDeviceVerification = false;
/// توكن التسجيل — صالح لمسارات التسجيل وحدها (loginFromGoogle، register،
/// رفع المستندات…). يُستعمل قبل وجود صفّ للسائق، ويُستبدل بتوكن driver
/// فور اكتمال الدخول.
Future<bool> _getRegistrationJwt() async {
final payload = {
'id': box.read(BoxName.driverID) ?? AK.newId,
'password': AK.passnpassenger,
'aud': '${AK.allowed}$dev',
'fingerPrint':
box.read(BoxName.deviceFingerprint) ??
await DeviceHelper.getDeviceFingerprint(),
};
final response0 = await http
.post(Uri.parse(AppLink.loginFirstTimeDriver), body: payload)
.timeout(const Duration(seconds: 30));
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);
String? jwt;
if (decodedResponse1['message'] is Map &&
decodedResponse1['message']['jwt'] != null) {
jwt = decodedResponse1['message']['jwt'];
} else {
jwt = decodedResponse1['jwt'];
}
if (jwt != null) {
await storage.write(key: BoxName.jwt, value: jwt);
await EncryptionHelper.initialize();
return _onJwtSuccess();
}
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
}
return _onJwtFailure('firstTime: HTTP ${response0.statusCode}');
}
bool _onJwtSuccess() {
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
@@ -605,13 +666,37 @@ class LoginDriverController extends GetxController {
box.write(BoxName.carTypeOfDriver, 'Awfar Car');
}
// ✅ الحصول على توكن access بدل registration قبل أي طلبات بعد تسجيل الدخول
// ═══════════════════════════════════════════════════════════
// ترقية التوكن: حتى هنا كنا نستعمل توكن registration (المسموح
// له بـ loginFromGoogle فقط). الآن صار عندنا driverID، فنستبدله
// بتوكن driver قبل أي طلب لاحق — بدون هذه الخطوة يردّ كل
// endpoint محمي 403 "Token not authorized for this action".
// ═══════════════════════════════════════════════════════════
Log.print('🔑 Getting access token after login...');
String fingerPrint = await DeviceHelper.getDeviceFingerprint();
// await storage.write(
// key: BoxName.fingerPrint, value: fingerPrint.toString());
// await getJWT();
// Log.print('🔑 Access token obtained.');
await storage.write(key: BoxName.fingerPrint, value: fingerPrint);
final upgraded = await getJWT(force: true);
Log.print(
upgraded
? '🔑 Access token obtained.'
: '⚠️ تعذّر الحصول على توكن driver بعد الدخول.',
);
// جهاز جديد: البصمة غير مربوطة، فلا يمكن إصدار توكن driver إلا
// بعد OTP يعيد الربط. نحوّله مباشرة بدل إدخاله بتوكن بلا صلاحيات.
if (!upgraded && needsDeviceVerification) {
isloading = false;
update();
Get.offAll(
() => OtpVerificationPage(
phone: d['phone'].toString(),
deviceToken: fingerPrint,
token: '',
ptoken: '',
),
);
return true;
}
// add invitations
if (box.read(BoxName.isInstall) == null ||
@@ -638,8 +723,15 @@ class LoginDriverController extends GetxController {
final serverFp = serverData['data'][0]['fingerPrint'].toString();
final tokenMismatch =
storedToken.isNotEmpty && serverToken != storedToken;
final fingerprintMismatch =
serverFp.isNotEmpty && serverFp != fingerPrint.toString();
// البصمة تُخزَّن مُهشّمة (sha256 + FP_PEPPER) بعد أول تجديد
// توكن. التطبيق لا يملك الـ pepper فلا يستطيع مقارنتها —
// ومقارنتها خاماً تعطي "اختلاف" دائماً وتفرض OTP بلا سبب.
// في هذه الحالة نتّكل على تحقّق السيرفر عند إصدار التوكن
// (loginJwtDriver يردّ 401 لجهاز غير مربوط).
final isHashedFp = RegExp(r'^[a-f0-9]{64}$').hasMatch(serverFp);
final fingerprintMismatch = serverFp.isNotEmpty &&
!isHashedFp &&
serverFp != fingerPrint.toString();
Log.print(
'🔍 [FP_COMPARE] serverFp: $serverFp | localFp: ${fingerPrint.toString()} | fingerprintMismatch: $fingerprintMismatch | tokenMismatch: $tokenMismatch | serverToken: $serverToken | storedToken: $storedToken',
);
@@ -10,6 +10,7 @@ import '../../../main.dart';
import '../../../views/widgets/error_snakbar.dart';
import '../../firebase/notification_service.dart';
import '../../functions/crud.dart';
import 'login_captin_controller.dart';
class OtpVerificationController extends GetxController {
final String phone;
@@ -95,6 +96,25 @@ class OtpVerificationController extends GetxController {
response != 'no_internet') {
Log.print('response (already decoded): ${response}');
// ═══════════════════════════════════════════════════════════
// التحقق نجح ⇒ السيرفر ربط بصمة هذا الجهاز وأصدر توكن driver.
// نحفظه فوراً: هو التوكن الوحيد الذي يملك صلاحيات بعد تغيير
// الجهاز، وبدونه تبقى كل الطلبات على 403.
// ═══════════════════════════════════════════════════════════
final issuedJwt = (response is Map && response['message'] is Map)
? response['message']['jwt']?.toString()
: null;
if (issuedJwt != null && issuedJwt.isNotEmpty) {
await storage.write(key: BoxName.jwt, value: issuedJwt);
LoginDriverController.needsDeviceVerification = false;
Log.print('🔑 تم حفظ توكن driver بعد تحقق تغيير الجهاز.');
} else {
// السيرفر لم يرجع توكناً — نطلب واحداً الآن بعد أن صارت
// البصمة مربوطة، وإلا دخل الكابتن بتوكن بلا صلاحيات.
LoginDriverController.needsDeviceVerification = false;
await Get.put(LoginDriverController()).getJWT(force: true);
}
// توجه إلى الصفحة التالية
await CRUD().post(
link: AppLink.addTokensDriver,
@@ -104,15 +124,19 @@ class OtpVerificationController extends GetxController {
'captain_id': box.read(BoxName.driverID).toString(),
});
await NotificationService.sendNotification(
target: ptoken.toString(),
title: 'token change'.tr,
body: 'token change'.tr,
isTopic: false,
tone: 'cancel',
driverList: [],
category: 'token change',
);
// إشعار الجهاز القديم — يُرسل فقط إذا عرفنا توكنه. عند دخول جهاز
// جديد لا نملك ptoken (تعذّر جلبه بلا صلاحيات) فنتخطّاه.
if (ptoken.isNotEmpty) {
await NotificationService.sendNotification(
target: ptoken,
title: 'token change'.tr,
body: 'token change'.tr,
isTopic: false,
tone: 'cancel',
driverList: [],
category: 'token change',
);
}
Get.offAll(() => HomeCaptain());
} else {
@@ -5,6 +5,7 @@ import 'package:siro_driver/print.dart';
import 'package:siro_driver/views/home/on_boarding_page.dart';
import 'package:siro_driver/views/widgets/error_snakbar.dart';
import 'package:siro_driver/controller/functions/country_logic.dart';
import 'package:siro_driver/controller/functions/package_info.dart';
import '../../../constant/box_name.dart';
import '../../../constant/links.dart';
@@ -66,13 +67,18 @@ class PhoneAuthHelper {
try {
final fixedPhone = CountryLogic.formatCurrentCountryPhone(phoneNumber);
Log.print('fixedPhone: $fixedPhone');
final fingerPrint = box.read(BoxName.deviceFingerprint)?.toString() ??
await DeviceHelper.getDeviceFingerprint();
final response = await CRUD().post(
link: _verifyOtpUrl,
payload: {
'phone_number': fixedPhone,
'token_code': otpCode,
'context': 'login',
'user_type': 'driver'
'user_type': 'driver',
// البصمة تُدمج داخل التوكن الذي يصدره السيرفر، وبدونها يُرفض
// كل طلب لاحق بـ 403 "Device verification required".
'fingerPrint': fingerPrint,
},
);
@@ -85,6 +91,14 @@ class PhoneAuthHelper {
box.write(BoxName.phoneDriver, phoneNumber);
box.write(BoxName.driverID, data['message']['driverID']);
// ✅ التحقق ينجح ⇒ السيرفر يصدر توكن driver كامل الصلاحيات.
// حفظه هنا هو ما يمنع بقاء توكن التسجيل مستعملاً بعد الدخول.
final issuedJwt = data['message']['jwt']?.toString();
if (issuedJwt != null && issuedJwt.isNotEmpty) {
await storage.write(key: BoxName.jwt, value: issuedJwt);
Log.print('🔑 تم حفظ توكن driver الصادر من التحقق بالـ OTP.');
}
if (isRegistered) {
// ✅ السائق مسجل مسبقًا - سجل دخوله واذهب إلى الصفحة الرئيسية
final driver = data['message']['driver'];
+40 -28
View File
@@ -28,31 +28,42 @@ class CRUD {
// ── فحص صلاحية JWT بدون مكتبات خارجية ──────────────────────
static bool _isJwtValid(String? token) {
if (token == null || token.isEmpty) return false;
try {
final parts = token.split('.');
if (parts.length != 3) return false;
// فك تشفير الـ payload (الجزء الثاني)
String payload = parts[1];
// إضافة padding للـ base64
switch (payload.length % 4) {
case 2:
payload += '==';
break;
case 3:
payload += '=';
break;
}
final decoded = jsonDecode(utf8.decode(base64Url.decode(payload)));
final exp = decoded['exp'];
if (exp == null) return false;
// نعتبر التوكن منتهي قبل 30 ثانية من انتهاء الصلاحية (buffer)
return DateTime.now().millisecondsSinceEpoch < (exp * 1000 - 30000);
} catch (_) {
final decoded = LoginDriverController.decodeJwtPayload(token);
if (decoded == null) return false;
final exp = decoded['exp'];
if (exp is! int) return false;
// نعتبر التوكن منتهي قبل 30 ثانية من انتهاء الصلاحية (buffer)
if (DateTime.now().millisecondsSinceEpoch >= (exp * 1000 - 30000)) {
return false;
}
// توكن التسجيل صالح زمنياً لكن الباك إند يرفضه بـ 403 على أي endpoint
// محمي. ما دام عندنا driverID فالتسجيل انتهى ويلزمنا توكن driver.
if (LoginDriverController.isRegistrationToken(decoded) &&
(box.read(BoxName.driverID)?.toString().isNotEmpty ?? false)) {
return false;
}
return true;
}
/// الباك إند يردّ 403 بهذه الرسالة تحديداً عندما يكون التوكن من نوع
/// registration على endpoint محمي — وهي الحالة الوحيدة من حالات الـ 403
/// التي يحلّها تجديد التوكن (بقيّتها: بصمة جهاز / HMAC / سلامة التطبيق).
static bool _isUnauthorizedTokenType(String body) =>
body.contains('Token not authorized for this action');
/// endpoints عامة لا يتحقق فيها الباك إند من التوكن (طلب/تحقق OTP،
/// تسجيل الدخول، التسجيل، تسجيل الأخطاء). حجب الطلب عنها عند غياب
/// التوكن يقفل بالضبط المسار الذي يستعيد به التطبيق توكناً صالحاً —
/// وهو ما كان يترك جهازاً جديداً عالقاً بلا مخرج.
static bool _isPublicEndpoint(String link) =>
link.contains('/auth/otp/') ||
link.contains('login') ||
link.contains('register') ||
link.contains('errorApp.php');
static Future<void> addError(
String error, String details, String where) async {
try {
@@ -204,8 +215,9 @@ class CRUD {
return 'rate_limited';
}
// 401 → تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
if (sc == 401) {
// 401 (منتهي) أو 403 بتوكن تسجيل (بلا صلاحيات) →
// تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
if (sc == 401 || (sc == 403 && _isUnauthorizedTokenType(body))) {
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
final isNonCritical = link.contains('errorApp.php');
if (isNonCritical || !allowRefresh) return 'token_expired';
@@ -245,11 +257,9 @@ class CRUD {
Map<String, dynamic>? payload,
}) async {
String token = await _ensureJwt();
if (token.isEmpty) {
if (token.isEmpty && !_isPublicEndpoint(link)) {
// إذا فشل الحصول على توكن، لا ترسل الطلب للباك إند لأنّه سيرفض حتماً.
if (!link.contains('login') && !link.contains('errorApp.php')) {
return 'token_expired';
}
return 'token_expired';
}
final headers = {
@@ -292,7 +302,9 @@ class CRUD {
var jsonData = jsonDecode(response.body);
if (jsonData['status'] == 'success') return response.body;
return jsonData['status'];
} else if (response.statusCode == 401) {
} else if (response.statusCode == 401 ||
(response.statusCode == 403 &&
_isUnauthorizedTokenType(response.body))) {
// تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
// (allowRefresh: false تمنع أي تكرار إضافي) — نفس سلوك _makeRequest.
if (!allowRefresh) return 'token_expired';
@@ -350,29 +350,32 @@ class UserHeader extends StatelessWidget {
),
child: controller.isloading
? const CircularProgressIndicator(color: Colors.white)
: CircleAvatar(
backgroundImage: (box
.read(BoxName.driverPhotoUrl)
?.toString()
.isNotEmpty ==
true)
? NetworkImage(box.read(BoxName.driverPhotoUrl))
: null,
: Builder(builder: (context) {
final photoUrl =
box.read(BoxName.driverPhotoUrl)?.toString() ?? '';
final avatarImage = photoUrl.isNotEmpty
? NetworkImage(photoUrl)
: null;
// [تعديل هام]: في حال فشل تحميل الصورة (غير موجودة)
onBackgroundImageError: (exception, stackTrace) {
// طباعة الخطأ في الكونسول للتوضيح
debugPrint(
"Profile image not found or error loading: $exception");
// استدعاء نافذة التنبيه
_showUploadPhotoDialog(context, controller);
},
return CircleAvatar(
backgroundImage: avatarImage,
// أيقونة بديلة تظهر في الخلفية إذا لم تكن الصورة موجودة
backgroundColor: Colors.grey.shade300,
child: const Icon(Icons.person,
size: 40, color: Colors.white),
),
// Flutter يمنع onBackgroundImageError بلا صورة
// (assertion)، فنمرّره فقط عند وجود صورة فعلية.
onBackgroundImageError: avatarImage == null
? null
: (exception, stackTrace) {
debugPrint(
"Profile image not found or error loading: $exception");
_showUploadPhotoDialog(context, controller);
},
// أيقونة بديلة تظهر في الخلفية إذا لم تكن الصورة موجودة
backgroundColor: Colors.grey.shade300,
child: const Icon(Icons.person,
size: 40, color: Colors.white),
);
}),
),
Positioned(
bottom: -5,