changes
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\VikonIntegration\Tasks;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
|
||||
/**
|
||||
* Task: Makes secure HTTP requests to Vikon API (db-nica.ru)
|
||||
*
|
||||
* SSL verification is ENABLED by default (unlike old vikon_core).
|
||||
* Uses Laravel Http facade with proper timeout and retry settings.
|
||||
*/
|
||||
class CallVikonApiTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $apiDomain,
|
||||
private readonly string $authDomain,
|
||||
private readonly string $filemanagerDomain,
|
||||
private readonly int $timeout,
|
||||
private readonly int $retries,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET request to Vikon API
|
||||
*/
|
||||
public function get(string $endpoint, array $headers = [], string $service = 'api'): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$baseUrl = $this->resolveBaseUrl($service);
|
||||
$url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
return $this->makeRequest('get', $url, [], $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request to Vikon API
|
||||
*/
|
||||
public function post(string $endpoint, array $data = [], array $headers = [], string $service = 'api'): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$baseUrl = $this->resolveBaseUrl($service);
|
||||
$url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
return $this->makeRequest('post', $url, $data, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request with Bearer token authorization
|
||||
*/
|
||||
public function getWithToken(string $endpoint, string $token, string $service = 'api'): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$baseUrl = $this->resolveBaseUrl($service);
|
||||
$url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
return $this->makeRequest('get', $url, [], [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request with Bearer token authorization
|
||||
*/
|
||||
public function postWithToken(string $endpoint, string $token, array $data = [], string $service = 'api'): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$baseUrl = $this->resolveBaseUrl($service);
|
||||
$url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
return $this->makeRequest('post', $url, $data, [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download binary content (ZIP archive) with Bearer token
|
||||
*/
|
||||
public function downloadWithToken(string $endpoint, string $token, string $service = 'api'): string
|
||||
{
|
||||
$baseUrl = $this->resolveBaseUrl($service);
|
||||
$url = rtrim($baseUrl, '/') . '/' . ltrim($endpoint, '/');
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Accept-Encoding' => 'zip, gzip',
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->timeout($this->timeout)
|
||||
->retry($this->retries, 1000, function ($exception, $response) {
|
||||
if ($response && $response->successful()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException(
|
||||
'Vikon API error: ' . $this->extractErrorMessage($response) . ' (HTTP ' . $response->status() . ')'
|
||||
);
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve base URL by service type
|
||||
*/
|
||||
private function resolveBaseUrl(string $service): string
|
||||
{
|
||||
return match ($service) {
|
||||
'auth' => $this->authDomain,
|
||||
'filemanager' => $this->filemanagerDomain,
|
||||
default => $this->apiDomain,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request with common settings
|
||||
*
|
||||
* @throws ConnectionException
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
private function makeRequest(string $method, string $url, array $data, array $headers): \Illuminate\Http\Client\Response
|
||||
{
|
||||
$http = Http::withHeaders(array_merge([
|
||||
'Accept' => 'application/json',
|
||||
], $headers))
|
||||
->timeout($this->timeout)
|
||||
->retry($this->retries, 1000, function ($exception, $response) {
|
||||
if ($response && $response->successful()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
$response = match ($method) {
|
||||
'get' => $data ? $http->get($url, $data) : $http->get($url),
|
||||
'post' => $http->post($url, $data),
|
||||
};
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException(
|
||||
'Vikon API error: ' . $this->extractErrorMessage($response) . ' (HTTP ' . $response->status() . ')'
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from API response
|
||||
*/
|
||||
private function extractErrorMessage(\Illuminate\Http\Client\Response $response): string
|
||||
{
|
||||
$body = $response->json();
|
||||
|
||||
if (isset($body['message'])) {
|
||||
return $body['message'];
|
||||
}
|
||||
|
||||
if (isset($body['error'])) {
|
||||
return $body['error'];
|
||||
}
|
||||
|
||||
if (isset($body['messages']) && is_array($body['messages'])) {
|
||||
return implode('; ', $body['messages']);
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\VikonIntegration\Tasks;
|
||||
|
||||
/**
|
||||
* Task: Check if current entry point is allowed in Vikon settings
|
||||
*/
|
||||
class CheckVikonEntryPointTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CallVikonApiTask $callVikonApiTask,
|
||||
private readonly string $clientId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verify that the current URL is registered as valid entry point
|
||||
*
|
||||
* @return bool
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function run(string $entryPoint): bool
|
||||
{
|
||||
$response = $this->callVikonApiTask->get(
|
||||
'oauth2/checkEntryPoint',
|
||||
[
|
||||
'client_id' => $this->clientId,
|
||||
'entry_point' => $entryPoint,
|
||||
]
|
||||
);
|
||||
|
||||
$body = $response->json();
|
||||
|
||||
return isset($body['success']) && $body['success'] === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\VikonIntegration\Tasks;
|
||||
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Task: Safely extracts ZIP archives with Zip Slip protection
|
||||
*
|
||||
* Unlike old vikon_core which used unpackZip() without path validation,
|
||||
* this task validates every entry to prevent directory traversal attacks.
|
||||
*/
|
||||
class ExtractZipArchiveTask
|
||||
{
|
||||
/**
|
||||
* Extract ZIP archive to destination with security checks
|
||||
*
|
||||
* @param string $zipPath Absolute path to ZIP file
|
||||
* @param string $destination Absolute path to extraction directory
|
||||
* @return bool
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function run(string $zipPath, string $destination): bool
|
||||
{
|
||||
if (!file_exists($zipPath)) {
|
||||
throw new \RuntimeException("ZIP file not found: {$zipPath}");
|
||||
}
|
||||
|
||||
if (!is_writable(dirname($destination))) {
|
||||
throw new \RuntimeException("Destination directory is not writable: " . dirname($destination));
|
||||
}
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$openResult = $zip->open($zipPath);
|
||||
|
||||
if ($openResult !== true) {
|
||||
throw new \RuntimeException("Failed to open ZIP archive (code: {$openResult})");
|
||||
}
|
||||
|
||||
// Zip Slip protection: validate every entry
|
||||
$this->validateZipEntries($zip, $destination);
|
||||
|
||||
// Extract with overwrite
|
||||
$extractResult = $zip->extractTo($destination);
|
||||
|
||||
$zip->close();
|
||||
|
||||
if (!$extractResult) {
|
||||
throw new \RuntimeException('Failed to extract ZIP archive');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all ZIP entries to prevent Zip Slip attack
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
private function validateZipEntries(ZipArchive $zip, string $destination): void
|
||||
{
|
||||
$realDestination = realpath($destination);
|
||||
|
||||
if ($realDestination === false) {
|
||||
throw new \RuntimeException('Destination directory does not exist');
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$filename = $zip->getNameIndex($i);
|
||||
|
||||
// Check for path traversal patterns
|
||||
if ($this->containsPathTraversal($filename)) {
|
||||
throw new \RuntimeException(
|
||||
"Potentially malicious ZIP entry detected: {$filename}"
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve full path and verify it's within destination
|
||||
$fullPath = realpath($realDestination . '/' . $filename);
|
||||
|
||||
if ($fullPath === false) {
|
||||
// File doesn't exist yet (will be created), check parent directory
|
||||
$parentDir = dirname($realDestination . '/' . $filename);
|
||||
if (strpos($parentDir, $realDestination) !== 0) {
|
||||
throw new \RuntimeException(
|
||||
"ZIP entry escapes destination directory: {$filename}"
|
||||
);
|
||||
}
|
||||
} elseif (strpos($fullPath, $realDestination) !== 0) {
|
||||
throw new \RuntimeException(
|
||||
"ZIP entry escapes destination directory: {$filename}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filename contains path traversal patterns
|
||||
*/
|
||||
private function containsPathTraversal(string $filename): bool
|
||||
{
|
||||
// Check for directory traversal sequences
|
||||
if (strpos($filename, '..') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for absolute paths on Windows
|
||||
if (preg_match('/^[a-zA-Z]:/', $filename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for absolute paths on Unix
|
||||
if (strpos($filename, '/') === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\VikonIntegration\Tasks;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Task: Manage module files (scan, remove, create directories)
|
||||
*
|
||||
* Replaces old vikon_core Filesystem class with Laravel's File facade.
|
||||
* All operations are scoped to specific module to prevent accidental deletion.
|
||||
*/
|
||||
class ManageModuleFilesTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $basePath,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get module root path
|
||||
*/
|
||||
public function getModulePath(int $moduleId, string $moduleFolder): string
|
||||
{
|
||||
return $this->basePath . '/' . $moduleFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely remove directory with path validation
|
||||
*
|
||||
* @param string $path Path to remove
|
||||
* @param string $allowedBasePath Base path constraint
|
||||
* @param bool $recursive Remove recursively
|
||||
* @return bool
|
||||
*/
|
||||
public function safeRemove(string $path, string $allowedBasePath, bool $recursive = false): bool
|
||||
{
|
||||
// Prevent path traversal
|
||||
if (!$this->isPathWithinBase($path, $allowedBasePath)) {
|
||||
\Illuminate\Support\Facades\Log::warning('Attempted to remove path outside allowed base', [
|
||||
'path' => $path,
|
||||
'allowed_base' => $allowedBasePath,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return File::delete($path);
|
||||
}
|
||||
|
||||
return File::deleteDirectory($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely create directory
|
||||
*/
|
||||
public function safeMkdir(string $path, int $mode = 0755): bool
|
||||
{
|
||||
if (File::isDirectory($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return File::makeDirectory($path, $mode, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely create file with content
|
||||
*/
|
||||
public function safeCreateFile(string $path, string $content = '', int $mode = 0644): bool
|
||||
{
|
||||
if (File::exists($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!File::isDirectory($directory)) {
|
||||
File::makeDirectory($directory, 0755, true, true);
|
||||
}
|
||||
|
||||
$result = File::put($path, $content);
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chmod($path, $mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory safely (excludes . and ..)
|
||||
*
|
||||
* @return array|false
|
||||
*/
|
||||
public function safeScandir(string $path): array|false
|
||||
{
|
||||
if (!File::isDirectory($path) || !File::isReadable($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entries = File::directories($path);
|
||||
$files = File::files($path);
|
||||
|
||||
return array_merge(
|
||||
array_map('basename', $entries),
|
||||
array_map('basename', $files)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename file with path validation
|
||||
*/
|
||||
public function safeRename(string $oldPath, string $newPath, string $allowedBasePath): bool
|
||||
{
|
||||
if (!$this->isPathWithinBase($oldPath, $allowedBasePath)) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->isPathWithinBase($newPath, $allowedBasePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File::exists($oldPath)) {
|
||||
return false;
|
||||
}
|
||||
if (File::exists($newPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newDir = dirname($newPath);
|
||||
if (!File::isDirectory($newDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return File::move($oldPath, $newPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace directory with rename (atomic operation)
|
||||
*
|
||||
* Old -> _old
|
||||
* New -> Old
|
||||
*/
|
||||
public function replaceWithRename(string $sourceDir, string $targetDir, string $allowedBasePath): bool
|
||||
{
|
||||
if (!$this->isPathWithinBase($sourceDir, $allowedBasePath)) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->isPathWithinBase($targetDir, $allowedBasePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File::exists($sourceDir)) {
|
||||
return false;
|
||||
}
|
||||
if (File::exists($targetDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parentDir = dirname($targetDir);
|
||||
if (!is_writable($parentDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File::makeDirectory($targetDir, 0755, true, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entries = $this->safeScandir($sourceDir);
|
||||
if (empty($entries)) {
|
||||
return File::deleteDirectory($sourceDir);
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$item = $sourceDir . '/' . $entry;
|
||||
$newPath = $targetDir . '/' . $entry;
|
||||
|
||||
if (File::isDirectory($item)) {
|
||||
if (!$this->replaceWithRename($item, $newPath, $allowedBasePath)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!File::move($item, $newPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return File::deleteDirectory($sourceDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is within allowed base directory
|
||||
*/
|
||||
private function isPathWithinBase(string $path, string $basePath): bool
|
||||
{
|
||||
$realPath = realpath($path);
|
||||
$realBase = realpath($basePath);
|
||||
|
||||
// If base directory doesn't exist, path cannot be valid
|
||||
if ($realBase === false) {
|
||||
\Illuminate\Support\Facades\Log::error('Base path does not exist', [
|
||||
'base' => $basePath,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($realPath === false) {
|
||||
// For non-existent paths, check parent directory
|
||||
$parentDir = dirname($path);
|
||||
$realParent = realpath($parentDir);
|
||||
|
||||
if ($realParent === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_starts_with($realParent . '/', $realBase . '/');
|
||||
}
|
||||
|
||||
return str_starts_with($realPath . '/', $realBase . '/') || $realPath === $realBase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\VikonIntegration\Tasks;
|
||||
|
||||
/**
|
||||
* Task: Refresh Vikon access token using refresh_token
|
||||
*
|
||||
* Old vikon_core used refresh_token.php with SSL verification disabled.
|
||||
* This task uses secure Http client with proper error handling.
|
||||
*/
|
||||
class RefreshVikonTokenTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CallVikonApiTask $callVikonApiTask,
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Exchange refresh_token for new access_token and refresh_token
|
||||
*
|
||||
* Uses api_domain (db-nica.ru) as per old update/refresh_access_token.php
|
||||
* NOT auth_domain (auth.db-nica.ru)
|
||||
*
|
||||
* @return array{access_token: string, refresh_token: string}
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function run(string $refreshToken): array
|
||||
{
|
||||
$response = $this->callVikonApiTask->post('oauth2/RefreshToken', [
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'grant_type' => 'refresh_token',
|
||||
], [], 'api'); // <-- api domain (db-nica.ru), NOT auth domain
|
||||
|
||||
$body = $response->json();
|
||||
|
||||
if (!isset($body['access_token']) || !isset($body['refresh_token'])) {
|
||||
throw new \RuntimeException(
|
||||
'Invalid token refresh response: ' . ($body['message'] ?? 'Unknown error')
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $body['access_token'],
|
||||
'refresh_token' => $body['refresh_token'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\VikonIntegration\Tasks;
|
||||
|
||||
/**
|
||||
* Task: Validate Vikon access token by checking with remote API
|
||||
*/
|
||||
class ValidateVikonTokenTask
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CallVikonApiTask $callVikonApiTask,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if access_token is valid
|
||||
*
|
||||
* Uses auth_domain (auth.db-nica.ru) + api/profile_applicant/check_access_token
|
||||
* as per old scripts/check_token.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function run(string $accessToken): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->callVikonApiTask->getWithToken(
|
||||
'api/profile_applicant/check_access_token',
|
||||
$accessToken,
|
||||
'auth' // <-- auth domain (auth.db-nica.ru)
|
||||
);
|
||||
|
||||
return $response->successful();
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('Vikon token validation failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user