diff --git a/pom.xml b/pom.xml index dc78ec59..5749c203 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.iemr.mmu mmu-api - 3.9.2 + 3.12.0 war MMU-API diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 34875d64..cd33f081 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -106,4 +106,10 @@ logging.file.name=@env.MMU_API_LOGGING_FILE_NAME@ springdoc.api-docs.enabled=@env.SWAGGER_DOC_ENABLED@ springdoc.swagger-ui.enabled=@env.SWAGGER_DOC_ENABLED@ -cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ \ No newline at end of file +cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ + +## BenID auto top-up URLs (feature stays off: stoptb.* not set for these builds) +benCheckUrlLocal=@env.IDENTITY_API_BASE_URL@id/checkAvailablBenIDLocalServer +centralLoginUrl=@env.COMMON_API_OPEN_SYNC@user/userAuthenticate +centralRefreshTokenUrl=@env.COMMON_API_OPEN_SYNC@user/refreshToken +centralSessionUrl=@env.COMMON_API_OPEN_SYNC@user/getLoginResponse diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index be5f3608..38be2dce 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -104,4 +104,18 @@ logging.path=logs logging.file.name=${MMU_API_LOGGING_FILE_NAME} springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED} -springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} \ No newline at end of file +springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} + +## BenID auto top-up (StopTB laptops only; off unless STOPTB_ENFORCE_VANID=true and STOPTB_VAN_ID>0) +benCheckUrlLocal=${IDENTITY_API_BASE_URL}id/checkAvailablBenIDLocalServer +centralLoginUrl=${COMMON_API_OPEN_SYNC}user/userAuthenticate +centralRefreshTokenUrl=${COMMON_API_OPEN_SYNC}user/refreshToken +centralSessionUrl=${COMMON_API_OPEN_SYNC}user/getLoginResponse +stoptb.enforce.vanid=${STOPTB_ENFORCE_VANID:false} +stoptb.van.id=${STOPTB_VAN_ID:0} +benid.pool.lowerLimit=${BENID_POOL_LOWER_LIMIT:2000} +benid.pool.batchSize=${BENID_POOL_BATCH_SIZE:1000} +benid.pool.bootstrapSize=${BENID_POOL_BOOTSTRAP_SIZE:10000} +benid.token.dir=${BENID_TOKEN_DIR:/opt/amrit/data} +benid.autoTopUp.initialDelayMs=${BENID_AUTO_TOPUP_INITIAL_DELAY_MS:120000} +benid.autoTopUp.checkIntervalMs=${BENID_AUTO_TOPUP_CHECK_INTERVAL_MS:300000} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 515bb23c..63dd2fa8 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -120,4 +120,18 @@ logging.path=logs/ logging.file.name=logs/mmu-api.log -cors.allowed-origins=http://localhost:* \ No newline at end of file +cors.allowed-origins=http://localhost:* + +## BenID auto top-up (StopTB laptops only) +benCheckUrlLocal=http://localhost:8094/id/checkAvailablBenIDLocalServer +centralLoginUrl=https://uatstoptb.piramalswasthya.org/common-api/user/userAuthenticate +centralRefreshTokenUrl=https://uatstoptb.piramalswasthya.org/common-api/user/refreshToken +centralSessionUrl=https://uatstoptb.piramalswasthya.org/common-api/user/getLoginResponse +stoptb.enforce.vanid=false +stoptb.van.id=0 +benid.pool.lowerLimit=2000 +benid.pool.batchSize=1000 +benid.pool.bootstrapSize=10000 +benid.token.dir=/opt/amrit/data +benid.autoTopUp.initialDelayMs=120000 +benid.autoTopUp.checkIntervalMs=300000 diff --git a/src/main/java/com/iemr/mmu/Application.java b/src/main/java/com/iemr/mmu/Application.java index 712d34f5..8fb40ccf 100644 --- a/src/main/java/com/iemr/mmu/Application.java +++ b/src/main/java/com/iemr/mmu/Application.java @@ -29,11 +29,13 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scheduling.annotation.EnableScheduling; import com.iemr.mmu.data.login.Users; import com.iemr.mmu.utils.IEMRApplBeans; @SpringBootApplication +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java b/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java new file mode 100644 index 00000000..71533a5d --- /dev/null +++ b/src/main/java/com/iemr/mmu/controller/benid/CentralAuthController.java @@ -0,0 +1,94 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.controller.benid; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; +import com.iemr.mmu.service.benid.CentralAuthService; +import com.iemr.mmu.utils.response.OutputResponse; + +import io.swagger.v3.oas.annotations.Operation; + +@RestController +@RequestMapping(value = "/centralAuth", headers = "Authorization", produces = "application/json") +public class CentralAuthController { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + @Autowired + private CentralAuthService centralAuthService; + + @Autowired + private BenIdAutoTopUpService benIdAutoTopUpService; + + @Operation(summary = "Automatic BenID top-up and central connection status") + @GetMapping(value = "/status") + public String status() { + OutputResponse response = new OutputResponse(); + try { + response.setResponse(benIdAutoTopUpService.status().toString()); + } catch (Exception e) { + logger.error("Error reading BenID auto top-up status: " + e.getMessage()); + response.setError(e); + } + return response.toString(); + } + + /** + * Called by MMU-UI right after a successful login. Returns immediately; the + * central login and any top-up happen in the background. + */ + @Operation(summary = "Connect this laptop to central with the user's login (one time)") + @PostMapping(value = "/capture", consumes = "application/json") + public String capture(@RequestBody String requestOBJ) { + OutputResponse response = new OutputResponse(); + try { + if (!centralAuthService.isEnabled()) { + response.setResponse("disabled"); + return response.toString(); + } + JSONObject obj = new JSONObject(requestOBJ); + String userName = obj.optString("userName", null); + String password = obj.optString("password", null); + boolean force = obj.optBoolean("reconnect", false); + benIdAutoTopUpService.runInBackground(() -> { + if (centralAuthService.capture(userName, password, force)) { + benIdAutoTopUpService.triggerNow(); + } + }); + response.setResponse("accepted"); + } catch (Exception e) { + logger.error("Error in central capture: " + e.getMessage()); + response.setError(e); + } + return response.toString(); + } +} diff --git a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java index 3d1f211e..76a9477b 100644 --- a/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java +++ b/src/main/java/com/iemr/mmu/controller/dataSyncActivity/StartSyncActivity.java @@ -37,6 +37,7 @@ import org.springframework.web.bind.annotation.RestController; import com.google.gson.Gson; +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; import com.iemr.mmu.service.dataSyncActivity.DownSyncDataFromServerImpl; import com.iemr.mmu.service.dataSyncActivity.DownloadDataFromServerImpl; import com.iemr.mmu.service.dataSyncActivity.DownloadDataFromServerTransactionalImpl; @@ -71,6 +72,9 @@ public class StartSyncActivity { @Autowired private DownSyncDataFromServerImpl downSyncDataFromServerImpl; + @Autowired + private BenIdAutoTopUpService benIdAutoTopUpService; + private static final String GROUP_ID = "groupID"; private static final String PROVIDER_SERVICE_MAP_ID = "providerServiceMapID"; @@ -89,6 +93,8 @@ public String dataSyncToServer(@RequestBody String requestOBJ, serverAuthorization, jwtToken); // if (s != null) response.setResponse(s); + // internet is up right now: top up the BenID pool if it is low + benIdAutoTopUpService.triggerNow(); // else // response.setError(5000, "Error in data sync"); } else { @@ -180,8 +186,11 @@ public String startMasterDownload(@RequestBody String requestOBJ, if (s.equalsIgnoreCase("inProgress")) response.setError(5000, "Download is already in progress from different device, kindly wait to finish this"); - else + else { response.setResponse(s); + // internet is up right now: top up the BenID pool if it is low + benIdAutoTopUpService.triggerNow(); + } } else response.setError(5000, s); } else { diff --git a/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java b/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java new file mode 100644 index 00000000..56b78f16 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/benid/BenIdAutoTopUpService.java @@ -0,0 +1,372 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.locks.ReentrantLock; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.iemr.mmu.utils.RestTemplateUtil; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +/** + * Keeps the local BenID pool filled without anyone clicking "Generate BenID". + * + * Runs only on StopTB laptops (stoptb.enforce.vanid=true and stoptb.van.id>0). + * Pool empty -> bootstrap size; pool below lower limit -> batch size. IDs + * received from central are written to a pending file before the local import, + * so a failed import is retried with the same IDs instead of reserving new ones. + */ +@Service +public class BenIdAutoTopUpService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + /** Shared with the manual "Generate BenID" button so both never run together. */ + public static final ReentrantLock GENERATION_LOCK = new ReentrantLock(); + + private static final String PENDING_FILE_NAME = "benid-pending-import.json"; + private static final long TOKEN_KEEP_ALIVE_MS = 24 * 60 * 60_000L; + + @Autowired + private CentralAuthService centralAuthService; + + @Value("${benGenUrlCentral:}") + private String benGenUrlCentral; + + @Value("${benImportUrlLocal:}") + private String benImportUrlLocal; + + @Value("${benCheckUrlLocal:}") + private String benCheckUrlLocal; + + @Value("${benid.pool.lowerLimit:2000}") + private long lowerLimit; + + @Value("${benid.pool.batchSize:1000}") + private long batchSize; + + @Value("${benid.pool.bootstrapSize:10000}") + private long bootstrapSize; + + // central is asked for at most this many IDs per request: a big request can outlast the + // central gateway timeout, and a reply lost after central created the IDs wastes them + @Value("${benid.pool.maxPerRequest:1000}") + private long maxPerRequest; + + @Value("${benid.token.dir:/opt/amrit/data}") + private String dataDir; + + // wait after the 1st, 2nd, 3rd and later failures (5, 15, 30, 60 minutes) + @Value("${benid.autoTopUp.retryDelaysMs:300000,900000,1800000,3600000}") + private long[] retryDelaysMs; + + @Value("${benid.http.connectTimeoutMs:15000}") + private int connectTimeoutMs; + + @Value("${benid.http.readTimeoutMs:60000}") + private int readTimeoutMs; + + private RestTemplate restTemplate; + + private volatile int failureCount; + private volatile long nextAttemptAt; + private volatile long lastPoolCount = -1; + private volatile long lastTopUpAt; + private volatile long lastTopUpCount; + private volatile String lastError; + private volatile int pendingImportFailures; + + // one background thread for this feature, so network waits never hold shared pool threads + private final ExecutorService background = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "benid-auto-topup"); + t.setDaemon(true); + return t; + }); + + private static final int MAX_PENDING_IMPORT_FAILURES = 3; + + @PostConstruct + public void init() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeoutMs); + factory.setReadTimeout(readTimeoutMs); + restTemplate = new RestTemplate(factory); + } + + @PreDestroy + public void shutdown() { + background.shutdownNow(); + } + + /** Runs a task on this feature's own background thread. */ + public void runInBackground(Runnable task) { + background.submit(task); + } + + /** Regular check; does nothing on central or non-StopTB deployments. */ + @Scheduled(initialDelayString = "${benid.autoTopUp.initialDelayMs:120000}", fixedDelayString = "${benid.autoTopUp.checkIntervalMs:300000}") + public void scheduledCheck() { + runOnce(false); + } + + /** Run straight away (after a login or a Data Sync), in the background. */ + public void triggerNow() { + if (centralAuthService.isEnabled()) { + background.submit(() -> runOnce(true)); + } + } + + private void runOnce(boolean ignoreBackoff) { + if (!centralAuthService.isEnabled()) { + return; + } + if (!ignoreBackoff && System.currentTimeMillis() < nextAttemptAt) { + return; + } + if (!GENERATION_LOCK.tryLock()) { + logger.info("BenID generation already running - skipping this check"); + return; + } + try { + // IDs already received but not saved locally are imported first + if (importPendingIfAny()) { + onSuccess(); + } + + long available = countLocalPool(); + lastPoolCount = available; + long needed = available <= 0 ? bootstrapSize : (available < lowerLimit ? batchSize : 0); + if (needed == 0) { + centralAuthService.keepAliveIfDue(TOKEN_KEEP_ALIVE_MS); + onSuccess(); + return; + } + + String key = centralAuthService.getCentralSessionKey(); + if (key == null) { + lastError = "No central connection - waiting for a user login"; + logger.warn("BenID pool low ({} for vanID {}) but no central token - waiting for a user login", + available, centralAuthService.getVanID()); + return; + } + + int imported = 0; + for (long remaining = needed; remaining > 0;) { + long chunk = Math.min(remaining, Math.max(1, maxPerRequest)); + imported += generateAndImport(centralAuthService.getVanID(), chunk, key, key, "datasync"); + remaining -= chunk; + } + lastPoolCount = countLocalPool(); + lastTopUpAt = System.currentTimeMillis(); + lastTopUpCount = imported; + logger.info("BenID auto top-up: vanID {} +{} IDs, pool now {}", centralAuthService.getVanID(), imported, + lastPoolCount); + onSuccess(); + } catch (Exception e) { + onFailure(e); + } finally { + GENERATION_LOCK.unlock(); + } + } + + /** + * Asks central for IDs for the van and imports them locally. The central + * response is written to the pending file first, so a failed local import + * does not lose IDs that central has already reserved for this van. + * + * @return number of IDs imported + */ + public int generateAndImport(int vanID, long count, String centralAuthorization, String localAuthorization, + String localToken) throws Exception { + JSONObject request = new JSONObject(); + request.put("vanID", vanID); + request.put("benIDRequired", count); + + HttpEntity centralRequest = RestTemplateUtil.createRequestEntity(request.toString(), + centralAuthorization, "datasync"); + logger.info("Requesting {} BenIDs from central for vanID {}", count, vanID); + long start = System.currentTimeMillis(); + ResponseEntity response = restTemplate.exchange(benGenUrlCentral, HttpMethod.POST, centralRequest, + String.class); + logger.info("Central BenID generation answered in {} ms", System.currentTimeMillis() - start); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200 || !obj.has("data")) { + throw new IllegalStateException("Central BenID generation failed: " + obj.optString("errorMessage")); + } + JSONArray ids = obj.getJSONArray("data"); + for (int k = 0; k < ids.length(); k++) { + ids.getJSONObject(k).put("vanID", vanID); + } + writePending(ids.toString()); + long importStart = System.currentTimeMillis(); + importLocally(ids.toString(), localAuthorization, localToken); + deletePending(); + logger.info("Imported {} BenIDs locally in {} ms", ids.length(), System.currentTimeMillis() - importStart); + return ids.length(); + } + + private boolean importPendingIfAny() throws Exception { + Path pending = pendingPath(); + if (!Files.exists(pending)) { + return false; + } + logger.info("Importing BenIDs left from an earlier run"); + try { + String payload = new String(Files.readAllBytes(pending), StandardCharsets.UTF_8); + importLocally(payload, "datasync", "datasync"); + } catch (Exception e) { + pendingImportFailures++; + if (pendingImportFailures < MAX_PENDING_IMPORT_FAILURES) { + throw e; + } + // a file that keeps failing (e.g. half-written at a power cut) must not stop top-ups + Path setAside = Paths.get(dataDir, "benid-pending-import.failed-" + System.currentTimeMillis() + ".json"); + Files.move(pending, setAside, StandardCopyOption.REPLACE_EXISTING); + pendingImportFailures = 0; + logger.warn("Pending BenID file could not be imported {} times; moved to {} and continuing: {}", + MAX_PENDING_IMPORT_FAILURES, setAside, e.getMessage()); + return false; + } + pendingImportFailures = 0; + deletePending(); + return true; + } + + private void importLocally(String payload, String authorization, String token) { + HttpEntity localRequest = RestTemplateUtil.createRequestEntity(payload, authorization, token); + ResponseEntity response = restTemplate.exchange(benImportUrlLocal, HttpMethod.POST, localRequest, + String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200) { + throw new IllegalStateException("Local BenID import failed: " + obj.optString("errorMessage")); + } + } + + public long countLocalPool() { + HttpEntity request = RestTemplateUtil.createRequestEntity(null, "datasync", "datasync"); + ResponseEntity response = restTemplate.exchange(benCheckUrlLocal, HttpMethod.GET, request, + String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200) { + throw new IllegalStateException("Local BenID count failed: " + obj.optString("errorMessage")); + } + Object data = obj.get("data"); + JSONObject dataObj = data instanceof JSONObject ? (JSONObject) data : new JSONObject(data.toString()); + return Long.parseLong(dataObj.get("response").toString()); + } + + private void onSuccess() { + failureCount = 0; + nextAttemptAt = 0; + lastError = null; + } + + private void onFailure(Exception e) { + long base = retryDelaysMs[Math.min(failureCount, retryDelaysMs.length - 1)]; + // +/-20% so that many laptops do not all retry at the same moment after an outage + long delay = base <= 0 ? 0 : (long) (base * ThreadLocalRandom.current().nextDouble(0.8, 1.2)); + failureCount++; + nextAttemptAt = System.currentTimeMillis() + delay; + lastError = e.getMessage(); + logger.warn("BenID auto top-up failed (attempt {}), next try in {} s: {}", failureCount, delay / 1000, + e.getMessage()); + } + + private Path pendingPath() { + return Paths.get(dataDir, PENDING_FILE_NAME); + } + + private void writePending(String payload) { + try { + Files.createDirectories(Paths.get(dataDir)); + // write a temp file and swap it in, so a power cut never leaves a half-written file + Path tmp = Paths.get(dataDir, PENDING_FILE_NAME + ".tmp"); + Files.write(tmp, payload.getBytes(StandardCharsets.UTF_8)); + try { + Files.move(tmp, pendingPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, pendingPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + logger.warn("Could not write pending BenID file: {}", e.getMessage()); + } + } + + private void deletePending() { + try { + Files.deleteIfExists(pendingPath()); + } catch (Exception e) { + logger.warn("Could not delete pending BenID file: {}", e.getMessage()); + } + } + + // ---------------------------------------------------------------- status + + public JSONObject status() { + JSONObject s = new JSONObject(); + s.put("enabled", centralAuthService.isEnabled()); + if (!centralAuthService.isEnabled()) { + return s; + } + s.put("vanID", centralAuthService.getVanID()); + s.put("centralConnection", centralAuthService.getState().name()); + s.put("connectedUser", centralAuthService.getConnectedUser()); + try { + lastPoolCount = countLocalPool(); + } catch (Exception e) { + logger.debug("Live BenID count not available: {}", e.getMessage()); + } + s.put("poolCount", lastPoolCount); + s.put("lowerLimit", lowerLimit); + s.put("lastTopUpAt", lastTopUpAt); + s.put("lastTopUpCount", lastTopUpCount); + s.put("pendingImport", Files.exists(pendingPath())); + String error = lastError != null ? lastError : centralAuthService.getLastError(); + if (error != null) { + s.put("lastError", error); + } + return s; + } +} diff --git a/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java b/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java new file mode 100644 index 00000000..d065eb19 --- /dev/null +++ b/src/main/java/com/iemr/mmu/service/benid/CentralAuthService.java @@ -0,0 +1,416 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import jakarta.annotation.PostConstruct; + +/** + * Keeps this laptop connected to the central server without storing any + * central password. + * + * The operator's normal laptop login is passed here once; it is used for a + * single central login as an API client, which returns a refresh token. Only + * that refresh token is kept (AES-GCM encrypted with a key tied to this + * machine). Each time a central session is needed the refresh token is + * exchanged for a new JWT + refresh token, and the JWT for a central session + * key. + */ +@Service +public class CentralAuthService { + + private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); + + public enum State { + DISABLED, NOT_CONNECTED, CONNECTED, RECONNECT_NEEDED, PASSWORD_MISMATCH + } + + // Central refresh tokens are only issued to API (mobile) clients. + private static final String API_CLIENT_USER_AGENT = "okhttp/4.12.0"; + private static final String TOKEN_FILE_NAME = "central-refresh-token"; + // user name only (no password): a laptop password central rejected is not sent again, + // also after a restart, so the central account cannot get locked by repeated attempts + private static final String REJECTED_FILE_NAME = "central-login-rejected"; + + @Value("${stoptb.enforce.vanid:false}") + private boolean enforceVanID; + + @Value("${stoptb.van.id:0}") + private int vanID; + + @Value("${centralLoginUrl:}") + private String centralLoginUrl; + + @Value("${centralRefreshTokenUrl:}") + private String centralRefreshTokenUrl; + + @Value("${centralSessionUrl:}") + private String centralSessionUrl; + + @Value("${benid.token.dir:/opt/amrit/data}") + private String tokenDir; + + @Value("${benid.token.secret:amrit-mmu-benid}") + private String tokenSecret; + + @Value("${benid.http.connectTimeoutMs:15000}") + private int connectTimeoutMs; + + @Value("${benid.http.readTimeoutMs:60000}") + private int readTimeoutMs; + + private RestTemplate restTemplate; + + // in-memory copy; the file is only a durable backup of this value + private volatile String refreshToken; + private volatile State state = State.NOT_CONNECTED; + private volatile String lastError; + private volatile long lastRefreshAt; + private volatile String connectedUser; + private volatile String rejectedUser; + + @PostConstruct + public void init() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectTimeoutMs); + factory.setReadTimeout(readTimeoutMs); + restTemplate = new RestTemplate(factory); + + if (!isEnabled()) { + state = State.DISABLED; + logger.info("BenID auto top-up disabled (stoptb.enforce.vanid={}, stoptb.van.id={})", enforceVanID, + vanID); + return; + } + refreshToken = readTokenFile(); + rejectedUser = readRejectedUser(); + state = refreshToken != null ? State.CONNECTED : State.NOT_CONNECTED; + logger.info("BenID auto top-up enabled for vanID {}; central token {}", vanID, + refreshToken != null ? "loaded" : "not present yet"); + } + + public boolean isEnabled() { + return enforceVanID && vanID > 0 && !centralLoginUrl.isEmpty(); + } + + public int getVanID() { + return vanID; + } + + public boolean hasToken() { + return refreshToken != null; + } + + public State getState() { + return state; + } + + public String getLastError() { + return lastError; + } + + public long getLastRefreshAt() { + return lastRefreshAt; + } + + public String getConnectedUser() { + return connectedUser; + } + + /** + * One central login with the operator's credentials (password already + * encrypted the way MMU-UI sends it to Common-API). Skipped when a working + * token already exists, so central is not hit on every laptop login. A + * rejected password is never retried, so the central account cannot get + * locked by this. + * + * @param force true when the user explicitly reconnects (e.g. Data Sync + * login), even if a token exists + * @return true if a new token was saved + */ + public synchronized boolean capture(String userName, String encryptedPassword, boolean force) { + if (!isEnabled() || userName == null || encryptedPassword == null) { + return false; + } + if (!force && refreshToken != null && state == State.CONNECTED) { + return false; + } + // a laptop password central already rejected is not sent again (account lock risk); + // only a Data Sync login (force, real central password) tries again + if (!force && userName.trim().equalsIgnoreCase(rejectedUser)) { + logger.info("Central login skipped for user {}: password was rejected before", userName); + return false; + } + try { + JSONObject body = new JSONObject(); + body.put("userName", userName.trim()); + body.put("password", encryptedPassword); + body.put("doLogout", false); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.USER_AGENT, API_CLIENT_USER_AGENT); + + ResponseEntity response = restTemplate.exchange(centralLoginUrl, HttpMethod.POST, + new HttpEntity<>(body.toString(), headers), String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") == 200 && obj.has("data") + && obj.getJSONObject("data").has("refreshToken")) { + saveToken(obj.getJSONObject("data").getString("refreshToken")); + connectedUser = userName.trim(); + rejectedUser = null; + deleteRejectedUser(); + state = State.CONNECTED; + lastError = null; + logger.info("Central token captured for user {}", connectedUser); + return true; + } + // wrong username/password or account problem: do not retry with these credentials + lastError = obj.optString("errorMessage", "Central login rejected"); + rejectedUser = userName.trim(); + saveRejectedUser(rejectedUser); + state = State.PASSWORD_MISMATCH; + logger.warn("Central login rejected for user {}: {}", userName, lastError); + } catch (Exception e) { + // network problem: nothing saved, next login tries again + lastError = "Central server not reachable: " + e.getMessage(); + logger.warn("Central login not possible right now: {}", e.getMessage()); + } + return false; + } + + /** + * Returns a fresh central session key (refreshToken -> JWT -> session key), + * or null if this laptop has no usable token. Throws on network errors so + * callers can retry later. + */ + public synchronized String getCentralSessionKey() throws Exception { + if (!isEnabled() || refreshToken == null) { + return null; + } + String jwt; + long start = System.currentTimeMillis(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + JSONObject body = new JSONObject(); + body.put("refreshToken", refreshToken); + ResponseEntity response = restTemplate.exchange(centralRefreshTokenUrl, HttpMethod.POST, + new HttpEntity<>(body.toString(), headers), String.class); + JSONObject obj = new JSONObject(response.getBody()); + jwt = obj.getString("jwtToken"); + if (obj.has("refreshToken")) { + saveToken(obj.getString("refreshToken")); + } + lastRefreshAt = System.currentTimeMillis(); + logger.info("Central token refreshed in {} ms", lastRefreshAt - start); + } catch (HttpStatusCodeException e) { + if (e.getStatusCode().value() == 401) { + // expired, revoked or user deactivated on central + logger.warn("Central refresh token rejected ({}); a new login is needed", + e.getResponseBodyAsString()); + deleteToken(); + state = State.RECONNECT_NEEDED; + lastError = "Central connection expired, please log in again"; + return null; + } + throw e; + } + + // JWT -> central session key; Common-API creates a new Redis session for it + long sessionStart = System.currentTimeMillis(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Jwttoken", jwt); + ResponseEntity response = restTemplate.exchange(centralSessionUrl, HttpMethod.POST, + new HttpEntity<>("{}", headers), String.class); + JSONObject obj = new JSONObject(response.getBody()); + if (obj.optInt("statusCode") != 200 || !obj.has("data")) { + throw new IllegalStateException("Central session not created: " + obj.optString("errorMessage")); + } + Object data = obj.get("data"); + JSONObject dataObj = data instanceof JSONObject ? (JSONObject) data : new JSONObject(data.toString()); + state = State.CONNECTED; + lastError = null; + logger.info("Central session created in {} ms", System.currentTimeMillis() - sessionStart); + return dataObj.getString("key"); + } + + /** Keeps the 90-day token alive even when no IDs are needed. */ + public void keepAliveIfDue(long maxAgeMs) { + if (refreshToken != null && System.currentTimeMillis() - lastRefreshAt > maxAgeMs) { + try { + getCentralSessionKey(); + } catch (Exception e) { + logger.debug("Token keep-alive skipped: {}", e.getMessage()); + } + } + } + + // ---------------------------------------------------------------- token file + + private Path tokenPath() { + return Paths.get(tokenDir, TOKEN_FILE_NAME); + } + + private void saveToken(String token) { + refreshToken = token; + try { + Path dir = Paths.get(tokenDir); + Files.createDirectories(dir); + setPermissions(dir, "rwx------"); + Path tmp = dir.resolve(TOKEN_FILE_NAME + ".tmp"); + Files.write(tmp, encrypt(token).getBytes(StandardCharsets.UTF_8)); + setPermissions(tmp, "rw-------"); + try { + Files.move(tmp, tokenPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, tokenPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + // keep working from memory; the token is captured again after a restart + logger.warn("Could not save central token to {} (kept in memory only): {}", tokenDir, e.getMessage()); + } + } + + private String readTokenFile() { + try { + Path path = tokenPath(); + if (Files.exists(path)) { + return decrypt(new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim()); + } + } catch (Exception e) { + logger.warn("Stored central token could not be read, a new login is needed: {}", e.getMessage()); + } + return null; + } + + private void deleteToken() { + refreshToken = null; + try { + Files.deleteIfExists(tokenPath()); + } catch (Exception e) { + logger.warn("Could not delete central token file: {}", e.getMessage()); + } + } + + private String readRejectedUser() { + try { + Path path = Paths.get(tokenDir, REJECTED_FILE_NAME); + if (Files.exists(path)) { + String user = new String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim(); + return user.isEmpty() ? null : user; + } + } catch (Exception e) { + logger.warn("Could not read rejected-login marker: {}", e.getMessage()); + } + return null; + } + + private void saveRejectedUser(String user) { + try { + Path dir = Paths.get(tokenDir); + Files.createDirectories(dir); + Path path = dir.resolve(REJECTED_FILE_NAME); + Files.write(path, user.getBytes(StandardCharsets.UTF_8)); + setPermissions(path, "rw-------"); + } catch (Exception e) { + logger.warn("Could not save rejected-login marker: {}", e.getMessage()); + } + } + + private void deleteRejectedUser() { + try { + Files.deleteIfExists(Paths.get(tokenDir, REJECTED_FILE_NAME)); + } catch (Exception e) { + logger.warn("Could not delete rejected-login marker: {}", e.getMessage()); + } + } + + private static void setPermissions(Path path, String perms) { + try { + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(perms)); + } catch (Exception e) { + // non-POSIX file system; nothing more we can do + } + } + + private SecretKeySpec machineKey() throws Exception { + String machineId; + try { + machineId = new String(Files.readAllBytes(Paths.get("/etc/machine-id")), StandardCharsets.UTF_8).trim(); + } catch (Exception e) { + machineId = InetAddress.getLocalHost().getHostName(); + } + byte[] key = MessageDigest.getInstance("SHA-256") + .digest((machineId + ":" + tokenSecret).getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(key, "AES"); + } + + private String encrypt(String plain) throws Exception { + byte[] iv = new byte[12]; + new SecureRandom().nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, machineKey(), new GCMParameterSpec(128, iv)); + byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + enc.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(enc, 0, out, iv.length, enc.length); + return Base64.getEncoder().encodeToString(out); + } + + private String decrypt(String encoded) throws Exception { + byte[] in = Base64.getDecoder().decode(encoded); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, machineKey(), new GCMParameterSpec(128, in, 0, 12)); + return new String(cipher.doFinal(in, 12, in.length - 12), StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java index 6a6d12bc..3ad4eaa8 100644 --- a/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java +++ b/src/main/java/com/iemr/mmu/service/dataSyncActivity/DownloadDataFromServerImpl.java @@ -30,6 +30,7 @@ import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.json.JSONObject; import org.slf4j.Logger; @@ -45,6 +46,7 @@ import com.google.gson.Gson; import com.iemr.mmu.data.syncActivity_syncLayer.MasterDownloadDataDigester; +import com.iemr.mmu.service.benid.BenIdAutoTopUpService; import com.iemr.mmu.data.syncActivity_syncLayer.SyncDownloadMaster; import com.iemr.mmu.data.syncActivity_syncLayer.TempVan; import com.iemr.mmu.repo.syncActivity_syncLayer.SyncDownloadMasterRepo; @@ -326,6 +328,10 @@ public Map getDownloadStatus() { public int callCentralAPIToGenerateBenIDAndimportToLocal(String requestOBJ, String Authorization, String ServerAuthorization, String token) throws Exception { int i = 0, i1 = 0; + // never run together with the automatic top-up, so the same IDs are not requested twice + if (!BenIdAutoTopUpService.GENERATION_LOCK.tryLock(2, TimeUnit.MINUTES)) { + throw new Exception("BenID generation is already running, please try again in a few minutes"); + } try{ JSONObject originalRequest = new JSONObject(requestOBJ); BigInteger vanID = null; @@ -378,6 +384,8 @@ public int callCentralAPIToGenerateBenIDAndimportToLocal(String requestOBJ, Stri } catch (Exception e) { logger.error("Error while generating catch UNIQUE_ID at central server: " + e.getMessage()); throw new Exception("Error while generating catch UNIQUE_ID at central server: " + e.getMessage()); + } finally { + BenIdAutoTopUpService.GENERATION_LOCK.unlock(); } return i; } diff --git a/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java b/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java new file mode 100644 index 00000000..c10e6230 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/benid/BenIdAutoTopUpServiceTest.java @@ -0,0 +1,272 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +class BenIdAutoTopUpServiceTest { + + private static final String GENERATE = "http://central/common-api/beneficiary/generateBeneficiaryIDs"; + private static final String IMPORT = "http://local/id/saveGeneratedBenIDToLocalServer"; + private static final String COUNT = "http://local/id/checkAvailablBenIDLocalServer"; + + @TempDir + Path dataDir; + + private BenIdAutoTopUpService service; + private CentralAuthService centralAuth; + private MockRestServiceServer server; + + @BeforeEach + void setUp() throws Exception { + centralAuth = mock(CentralAuthService.class); + when(centralAuth.isEnabled()).thenReturn(true); + when(centralAuth.getVanID()).thenReturn(403); + when(centralAuth.getState()).thenReturn(CentralAuthService.State.CONNECTED); + when(centralAuth.getCentralSessionKey()).thenReturn("SESSION-KEY"); + + service = new BenIdAutoTopUpService(); + ReflectionTestUtils.setField(service, "centralAuthService", centralAuth); + ReflectionTestUtils.setField(service, "benGenUrlCentral", GENERATE); + ReflectionTestUtils.setField(service, "benImportUrlLocal", IMPORT); + ReflectionTestUtils.setField(service, "benCheckUrlLocal", COUNT); + ReflectionTestUtils.setField(service, "lowerLimit", 2000L); + ReflectionTestUtils.setField(service, "batchSize", 1000L); + ReflectionTestUtils.setField(service, "bootstrapSize", 10000L); + ReflectionTestUtils.setField(service, "maxPerRequest", 1000L); + ReflectionTestUtils.setField(service, "dataDir", dataDir.toString()); + ReflectionTestUtils.setField(service, "retryDelaysMs", new long[] { 0L }); + ReflectionTestUtils.setField(service, "connectTimeoutMs", 1000); + ReflectionTestUtils.setField(service, "readTimeoutMs", 1000); + service.init(); + server = MockRestServiceServer.bindTo((RestTemplate) ReflectionTestUtils.getField(service, "restTemplate")) + .build(); + } + + @AfterEach + void releaseLock() { + while (BenIdAutoTopUpService.GENERATION_LOCK.isHeldByCurrentThread()) { + BenIdAutoTopUpService.GENERATION_LOCK.unlock(); + } + } + + private void expectCount(long n) { + server.expect(requestTo(COUNT)) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"response\":\"" + n + "\"}}", + MediaType.APPLICATION_JSON)); + } + + private static String ids(int n) { + StringBuilder sb = new StringBuilder("["); + for (int i = 1; i <= n; i++) { + sb.append(i > 1 ? "," : "").append("{\"benRegId\":").append(5000 + i) + .append(",\"beneficiaryId\":").append(900000000000L + i).append("}"); + } + return sb.append("]").toString(); + } + + private void expectGenerate(long requested, int returned) { + server.expect(requestTo(GENERATE)).andExpect(header("Authorization", "SESSION-KEY")) + .andExpect(content().string(Matchers.containsString("\"benIDRequired\":" + requested))) + .andExpect(content().string(Matchers.containsString("\"vanID\":403"))) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":" + ids(returned) + "}", + MediaType.APPLICATION_JSON)); + } + + private void expectImportOk() { + server.expect(requestTo(IMPORT)).andExpect(content().string(Matchers.containsString("\"vanID\":403"))) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"response\":\"ok\"}}", + MediaType.APPLICATION_JSON)); + } + + private void run() { + ReflectionTestUtils.invokeMethod(service, "runOnce", true); + } + + private Path pending() { + return dataDir.resolve("benid-pending-import.json"); + } + + @Test + void doesNothingWhenDisabled() throws Exception { + when(centralAuth.isEnabled()).thenReturn(false); + server.expect(ExpectedCount.never(), requestTo(COUNT)); + run(); + server.verify(); + verify(centralAuth, never()).getCentralSessionKey(); + } + + @Test + void emptyPoolGetsBootstrapSize() { + ReflectionTestUtils.setField(service, "bootstrapSize", 800L); + expectCount(0); + expectGenerate(800, 3); + expectImportOk(); + expectCount(3); + run(); + server.verify(); + assertFalse(Files.exists(pending()), "pending file removed after a successful import"); + } + + @Test + void lowPoolGetsBatchSize() { + expectCount(1999); + expectGenerate(1000, 2); + expectImportOk(); + expectCount(2001); + run(); + server.verify(); + } + + @Test + void healthyPoolDoesNotCallCentral() throws Exception { + expectCount(2000); + server.expect(ExpectedCount.never(), requestTo(GENERATE)); + run(); + server.verify(); + verify(centralAuth, never()).getCentralSessionKey(); + } + + @Test + void noCentralTokenWaitsForLogin() throws Exception { + when(centralAuth.getCentralSessionKey()).thenReturn(null); + expectCount(0); + server.expect(ExpectedCount.never(), requestTo(GENERATE)); + run(); + server.verify(); + assertTrue(((String) ReflectionTestUtils.getField(service, "lastError")).contains("waiting for a user login")); + } + + @Test + void centralErrorLeavesNothingBehind() { + expectCount(0); + server.expect(requestTo(GENERATE)).andRespond(withStatus(HttpStatus.GATEWAY_TIMEOUT)); + run(); + server.verify(); + assertFalse(Files.exists(pending())); + assertTrue(ReflectionTestUtils.getField(service, "lastError") != null); + } + + @Test + void failedLocalImportKeepsIdsAndImportsThemNextRun() throws Exception { + ReflectionTestUtils.setField(service, "bootstrapSize", 800L); + expectCount(0); + expectGenerate(800, 3); + server.expect(requestTo(IMPORT)).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); + // next run: pending IDs are imported first, then the pool is healthy + expectImportOk(); + expectCount(2500); + run(); + assertTrue(Files.exists(pending()), "IDs received from central must be kept"); + assertEquals(3, Files.readString(pending()).split("benRegId").length - 1); + + run(); + server.verify(); + assertFalse(Files.exists(pending())); + } + + @Test + void skipsWhileManualGenerationRuns() { + BenIdAutoTopUpService.GENERATION_LOCK.lock(); + try { + Thread other = new Thread(this::run); + server.expect(ExpectedCount.never(), requestTo(COUNT)); + other.start(); + other.join(5000); + server.verify(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + BenIdAutoTopUpService.GENERATION_LOCK.unlock(); + } + } + + @Test + void bigDownloadIsSplitIntoRequestsOfAtMost1000() { + ReflectionTestUtils.setField(service, "bootstrapSize", 2500L); + expectCount(0); + expectGenerate(1000, 2); + expectImportOk(); + expectGenerate(1000, 2); + expectImportOk(); + expectGenerate(500, 2); + expectImportOk(); + expectCount(2500); + run(); + server.verify(); + } + + @Test + void pendingFileThatKeepsFailingIsSetAsideAndTopUpsContinue() throws Exception { + Files.writeString(pending(), "[{\"benRegId\":12"); // half-written by a power cut + for (int i = 0; i < 3; i++) { + server.expect(requestTo(IMPORT)).andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); + } + expectCount(2500); + run(); + run(); + assertTrue(Files.exists(pending()), "kept while retries are left"); + run(); + server.verify(); + assertFalse(Files.exists(pending()), "set aside after 3 failures"); + try (java.util.stream.Stream files = Files.list(dataDir)) { + assertTrue(files.anyMatch(f -> f.getFileName().toString().startsWith("benid-pending-import.failed-"))); + } + } + + @Test + void retryWaitIsSpreadPlusMinus20Percent() { + ReflectionTestUtils.setField(service, "retryDelaysMs", new long[] { 10000L }); + for (int i = 0; i < 20; i++) { + long before = System.currentTimeMillis(); + ReflectionTestUtils.invokeMethod(service, "onFailure", new RuntimeException("down")); + long wait = (long) ReflectionTestUtils.getField(service, "nextAttemptAt") - before; + assertTrue(wait >= 7990 && wait <= 12010, "wait " + wait + " ms is outside 8-12 s"); + } + } +} diff --git a/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java b/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java new file mode 100644 index 00000000..d6ba45a0 --- /dev/null +++ b/src/test/java/com/iemr/mmu/service/benid/CentralAuthServiceTest.java @@ -0,0 +1,276 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technology +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.mmu.service.benid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +class CentralAuthServiceTest { + + private static final String LOGIN = "http://central/common-api/user/userAuthenticate"; + private static final String REFRESH = "http://central/common-api/user/refreshToken"; + private static final String SESSION = "http://central/common-api/user/getLoginResponse"; + + @TempDir + Path tokenDir; + + private CentralAuthService service; + private MockRestServiceServer central; + + @BeforeEach + void setUp() { + service = newService(true, 403); + } + + private CentralAuthService newService(boolean enforce, int vanID) { + CentralAuthService s = new CentralAuthService(); + ReflectionTestUtils.setField(s, "enforceVanID", enforce); + ReflectionTestUtils.setField(s, "vanID", vanID); + ReflectionTestUtils.setField(s, "centralLoginUrl", LOGIN); + ReflectionTestUtils.setField(s, "centralRefreshTokenUrl", REFRESH); + ReflectionTestUtils.setField(s, "centralSessionUrl", SESSION); + ReflectionTestUtils.setField(s, "tokenDir", tokenDir.toString()); + ReflectionTestUtils.setField(s, "tokenSecret", "test-secret"); + ReflectionTestUtils.setField(s, "connectTimeoutMs", 1000); + ReflectionTestUtils.setField(s, "readTimeoutMs", 1000); + s.init(); + RestTemplate rt = (RestTemplate) ReflectionTestUtils.getField(s, "restTemplate"); + central = MockRestServiceServer.bindTo(rt).build(); + return s; + } + + private void expectLoginOk(String refreshToken) { + central.expect(requestTo(LOGIN)).andExpect(method(HttpMethod.POST)) + .andExpect(header("User-Agent", "okhttp/4.12.0")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"doLogout\":false"))) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"refreshToken\":\"" + refreshToken + + "\",\"key\":\"k\"}}", MediaType.APPLICATION_JSON)); + } + + private void expectLoginRejected() { + central.expect(requestTo(LOGIN)).andRespond(withSuccess( + "{\"statusCode\":5002,\"errorMessage\":\"Invalid username or password\"}", MediaType.APPLICATION_JSON)); + } + + @Test + void disabledWithoutVanId() { + CentralAuthService off = newService(true, 0); + assertFalse(off.isEnabled()); + assertEquals(CentralAuthService.State.DISABLED, off.getState()); + assertFalse(off.capture("stoptb1", "pw", true)); + central.verify(); // nothing sent to central + } + + @Test + void disabledWhenEnforceFlagOff() { + CentralAuthService off = newService(false, 403); + assertFalse(off.isEnabled()); + assertFalse(off.capture("stoptb1", "pw", true)); + central.verify(); + } + + @Test + void captureStoresOnlyEncryptedTokenWithOwnerOnlyPermissions() throws Exception { + expectLoginOk("RT-secret-123"); + + assertTrue(service.capture("stoptb1", "encrypted-pw", false)); + + Path file = tokenDir.resolve("central-refresh-token"); + String stored = Files.readString(file, StandardCharsets.UTF_8); + assertFalse(stored.contains("RT-secret-123"), "token must not be stored in plain text"); + assertFalse(stored.contains("encrypted-pw"), "password must never be stored"); + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file))); + assertEquals(CentralAuthService.State.CONNECTED, service.getState()); + central.verify(); + } + + @Test + void tokenIsReadBackAfterRestart() { + expectLoginOk("RT-1"); + service.capture("stoptb1", "pw", false); + + CentralAuthService restarted = newService(true, 403); + assertTrue(restarted.hasToken()); + assertEquals(CentralAuthService.State.CONNECTED, restarted.getState()); + } + + @Test + void corruptTokenFileMeansNewLoginNeeded() throws Exception { + Files.writeString(tokenDir.resolve("central-refresh-token"), "not-a-valid-token"); + CentralAuthService s = newService(true, 403); + assertFalse(s.hasToken()); + assertEquals(CentralAuthService.State.NOT_CONNECTED, s.getState()); + } + + @Test + void secondLoginIsSkippedWhileTokenWorks() { + expectLoginOk("RT-1"); + service.capture("stoptb1", "pw", false); + + assertFalse(service.capture("stoptb1", "pw", false)); + central.verify(); // only one central login + } + + @Test + void rejectedPasswordIsNeverSentAgainWithoutReconnect() { + expectLoginRejected(); + + assertFalse(service.capture("stoptb1", "wrong", false)); + assertEquals(CentralAuthService.State.PASSWORD_MISMATCH, service.getState()); + // next laptop login with the same user must not reach central (account lock risk) + assertFalse(service.capture("stoptb1", "wrong", false)); + central.verify(); + } + + @Test + void dataSyncReconnectRetriesAfterRejection() { + expectLoginRejected(); + expectLoginOk("RT-2"); + + service.capture("stoptb1", "wrong", false); + assertTrue(service.capture("stoptb1", "central-pw", true)); + assertEquals(CentralAuthService.State.CONNECTED, service.getState()); + central.verify(); + } + + @Test + void centralDownIsNotTreatedAsWrongPassword() { + central.expect(requestTo(LOGIN)).andRespond(withStatus(HttpStatus.BAD_GATEWAY)); + expectLoginOk("RT-3"); + + assertFalse(service.capture("stoptb1", "pw", false)); + // network problem: the next login tries again + assertTrue(service.capture("stoptb1", "pw", false)); + central.verify(); + } + + @Test + void sessionKeyFromRefreshTokenAndTokenRotation() throws Exception { + expectLoginOk("RT-old"); + central.expect(requestTo(REFRESH)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("RT-old"))) + .andRespond(withSuccess("{\"jwtToken\":\"JWT-1\",\"refreshToken\":\"RT-new\"}", + MediaType.APPLICATION_JSON)); + central.expect(requestTo(SESSION)).andExpect(header("Jwttoken", "JWT-1")) + .andRespond(withSuccess("{\"statusCode\":200,\"data\":{\"key\":\"SESSION-KEY\"}}", + MediaType.APPLICATION_JSON)); + // the next refresh must use the rotated token + central.expect(requestTo(REFRESH)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("RT-new"))) + .andRespond(withSuccess("{\"jwtToken\":\"JWT-2\"}", MediaType.APPLICATION_JSON)); + central.expect(requestTo(SESSION)).andRespond(withSuccess( + "{\"statusCode\":200,\"data\":\"{\\\"key\\\":\\\"KEY-2\\\"}\"}", MediaType.APPLICATION_JSON)); + + service.capture("stoptb1", "pw", false); + String fileBefore = Files.readString(tokenDir.resolve("central-refresh-token")); + + assertEquals("SESSION-KEY", service.getCentralSessionKey()); + assertFalse(fileBefore.equals(Files.readString(tokenDir.resolve("central-refresh-token"))), + "rotated refresh token must be saved"); + assertEquals("KEY-2", service.getCentralSessionKey()); + central.verify(); + } + + @Test + void revokedTokenIsDeletedAndReconnectNeeded() { + expectLoginOk("RT-1"); + central.expect(requestTo(REFRESH)).andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + + service.capture("stoptb1", "pw", false); + + assertNull(assertDoesNotThrowValue()); + assertFalse(service.hasToken()); + assertFalse(Files.exists(tokenDir.resolve("central-refresh-token"))); + assertEquals(CentralAuthService.State.RECONNECT_NEEDED, service.getState()); + central.verify(); + } + + @Test + void noTokenMeansNoCentralCall() throws Exception { + central.expect(ExpectedCount.never(), requestTo(REFRESH)); + assertNull(service.getCentralSessionKey()); + central.verify(); + } + + private String assertDoesNotThrowValue() { + try { + return service.getCentralSessionKey(); + } catch (Exception e) { + throw new AssertionError("revoked token must not throw", e); + } + } + + @Test + void rejectedPasswordIsRememberedAfterRestart() { + expectLoginRejected(); + service.capture("stoptb1", "wrong", false); + central.verify(); + + CentralAuthService restarted = newService(true, 403); + // laptop login after the restart must not reach central with the rejected password + assertFalse(restarted.capture("stoptb1", "wrong", false)); + central.verify(); + } + + @Test + void successfulReconnectClearsTheRejectedMarker() { + expectLoginRejected(); + expectLoginOk("RT-9"); + service.capture("stoptb1", "wrong", false); + assertTrue(service.capture("stoptb1", "central-pw", true)); + assertFalse(Files.exists(tokenDir.resolve("central-login-rejected"))); + central.verify(); + } + + @Test + void rejectedMarkerHoldsNoPassword() throws Exception { + expectLoginRejected(); + service.capture("stoptb1", "secret-pw", false); + String marker = Files.readString(tokenDir.resolve("central-login-rejected")); + assertEquals("stoptb1", marker.trim()); + assertFalse(marker.contains("secret-pw")); + } +}