ABDM M2 compliance: strict EAtria auth/HFR and callback replay guards

This commit is contained in:
DevSoftTech Server 2026-06-26 17:52:21 +05:30
parent 03638979f0
commit 51e5ab026c
2 changed files with 76 additions and 100 deletions

View File

@ -3364,9 +3364,9 @@ class AbdmGateway extends BaseController
return $signatureFailure;
}
$timestampFailure = $this->validateGatewayToHmsTimestamp();
if ($timestampFailure !== null) {
return $timestampFailure;
$metaFailure = $this->validateGatewayToHmsRequestMeta();
if ($metaFailure !== null) {
return $metaFailure;
}
return null;
@ -3376,7 +3376,12 @@ class AbdmGateway extends BaseController
{
$secret = trim((string) ($this->readRuntimeSetting('GATEWAY_TO_HMS_HMAC_SECRET') ?: $this->readRuntimeSetting('EKA_WEBHOOK_SECRET')));
if ($secret === '') {
return null;
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'SERVER_MISCONFIGURED',
'message' => 'Missing gateway webhook HMAC secret',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
]);
}
$signature = trim((string) ($this->request->getHeaderLine('X-Eka-Signature') ?: ''));
@ -3408,11 +3413,26 @@ class AbdmGateway extends BaseController
return null;
}
private function validateGatewayToHmsTimestamp()
private function validateGatewayToHmsRequestMeta()
{
$requestId = trim((string) $this->request->getHeaderLine('X-Request-Id'));
if ($requestId === '') {
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Missing X-Request-Id header',
'request_id' => '',
]);
}
$timestamp = trim((string) $this->request->getHeaderLine('X-Timestamp'));
if ($timestamp === '') {
return null;
return $this->response->setStatusCode(401)->setJSON([
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Missing X-Timestamp header',
'request_id' => $requestId,
]);
}
try {
@ -3424,7 +3444,7 @@ class AbdmGateway extends BaseController
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Request timestamp outside allowed skew',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
'request_id' => $requestId,
]);
}
} catch (\Throwable) {
@ -3432,10 +3452,27 @@ class AbdmGateway extends BaseController
'ok' => 0,
'error' => 'UNAUTHORIZED',
'message' => 'Invalid X-Timestamp value',
'request_id' => trim((string) $this->request->getHeaderLine('X-Request-Id')),
'request_id' => $requestId,
]);
}
$cache = \Config\Services::cache();
$cacheKey = 'abdm_gateway_reqid_' . sha1($requestId);
try {
$existing = $cache->get($cacheKey);
if ($existing !== null) {
return $this->response->setStatusCode(409)->setJSON([
'ok' => 0,
'error' => 'REPLAY_REQUEST',
'message' => 'Duplicate X-Request-Id detected',
'request_id' => $requestId,
]);
}
$cache->save($cacheKey, 1, 600);
} catch (\Throwable $e) {
log_message('warning', '[AbdmGateway] replay-guard cache failed: ' . $e->getMessage());
}
return null;
}
@ -3482,11 +3519,18 @@ class AbdmGateway extends BaseController
}
if ($this->db->tableExists('hospital_setting')) {
$row = $this->db->table('hospital_setting')
$fields = $this->db->getFieldNames('hospital_setting') ?? [];
$orderCol = in_array('id', $fields, true)
? 'id'
: (in_array('s_id', $fields, true) ? 's_id' : null);
$builder = $this->db->table('hospital_setting')
->select('s_value')
->where('s_name', $name)
->get(1)
->getRowArray();
->where('s_name', $name);
if ($orderCol !== null) {
$builder->orderBy($orderCol, 'DESC');
}
$row = $builder->get(1)->getRowArray();
$value = trim((string) ($row['s_value'] ?? ''));
if ($value !== '') {
return $value;

View File

@ -18,6 +18,8 @@ namespace App\Libraries\Abdm;
*/
class EAtriaBridgeConnector implements AbdmConnectorInterface
{
private const REQUIRED_HFR_ID = 'IN0510000870';
private string $baseUrl;
private string $token;
private string $hfrId;
@ -40,7 +42,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$this->tokenSourceByValue[$this->token] = 'config.eatriaBridgeToken';
$this->tokenSource = 'config.eatriaBridgeToken';
}
$this->hfrId = '';
$this->hfrId = self::REQUIRED_HFR_ID;
$this->bridgeHospitalId = '';
$this->timeoutSec = (int) ($config->eatriaBridgeTimeoutSec ?? 30);
@ -70,12 +72,6 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
->select('s_name, s_value')
->whereIn('s_name', [
'EATRIA_BRIDGE_TOKEN',
'EKA_GATEWAY_TOKEN',
'GATEWAY_TO_HMS_TOKEN',
'ABDM_GATEWAY_TO_HMS_TOKEN',
'ABDM_BRIDGE_TOKEN',
'ABDM_GATEWAY_TOKEN',
'EATRIA_BRIDGE_API_KEY',
'EATRIA_BRIDGE_URL',
'ABDM_HFR_ID',
'H_HFR_ID',
@ -103,15 +99,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$tokenCandidates = [];
$tokenSourceByValue = [];
foreach ([
'EATRIA_BRIDGE_TOKEN',
'EKA_GATEWAY_TOKEN',
'GATEWAY_TO_HMS_TOKEN',
'ABDM_GATEWAY_TO_HMS_TOKEN',
'ABDM_BRIDGE_TOKEN',
'ABDM_GATEWAY_TOKEN',
'EATRIA_BRIDGE_API_KEY',
] as $tokenKey) {
foreach (['EATRIA_BRIDGE_TOKEN'] as $tokenKey) {
$rawToken = trim((string) ($dbSettings[$tokenKey] ?? ''));
if ($rawToken === '') {
continue;
@ -133,7 +121,12 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$dbUrl = trim((string) ($dbSettings['EATRIA_BRIDGE_URL'] ?? ''));
if ($dbUrl !== '') {
$this->baseUrl = rtrim($dbUrl, '/');
$host = (string) (parse_url($dbUrl, PHP_URL_HOST) ?? '');
if ($host !== '' && in_array(strtolower($host), ['127.0.0.1', 'localhost', '::1'], true)) {
log_message('warning', '[EAtriaBridge] Ignoring localhost EATRIA_BRIDGE_URL from hospital_setting');
} else {
$this->baseUrl = rtrim($dbUrl, '/');
}
}
$dbHfrId = trim((string) (
@ -142,9 +135,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
?? $dbSettings['ABDM_HOSPITAL_HFR_ID']
?? ''
));
if ($dbHfrId !== '') {
$this->hfrId = $dbHfrId;
if ($dbHfrId !== '' && strcasecmp($dbHfrId, self::REQUIRED_HFR_ID) !== 0) {
log_message('warning', '[EAtriaBridge] Overriding configured HFR ID with mandated HFR ' . self::REQUIRED_HFR_ID . ' (found ' . $dbHfrId . ')');
}
$this->hfrId = self::REQUIRED_HFR_ID;
$dbBridgeHospitalId = trim((string) (
$dbSettings['ABDM_BRIDGE_HOSPITAL_ID']
@ -337,7 +331,12 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
};
if ($this->token === '') {
log_message('warning', '[EAtriaBridge] Authorization token missing for request: ' . $method . ' ' . $url);
log_message('error', '[EAtriaBridge] Authorization token missing for request: ' . $method . ' ' . $url);
return [
'ok' => 0,
'http_code' => 0,
'error_text' => 'Missing EATRIA_BRIDGE_TOKEN in hospital_setting',
];
}
$attempt = $callWithToken($this->token);
@ -715,74 +714,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$body[$optional] = (string) $data[$optional];
}
$result = $this->post('/v3/records/push', $body);
$httpCode = (int) ($result['http_code'] ?? 0);
$authErrorText = strtolower(trim((string) ($result['message'] ?? $result['error_text'] ?? $result['error'] ?? '')));
$isAuthFailure = in_array($httpCode, [401, 403], true)
&& ($authErrorText === ''
|| str_contains($authErrorText, 'unauthorized')
|| str_contains($authErrorText, 'invalid authorization token')
|| str_contains($authErrorText, 'invalid token'));
// Some hospital keys are scoped to /v1/bridge dispatcher paths.
// Fallback to event dispatch when direct /v3/records/push returns auth failure.
if (! $isAuthFailure) {
return $result;
}
$dispatchPayload = [
'event_type' => 'abdm.fhir.share.requested',
'payload' => [
'hi_type' => $hiType,
'record_type' => $recordType,
'fhir_bundle' => $body['fhir_bundle'] ?? (object) [],
'bundle' => $body['fhir_bundle'] ?? (object) [],
'care_context_reference' => $careContextReference,
'care_context_display' => $careContextDisplay,
'queue_id' => $queueId,
'abha_id' => $abhaId,
'abha_address' => $abhaAddress,
'patient_id' => (string) ($data['patient_id'] ?? ''),
'patient_name' => $patientName,
'visit_date' => (string) ($data['visit_date'] ?? date('Y-m-d')),
'hfr_id' => $this->hfrId,
'hospital_id' => $this->bridgeHospitalId,
],
];
$dispatch = $this->post('/v1/bridge', $dispatchPayload);
$dispatchOk = (int) ($dispatch['ok'] ?? 0) === 1;
$dispatchNode = is_array($dispatch['dispatch'] ?? null) ? $dispatch['dispatch'] : [];
$dispatchHttp = (int) ($dispatchNode['http_code'] ?? $dispatch['http_code'] ?? 0);
$dispatchResponse = is_array($dispatchNode['response'] ?? null) ? $dispatchNode['response'] : [];
$dispatchErr = trim((string) (
$dispatchResponse['message']
?? $dispatchResponse['error_text']
?? $dispatchResponse['error']
?? $dispatch['message']
?? $dispatch['error_text']
?? $dispatch['error']
?? ''
));
if (! $dispatchOk) {
return array_merge($result, [
'fallback' => 'v1_bridge_dispatch_failed',
'fallback_http_code' => $dispatchHttp,
'fallback_error' => $dispatchErr !== '' ? $dispatchErr : 'Bridge dispatcher failed',
]);
}
return [
'ok' => (int) ($dispatchResponse['ok'] ?? 1),
'http_code' => $dispatchHttp > 0 ? $dispatchHttp : 200,
'id' => (int) ($dispatchResponse['id'] ?? $dispatchResponse['record_id'] ?? 0),
'queue_id' => (string) ($dispatchResponse['queue_id'] ?? $dispatchResponse['request_id'] ?? $dispatch['request_id'] ?? $queueId),
'message' => (string) ($dispatchResponse['message'] ?? 'Record dispatched via v1 bridge'),
'dispatch' => $dispatch,
'fallback' => 'v1_bridge_dispatch',
];
return $this->post('/v3/records/push', $body);
}
// -------------------------------------------------------------------------