forked from fa/breadcrumb-the-shire
309 lines
9.9 KiB
PHP
309 lines
9.9 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace MintyPHP\Module\Security\Service;
|
||
|
|
|
||
|
|
use MintyPHP\Http\SessionStoreInterface;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Thin Business Central OData V4 gateway for the Security module.
|
||
|
|
*
|
||
|
|
* Standalone and intentionally minimal: only the two queries the security-check
|
||
|
|
* wizard needs (customer search + domains-for-customer) plus a connection test.
|
||
|
|
* Supports Basic and OAuth2 auth modes. No remote assets, TLS verification on
|
||
|
|
* (GR-SEC-004).
|
||
|
|
*
|
||
|
|
* @api Consumed by the customer-search / customer-domains / settings-test pages.
|
||
|
|
*/
|
||
|
|
class SecurityBcGateway
|
||
|
|
{
|
||
|
|
public const ENTITY_CUSTOMER = 'Integration_Customer_Card';
|
||
|
|
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
|
||
|
|
|
||
|
|
private const CONNECT_TIMEOUT = 10;
|
||
|
|
private const REQUEST_TIMEOUT = 30;
|
||
|
|
|
||
|
|
public function __construct(
|
||
|
|
private readonly SecurityBcSettingsGateway $settingsGateway,
|
||
|
|
private readonly SecurityOAuthTokenService $oauthTokenService,
|
||
|
|
private readonly SessionStoreInterface $sessionStore
|
||
|
|
) {
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Search customers by name or number.
|
||
|
|
*
|
||
|
|
* Tries contains() first, falls back to startswith() / range filter for BC
|
||
|
|
* versions that reject contains().
|
||
|
|
*
|
||
|
|
* @return array<int, array<string, mixed>>
|
||
|
|
*/
|
||
|
|
public function searchCustomers(string $query): array
|
||
|
|
{
|
||
|
|
$query = trim($query);
|
||
|
|
if ($query === '') {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
$escaped = $this->escapeODataStrictUserInput($query);
|
||
|
|
$upperQuery = mb_strtoupper($escaped);
|
||
|
|
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail';
|
||
|
|
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||
|
|
|
||
|
|
$strategies = [
|
||
|
|
"contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')",
|
||
|
|
"startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')",
|
||
|
|
"Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'",
|
||
|
|
];
|
||
|
|
|
||
|
|
$lastException = null;
|
||
|
|
foreach ($strategies as $filter) {
|
||
|
|
$url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select;
|
||
|
|
try {
|
||
|
|
$response = $this->request('GET', $url);
|
||
|
|
if ($response !== null) {
|
||
|
|
return $this->extractODataValues($response);
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
|
||
|
|
} catch (\RuntimeException $e) {
|
||
|
|
$lastException = $e;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
throw $lastException ?? new \RuntimeException('All OData search strategies failed');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get active domains for a customer.
|
||
|
|
*
|
||
|
|
* @return array<int, array<string, mixed>>
|
||
|
|
*/
|
||
|
|
public function listDomainsForCustomer(string $customerNo): array
|
||
|
|
{
|
||
|
|
$customerNo = trim($customerNo);
|
||
|
|
if ($customerNo === '') {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
$escaped = $this->escapeODataStrictUserInput($customerNo);
|
||
|
|
$filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'";
|
||
|
|
$select = 'No,Customer_No,URL,State';
|
||
|
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
|
||
|
|
. '?$filter=' . rawurlencode($filter)
|
||
|
|
. '&$top=200'
|
||
|
|
. '&$select=' . rawurlencode($select)
|
||
|
|
. '&$orderby=' . rawurlencode('URL asc');
|
||
|
|
|
||
|
|
$response = $this->request('GET', $url);
|
||
|
|
if ($response === null) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return $this->extractODataValues($response);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test the configured BC connection.
|
||
|
|
*
|
||
|
|
* @return array{ok: bool, error?: string, url?: string, http_code?: int}
|
||
|
|
*/
|
||
|
|
public function testConnection(): array
|
||
|
|
{
|
||
|
|
if (!$this->settingsGateway->isConfigured()) {
|
||
|
|
return ['ok' => false, 'error' => 'Configuration incomplete'];
|
||
|
|
}
|
||
|
|
|
||
|
|
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) . '?$top=1&$select=No';
|
||
|
|
|
||
|
|
$ch = curl_init();
|
||
|
|
if ($ch === false) {
|
||
|
|
return ['ok' => false, 'error' => 'curl_init failed'];
|
||
|
|
}
|
||
|
|
|
||
|
|
curl_setopt_array($ch, [
|
||
|
|
CURLOPT_URL => $url,
|
||
|
|
CURLOPT_RETURNTRANSFER => true,
|
||
|
|
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||
|
|
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||
|
|
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
|
||
|
|
CURLOPT_CUSTOMREQUEST => 'GET',
|
||
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
||
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->applyAuth($ch);
|
||
|
|
|
||
|
|
$responseBody = curl_exec($ch);
|
||
|
|
$curlErrno = curl_errno($ch);
|
||
|
|
$curlError = curl_error($ch);
|
||
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
|
|
if ($curlErrno !== 0) {
|
||
|
|
return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url];
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($httpCode < 200 || $httpCode >= 300) {
|
||
|
|
$hint = match ($httpCode) {
|
||
|
|
401 => 'Unauthorized — check credentials',
|
||
|
|
403 => 'Forbidden — user has no access to this endpoint',
|
||
|
|
404 => 'Not found — check OData URL and company name',
|
||
|
|
default => 'HTTP ' . $httpCode,
|
||
|
|
};
|
||
|
|
|
||
|
|
return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!is_string($responseBody)) {
|
||
|
|
return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode];
|
||
|
|
}
|
||
|
|
|
||
|
|
$decoded = json_decode($responseBody, true);
|
||
|
|
if (!is_array($decoded)) {
|
||
|
|
return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode];
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['ok' => true, 'url' => $url, 'http_code' => $httpCode];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<string, mixed>|null
|
||
|
|
*/
|
||
|
|
private function request(string $method, string $url): ?array
|
||
|
|
{
|
||
|
|
if (!$this->settingsGateway->isConfigured()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$ch = curl_init();
|
||
|
|
if ($ch === false) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
curl_setopt_array($ch, [
|
||
|
|
CURLOPT_URL => $url,
|
||
|
|
CURLOPT_RETURNTRANSFER => true,
|
||
|
|
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||
|
|
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||
|
|
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
|
||
|
|
CURLOPT_CUSTOMREQUEST => $method,
|
||
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
||
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->applyAuth($ch);
|
||
|
|
|
||
|
|
$responseBody = curl_exec($ch);
|
||
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
|
|
unset($ch);
|
||
|
|
|
||
|
|
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
|
||
|
|
if ($httpCode >= 400 && $httpCode < 500) {
|
||
|
|
throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$decoded = json_decode($responseBody, true);
|
||
|
|
if (!is_array($decoded)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $decoded;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param \CurlHandle $ch
|
||
|
|
*/
|
||
|
|
private function applyAuth($ch): void
|
||
|
|
{
|
||
|
|
$authMode = $this->settingsGateway->getAuthMode();
|
||
|
|
|
||
|
|
if ($authMode === SecurityBcSettingsGateway::AUTH_MODE_BASIC) {
|
||
|
|
$user = $this->settingsGateway->getBasicUser() ?? '';
|
||
|
|
$password = $this->settingsGateway->getBasicPassword() ?? '';
|
||
|
|
curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password);
|
||
|
|
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($authMode !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$tenantId = $this->resolveCurrentTenantId();
|
||
|
|
if ($tenantId <= 0) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$token = $this->oauthTokenService->getAccessToken($tenantId, $this->buildOAuthAudience());
|
||
|
|
if ($token === null || trim($token) === '') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||
|
|
'Accept: application/json',
|
||
|
|
'OData-Version: 4.0',
|
||
|
|
'Authorization: Bearer ' . $token,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string, mixed> $response
|
||
|
|
* @return array<int, array<string, mixed>>
|
||
|
|
*/
|
||
|
|
private function extractODataValues(array $response): array
|
||
|
|
{
|
||
|
|
$values = $response['value'] ?? [];
|
||
|
|
if (!is_array($values)) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
return array_values($values);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws \InvalidArgumentException If the value contains disallowed characters.
|
||
|
|
*/
|
||
|
|
private function escapeODataStrictUserInput(string $value): string
|
||
|
|
{
|
||
|
|
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
|
||
|
|
throw new \InvalidArgumentException('Search query contains invalid characters.');
|
||
|
|
}
|
||
|
|
|
||
|
|
return str_replace("'", "''", $value);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function resolveCurrentTenantId(): int
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$session = $this->sessionStore->all();
|
||
|
|
} catch (\Throwable) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||
|
|
|
||
|
|
return $tenantId > 0 ? $tenantId : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function buildOAuthAudience(): ?string
|
||
|
|
{
|
||
|
|
$baseUrl = trim($this->settingsGateway->getODataBaseUrl());
|
||
|
|
if ($baseUrl === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$parsed = parse_url($baseUrl);
|
||
|
|
if (!is_array($parsed) || empty($parsed['host'])) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
|
||
|
|
$host = (string) $parsed['host'];
|
||
|
|
$port = isset($parsed['port']) ? ':' . (int) $parsed['port'] : '';
|
||
|
|
|
||
|
|
return $scheme . '://' . $host . $port;
|
||
|
|
}
|
||
|
|
}
|