diff --git a/app/modules_app/auth/login.php b/app/modules_app/auth/login.php index 24db0d9..9f702c1 100644 --- a/app/modules_app/auth/login.php +++ b/app/modules_app/auth/login.php @@ -39,38 +39,77 @@ if (!$user || !password_verify($password, $user['password_hash'])) { json_error('بيانات الدخول غير صحيحة', 401); } -// 3. Issue Token +// 3. Handle device registration if provided (for mobile app login) +$deviceId = $data['device_id'] ?? null; +$deviceName = $data['device_name'] ?? 'Web Browser'; +$deviceSecret = null; + +if ($deviceId) { + $deviceSecret = hash('sha256', $user['id'] . $deviceId . bin2hex(random_bytes(16))); + $stmt = $db->prepare(" + INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, device_secret, is_trusted, last_seen_at) + VALUES (UUID(), ?, ?, ?, ?, ?, ?, TRUE, NOW()) + ON DUPLICATE KEY UPDATE + device_name = VALUES(device_name), + platform = VALUES(platform), + app_version = VALUES(app_version), + device_secret = VALUES(device_secret), + is_trusted = TRUE, + last_seen_at = NOW(), + updated_at = NOW() + "); + $stmt->execute([ + $user['id'], + $deviceId, + $deviceName, + $data['platform'] ?? 'web', + $data['app_version'] ?? '1.0.0', + password_hash($deviceSecret, PASSWORD_DEFAULT), + ]); +} + +// 4. Issue Token $secret = env('JWT_SECRET'); if (!$secret || strlen($secret) < 32) { error_log('FATAL: JWT_SECRET is missing or too short in .env'); json_error('Server configuration error', 500); } + +// Longer expiry for mobile (30 days), short for web (15 mins) +$expiry = $deviceId ? (30 * 24 * 3600) : (15 * 60); + $payload = [ 'user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role'], - 'exp' => time() + (15 * 60) // 15 minutes + 'device_id' => $deviceId, + 'source' => $deviceId ? 'mobile' : 'web', + 'exp' => time() + $expiry ]; $token = JWT::encode($payload, $secret); -// 4. Update Refresh Token (Hashed before storage for security) +// 5. Update Refresh Token (Hashed before storage for security) $refreshToken = bin2hex(random_bytes(32)); $refreshTokenHash = hash('sha256', $refreshToken); -$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?"); +$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?"); $stmt->execute([$refreshTokenHash, $user['id']]); -// 7. Secure Refresh Token delivery via HttpOnly Cookie -setcookie('refresh_token', $refreshToken, [ - 'expires' => time() + (7 * 24 * 60 * 60), // 7 days - 'path' => '/api/v1/auth/refresh', - 'secure' => true, - 'httponly' => true, - 'samesite' => 'Strict', -]); +// 6. Secure Refresh Token delivery via HttpOnly Cookie (for web) +if (!$deviceId) { + setcookie('refresh_token', $refreshToken, [ + 'expires' => time() + (7 * 24 * 60 * 60), // 7 days + 'path' => '/api/v1/auth/refresh', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); +} json_success([ 'access_token' => $token, + 'refresh_token' => $refreshToken, + 'device_secret' => $deviceSecret, 'user' => [ 'id' => $user['id'], 'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']), diff --git a/app/modules_app/auth/mobile_verify_otp.php b/app/modules_app/auth/mobile_verify_otp.php index 943224b..0437d44 100644 --- a/app/modules_app/auth/mobile_verify_otp.php +++ b/app/modules_app/auth/mobile_verify_otp.php @@ -175,6 +175,7 @@ json_success([ 'user' => [ 'id' => $user['id'], 'name' => $userName, + 'email' => (\App\Core\Encryption::decrypt($user['email']) ?: $user['email']), 'role' => $user['role'], 'tenant_id' => $user['tenant_id'], ], diff --git a/musadaq-app/lib/core/storage/secure_storage.dart b/musadaq-app/lib/core/storage/secure_storage.dart index d055512..c9d22ce 100644 --- a/musadaq-app/lib/core/storage/secure_storage.dart +++ b/musadaq-app/lib/core/storage/secure_storage.dart @@ -6,6 +6,7 @@ class SecureStorage { static const String _keyToken = 'jwt_token'; static const String _keyDeviceSecret = 'device_secret'; static const String _keyUserId = 'user_id'; + static const String _keyEmail = 'user_email'; Future saveToken(String token) async { await _storage.write(key: _keyToken, value: token); @@ -23,6 +24,14 @@ class SecureStorage { return await _storage.read(key: _keyDeviceSecret); } + Future saveEmail(String email) async { + await _storage.write(key: _keyEmail, value: email); + } + + Future getEmail() async { + return await _storage.read(key: _keyEmail); + } + Future clearAll() async { await _storage.deleteAll(); } diff --git a/musadaq-app/lib/features/auth/controllers/auth_controller.dart b/musadaq-app/lib/features/auth/controllers/auth_controller.dart index cbc81f7..a019ec7 100644 --- a/musadaq-app/lib/features/auth/controllers/auth_controller.dart +++ b/musadaq-app/lib/features/auth/controllers/auth_controller.dart @@ -96,11 +96,18 @@ class AuthController extends GetxController { // Save secure data await _storage.saveToken(data['access_token']); await _storage.saveDeviceSecret(data['device_secret']); + if (data['user']['email'] != null) { + await _storage.saveEmail(data['user']['email']); + } AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح'); - // Navigate to Biometric Setup - Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP); + // Navigate to Biometric Setup (unless it's the reviewer) + if (data['user']['email'] == 'reviewer@musadaq.jo') { + Get.offAllNamed(AppRoutes.MAIN); + } else { + Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP); + } } } on DioException catch (e, stackTrace) { AppLogger.error('OTP Verify Failed', e.response?.data, stackTrace); @@ -109,4 +116,72 @@ class AuthController extends GetxController { isLoading.value = false; } } + + Future loginWithEmail(String email, String password) async { + try { + if (email.trim().isEmpty || password.trim().isEmpty) { + AppSnackbar.showError('خطأ', 'الرجاء إدخال البريد الإلكتروني وكلمة المرور'); + return; + } + isLoading.value = true; + + // Get device info + final deviceInfo = DeviceInfoPlugin(); + String deviceId = ''; + String deviceName = ''; + + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + deviceId = androidInfo.id; + deviceName = androidInfo.model; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + deviceId = iosInfo.identifierForVendor ?? 'unknown_ios'; + deviceName = iosInfo.name; + } + + final response = await _dio.post('auth/login', data: { + 'email': email, + 'password': password, + 'device_id': deviceId, + 'device_name': deviceName, + 'platform': Platform.operatingSystem, + 'app_version': '1.0.0', // Should ideally come from PackageInfo + }); + + if (response.statusCode == 200) { + AppLogger.print('Email Login Success. Tokens received.'); + final data = response.data['data']; + + // Save secure data + await _storage.saveToken(data['access_token']); + // Note: auth/login might not return device_secret, handle if missing + if (data['device_secret'] != null) { + await _storage.saveDeviceSecret(data['device_secret']); + } + + if (data['user']['email'] != null) { + await _storage.saveEmail(data['user']['email']); + } + + AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح'); + + // Navigate to Dashboard for reviewer, else Biometric Setup + if (email == 'reviewer@musadaq.jo') { + Get.offAllNamed(AppRoutes.MAIN); + } else { + Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP); + } + } + } on DioException catch (e, stackTrace) { + AppLogger.error('Email Login Failed', e.response?.data, stackTrace); + String errorMessage = 'بيانات الدخول غير صحيحة'; + if (e.response?.data != null && e.response?.data is Map) { + errorMessage = e.response?.data['message'] ?? errorMessage; + } + AppSnackbar.showError('خطأ', errorMessage); + } finally { + isLoading.value = false; + } + } } diff --git a/musadaq-app/lib/features/auth/controllers/biometric_controller.dart b/musadaq-app/lib/features/auth/controllers/biometric_controller.dart index e4b2021..0d2548f 100644 --- a/musadaq-app/lib/features/auth/controllers/biometric_controller.dart +++ b/musadaq-app/lib/features/auth/controllers/biometric_controller.dart @@ -31,12 +31,20 @@ class BiometricController extends GetxController { } Future authenticateAndGoToDashboard() async { + // REVIEWER BYPASS: If the user is the app reviewer, skip biometrics entirely + final userEmail = await _storage.getEmail(); + if (userEmail == 'reviewer@musadaq.jo') { + AppLogger.print('Reviewer account detected. Bypassing biometrics.'); + Get.offAllNamed(AppRoutes.MAIN); + return; + } + // Ensure we have checked biometric status first await checkBiometrics(); if (!isBiometricAvailable.value) { AppLogger.print('Biometrics not available, going directly to dashboard.'); - Get.offAllNamed(AppRoutes.DASHBOARD); + Get.offAllNamed(AppRoutes.MAIN); return; } diff --git a/musadaq-app/lib/features/auth/views/phone_input_view.dart b/musadaq-app/lib/features/auth/views/phone_input_view.dart index 8db72e4..6dfca44 100644 --- a/musadaq-app/lib/features/auth/views/phone_input_view.dart +++ b/musadaq-app/lib/features/auth/views/phone_input_view.dart @@ -7,6 +7,7 @@ class PhoneInputView extends StatelessWidget { final AuthController controller = Get.put(AuthController()); final TextEditingController phoneController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); @override Widget build(BuildContext context) { @@ -36,24 +37,47 @@ class PhoneInputView extends StatelessWidget { ), const SizedBox(height: 8), const Text( - 'أدخل رقم هاتفك المسجل في النظام لتسجيل الدخول', + 'أدخل رقم هاتفك أو البريد الإلكتروني لتسجيل الدخول', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), const SizedBox(height: 32), TextField( controller: phoneController, - keyboardType: TextInputType.phone, + keyboardType: TextInputType.emailAddress, textDirection: TextDirection.ltr, + onChanged: (val) => controller.phone.value = val, decoration: InputDecoration( - labelText: 'رقم الهاتف', - prefixIcon: const Icon(Icons.phone), + labelText: 'رقم الهاتف أو البريد الإلكتروني', + prefixIcon: const Icon(Icons.person_outline), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), + Obx(() { + final isEmail = controller.phone.value.contains('@'); + if (!isEmail) return const SizedBox.shrink(); + + return Column( + children: [ + TextField( + controller: passwordController, + obscureText: true, + textDirection: TextDirection.ltr, + decoration: InputDecoration( + labelText: 'كلمة المرور', + prefixIcon: const Icon(Icons.lock_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 24), + ], + ); + }), Obx(() => ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -63,10 +87,24 @@ class PhoneInputView extends StatelessWidget { ), onPressed: controller.isLoading.value ? null - : () => controller.requestOtp(phoneController.text), + : () { + if (controller.phone.value.contains('@')) { + controller.loginWithEmail( + controller.phone.value, + passwordController.text + ); + } else { + controller.requestOtp(phoneController.text); + } + }, child: controller.isLoading.value ? const CircularProgressIndicator(color: Colors.white) - : const Text('إرسال رمز التحقق', style: TextStyle(fontSize: 16)), + : Text( + controller.phone.value.contains('@') + ? 'تسجيل الدخول' + : 'إرسال رمز التحقق', + style: const TextStyle(fontSize: 16) + ), )), ], ), diff --git a/scratch/check_reviewer.php b/scratch/check_reviewer.php new file mode 100644 index 0000000..5e58c62 --- /dev/null +++ b/scratch/check_reviewer.php @@ -0,0 +1,35 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +try { + $pdo = new PDO($dsn, $user, $pass, $options); +} catch (\PDOException $e) { + throw new \PDOException($e->getMessage(), (int)$e->getCode()); +} + +$email = 'reviewer@musadaq.jo'; +$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?"); +$stmt->execute([$email]); +$user = $stmt->fetch(); + +if ($user) { + echo "User found:\n"; + print_r($user); +} else { + echo "User NOT found.\n"; +}