diff --git a/medplat-android/.gradle/7.2/fileChanges/last-build.bin b/medplat-android/.gradle/7.2/fileChanges/last-build.bin new file mode 100644 index 000000000..f76dd238a Binary files /dev/null and b/medplat-android/.gradle/7.2/fileChanges/last-build.bin differ diff --git a/medplat-android/.gradle/7.2/fileHashes/fileHashes.lock b/medplat-android/.gradle/7.2/fileHashes/fileHashes.lock new file mode 100644 index 000000000..eeea1d5d2 Binary files /dev/null and b/medplat-android/.gradle/7.2/fileHashes/fileHashes.lock differ diff --git a/medplat-android/.gradle/7.2/gc.properties b/medplat-android/.gradle/7.2/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/medplat-android/.gradle/checksums/checksums.lock b/medplat-android/.gradle/checksums/checksums.lock new file mode 100644 index 000000000..f8abfb771 Binary files /dev/null and b/medplat-android/.gradle/checksums/checksums.lock differ diff --git a/medplat-android/.gradle/vcs-1/gc.properties b/medplat-android/.gradle/vcs-1/gc.properties new file mode 100644 index 000000000..e69de29bb diff --git a/medplat-web/pom.xml b/medplat-web/pom.xml index 9a95bf8aa..c52a55d80 100644 --- a/medplat-web/pom.xml +++ b/medplat-web/pom.xml @@ -268,6 +268,12 @@ xstream 1.4.20 + + + com.bucket4j + bucket4j-core + 8.1.1 + diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java new file mode 100644 index 000000000..881804dfc --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitConfig.java @@ -0,0 +1,19 @@ +package com.argusoft.medplat.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimitConfig { + + @Bean + public FilterRegistrationBean rateLimitingFilter() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new RateLimitingFilter()); + registration.addUrlPatterns("/api/*"); + registration.setName("rateLimitingFilter"); + registration.setOrder(1); + return registration; + } +} \ No newline at end of file diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitDao.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitDao.java new file mode 100644 index 000000000..87a82f823 --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitDao.java @@ -0,0 +1,60 @@ +package com.argusoft.medplat.config; + +import org.hibernate.SessionFactory; +import org.hibernate.query.NativeQuery; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; + +@Repository +@Transactional +public class RateLimitDao { + + @Autowired + private SessionFactory sessionFactory; + + // Read config value from DB (e.g. RATE_LIMIT_GLOBAL) + public String getConfigValue(String key, String defaultValue) { + try { + String sql = "SELECT config_value FROM rate_limit_config WHERE config_key = :key"; + NativeQuery query = sessionFactory.getCurrentSession().createNativeQuery(sql); + query.setParameter("key", key); + String result = query.uniqueResult(); + return result != null ? result : defaultValue; + } catch (Exception e) { + return defaultValue; + } + } + + // Read sensitive endpoints list from DB + public List getSensitiveEndpoints() { + try { + String sql = "SELECT endpoint_pattern FROM rate_limit_sensitive_endpoints"; + NativeQuery query = sessionFactory.getCurrentSession().createNativeQuery(sql); + return query.list(); + } catch (Exception e) { + return List.of("/login", "/users", "/oauth", "/password"); + } + } + + // Save violation log to DB + public void saveViolationLog(String ipAddress, String endpoint, boolean isSensitive) { + try { + String sql = "INSERT INTO rate_limit_violation_log " + + "(ip_address, endpoint, is_sensitive, violated_at) " + + "VALUES (:ip, :endpoint, :isSensitive, :violatedAt)"; + NativeQuery query = sessionFactory.getCurrentSession().createNativeQuery(sql); + query.setParameter("ip", ipAddress); + query.setParameter("endpoint", endpoint); + query.setParameter("isSensitive", isSensitive); + query.setParameter("violatedAt", new Date()); + query.executeUpdate(); + } catch (Exception e) { + // fallback log to console if DB fails + System.err.println("[RATE LIMIT LOG FAILED] " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java new file mode 100644 index 000000000..3af105735 --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitViolationLog.java @@ -0,0 +1,46 @@ +package com.argusoft.medplat.config; + +import javax.persistence.*; +import java.util.Date; + +@Entity +@Table(name = "rate_limit_violation_log") +public class RateLimitViolationLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "endpoint") + private String endpoint; + + @Column(name = "is_sensitive") + private Boolean isSensitive; + + @Column(name = "violated_at") + @Temporal(TemporalType.TIMESTAMP) + private Date violatedAt; + + public RateLimitViolationLog() {} + + public RateLimitViolationLog(String ipAddress, String endpoint, Boolean isSensitive) { + this.ipAddress = ipAddress; + this.endpoint = endpoint; + this.isSensitive = isSensitive; + this.violatedAt = new Date(); + } + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public String getIpAddress() { return ipAddress; } + public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; } + public String getEndpoint() { return endpoint; } + public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public Boolean getIsSensitive() { return isSensitive; } + public void setIsSensitive(Boolean isSensitive) { this.isSensitive = isSensitive; } + public Date getViolatedAt() { return violatedAt; } + public void setViolatedAt(Date violatedAt) { this.violatedAt = violatedAt; } +} \ No newline at end of file diff --git a/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java new file mode 100644 index 000000000..444235d68 --- /dev/null +++ b/medplat-web/src/main/java/com/argusoft/medplat/config/RateLimitingFilter.java @@ -0,0 +1,97 @@ +package com.argusoft.medplat.config; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Order(1) +public class RateLimitingFilter implements Filter { + + private static final Logger log = LoggerFactory.getLogger(RateLimitingFilter.class); + + @Autowired + private RateLimitDao rateLimitDao; + + private final Map buckets = new ConcurrentHashMap<>(); + + private Bucket createBucket(int limit) { + return Bucket.builder() + .addLimit(Bandwidth.classic(limit, + Refill.intervally(limit, Duration.ofMinutes(1)))) + .build(); + } + + private boolean isSensitivePath(String path) { + try { + List sensitiveEndpoints = rateLimitDao.getSensitiveEndpoints(); + return sensitiveEndpoints.stream().anyMatch(path::contains); + } catch (Exception e) { + // fallback if DB not available + return path.contains("/login") || path.contains("/users") || + path.contains("/oauth") || path.contains("/password"); + } + } + + private int getLimit(String key, String defaultValue) { + try { + String value = rateLimitDao.getConfigValue(key, defaultValue); + return Integer.parseInt(value); + } catch (Exception e) { + return Integer.parseInt(defaultValue); + } + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, + FilterChain chain) throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + String ip = request.getRemoteAddr(); + String path = request.getRequestURI(); + boolean sensitive = isSensitivePath(path); + + int limit = sensitive + ? getLimit("RATE_LIMIT_SENSITIVE", "10") + : getLimit("RATE_LIMIT_GLOBAL", "100"); + + String bucketKey = ip + ":" + (sensitive ? "sensitive" : "global"); + Bucket bucket = buckets.computeIfAbsent(bucketKey, k -> createBucket(limit)); + + if (bucket.tryConsume(1)) { + chain.doFilter(req, res); + } else { + // Log violation to DB + log.warn("[RATE LIMIT VIOLATED] IP={} | Path={} | Sensitive={}", ip, path, sensitive); + rateLimitDao.saveViolationLog(ip, path, sensitive); + + response.setStatus(429); + response.setContentType("application/json"); + response.getWriter().write( + "{\"status\": 429, " + + "\"error\": \"Too Many Requests\", " + + "\"message\": \"Rate limit exceeded. Please try again later.\"}" + ); + } + } +} \ No newline at end of file diff --git a/medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql b/medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql new file mode 100644 index 000000000..a12c00c56 --- /dev/null +++ b/medplat-web/src/main/resources/db/migration/V21__rate_limit_config_and_logs.sql @@ -0,0 +1,33 @@ +-- Table to store rate limit configurations +CREATE TABLE IF NOT EXISTS rate_limit_config ( + id SERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value VARCHAR(100) NOT NULL, + description VARCHAR(255) +); + +-- Default values +INSERT INTO rate_limit_config (config_key, config_value, description) +VALUES + ('RATE_LIMIT_GLOBAL', '100', 'Max requests per minute per IP for all endpoints'), + ('RATE_LIMIT_SENSITIVE', '10', 'Max requests per minute per IP for sensitive endpoints') +ON CONFLICT (config_key) DO NOTHING; + +-- Sensitive endpoints stored in DB +CREATE TABLE IF NOT EXISTS rate_limit_sensitive_endpoints ( + id SERIAL PRIMARY KEY, + endpoint_pattern VARCHAR(255) NOT NULL UNIQUE +); + +INSERT INTO rate_limit_sensitive_endpoints (endpoint_pattern) +VALUES ('/login'), ('/users'), ('/oauth'), ('/password') +ON CONFLICT (endpoint_pattern) DO NOTHING; + +-- Table to log rate limit violations +CREATE TABLE IF NOT EXISTS rate_limit_violation_log ( + id SERIAL PRIMARY KEY, + ip_address VARCHAR(100), + endpoint VARCHAR(255), + is_sensitive BOOLEAN, + violated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/medplat-web/src/main/resources/root-config.properties b/medplat-web/src/main/resources/root-config.properties index baf0bf7e2..ac07e4ae9 100644 --- a/medplat-web/src/main/resources/root-config.properties +++ b/medplat-web/src/main/resources/root-config.properties @@ -1 +1,3 @@ -appName=medplat \ No newline at end of file +appName=medplat +RATE_LIMIT_GLOBAL=100 +RATE_LIMIT_SENSITIVE=10 \ No newline at end of file