false, 'message' => 'method_not_allowed']); exit; } require_once __DIR__ . '/../includes/Database.php'; require_once __DIR__ . '/../includes/Redis.php'; require_once __DIR__ . '/../includes/RateLimit.php'; require_once __DIR__ . '/../includes/Auth.php'; require_once __DIR__ . '/../includes/Logger.php'; // Authenticate — requires app key Auth::requireAuth('app'); $input = json_decode(file_get_contents('php://input'), true); if (!$input || !isset($input['phone']) || !isset($input['otp'])) { http_response_code(400); echo json_encode(['success' => false, 'message' => 'missing_phone_or_otp']); RequestLogger::log('verify-otp', 'POST', $input, 400, 'missing_fields'); exit; } $phone = trim($input['phone']); $otp = trim($input['otp']); // Validate phone format if (!preg_match('/^\+[1-9]\d{6,14}$/', $phone)) { http_response_code(400); echo json_encode(['success' => false, 'message' => 'invalid_phone_format']); RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_phone'); exit; } // Validate OTP format (3 or 4 digits) if (!preg_match('/^\d{3,4}$/', $otp)) { http_response_code(400); echo json_encode(['success' => false, 'message' => 'invalid_otp_format']); RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_otp_format'); exit; } $redis = RedisClient::getInstance(); $db = Database::getInstance(); $redisKey = "otp:{$phone}"; // Check if OTP exists in Redis $stored = $redis->get($redisKey); if ($stored === false || $stored === null) { // OTP expired or never existed echo json_encode([ 'success' => false, 'message' => 'expired', ]); RequestLogger::log('verify-otp', 'POST', $input, 200, 'expired'); exit; } // Decode stored data if (is_string($stored)) { $otpData = json_decode($stored, true); } else { $otpData = $stored; } if (!is_array($otpData) || !isset($otpData['otp'])) { // Corrupted data — clean up $redis->del($redisKey); echo json_encode(['success' => false, 'message' => 'expired']); RequestLogger::log('verify-otp', 'POST', $input, 200, 'corrupted_data'); exit; } // Check max attempts if (isset($otpData['attempts']) && (int) $otpData['attempts'] >= MAX_OTP_ATTEMPTS) { $redis->del($redisKey); echo json_encode([ 'success' => false, 'message' => 'max_attempts', ]); RequestLogger::log('verify-otp', 'POST', $input, 200, 'max_attempts'); exit; } // Increment attempt counter $otpData['attempts'] = (int) ($otpData['attempts'] ?? 0) + 1; $ttl = $redis->ttl($redisKey); if ($ttl > 0) { $redis->setex($redisKey, $ttl, json_encode($otpData)); } // Timing-safe comparison if (hash_equals($otpData['otp'], $otp)) { // Success — clean up Redis $redis->del($redisKey); // Update database try { $stmt = $db->prepare( "UPDATE otp_requests SET verified_at = NOW(), status = 'verified' WHERE phone = ? AND otp_code = ? AND verified_at IS NULL ORDER BY created_at DESC LIMIT 1" ); $stmt->execute([$phone, $otp]); } catch (\Throwable $e) { // Non-critical — OTP is already verified via Redis error_log('verify-otp DB update error: ' . $e->getMessage()); } echo json_encode([ 'success' => true, 'message' => 'verified', ]); RequestLogger::log('verify-otp', 'POST', $input, 200); } else { // Wrong OTP $remainingAttempts = MAX_OTP_ATTEMPTS - (int) $otpData['attempts']; echo json_encode([ 'success' => false, 'message' => 'invalid_otp', 'remaining_attempts' => max(0, $remainingAttempts), ]); RequestLogger::log('verify-otp', 'POST', $input, 200, 'invalid_otp'); }