diff --git a/pom.xml b/pom.xml index 171ab162..6a821d7d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common-API common-api - 3.6.1 + 3.8.0 war Common-API diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index 9f54a35d..f2b774a3 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -203,5 +203,9 @@ platform.feedback.ratelimit.day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT platform.feedback.ratelimit.user-day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT@ platform.feedback.ratelimit.fail-window-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES@ platform.feedback.ratelimit.backoff-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES@ +otp.ratelimit.enabled=@env.OTP_RATELIMIT_ENABLED@ +otp.ratelimit.minute-limit=@env.OTP_RATELIMIT_MINUTE_LIMIT@ +otp.ratelimit.hour-limit=@env.OTP_RATELIMIT_HOUR_LIMIT@ +otp.ratelimit.day-limit=@env.OTP_RATELIMIT_DAY_LIMIT@ generateBeneficiaryIDs-api-url=@env.GEN_BENEFICIARY_IDS_API_URL@ diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 59cb580d..a5c633e4 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -206,4 +206,8 @@ platform.feedback.ratelimit.day-limit=${PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT} platform.feedback.ratelimit.user-day-limit=${PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT} platform.feedback.ratelimit.fail-window-minutes=${PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES} platform.feedback.ratelimit.backoff-minutes=${PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES} +otp.ratelimit.enabled=${OTP_RATELIMIT_ENABLED} +otp.ratelimit.minute-limit=${OTP_RATELIMIT_MINUTE_LIMIT} +otp.ratelimit.hour-limit=${OTP_RATELIMIT_HOUR_LIMIT} +otp.ratelimit.day-limit=${OTP_RATELIMIT_DAY_LIMIT} generateBeneficiaryIDs-api-url={GEN_BENEFICIARY_IDS_API_URL} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 03f0d915..e3b5c031 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -226,5 +226,10 @@ platform.feedback.ratelimit.user-day-limit=50 platform.feedback.ratelimit.fail-window-minutes=5 platform.feedback.ratelimit.backoff-minutes=15 +# --- OTP Rate Limiting (per mobile number) --- +otp.ratelimit.minute-limit=3 +otp.ratelimit.hour-limit=10 +otp.ratelimit.day-limit=20 + ### generate Beneficiary IDs URL generateBeneficiaryIDs-api-url=/generateBeneficiaryController/generateBeneficiaryIDs diff --git a/src/main/java/com/iemr/common/controller/beneficiaryConsent/BeneficiaryConsentController.java b/src/main/java/com/iemr/common/controller/beneficiaryConsent/BeneficiaryConsentController.java index 77492d89..8750c0a1 100644 --- a/src/main/java/com/iemr/common/controller/beneficiaryConsent/BeneficiaryConsentController.java +++ b/src/main/java/com/iemr/common/controller/beneficiaryConsent/BeneficiaryConsentController.java @@ -22,6 +22,7 @@ package com.iemr.common.controller.beneficiaryConsent; import com.iemr.common.data.beneficiaryConsent.BeneficiaryConsentRequest; +import com.iemr.common.exception.OtpRateLimitException; import com.iemr.common.service.beneficiaryOTPHandler.BeneficiaryOTPHandler; import com.iemr.common.utils.mapper.InputMapper; import com.iemr.common.utils.response.OutputResponse; @@ -58,7 +59,9 @@ public String sendConsent(@Param(value = "{\"mobNo\":\"String\"}") @RequestBody logger.info(success.toString()); response.setResponse(success); - + } catch (OtpRateLimitException e) { + logger.warn("OTP rate limit hit for sendConsent: " + e.getMessage()); + response.setError(429, e.getMessage()); } catch (Exception e) { response.setError(500, "error : " + e); } @@ -105,6 +108,9 @@ public String resendConsent(@Param(value = "{\"mobNo\":\"String\"}") @RequestBod else response.setError(500, "failure"); + } catch (OtpRateLimitException e) { + logger.warn("OTP rate limit hit for resendConsent: " + e.getMessage()); + response.setError(429, e.getMessage()); } catch (Exception e) { logger.error("error in re-sending Consent : " + e); response.setError(500, "error : " + e); diff --git a/src/main/java/com/iemr/common/exception/OtpRateLimitException.java b/src/main/java/com/iemr/common/exception/OtpRateLimitException.java new file mode 100644 index 00000000..a0f3b53f --- /dev/null +++ b/src/main/java/com/iemr/common/exception/OtpRateLimitException.java @@ -0,0 +1,30 @@ +/* + * 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.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) +public class OtpRateLimitException extends RuntimeException { + public OtpRateLimitException(String message) { super(message); } +} diff --git a/src/main/java/com/iemr/common/service/beneficiaryOTPHandler/BeneficiaryOTPHandlerImpl.java b/src/main/java/com/iemr/common/service/beneficiaryOTPHandler/BeneficiaryOTPHandlerImpl.java index 42e0acfe..3cc0a709 100644 --- a/src/main/java/com/iemr/common/service/beneficiaryOTPHandler/BeneficiaryOTPHandlerImpl.java +++ b/src/main/java/com/iemr/common/service/beneficiaryOTPHandler/BeneficiaryOTPHandlerImpl.java @@ -32,6 +32,7 @@ import com.iemr.common.repository.sms.SMSTemplateRepository; import com.iemr.common.repository.sms.SMSTypeRepository; import com.iemr.common.service.otp.OTPHandler; +import com.iemr.common.service.otp.OtpRateLimiterService; import com.iemr.common.service.users.IEMRAdminUserServiceImpl; import com.iemr.common.utils.config.ConfigProperties; import com.iemr.common.utils.http.HttpUtils; @@ -59,6 +60,8 @@ public class BeneficiaryOTPHandlerImpl implements BeneficiaryOTPHandler { HttpUtils httpUtils; @Autowired private IEMRAdminUserServiceImpl iEMRAdminUserServiceImpl; + @Autowired + private OtpRateLimiterService otpRateLimiterService; final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); @Autowired @@ -107,6 +110,7 @@ public String load(String key) { */ @Override public String sendOTP(BeneficiaryConsentRequest obj) throws Exception { + otpRateLimiterService.checkRateLimit(obj.getMobNo()); int otp = generateOTP(obj.getMobNo()); return sendSMS(otp, obj); } @@ -141,6 +145,7 @@ public JSONObject validateOTP(BeneficiaryConsentRequest obj) throws Exception { */ @Override public String resendOTP(BeneficiaryConsentRequest obj) throws Exception { + otpRateLimiterService.checkRateLimit(obj.getMobNo()); int otp = generateOTP(obj.getMobNo()); return sendSMS(otp, obj); } diff --git a/src/main/java/com/iemr/common/service/otp/OtpRateLimiterService.java b/src/main/java/com/iemr/common/service/otp/OtpRateLimiterService.java new file mode 100644 index 00000000..da06a64b --- /dev/null +++ b/src/main/java/com/iemr/common/service/otp/OtpRateLimiterService.java @@ -0,0 +1,104 @@ +/* + * 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.common.service.otp; + +import com.iemr.common.exception.OtpRateLimitException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.concurrent.TimeUnit; + +/** + * Rate-limits OTP send/resend requests per mobile number using Redis counters. + * + * Limits (configurable via properties): + * otp.ratelimit.minute-limit – max OTPs per minute (default 3) + * otp.ratelimit.hour-limit – max OTPs per hour (default 10) + * otp.ratelimit.day-limit – max OTPs per day (default 20) + * + * Redis key pattern: + * rl:otp:min:{mobNo}:{minuteSlot} TTL 60 s + * rl:otp:hr:{mobNo}:{hourSlot} TTL 3600 s + * rl:otp:day:{mobNo}:{yyyyMMdd} TTL 86400 s + */ +@Component +public class OtpRateLimiterService { + + private final StringRedisTemplate redis; + + @Value("${otp.ratelimit.enabled:true}") + private boolean enabled; + + @Value("${otp.ratelimit.minute-limit:3}") + private int minuteLimit; + + @Value("${otp.ratelimit.hour-limit:10}") + private int hourLimit; + + @Value("${otp.ratelimit.day-limit:20}") + private int dayLimit; + + public OtpRateLimiterService(StringRedisTemplate redis) { + this.redis = redis; + } + + /** + * Checks all three rate-limit windows for the given mobile number. + * Throws {@link OtpRateLimitException} if any limit is exceeded. + * No-op when otp.ratelimit.enabled=false. + */ + public void checkRateLimit(String mobNo) { + if (!enabled) return; + String today = LocalDate.now(ZoneId.of("Asia/Kolkata")) + .toString().replaceAll("-", ""); // yyyyMMdd + long minuteSlot = System.currentTimeMillis() / 60_000L; + long hourSlot = System.currentTimeMillis() / 3_600_000L; + + String minKey = "rl:otp:min:" + mobNo + ":" + minuteSlot; + String hourKey = "rl:otp:hr:" + mobNo + ":" + hourSlot; + String dayKey = "rl:otp:day:" + mobNo + ":" + today; + + if (incrementWithExpire(minKey, 60L) > minuteLimit) { + throw new OtpRateLimitException( + "OTP request limit exceeded. Maximum " + minuteLimit + " OTPs allowed per minute. Please try again later."); + } + if (incrementWithExpire(hourKey, 3600L) > hourLimit) { + throw new OtpRateLimitException( + "OTP request limit exceeded. Maximum " + hourLimit + " OTPs allowed per hour. Please try again later."); + } + if (incrementWithExpire(dayKey, 86400L) > dayLimit) { + throw new OtpRateLimitException( + "OTP request limit exceeded. Maximum " + dayLimit + " OTPs allowed per day. Please try again tomorrow."); + } + } + + private long incrementWithExpire(String key, long ttlSeconds) { + Long value = redis.opsForValue().increment(key, 1L); + if (value != null && value == 1L) { + redis.expire(key, ttlSeconds, TimeUnit.SECONDS); + } + return value == null ? 0L : value; + } +}