First version of the security module
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user