1
0
Files
breadcrumb-the-shire/modules/security/lib/Module/Security/Service/SecurityBcGateway.php

309 lines
9.9 KiB
PHP
Raw Normal View History

2026-06-22 13:19:05 +02:00
<?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;
}
}