Update: 2026-05-13 22:58:30

This commit is contained in:
Hamza-Ayed
2026-05-13 22:58:30 +03:00
parent 30da101415
commit 1ca7e01ce0
7 changed files with 227 additions and 22 deletions

View File

@@ -39,28 +39,64 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
json_error('بيانات الدخول غير صحيحة', 401); 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'); $secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) { if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env'); error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500); 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 = [ $payload = [
'user_id' => $user['id'], 'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'], 'tenant_id' => $user['tenant_id'],
'role' => $user['role'], 'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes 'device_id' => $deviceId,
'source' => $deviceId ? 'mobile' : 'web',
'exp' => time() + $expiry
]; ];
$token = JWT::encode($payload, $secret); $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)); $refreshToken = bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $refreshToken); $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']]); $stmt->execute([$refreshTokenHash, $user['id']]);
// 7. Secure Refresh Token delivery via HttpOnly Cookie // 6. Secure Refresh Token delivery via HttpOnly Cookie (for web)
if (!$deviceId) {
setcookie('refresh_token', $refreshToken, [ setcookie('refresh_token', $refreshToken, [
'expires' => time() + (7 * 24 * 60 * 60), // 7 days 'expires' => time() + (7 * 24 * 60 * 60), // 7 days
'path' => '/api/v1/auth/refresh', 'path' => '/api/v1/auth/refresh',
@@ -68,9 +104,12 @@ setcookie('refresh_token', $refreshToken, [
'httponly' => true, 'httponly' => true,
'samesite' => 'Strict', 'samesite' => 'Strict',
]); ]);
}
json_success([ json_success([
'access_token' => $token, 'access_token' => $token,
'refresh_token' => $refreshToken,
'device_secret' => $deviceSecret,
'user' => [ 'user' => [
'id' => $user['id'], 'id' => $user['id'],
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']), 'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),

View File

@@ -175,6 +175,7 @@ json_success([
'user' => [ 'user' => [
'id' => $user['id'], 'id' => $user['id'],
'name' => $userName, 'name' => $userName,
'email' => (\App\Core\Encryption::decrypt($user['email']) ?: $user['email']),
'role' => $user['role'], 'role' => $user['role'],
'tenant_id' => $user['tenant_id'], 'tenant_id' => $user['tenant_id'],
], ],

View File

@@ -6,6 +6,7 @@ class SecureStorage {
static const String _keyToken = 'jwt_token'; static const String _keyToken = 'jwt_token';
static const String _keyDeviceSecret = 'device_secret'; static const String _keyDeviceSecret = 'device_secret';
static const String _keyUserId = 'user_id'; static const String _keyUserId = 'user_id';
static const String _keyEmail = 'user_email';
Future<void> saveToken(String token) async { Future<void> saveToken(String token) async {
await _storage.write(key: _keyToken, value: token); await _storage.write(key: _keyToken, value: token);
@@ -23,6 +24,14 @@ class SecureStorage {
return await _storage.read(key: _keyDeviceSecret); return await _storage.read(key: _keyDeviceSecret);
} }
Future<void> saveEmail(String email) async {
await _storage.write(key: _keyEmail, value: email);
}
Future<String?> getEmail() async {
return await _storage.read(key: _keyEmail);
}
Future<void> clearAll() async { Future<void> clearAll() async {
await _storage.deleteAll(); await _storage.deleteAll();
} }

View File

@@ -96,12 +96,19 @@ class AuthController extends GetxController {
// Save secure data // Save secure data
await _storage.saveToken(data['access_token']); await _storage.saveToken(data['access_token']);
await _storage.saveDeviceSecret(data['device_secret']); await _storage.saveDeviceSecret(data['device_secret']);
if (data['user']['email'] != null) {
await _storage.saveEmail(data['user']['email']);
}
AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح'); AppSnackbar.showSuccess('مرحباً بك', 'تم تسجيل الدخول بنجاح');
// Navigate to 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); Get.offAllNamed(AppRoutes.BIOMETRIC_SETUP);
} }
}
} on DioException catch (e, stackTrace) { } on DioException catch (e, stackTrace) {
AppLogger.error('OTP Verify Failed', e.response?.data, stackTrace); AppLogger.error('OTP Verify Failed', e.response?.data, stackTrace);
AppSnackbar.showError('خطأ', e.response?.data['message'] ?? 'رمز التحقق غير صحيح'); AppSnackbar.showError('خطأ', e.response?.data['message'] ?? 'رمز التحقق غير صحيح');
@@ -109,4 +116,72 @@ class AuthController extends GetxController {
isLoading.value = false; isLoading.value = false;
} }
} }
Future<void> 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;
}
}
} }

View File

@@ -31,12 +31,20 @@ class BiometricController extends GetxController {
} }
Future<void> authenticateAndGoToDashboard() async { Future<void> 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 // Ensure we have checked biometric status first
await checkBiometrics(); await checkBiometrics();
if (!isBiometricAvailable.value) { if (!isBiometricAvailable.value) {
AppLogger.print('Biometrics not available, going directly to dashboard.'); AppLogger.print('Biometrics not available, going directly to dashboard.');
Get.offAllNamed(AppRoutes.DASHBOARD); Get.offAllNamed(AppRoutes.MAIN);
return; return;
} }

View File

@@ -7,6 +7,7 @@ class PhoneInputView extends StatelessWidget {
final AuthController controller = Get.put(AuthController()); final AuthController controller = Get.put(AuthController());
final TextEditingController phoneController = TextEditingController(); final TextEditingController phoneController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -36,24 +37,47 @@ class PhoneInputView extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'أدخل رقم هاتفك المسجل في النظام لتسجيل الدخول', 'أدخل رقم هاتفك أو البريد الإلكتروني لتسجيل الدخول',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
TextField( TextField(
controller: phoneController, controller: phoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.emailAddress,
textDirection: TextDirection.ltr,
onChanged: (val) => controller.phone.value = val,
decoration: InputDecoration(
labelText: 'رقم الهاتف أو البريد الإلكتروني',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
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, textDirection: TextDirection.ltr,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'رقم الهاتف', labelText: 'كلمة المرور',
prefixIcon: const Icon(Icons.phone), prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
],
);
}),
Obx(() => ElevatedButton( Obx(() => ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
@@ -63,10 +87,24 @@ class PhoneInputView extends StatelessWidget {
), ),
onPressed: controller.isLoading.value onPressed: controller.isLoading.value
? null ? 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 child: controller.isLoading.value
? const CircularProgressIndicator(color: Colors.white) ? const CircularProgressIndicator(color: Colors.white)
: const Text('إرسال رمز التحقق', style: TextStyle(fontSize: 16)), : Text(
controller.phone.value.contains('@')
? 'تسجيل الدخول'
: 'إرسال رمز التحقق',
style: const TextStyle(fontSize: 16)
),
)), )),
], ],
), ),

View File

@@ -0,0 +1,35 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
// Use basic PDO to check the database
$host = '127.0.0.1';
$db = 'musadaqDb';
$user = 'musadaqUser';
$pass = 'FWVG3vx2fhrwUULXa6E4';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => 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";
}