Update: 2026-08-08 13:33:53
This commit is contained in:
@@ -147,7 +147,70 @@ try {
|
|||||||
$stmtUpd = $con->prepare($sqlUpdate);
|
$stmtUpd = $con->prepare($sqlUpdate);
|
||||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||||
$stmtUpd->execute();
|
$stmtUpd->execute();
|
||||||
jsonSuccess(null, "Your phone number has been verified.");
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// تغيير الجهاز: التحقق وحده لا يكفي. لا بد من ربط بصمة
|
||||||
|
// الجهاز الجديد ثم إصدار توكن driver — وإلا بقي التطبيق
|
||||||
|
// على توكن التسجيل ورجع كل endpoint محمي 403، وهو ما كان
|
||||||
|
// يجعل شاشة OTP "تنجح" ثم لا يعمل شيء بعدها.
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
$chk = $con->prepare("SELECT id FROM driver WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
|
||||||
|
global $blindIndex;
|
||||||
|
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone_number) : null;
|
||||||
|
$chk->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
|
||||||
|
$driverRow = $chk->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$driverRow) {
|
||||||
|
jsonSuccess(null, "Your phone number has been verified.");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$driverID = (string)$driverRow['id'];
|
||||||
|
|
||||||
|
$fpRaw = filterRequest("fingerprint") ?? filterRequest("fingerPrint") ?? ($_SERVER['HTTP_X_DEVICE_FP'] ?? null);
|
||||||
|
if ($fpRaw === null && function_exists('getallheaders')) {
|
||||||
|
$hdrs = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||||
|
$fpRaw = $hdrs['x-device-fp'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fpRaw)) {
|
||||||
|
jsonError("Device fingerprint is required to complete device change.");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fpPepper = getenv('FP_PEPPER') ?: '';
|
||||||
|
$fpHashed = !empty($fpPepper) ? hash('sha256', $fpRaw . $fpPepper) : $fpRaw;
|
||||||
|
$fcmToken = filterRequest("token");
|
||||||
|
|
||||||
|
// ربط الجهاز الجديد — نفس الصيغة التي يتوقعها loginJwtDriver.
|
||||||
|
$hasRow = $con->prepare("SELECT id FROM driverToken WHERE captain_id = ?");
|
||||||
|
$hasRow->execute([$driverID]);
|
||||||
|
if ($hasRow->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
if (!empty($fcmToken)) {
|
||||||
|
$con->prepare("UPDATE driverToken SET fingerPrint = ?, token = ? WHERE captain_id = ?")
|
||||||
|
->execute([$fpHashed, $encryptionHelper->encryptData($fcmToken), $driverID]);
|
||||||
|
} else {
|
||||||
|
$con->prepare("UPDATE driverToken SET fingerPrint = ? WHERE captain_id = ?")
|
||||||
|
->execute([$fpHashed, $driverID]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$con->prepare("INSERT INTO driverToken (token, fingerPrint, captain_id, created_at) VALUES (?, ?, ?, NOW())")
|
||||||
|
->execute([
|
||||||
|
!empty($fcmToken) ? $encryptionHelper->encryptData($fcmToken) : '',
|
||||||
|
$fpHashed,
|
||||||
|
$driverID,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$audDriver = filterRequest("aud") ?: filterRequest("audience") ?: (getenv('allowedDriver2') ?: 'driver-app:ios');
|
||||||
|
$jwtSvc = new JwtService($redis);
|
||||||
|
$accessToken = $jwtSvc->generateAccessToken($driverID, 'driver', $audDriver, $fpRaw);
|
||||||
|
|
||||||
|
jsonSuccess([
|
||||||
|
"driverID" => $driverID,
|
||||||
|
"jwt" => $accessToken,
|
||||||
|
"access_token" => $accessToken,
|
||||||
|
], "Your phone number has been verified.");
|
||||||
} else {
|
} else {
|
||||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,15 +78,28 @@ try {
|
|||||||
unauthorizedDriver();
|
unauthorizedDriver();
|
||||||
}
|
}
|
||||||
|
|
||||||
$fpPepper = getenv('FP_PEPPER') ?: '';
|
// driverToken.fingerPrint مكتوب بصيغتين حسب آخر endpoint لمسه:
|
||||||
$expectedFp = !empty($fpPepper) ? hash('sha256', $fingerprint . $fpPepper) : $fingerprint;
|
// • خام — من verify.php و ride/firebase/addDriver.php
|
||||||
|
// • hash مملّح — من loginJwtWalletDriver.php ومن هنا
|
||||||
|
// القبول على صيغة واحدة فقط كان يرفض كل سائق OTP لم يفتح المحفظة
|
||||||
|
// بعد (401 دائم ⇒ يستحيل ترقية توكن التسجيل). نقبل الاثنتين ثم
|
||||||
|
// نُوحّد الصف على الصيغة المُهشّمة.
|
||||||
|
$fpPepper = getenv('FP_PEPPER') ?: '';
|
||||||
|
$storedFp = $fpRow['fingerPrint'];
|
||||||
|
$hashedFp = !empty($fpPepper) ? hash('sha256', $fingerprint . $fpPepper) : $fingerprint;
|
||||||
|
|
||||||
if (!hash_equals($fpRow['fingerPrint'], $expectedFp)) {
|
if (!hash_equals($storedFp, $hashedFp) && !hash_equals($storedFp, $fingerprint)) {
|
||||||
securityLog("LoginDriver(OTP): fingerprint mismatch", [
|
securityLog("LoginDriver(OTP): fingerprint mismatch", [
|
||||||
'driver_id' => $driver['id'],
|
'driver_id' => $driver['id'],
|
||||||
]);
|
]);
|
||||||
unauthorizedDriver();
|
unauthorizedDriver();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// توحيد التخزين على الصيغة المُهشّمة (لا نحفظ البصمة الخام).
|
||||||
|
if (!hash_equals($storedFp, $hashedFp)) {
|
||||||
|
$con->prepare('UPDATE driverToken SET fingerPrint = :fp WHERE captain_id = :cid')
|
||||||
|
->execute([':fp' => $hashedFp, ':cid' => $driver['id']]);
|
||||||
|
}
|
||||||
// ✅ تم التحقق عبر fingerprint — نتابع لتوليد JWT
|
// ✅ تم التحقق عبر fingerprint — نتابع لتوليد JWT
|
||||||
} else {
|
} else {
|
||||||
// ── المسار التقليدي: التحقق عبر password + HMAC ──────────
|
// ── المسار التقليدي: التحقق عبر password + HMAC ──────────
|
||||||
|
|||||||
@@ -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 {
|
Future<bool> _getJwtInternal() async {
|
||||||
await EncryptionHelper.initialize();
|
await EncryptionHelper.initialize();
|
||||||
|
|
||||||
String? secureJwt = await storage.read(key: BoxName.jwt);
|
dev = Platform.isAndroid ? 'android' : 'ios';
|
||||||
if (secureJwt != null && secureJwt.isNotEmpty) {
|
final driverId = box.read(BoxName.driverID)?.toString() ?? '';
|
||||||
bool isTokenValid = false;
|
final isRegistering = driverId.isEmpty;
|
||||||
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 (_) {}
|
|
||||||
|
|
||||||
if (isTokenValid) {
|
String? secureJwt = await storage.read(key: BoxName.jwt);
|
||||||
Log.print('🔑 Valid JWT found in secure storage. Skipping generation.');
|
final cached = decodeJwtPayload(secureJwt);
|
||||||
_jwtFailures = 0;
|
if (cached != null) {
|
||||||
_jwtCooldownUntil = DateTime(2000);
|
final exp = cached['exp'];
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
dev = Platform.isAndroid ? 'android' : 'ios';
|
|
||||||
final driverId = box.read(BoxName.driverID);
|
|
||||||
final isRegistering = driverId == null || driverId.toString().isEmpty;
|
|
||||||
|
|
||||||
if (isRegistering &&
|
if (isRegistering &&
|
||||||
box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
||||||
var payload = {
|
return _getRegistrationJwt();
|
||||||
'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}');
|
|
||||||
} else {
|
} else {
|
||||||
await EncryptionHelper.initialize();
|
await EncryptionHelper.initialize();
|
||||||
|
|
||||||
@@ -387,14 +382,80 @@ class LoginDriverController extends GetxController {
|
|||||||
|
|
||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
await storage.write(key: BoxName.jwt, value: jwt);
|
await storage.write(key: BoxName.jwt, value: jwt);
|
||||||
|
needsDeviceVerification = false;
|
||||||
return _onJwtSuccess();
|
return _onJwtSuccess();
|
||||||
}
|
}
|
||||||
return _onJwtFailure('renew: لا يوجد jwt بالرد');
|
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}');
|
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() {
|
bool _onJwtSuccess() {
|
||||||
_jwtFailures = 0;
|
_jwtFailures = 0;
|
||||||
_jwtCooldownUntil = DateTime(2000);
|
_jwtCooldownUntil = DateTime(2000);
|
||||||
@@ -605,13 +666,37 @@ class LoginDriverController extends GetxController {
|
|||||||
box.write(BoxName.carTypeOfDriver, 'Awfar Car');
|
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...');
|
Log.print('🔑 Getting access token after login...');
|
||||||
String fingerPrint = await DeviceHelper.getDeviceFingerprint();
|
String fingerPrint = await DeviceHelper.getDeviceFingerprint();
|
||||||
// await storage.write(
|
await storage.write(key: BoxName.fingerPrint, value: fingerPrint);
|
||||||
// key: BoxName.fingerPrint, value: fingerPrint.toString());
|
final upgraded = await getJWT(force: true);
|
||||||
// await getJWT();
|
Log.print(
|
||||||
// Log.print('🔑 Access token obtained.');
|
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
|
// add invitations
|
||||||
if (box.read(BoxName.isInstall) == null ||
|
if (box.read(BoxName.isInstall) == null ||
|
||||||
@@ -638,8 +723,15 @@ class LoginDriverController extends GetxController {
|
|||||||
final serverFp = serverData['data'][0]['fingerPrint'].toString();
|
final serverFp = serverData['data'][0]['fingerPrint'].toString();
|
||||||
final tokenMismatch =
|
final tokenMismatch =
|
||||||
storedToken.isNotEmpty && serverToken != storedToken;
|
storedToken.isNotEmpty && serverToken != storedToken;
|
||||||
final fingerprintMismatch =
|
// البصمة تُخزَّن مُهشّمة (sha256 + FP_PEPPER) بعد أول تجديد
|
||||||
serverFp.isNotEmpty && serverFp != fingerPrint.toString();
|
// توكن. التطبيق لا يملك الـ pepper فلا يستطيع مقارنتها —
|
||||||
|
// ومقارنتها خاماً تعطي "اختلاف" دائماً وتفرض OTP بلا سبب.
|
||||||
|
// في هذه الحالة نتّكل على تحقّق السيرفر عند إصدار التوكن
|
||||||
|
// (loginJwtDriver يردّ 401 لجهاز غير مربوط).
|
||||||
|
final isHashedFp = RegExp(r'^[a-f0-9]{64}$').hasMatch(serverFp);
|
||||||
|
final fingerprintMismatch = serverFp.isNotEmpty &&
|
||||||
|
!isHashedFp &&
|
||||||
|
serverFp != fingerPrint.toString();
|
||||||
Log.print(
|
Log.print(
|
||||||
'🔍 [FP_COMPARE] serverFp: $serverFp | localFp: ${fingerPrint.toString()} | fingerprintMismatch: $fingerprintMismatch | tokenMismatch: $tokenMismatch | serverToken: $serverToken | storedToken: $storedToken',
|
'🔍 [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 '../../../views/widgets/error_snakbar.dart';
|
||||||
import '../../firebase/notification_service.dart';
|
import '../../firebase/notification_service.dart';
|
||||||
import '../../functions/crud.dart';
|
import '../../functions/crud.dart';
|
||||||
|
import 'login_captin_controller.dart';
|
||||||
|
|
||||||
class OtpVerificationController extends GetxController {
|
class OtpVerificationController extends GetxController {
|
||||||
final String phone;
|
final String phone;
|
||||||
@@ -95,6 +96,25 @@ class OtpVerificationController extends GetxController {
|
|||||||
response != 'no_internet') {
|
response != 'no_internet') {
|
||||||
Log.print('response (already decoded): ${response}');
|
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(
|
await CRUD().post(
|
||||||
link: AppLink.addTokensDriver,
|
link: AppLink.addTokensDriver,
|
||||||
@@ -104,15 +124,19 @@ class OtpVerificationController extends GetxController {
|
|||||||
'captain_id': box.read(BoxName.driverID).toString(),
|
'captain_id': box.read(BoxName.driverID).toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await NotificationService.sendNotification(
|
// إشعار الجهاز القديم — يُرسل فقط إذا عرفنا توكنه. عند دخول جهاز
|
||||||
target: ptoken.toString(),
|
// جديد لا نملك ptoken (تعذّر جلبه بلا صلاحيات) فنتخطّاه.
|
||||||
title: 'token change'.tr,
|
if (ptoken.isNotEmpty) {
|
||||||
body: 'token change'.tr,
|
await NotificationService.sendNotification(
|
||||||
isTopic: false,
|
target: ptoken,
|
||||||
tone: 'cancel',
|
title: 'token change'.tr,
|
||||||
driverList: [],
|
body: 'token change'.tr,
|
||||||
category: 'token change',
|
isTopic: false,
|
||||||
);
|
tone: 'cancel',
|
||||||
|
driverList: [],
|
||||||
|
category: 'token change',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Get.offAll(() => HomeCaptain());
|
Get.offAll(() => HomeCaptain());
|
||||||
} else {
|
} 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/home/on_boarding_page.dart';
|
||||||
import 'package:siro_driver/views/widgets/error_snakbar.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/country_logic.dart';
|
||||||
|
import 'package:siro_driver/controller/functions/package_info.dart';
|
||||||
|
|
||||||
import '../../../constant/box_name.dart';
|
import '../../../constant/box_name.dart';
|
||||||
import '../../../constant/links.dart';
|
import '../../../constant/links.dart';
|
||||||
@@ -66,13 +67,18 @@ class PhoneAuthHelper {
|
|||||||
try {
|
try {
|
||||||
final fixedPhone = CountryLogic.formatCurrentCountryPhone(phoneNumber);
|
final fixedPhone = CountryLogic.formatCurrentCountryPhone(phoneNumber);
|
||||||
Log.print('fixedPhone: $fixedPhone');
|
Log.print('fixedPhone: $fixedPhone');
|
||||||
|
final fingerPrint = box.read(BoxName.deviceFingerprint)?.toString() ??
|
||||||
|
await DeviceHelper.getDeviceFingerprint();
|
||||||
final response = await CRUD().post(
|
final response = await CRUD().post(
|
||||||
link: _verifyOtpUrl,
|
link: _verifyOtpUrl,
|
||||||
payload: {
|
payload: {
|
||||||
'phone_number': fixedPhone,
|
'phone_number': fixedPhone,
|
||||||
'token_code': otpCode,
|
'token_code': otpCode,
|
||||||
'context': 'login',
|
'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.phoneDriver, phoneNumber);
|
||||||
box.write(BoxName.driverID, data['message']['driverID']);
|
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) {
|
if (isRegistered) {
|
||||||
// ✅ السائق مسجل مسبقًا - سجل دخوله واذهب إلى الصفحة الرئيسية
|
// ✅ السائق مسجل مسبقًا - سجل دخوله واذهب إلى الصفحة الرئيسية
|
||||||
final driver = data['message']['driver'];
|
final driver = data['message']['driver'];
|
||||||
|
|||||||
@@ -28,31 +28,42 @@ class CRUD {
|
|||||||
|
|
||||||
// ── فحص صلاحية JWT بدون مكتبات خارجية ──────────────────────
|
// ── فحص صلاحية JWT بدون مكتبات خارجية ──────────────────────
|
||||||
static bool _isJwtValid(String? token) {
|
static bool _isJwtValid(String? token) {
|
||||||
if (token == null || token.isEmpty) return false;
|
final decoded = LoginDriverController.decodeJwtPayload(token);
|
||||||
try {
|
if (decoded == null) return false;
|
||||||
final parts = token.split('.');
|
|
||||||
if (parts.length != 3) return false;
|
final exp = decoded['exp'];
|
||||||
// فك تشفير الـ payload (الجزء الثاني)
|
if (exp is! int) return false;
|
||||||
String payload = parts[1];
|
// نعتبر التوكن منتهي قبل 30 ثانية من انتهاء الصلاحية (buffer)
|
||||||
// إضافة padding للـ base64
|
if (DateTime.now().millisecondsSinceEpoch >= (exp * 1000 - 30000)) {
|
||||||
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 (_) {
|
|
||||||
return false;
|
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(
|
static Future<void> addError(
|
||||||
String error, String details, String where) async {
|
String error, String details, String where) async {
|
||||||
try {
|
try {
|
||||||
@@ -204,8 +215,9 @@ class CRUD {
|
|||||||
return 'rate_limited';
|
return 'rate_limited';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 401 → تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
|
// 401 (منتهي) أو 403 بتوكن تسجيل (بلا صلاحيات) →
|
||||||
if (sc == 401) {
|
// تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
|
||||||
|
if (sc == 401 || (sc == 403 && _isUnauthorizedTokenType(body))) {
|
||||||
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
||||||
final isNonCritical = link.contains('errorApp.php');
|
final isNonCritical = link.contains('errorApp.php');
|
||||||
if (isNonCritical || !allowRefresh) return 'token_expired';
|
if (isNonCritical || !allowRefresh) return 'token_expired';
|
||||||
@@ -245,11 +257,9 @@ class CRUD {
|
|||||||
Map<String, dynamic>? payload,
|
Map<String, dynamic>? payload,
|
||||||
}) async {
|
}) async {
|
||||||
String token = await _ensureJwt();
|
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 = {
|
final headers = {
|
||||||
@@ -292,7 +302,9 @@ 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 == 401) {
|
} else if (response.statusCode == 401 ||
|
||||||
|
(response.statusCode == 403 &&
|
||||||
|
_isUnauthorizedTokenType(response.body))) {
|
||||||
// تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
|
// تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
|
||||||
// (allowRefresh: false تمنع أي تكرار إضافي) — نفس سلوك _makeRequest.
|
// (allowRefresh: false تمنع أي تكرار إضافي) — نفس سلوك _makeRequest.
|
||||||
if (!allowRefresh) return 'token_expired';
|
if (!allowRefresh) return 'token_expired';
|
||||||
|
|||||||
@@ -350,29 +350,32 @@ class UserHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: controller.isloading
|
child: controller.isloading
|
||||||
? const CircularProgressIndicator(color: Colors.white)
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
: CircleAvatar(
|
: Builder(builder: (context) {
|
||||||
backgroundImage: (box
|
final photoUrl =
|
||||||
.read(BoxName.driverPhotoUrl)
|
box.read(BoxName.driverPhotoUrl)?.toString() ?? '';
|
||||||
?.toString()
|
final avatarImage = photoUrl.isNotEmpty
|
||||||
.isNotEmpty ==
|
? NetworkImage(photoUrl)
|
||||||
true)
|
: null;
|
||||||
? NetworkImage(box.read(BoxName.driverPhotoUrl))
|
|
||||||
: null,
|
|
||||||
|
|
||||||
// [تعديل هام]: في حال فشل تحميل الصورة (غير موجودة)
|
return CircleAvatar(
|
||||||
onBackgroundImageError: (exception, stackTrace) {
|
backgroundImage: avatarImage,
|
||||||
// طباعة الخطأ في الكونسول للتوضيح
|
|
||||||
debugPrint(
|
|
||||||
"Profile image not found or error loading: $exception");
|
|
||||||
// استدعاء نافذة التنبيه
|
|
||||||
_showUploadPhotoDialog(context, controller);
|
|
||||||
},
|
|
||||||
|
|
||||||
// أيقونة بديلة تظهر في الخلفية إذا لم تكن الصورة موجودة
|
// Flutter يمنع onBackgroundImageError بلا صورة
|
||||||
backgroundColor: Colors.grey.shade300,
|
// (assertion)، فنمرّره فقط عند وجود صورة فعلية.
|
||||||
child: const Icon(Icons.person,
|
onBackgroundImageError: avatarImage == null
|
||||||
size: 40, color: Colors.white),
|
? 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(
|
Positioned(
|
||||||
bottom: -5,
|
bottom: -5,
|
||||||
|
|||||||
Reference in New Issue
Block a user