Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
363ee51
WIP
aluapaula Apr 24, 2026
6fe7907
Refactoring of Method saveScheduledEncounter in class EncounterContro…
aluapaula May 21, 2026
a25a72c
Refactoring of Method saveScheduledEncounter in class EncounterContro…
aluapaula May 22, 2026
11482c9
Refactoring of Method saveScheduledEncounter in class EncounterContro…
aluapaula May 22, 2026
039854b
Refactoring of Method saveScheduledEncounter in class EncounterContro…
aluapaula May 22, 2026
440adb8
Add tests for Audit, EncounterScheduled, ImmediateMail and Mail servi…
aluapaula May 28, 2026
30b2924
Fixed transactional missing a qualifier
ywarnecke May 29, 2026
40f96ad
Merge branch 'v3.4.0' into 304-add-api-for-automated-scheduled-encoun…
aluapaula May 29, 2026
15e35fd
Add API endpoint for external scheduled encounter:
aluapaula Jun 5, 2026
4c8aba6
Add API endpoint for external scheduled encounter:
aluapaula Jun 10, 2026
7ee8b01
Add API endpoint for external scheduled encounter:
aluapaula Jun 12, 2026
29f6772
updated messages for configuration label to ensure correct formatting
aluapaula Jun 18, 2026
32eaef4
improved: DTO-Validation for EncounterScheduledApiRequestDTO
aluapaula Jun 18, 2026
2d06b07
fix: DTO-Validation for EncounterScheduledApiRequestDTO
aluapaula Jun 19, 2026
76009b4
implement: EncounterScheduledApiRequestDTOValidator for Validation of…
aluapaula Jun 19, 2026
ca99ad6
Refactor scheduled encounter save flow and harden API validation
aluapaula Jul 21, 2026
d584d39
Add rate limiting filter for scheduled encounter API
aluapaula Jul 23, 2026
87fce2b
Improve rate limiting filter for scheduled encounter API, add javadoc…
aluapaula Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion db/installationInit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ INSERT INTO `configuration` (`id`, `type`, `configuration_group_id`, `parent`, `
(90, 'GENERAL', 11, 87, 9, 'FHIRViaHL7v2SendingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7SendingFacility', NULL, NULL, '87e35940-97e2-4c3e-ae6c-b35ea65717e3', '', NULL),
(91, 'GENERAL', 11, 87, 10, 'FHIRViaHL7v2ReceivingApplication', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingApplication', NULL, NULL, 'd500a3a1-c1ce-4091-ace9-4557481dba37', '', NULL),
(92, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2ReceivingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingFacility', NULL, NULL, '613cc529-396e-4a06-b785-158c15629d02', '', NULL),
(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL);
(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL),
(94, 'GENERAL', 1, NULL, 17, 'enableApiTokenAccess', 'BOOLEAN', 'configuration.description.enableApiTokenAccess', 'GLOBAL', 'configuration.label.enableApiTokenAccess', NULL, NULL, '25fd8f5e-2e90-4cd1-8a30-d84c88889c5c', false, NULL),
(95, 'GENERAL', 1, 94, 18, 'apiKey', 'STRING', 'configuration.description.apiKey', 'GLOBAL', 'configuration.label.apiKey', NULL, NULL, '9b48f864-8cb7-4704-82e2-12045755e2aa', '', NULL);




Expand Down
4 changes: 3 additions & 1 deletion db/installationInitTest.sql
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ INSERT INTO `configuration` (`id`, `type`, `configuration_group_id`, `parent`, `
(90, 'GENERAL', 11, 87, 9, 'FHIRViaHL7v2SendingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7SendingFacility', NULL, NULL, '87e35940-97e2-4c3e-ae6c-b35ea65717e3', '', NULL),
(91, 'GENERAL', 11, 87, 10, 'FHIRViaHL7v2ReceivingApplication', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingApplication', NULL, NULL, 'd500a3a1-c1ce-4091-ace9-4557481dba37', '', NULL),
(92, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2ReceivingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingFacility', NULL, NULL, '613cc529-396e-4a06-b785-158c15629d02', '', NULL),
(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL);
(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL),
(94, 'GENERAL', 1, NULL, 17, 'enableApiTokenAccess', 'BOOLEAN', 'configuration.description.enableApiTokenAccess', 'GLOBAL', 'configuration.label.enableApiTokenAccess', NULL, NULL, '25fd8f5e-2e90-4cd1-8a30-d84c88889c5c', false, NULL),
(95, 'GENERAL', 1, 94, 18, 'apiKey', 'STRING', 'configuration.description.apiKey', 'GLOBAL', 'configuration.label.apiKey', NULL, NULL, '9b48f864-8cb7-4704-82e2-12045755e2aa', '', NULL);


CREATE TABLE IF NOT EXISTS `clinic_configuration` (
Expand Down
24 changes: 24 additions & 0 deletions db/update/v3.4.0.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
USE `moPat`;

SELECT MAX(id)
INTO @largest_id
FROM moPat.configuration;


-- Determine the highest position in configuration group 1
SELECT COALESCE(MAX(position), 0)
INTO @max_position_group_1
FROM `moPat`.`configuration`
WHERE `configuration_group_id` = 1;


-- Configuration group 1 is used here.
-- If your target server uses a different configuration group for these settings,
-- please adjust configuration_group_id accordingly.

-- Inserts:
-- 1) enableApiTokenAccess
-- 2) apiKey as child of enableApiTokenAccess
INSERT INTO moPat.configuration (`id`, `type`, `configuration_group_id`, `parent`, `position`, `attribute`, `configuration_type`, `description_message_code`, `class`, `label_message_code`, `test_method`, `update_method`, `uuid`, `value`, `pattern`) VALUES
Comment thread
aluapaula marked this conversation as resolved.
(@largest_id + 1, 'GENERAL', 1, NULL, @max_position_group_1 +1, 'enableApiTokenAccess', 'BOOLEAN', 'configuration.description.enableApiTokenAccess', 'GLOBAL', 'configuration.label.enableApiTokenAccess', NULL, NULL, '25fd8f5e-2e90-4cd1-8a30-d84c88889c5c', false, NULL),
(@largest_id + 2, 'GENERAL', 1, @largest_id + 1, @max_position_group_1 +2, 'apiKey', 'STRING', 'configuration.description.apiKey', 'GLOBAL', 'configuration.label.apiKey', NULL, NULL, '9b48f864-8cb7-4704-82e2-12045755e2aa', '', NULL);
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,17 @@
<type>jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>RELEASE</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.18.0</version>
</dependency>
</dependencies>
<repositories>
<repository>
Expand Down
104 changes: 104 additions & 0 deletions src/main/java/de/imi/mopat/auth/ApiRateLimitFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package de.imi.mopat.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import de.imi.mopat.service.ApiRateLimitService;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/**
* Filter that enforces rate limiting on the API endpoint {@code POST /encounter/schedule/api}.
* Applies both a global limit and a per-IP limit using token buckets.
* Requests exceeding either limit are rejected with HTTP 429.
*/
public class ApiRateLimitFilter extends OncePerRequestFilter {

private static final String API_PATH = "/encounter/schedule/api";
private static final String HTTP_METHOD = "POST";
private final ObjectMapper objectMapper = new ObjectMapper();
private final ApiRateLimitService apiRateLimitService;

public ApiRateLimitFilter(ApiRateLimitService apiRateLimitService) {
this.apiRateLimitService = apiRateLimitService;
}
/**
* Skips filtering for all requests that do not target the rate-limited endpoint.
*
* @param request the incoming HTTP request
* @return {@code true} if the request should not be filtered
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !API_PATH.equals(request.getServletPath())
|| !HTTP_METHOD.equalsIgnoreCase(request.getMethod());
}
/**
* Checks the global and per-IP rate limits. Forwards the request if both limits
* are satisfied, otherwise responds with HTTP 429.
*
* @param request the incoming HTTP request
* @param response the HTTP response
* @param filterChain the filter chain
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {

ConsumptionProbe globalProbe =
apiRateLimitService.getGlobalBucket().tryConsumeAndReturnRemaining(1);

if (!globalProbe.isConsumed()) {
writeTooManyRequestsResponse(response, globalProbe, "Global rate limit exceeded");
return;
}

String clientIp = request.getRemoteAddr();
Bucket ipBucket = apiRateLimitService.resolveIpBucket(clientIp);
ConsumptionProbe ipProbe = ipBucket.tryConsumeAndReturnRemaining(1);

if (!ipProbe.isConsumed()) {
writeTooManyRequestsResponse(response, ipProbe, "IP rate limit exceeded");
return;
}

response.setHeader("X-Rate-Limit-Global-Remaining",
String.valueOf(globalProbe.getRemainingTokens()));
response.setHeader("X-Rate-Limit-IP-Remaining",
String.valueOf(ipProbe.getRemainingTokens()));

filterChain.doFilter(request, response);
}
/**
* Writes a {@code 429 Too Many Requests} response including a retry-after header
* and a JSON error message.
*
* @param response the HTTP response
* @param probe the consumption probe containing refill timing information
* @param message the error message to include in the response body
*/
private void writeTooManyRequestsResponse(HttpServletResponse response,
ConsumptionProbe probe,
String message) throws IOException {
response.setStatus(429);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setHeader(
"X-Rate-Limit-Retry-After-Seconds",
String.valueOf(TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()))
);

objectMapper.writeValue(response.getWriter(), Map.of(
"error", message
));
}
}
3 changes: 2 additions & 1 deletion src/main/java/de/imi/mopat/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
@ComponentScan(basePackages = {"de.imi.mopat.auth", "de.imi.mopat.config",
"de.imi.mopat.controller", "de.imi.mopat.cron", "de.imi.mopat.dao", "de.imi.mopat.helper.model",
"de.imi.mopat.helper.controller", "de.imi.mopat.io", "de.imi.mopat.io.impl", "de.imi.mopat.io.importer",
"de.imi.mopat.io.importer.fhir", "de.imi.mopat.io.importer.odm", "de.imi.mopat.model", "de.imi.mopat.validator"})
"de.imi.mopat.io.importer.fhir", "de.imi.mopat.io.importer.odm", "de.imi.mopat.model",
"de.imi.mopat.validator","de.imi.mopat.service"})

