'required', 'otp' => 'required', ]); if ($errors) { json_error('رقم الهاتف ورمز التحقق مطلوبان', 422, $errors); } $phone = preg_replace('/[^0-9+]/', '', $data['phone']); $phoneHash = hash('sha256', $phone); $deviceId = $data['device_id'] ?? ''; $deviceName = $data['device_name'] ?? 'Unknown Device'; $platform = $data['platform'] ?? 'android'; $appVersion = $data['app_version'] ?? '1.0.0'; $pushToken = $data['push_token'] ?? null; if (empty($deviceId)) { json_error('معرّف الجهاز مطلوب', 422); } // 2. Load OTP from cache $cacheFile = STORAGE_PATH . '/cache/otp/otp_' . $phoneHash . '.json'; if (!file_exists($cacheFile)) { json_error('رمز التحقق غير صالح أو منتهي الصلاحية', 401); } $fp = fopen($cacheFile, 'r+'); if (!$fp) { json_error('خطأ في النظام', 500); } flock($fp, LOCK_EX); $content = stream_get_contents($fp); $otpData = json_decode($content, true); if (!$otpData || $otpData['expires_at'] < time()) { flock($fp, LOCK_UN); fclose($fp); @unlink($cacheFile); json_error('رمز التحقق منتهي الصلاحية. اطلب رمزاً جديداً.', 401); } // Check attempts if ($otpData['attempts'] >= $otpData['max_attempts']) { flock($fp, LOCK_UN); fclose($fp); @unlink($cacheFile); json_error('تجاوزت عدد المحاولات المسموحة. اطلب رمزاً جديداً.', 429); } // Verify OTP if (!password_verify($data['otp'], $otpData['hash'])) { $otpData['attempts']++; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($otpData)); flock($fp, LOCK_UN); fclose($fp); $remaining = $otpData['max_attempts'] - $otpData['attempts']; json_error("رمز التحقق غير صحيح. المحاولات المتبقية: {$remaining}", 401); } // OTP is valid — clean up flock($fp, LOCK_UN); fclose($fp); @unlink($cacheFile); // 3. Fetch user $db = Database::getInstance(); $userId = $otpData['user_id']; $stmt = $db->prepare("SELECT id, tenant_id, name, email, role, is_active FROM users WHERE id = ? LIMIT 1"); $stmt->execute([$userId]); $user = $stmt->fetch(); if (!$user || !$user['is_active']) { json_error('الحساب غير موجود أو معطّل', 403); } // 4. Generate device secret for HMAC $deviceSecret = hash('sha256', $userId . $deviceId . bin2hex(random_bytes(16))); // 5. Register/Update device $stmt = $db->prepare(" INSERT INTO user_devices (id, user_id, device_fingerprint, device_name, platform, app_version, push_token, 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), push_token = VALUES(push_token), device_secret = VALUES(device_secret), is_trusted = TRUE, last_seen_at = NOW(), updated_at = NOW() "); $stmt->execute([ $userId, $deviceId, $deviceName, $platform, $appVersion, $pushToken, password_hash($deviceSecret, PASSWORD_DEFAULT), // Store hashed ]); // 6. Generate JWT (30 days for mobile) $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); } $payload = [ 'user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role'], 'device_id' => $deviceId, 'source' => 'mobile', 'exp' => time() + (30 * 24 * 3600), // 30 days ]; $token = JWT::encode($payload, $secret); // 7. Generate refresh token $refreshToken = bin2hex(random_bytes(32)); $refreshTokenHash = hash('sha256', $refreshToken); $stmt = $db->prepare("UPDATE users SET refresh_token_hash = ?, last_login_at = NOW() WHERE id = ?"); $stmt->execute([$refreshTokenHash, $userId]); // 8. Decrypt name for response $userName = $user['name']; try { $decrypted = \App\Core\Encryption::decrypt($user['name']); if ($decrypted !== false) $userName = $decrypted; } catch (\Exception $e) { // Keep encrypted name } json_success([ 'access_token' => $token, 'refresh_token' => $refreshToken, 'device_secret' => $deviceSecret, // Client stores this securely for HMAC 'user' => [ 'id' => $user['id'], 'name' => $userName, 'role' => $user['role'], 'tenant_id' => $user['tenant_id'], ], ], 'تم التحقق بنجاح. مرحباً بك في مُصادَق!');