get('phone')); $channel = strtoupper(trim((string)$request->get('channel', 'SILENT_CALL'))); if (empty($phone) || strlen($phone) < 8) { Response::error('Valid Iraqi phone number is required.'); } $validChannels = ['SILENT_CALL', 'TELEGRAM', 'WHATSAPP_CAPTCHA', 'SMS_OTPIQ']; if (!in_array($channel, $validChannels, true)) { $channel = 'SILENT_CALL'; } // Generate 6-digit cryptographic OTP code $otp = (string)random_int(100000, 999999); $otpHash = hash('sha256', $otp); $pdo = Database::getConnection(); // 1. Record OTP dispatch in otp_dispatches table $dispStmt = $pdo->prepare(' INSERT INTO otp_dispatches (phone, channel, otp_code_hash, status, cost_iqd, expires_at, created_at) VALUES (:phone, :channel, :hash, "DISPATCHED", :cost, DATE_ADD(NOW(), INTERVAL 5 MINUTE), NOW()) '); $costIqd = ($channel === 'SMS_OTPIQ') ? 10.00 : 0.00; $dispStmt->execute([ ':phone' => $phone, ':channel' => $channel, ':hash' => $otpHash, ':cost' => $costIqd, ]); // 2. Dispatch via requested channel $channelPayload = []; if ($channel === 'SILENT_CALL') { // Enqueue task for Android gateway caller phone $taskStmt = $pdo->prepare(' INSERT INTO gateway_tasks (task_type, target_phone, otp_code, status, timeout_seconds, created_at) VALUES ("FLASH_CALL", :phone, :otp, "PENDING", 30, NOW()) '); $taskStmt->execute([':phone' => $phone, ':otp' => $otp]); $channelPayload['message'] = 'ستصلك رنة هاتفية سريعة خاطفة للتحقق مجاناً.'; } elseif ($channel === 'TELEGRAM') { $channelPayload['telegram_url'] = 'https://t.me/UrukPrizeBot?start=otp_' . $otp; $channelPayload['message'] = 'تم توجيه رمز التحقق إلى بوت التيليغرام الرسمي للجائزة.'; } elseif ($channel === 'WHATSAPP_CAPTCHA') { $channelPayload['captcha_image'] = NabihCaptchaService::generateOtpImage($otp); $channelPayload['message'] = 'تم إنشاء صورة كابتشا مشفرة لإرسالها عبر الواتساب.'; } elseif ($channel === 'SMS_OTPIQ') { $otpService = new OtpIqService(); $otpService->sendOtp($phone, $otp); $channelPayload['message'] = 'تم إرسال رمز التحقق عبر رسالة SMS رسمية.'; } // Audit log $stmt = $pdo->prepare(' INSERT INTO audit_logs (user_id, action, endpoint, method, ip_address, response_code, payload_snippet, created_at) VALUES (0, "OTP_REQUESTED", "/api/v1/auth/request-otp", "POST", :ip, 200, :payload, NOW()) '); $stmt->execute([ ':ip' => $request->getIp(), ':payload' => json_encode(['phone' => $phone, 'channel' => $channel]), ]); Response::success(array_merge([ 'phone' => $phone, 'channel' => $channel, 'expires_in_seconds' => 300, 'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null, ], $channelPayload), 'Verification OTP dispatched via ' . $channel); } /** * POST /api/v1/auth/verify-otp * Receives phone, otp, device_fingerprint, device_model, and optional full_name. */ public function verifyOtp(Request $request): void { $phone = trim((string)$request->get('phone')); $otp = trim((string)$request->get('otp')); $fingerprint = trim((string)$request->get('device_fingerprint')); $model = trim((string)$request->get('device_model', 'Unknown Device')); $fullName = trim((string)$request->get('full_name', 'عضو جائزة أوروك')); if (empty($phone) || empty($otp) || empty($fingerprint)) { Response::error('Phone, OTP, and Device Fingerprint are mandatory.'); } $pdo = Database::getConnection(); // 1. Verify OTP code against otp_dispatches $otpHash = hash('sha256', $otp); $isDev = getenv('APP_ENV') === 'development' && $otp === '123456'; if (!$isDev) { $verStmt = $pdo->prepare(' SELECT id FROM otp_dispatches WHERE phone = :phone AND otp_code_hash = :hash AND expires_at > NOW() AND status = "DISPATCHED" ORDER BY id DESC LIMIT 1 '); $verStmt->execute([':phone' => $phone, ':hash' => $otpHash]); $dispRecord = $verStmt->fetch(PDO::FETCH_ASSOC); if (!$dispRecord) { // Fallback check audit_logs for backward compatibility $fallbackStmt = $pdo->prepare(' SELECT id FROM audit_logs WHERE endpoint = "/api/v1/auth/request-otp" AND payload_snippet LIKE :needle AND created_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) ORDER BY id DESC LIMIT 1 '); $fallbackStmt->execute([':needle' => '%"phone":"' . $phone . '","otp":"' . $otp . '"%']); $auditRecord = $fallbackStmt->fetch(PDO::FETCH_ASSOC); if (!$auditRecord) { Response::error('Invalid or expired OTP code.', 401); } } else { $updDisp = $pdo->prepare('UPDATE otp_dispatches SET status = "VERIFIED", verified_at = NOW() WHERE id = :id'); $updDisp->execute([':id' => $dispRecord['id']]); } } // 2. Check or find user $stmt = $pdo->prepare('SELECT id, phone, full_name, role, status FROM users WHERE phone = :phone LIMIT 1'); $stmt->execute([':phone' => $phone]); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user) { // Register new user $ins = $pdo->prepare(' INSERT INTO users (phone, full_name, role, status, created_at) VALUES (:phone, :name, "MEMBER", "ACTIVE", NOW()) '); $ins->execute([':phone' => $phone, ':name' => $fullName]); $userId = (int)$pdo->lastInsertId(); $role = 'MEMBER'; } else { $userId = (int)$user['id']; $role = $user['role']; } // Generate per-device secret key and session token $deviceSecret = Security::generateRandomHex(32); $sessionToken = Security::generateRandomHex(32); // Bind device fingerprint and store secret in user_security table $secStmt = $pdo->prepare(' INSERT INTO user_security (user_id, device_fingerprint, device_secret, device_model, last_token, last_ip, last_active_at) VALUES (:uid, :fp, :secret, :model, :token, :ip, NOW()) ON DUPLICATE KEY UPDATE device_secret = VALUES(device_secret), device_model = VALUES(device_model), last_token = VALUES(last_token), last_ip = VALUES(last_ip), last_active_at = NOW(), is_locked = 0 '); $secStmt->execute([ ':uid' => $userId, ':fp' => $fingerprint, ':secret' => $deviceSecret, ':model' => $model, ':token' => $sessionToken, ':ip' => $request->getIp(), ]); Response::success([ 'user' => [ 'id' => $userId, 'phone' => $phone, 'full_name' => $user['full_name'] ?? $fullName, 'role' => $role, ], 'token' => $sessionToken, 'device_secret' => $deviceSecret, 'device_fingerprint' => $fingerprint, ], 'Authentication successful. Device bound and secured.'); } /** * GET /api/v1/auth/profile */ public function getProfile(Request $request): void { $userId = (int)$request->getHeader('x-user-id'); $pdo = Database::getConnection(); $stmt = $pdo->prepare(' SELECT u.id, u.phone, u.full_name, u.role, u.status, u.created_at, s.membership_number, s.status AS subscription_status, s.expires_at FROM users u LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = "ACTIVE" WHERE u.id = :uid ORDER BY s.id DESC LIMIT 1 '); $stmt->execute([':uid' => $userId]); $profile = $stmt->fetch(PDO::FETCH_ASSOC); Response::success($profile, 'User profile retrieved.'); } }