@PropertySources({
@PropertySource("classpath:mopat.properties"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package de.imi.mopat.config;

import com.mchange.v2.c3p0.ComboPooledDataSource;
import de.imi.mopat.helper.controller.NoOpAclCache;
import de.imi.mopat.auth.ApiRateLimitFilter;
import de.imi.mopat.auth.CustomAuthenticationFailureHandler;
import de.imi.mopat.auth.CustomPostAuthenticationChecks;
import de.imi.mopat.auth.CustomPreAuthenticationChecks;
Expand All @@ -11,8 +11,11 @@
import de.imi.mopat.auth.PinAuthorizationFilter;
import de.imi.mopat.auth.PepperedBCryptPasswordEncoder;
import de.imi.mopat.auth.RoleBasedAuthenticationSuccessHandler;
import de.imi.mopat.helper.controller.NoOpAclCache;
import java.beans.PropertyVetoException;
import java.util.Properties;

import de.imi.mopat.service.ApiRateLimitService;
import org.apache.groovy.util.Maps;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
Expand Down Expand Up @@ -59,10 +62,12 @@ public class ApplicationSecurityConfig {
CacheManager cacheManager;

private final Environment environment;
private final ApiRateLimitService apiRateLimitService;


public ApplicationSecurityConfig(Environment environment) {
public ApplicationSecurityConfig(Environment environment, ApiRateLimitService apiRateLimitService) {
this.environment = environment;
this.apiRateLimitService = apiRateLimitService;
}

/**
Expand Down Expand Up @@ -343,7 +348,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/mobile/survey/pseudonym",
"/error/maintenance",
"/error/internalservererror",
"/error/accessdenied"
"/error/accessdenied",
"/encounter/schedule/api"
).permitAll()
// Login page GET
.requestMatchers(HttpMethod.GET, "/mobile/user/login").permitAll()
Expand Down Expand Up @@ -383,6 +389,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF
.csrf(csrf -> csrf.disable());


// Add custom filter before authentification
http.addFilterBefore(
new ApiRateLimitFilter(apiRateLimitService),
UsernamePasswordAuthenticationFilter.class
);

// Add custom filter after authentication
http.addFilterAfter(pinAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

Expand Down
10 changes: 2 additions & 8 deletions src/main/java/de/imi/mopat/controller/BundleController.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,13 @@
import de.imi.mopat.dao.AnswerDao;
import de.imi.mopat.dao.BundleDao;
import de.imi.mopat.dao.ConditionDao;
import de.imi.mopat.dao.ExportTemplateDao;
import de.imi.mopat.dao.QuestionnaireDao;
import de.imi.mopat.dao.ScoreDao;
import de.imi.mopat.dao.user.AclClassDao;
import de.imi.mopat.dao.user.AclObjectIdentityDao;
import de.imi.mopat.helper.controller.AuthService;
import de.imi.mopat.helper.controller.BundleService;
import de.imi.mopat.service.BundleService;
import de.imi.mopat.helper.controller.LocaleHelper;
import de.imi.mopat.helper.controller.UserService;
import de.imi.mopat.helper.controller.ClinicService;
import de.imi.mopat.helper.model.BundleDTOMapper;
import de.imi.mopat.helper.model.QuestionnaireDTOMapper;
import de.imi.mopat.service.ClinicService;
import de.imi.mopat.model.Answer;
import de.imi.mopat.model.Bundle;
import de.imi.mopat.model.BundleClinic;
Expand All @@ -26,7 +21,6 @@
import de.imi.mopat.model.conditions.SelectAnswerCondition;
import de.imi.mopat.model.conditions.SliderAnswerThresholdCondition;
import de.imi.mopat.model.dto.BundleDTO;
import de.imi.mopat.model.dto.BundleQuestionnaireDTO;
import de.imi.mopat.model.dto.QuestionnaireDTO;
import de.imi.mopat.validator.BundleDTOValidator;

Expand Down
10 changes: 5 additions & 5 deletions src/main/java/de/imi/mopat/controller/ClinicController.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
import de.imi.mopat.helper.model.BundleDTOMapper;
import de.imi.mopat.helper.model.ClinicConfigurationDTOMapper;
import de.imi.mopat.helper.model.ClinicDTOMapper;
import de.imi.mopat.helper.controller.UserService;
import de.imi.mopat.service.UserService;
import de.imi.mopat.model.Bundle;
import de.imi.mopat.model.BundleClinic;
import de.imi.mopat.model.Clinic;
import de.imi.mopat.helper.controller.ClinicConfigurationMappingService;
import de.imi.mopat.helper.controller.ClinicConfigurationService;
import de.imi.mopat.helper.controller.ClinicService;
import de.imi.mopat.helper.controller.ConfigurationService;
import de.imi.mopat.service.ClinicConfigurationMappingService;
import de.imi.mopat.service.ClinicConfigurationService;
import de.imi.mopat.service.ClinicService;
import de.imi.mopat.service.ConfigurationService;
import de.imi.mopat.model.*;
import de.imi.mopat.model.dto.*;
import de.imi.mopat.model.user.User;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import de.imi.mopat.dao.ConditionDao;
import de.imi.mopat.dao.QuestionDao;
import de.imi.mopat.dao.QuestionnaireDao;
import de.imi.mopat.helper.controller.ConditionService;
import de.imi.mopat.service.ConditionService;
import de.imi.mopat.helper.model.BundleDTOMapper;
import de.imi.mopat.helper.model.ConditionDTOMapper;
import de.imi.mopat.model.Answer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.mchange.v1.db.sql.UnsupportedTypeException;
import de.imi.mopat.dao.ConfigurationDao;
import de.imi.mopat.dao.ConfigurationGroupDao;
import de.imi.mopat.helper.controller.ConfigurationService;
import de.imi.mopat.service.ConfigurationService;
import de.imi.mopat.helper.controller.MailSender;
import de.imi.mopat.helper.controller.MultiPartFileUploadBean;
import de.imi.mopat.helper.controller.StringUtilities;
Expand Down
Loading
Loading