Skip to content

Commit 1cc3888

Browse files
vishwab1claude
andauthored
fix: add OTP rate limiting to prevent OTP flooding on sendConsent endpoint (#373)
- Add OtpRateLimiterService with Redis-backed per-mobile rate limits (3/min, 10/hr, 20/day) - Add OtpRateLimitException for 429 responses - Integrate rate limiter in BeneficiaryOTPHandlerImpl and BeneficiaryConsentController - Add otp.ratelimit.* properties to common_ci and common_docker profiles - Update common_example.properties with new OTP rate limit config Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f487978 commit 1cc3888

8 files changed

Lines changed: 160 additions & 2 deletions

File tree

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.iemr.common-API</groupId>
88
<artifactId>common-api</artifactId>
9-
<version>3.6.1</version>
9+
<version>3.8.0</version>
1010
<packaging>war</packaging>
1111

1212
<name>Common-API</name>

src/main/environment/common_ci.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,5 +203,9 @@ platform.feedback.ratelimit.day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT
203203
platform.feedback.ratelimit.user-day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT@
204204
platform.feedback.ratelimit.fail-window-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES@
205205
platform.feedback.ratelimit.backoff-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES@
206+
otp.ratelimit.enabled=@env.OTP_RATELIMIT_ENABLED@
207+
otp.ratelimit.minute-limit=@env.OTP_RATELIMIT_MINUTE_LIMIT@
208+
otp.ratelimit.hour-limit=@env.OTP_RATELIMIT_HOUR_LIMIT@
209+
otp.ratelimit.day-limit=@env.OTP_RATELIMIT_DAY_LIMIT@
206210
generateBeneficiaryIDs-api-url=@env.GEN_BENEFICIARY_IDS_API_URL@
207211

src/main/environment/common_docker.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,8 @@ platform.feedback.ratelimit.day-limit=${PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT}
206206
platform.feedback.ratelimit.user-day-limit=${PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT}
207207
platform.feedback.ratelimit.fail-window-minutes=${PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES}
208208
platform.feedback.ratelimit.backoff-minutes=${PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES}
209+
otp.ratelimit.enabled=${OTP_RATELIMIT_ENABLED}
210+
otp.ratelimit.minute-limit=${OTP_RATELIMIT_MINUTE_LIMIT}
211+
otp.ratelimit.hour-limit=${OTP_RATELIMIT_HOUR_LIMIT}
212+
otp.ratelimit.day-limit=${OTP_RATELIMIT_DAY_LIMIT}
209213
generateBeneficiaryIDs-api-url={GEN_BENEFICIARY_IDS_API_URL}

src/main/environment/common_example.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,5 +226,10 @@ platform.feedback.ratelimit.user-day-limit=50
226226
platform.feedback.ratelimit.fail-window-minutes=5
227227
platform.feedback.ratelimit.backoff-minutes=15
228228

229+
# --- OTP Rate Limiting (per mobile number) ---
230+
otp.ratelimit.minute-limit=3
231+
otp.ratelimit.hour-limit=10
232+
otp.ratelimit.day-limit=20
233+
229234
### generate Beneficiary IDs URL
230235
generateBeneficiaryIDs-api-url=/generateBeneficiaryController/generateBeneficiaryIDs

src/main/java/com/iemr/common/controller/beneficiaryConsent/BeneficiaryConsentController.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
package com.iemr.common.controller.beneficiaryConsent;
2323

2424
import com.iemr.common.data.beneficiaryConsent.BeneficiaryConsentRequest;
25+
import com.iemr.common.exception.OtpRateLimitException;
2526
import com.iemr.common.service.beneficiaryOTPHandler.BeneficiaryOTPHandler;
2627
import com.iemr.common.utils.mapper.InputMapper;
2728
import com.iemr.common.utils.response.OutputResponse;
@@ -58,7 +59,9 @@ public String sendConsent(@Param(value = "{\"mobNo\":\"String\"}") @RequestBody
5859
logger.info(success.toString());
5960
response.setResponse(success);
6061

61-
62+
} catch (OtpRateLimitException e) {
63+
logger.warn("OTP rate limit hit for sendConsent: " + e.getMessage());
64+
response.setError(429, e.getMessage());
6265
} catch (Exception e) {
6366
response.setError(500, "error : " + e);
6467
}
@@ -105,6 +108,9 @@ public String resendConsent(@Param(value = "{\"mobNo\":\"String\"}") @RequestBod
105108
else
106109
response.setError(500, "failure");
107110

111+
} catch (OtpRateLimitException e) {
112+
logger.warn("OTP rate limit hit for resendConsent: " + e.getMessage());
113+
response.setError(429, e.getMessage());
108114
} catch (Exception e) {
109115
logger.error("error in re-sending Consent : " + e);
110116
response.setError(500, "error : " + e);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* AMRIT – Accessible Medical Records via Integrated Technology
3+
* Integrated EHR (Electronic Health Records) Solution
4+
*
5+
* Copyright (C) "Piramal Swasthya Management and Research Institute"
6+
*
7+
* This file is part of AMRIT.
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU General Public License as published by
11+
* the Free Software Foundation, either version 3 of the License, or
12+
* (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU General Public License
20+
* along with this program. If not, see https://www.gnu.org/licenses/.
21+
*/
22+
package com.iemr.common.exception;
23+
24+
import org.springframework.http.HttpStatus;
25+
import org.springframework.web.bind.annotation.ResponseStatus;
26+
27+
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
28+
public class OtpRateLimitException extends RuntimeException {
29+
public OtpRateLimitException(String message) { super(message); }
30+
}

src/main/java/com/iemr/common/service/beneficiaryOTPHandler/BeneficiaryOTPHandlerImpl.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.iemr.common.repository.sms.SMSTemplateRepository;
3333
import com.iemr.common.repository.sms.SMSTypeRepository;
3434
import com.iemr.common.service.otp.OTPHandler;
35+
import com.iemr.common.service.otp.OtpRateLimiterService;
3536
import com.iemr.common.service.users.IEMRAdminUserServiceImpl;
3637
import com.iemr.common.utils.config.ConfigProperties;
3738
import com.iemr.common.utils.http.HttpUtils;
@@ -59,6 +60,8 @@ public class BeneficiaryOTPHandlerImpl implements BeneficiaryOTPHandler {
5960
HttpUtils httpUtils;
6061
@Autowired
6162
private IEMRAdminUserServiceImpl iEMRAdminUserServiceImpl;
63+
@Autowired
64+
private OtpRateLimiterService otpRateLimiterService;
6265

6366
final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
6467
@Autowired
@@ -107,6 +110,7 @@ public String load(String key) {
107110
*/
108111
@Override
109112
public String sendOTP(BeneficiaryConsentRequest obj) throws Exception {
113+
otpRateLimiterService.checkRateLimit(obj.getMobNo());
110114
int otp = generateOTP(obj.getMobNo());
111115
return sendSMS(otp, obj);
112116
}
@@ -141,6 +145,7 @@ public JSONObject validateOTP(BeneficiaryConsentRequest obj) throws Exception {
141145
*/
142146
@Override
143147
public String resendOTP(BeneficiaryConsentRequest obj) throws Exception {
148+
otpRateLimiterService.checkRateLimit(obj.getMobNo());
144149
int otp = generateOTP(obj.getMobNo());
145150
return sendSMS(otp, obj);
146151
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* AMRIT – Accessible Medical Records via Integrated Technology
3+
* Integrated EHR (Electronic Health Records) Solution
4+
*
5+
* Copyright (C) "Piramal Swasthya Management and Research Institute"
6+
*
7+
* This file is part of AMRIT.
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU General Public License as published by
11+
* the Free Software Foundation, either version 3 of the License, or
12+
* (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU General Public License
20+
* along with this program. If not, see https://www.gnu.org/licenses/.
21+
*/
22+
package com.iemr.common.service.otp;
23+
24+
import com.iemr.common.exception.OtpRateLimitException;
25+
import org.springframework.beans.factory.annotation.Value;
26+
import org.springframework.data.redis.core.StringRedisTemplate;
27+
import org.springframework.stereotype.Component;
28+
29+
import java.time.LocalDate;
30+
import java.time.ZoneId;
31+
import java.util.concurrent.TimeUnit;
32+
33+
/**
34+
* Rate-limits OTP send/resend requests per mobile number using Redis counters.
35+
*
36+
* Limits (configurable via properties):
37+
* otp.ratelimit.minute-limit – max OTPs per minute (default 3)
38+
* otp.ratelimit.hour-limit – max OTPs per hour (default 10)
39+
* otp.ratelimit.day-limit – max OTPs per day (default 20)
40+
*
41+
* Redis key pattern:
42+
* rl:otp:min:{mobNo}:{minuteSlot} TTL 60 s
43+
* rl:otp:hr:{mobNo}:{hourSlot} TTL 3600 s
44+
* rl:otp:day:{mobNo}:{yyyyMMdd} TTL 86400 s
45+
*/
46+
@Component
47+
public class OtpRateLimiterService {
48+
49+
private final StringRedisTemplate redis;
50+
51+
@Value("${otp.ratelimit.enabled:true}")
52+
private boolean enabled;
53+
54+
@Value("${otp.ratelimit.minute-limit:3}")
55+
private int minuteLimit;
56+
57+
@Value("${otp.ratelimit.hour-limit:10}")
58+
private int hourLimit;
59+
60+
@Value("${otp.ratelimit.day-limit:20}")
61+
private int dayLimit;
62+
63+
public OtpRateLimiterService(StringRedisTemplate redis) {
64+
this.redis = redis;
65+
}
66+
67+
/**
68+
* Checks all three rate-limit windows for the given mobile number.
69+
* Throws {@link OtpRateLimitException} if any limit is exceeded.
70+
* No-op when otp.ratelimit.enabled=false.
71+
*/
72+
public void checkRateLimit(String mobNo) {
73+
if (!enabled) return;
74+
String today = LocalDate.now(ZoneId.of("Asia/Kolkata"))
75+
.toString().replaceAll("-", ""); // yyyyMMdd
76+
long minuteSlot = System.currentTimeMillis() / 60_000L;
77+
long hourSlot = System.currentTimeMillis() / 3_600_000L;
78+
79+
String minKey = "rl:otp:min:" + mobNo + ":" + minuteSlot;
80+
String hourKey = "rl:otp:hr:" + mobNo + ":" + hourSlot;
81+
String dayKey = "rl:otp:day:" + mobNo + ":" + today;
82+
83+
if (incrementWithExpire(minKey, 60L) > minuteLimit) {
84+
throw new OtpRateLimitException(
85+
"OTP request limit exceeded. Maximum " + minuteLimit + " OTPs allowed per minute. Please try again later.");
86+
}
87+
if (incrementWithExpire(hourKey, 3600L) > hourLimit) {
88+
throw new OtpRateLimitException(
89+
"OTP request limit exceeded. Maximum " + hourLimit + " OTPs allowed per hour. Please try again later.");
90+
}
91+
if (incrementWithExpire(dayKey, 86400L) > dayLimit) {
92+
throw new OtpRateLimitException(
93+
"OTP request limit exceeded. Maximum " + dayLimit + " OTPs allowed per day. Please try again tomorrow.");
94+
}
95+
}
96+
97+
private long incrementWithExpire(String key, long ttlSeconds) {
98+
Long value = redis.opsForValue().increment(key, 1L);
99+
if (value != null && value == 1L) {
100+
redis.expire(key, ttlSeconds, TimeUnit.SECONDS);
101+
}
102+
return value == null ? 0L : value;
103+
}
104+
}

0 commit comments

Comments
 (0)