Skip to content

Commit 6da5db9

Browse files
feat: add ApiKeyAuthFilter and update security config, request DTO, and related tests
1 parent 711b7bd commit 6da5db9

6 files changed

Lines changed: 193 additions & 40 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.countyhospital.healthapi.config;
2+
3+
import java.io.IOException;
4+
import java.util.Collections;
5+
import java.util.List;
6+
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
11+
import org.springframework.security.core.context.SecurityContextHolder;
12+
import org.springframework.web.filter.OncePerRequestFilter;
13+
14+
import jakarta.servlet.FilterChain;
15+
import jakarta.servlet.ServletException;
16+
import jakarta.servlet.http.HttpServletRequest;
17+
import jakarta.servlet.http.HttpServletResponse;
18+
19+
public class ApiKeyAuthFilter extends OncePerRequestFilter {
20+
21+
private static final Logger LOGGER = LoggerFactory.getLogger(ApiKeyAuthFilter.class);
22+
23+
private static final String API_KEY_HEADER = "X-API-KEY";
24+
private static final List<SimpleGrantedAuthority> API_USER_AUTHORITIES =
25+
Collections.singletonList(new SimpleGrantedAuthority("ROLE_API_USER"));
26+
27+
private final String validApiKey;
28+
29+
public ApiKeyAuthFilter(String validApiKey) {
30+
this.validApiKey = validApiKey;
31+
}
32+
33+
@Override
34+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
35+
FilterChain filterChain) throws ServletException, IOException {
36+
37+
String requestApiKey = extractApiKey(request);
38+
39+
if (validApiKey == null || validApiKey.trim().isEmpty()) {
40+
logger.warn("API key authentication is enabled but no valid API key is configured");
41+
sendErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
42+
"API key authentication misconfigured");
43+
return;
44+
}
45+
46+
if (requestApiKey == null) {
47+
LOGGER.warn("API key missing for request: {}", request.getRequestURI());
48+
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED,
49+
"API key required");
50+
return;
51+
}
52+
53+
if (!isValidApiKey(requestApiKey)) {
54+
LOGGER.warn("Invalid API key provided for request: {}", request.getRequestURI());
55+
sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED,
56+
"Invalid API key");
57+
return;
58+
}
59+
60+
// API key is valid, set up security context
61+
UsernamePasswordAuthenticationToken authentication =
62+
new UsernamePasswordAuthenticationToken("api-user", null, API_USER_AUTHORITIES);
63+
SecurityContextHolder.getContext().setAuthentication(authentication);
64+
65+
LOGGER.debug("API key authentication successful for request: {}", request.getRequestURI());
66+
filterChain.doFilter(request, response);
67+
}
68+
69+
private String extractApiKey(HttpServletRequest request) {
70+
// Check header first
71+
String apiKey = request.getHeader(API_KEY_HEADER);
72+
73+
// Fallback to query parameter (less secure, but sometimes needed)
74+
if (apiKey == null) {
75+
apiKey = request.getParameter("apiKey");
76+
}
77+
78+
return apiKey;
79+
}
80+
81+
private boolean isValidApiKey(String apiKey) {
82+
return validApiKey.equals(apiKey);
83+
}
84+
85+
private void sendErrorResponse(HttpServletResponse response, int status, String message)
86+
throws IOException {
87+
response.setStatus(status);
88+
response.setContentType("application/json");
89+
response.setCharacterEncoding("UTF-8");
90+
91+
String jsonResponse = String.format(
92+
"{\"timestamp\": \"%s\", \"status\": %d, \"error\": \"%s\", \"message\": \"%s\"}",
93+
java.time.LocalDateTime.now(),
94+
status,
95+
HttpServletResponse.SC_UNAUTHORIZED == status ? "Unauthorized" : "Internal Server Error",
96+
message
97+
);
98+
99+
response.getWriter().write(jsonResponse);
100+
}
101+
102+
@Override
103+
protected boolean shouldNotFilter(HttpServletRequest request) {
104+
// Skip authentication for certain paths
105+
String path = request.getRequestURI();
106+
return path.startsWith("/swagger-ui") ||
107+
path.startsWith("/v3/api-docs") ||
108+
path.startsWith("/actuator/health") ||
109+
path.equals("/error");
110+
}
111+
}

src/main/java/com/countyhospital/healthapi/config/SecurityConfig.java

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import org.slf4j.Logger;
77
import org.slf4j.LoggerFactory;
88
import org.springframework.beans.factory.annotation.Value;
9-
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
109
import org.springframework.context.annotation.Bean;
1110
import org.springframework.context.annotation.Configuration;
1211
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@@ -19,62 +18,100 @@
1918

2019
@Configuration
2120
@EnableMethodSecurity
22-
@ConditionalOnProperty(name = "app.security.enabled", havingValue = "true", matchIfMissing = false)
21+
// @ConditionalOnProperty(name = "app.security.enabled", havingValue = "true", matchIfMissing = false)
2322
public class SecurityConfig {
2423

2524
private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);
2625

27-
@Value("${app.security.api-key:}")
28-
private String validApiKey;
29-
3026
@Value("${app.security.cors.allowed-origins:*}")
3127
private List<String> allowedOrigins;
3228

29+
/**
30+
* ---------------------------------------------------------------------------
31+
* DEVELOPMENT SECURITY
32+
* ---------------------------------------------------------------------------
33+
*/
34+
@Bean
35+
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
36+
logger.warn("Security relaxed: all endpoints are currently open (development only)");
37+
38+
http
39+
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
40+
.csrf(csrf -> csrf.disable())
41+
.sessionManagement(session ->
42+
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
43+
)
44+
.authorizeHttpRequests(auth ->
45+
auth.anyRequest().permitAll() // Allow all endpoints
46+
);
47+
48+
return http.build();
49+
}
50+
51+
/**
52+
*
53+
* PRODUCTION SECURITY
54+
*
55+
*/
56+
57+
/*
3358
@Bean
3459
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
35-
logger.info("Configuring API security with API key authentication");
60+
logger.info("Configuring API security with authentication");
3661
3762
http
3863
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
3964
.csrf(csrf -> csrf.disable())
40-
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
65+
.sessionManagement(session ->
66+
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
67+
)
4168
.authorizeHttpRequests(auth -> auth
42-
.requestMatchers(
43-
"/swagger-ui/**",
44-
"/v3/api-docs/**",
45-
"/swagger-resources/**",
46-
"/webjars/**",
47-
"/actuator/health",
48-
"/error"
49-
).permitAll()
50-
.anyRequest().authenticated()
69+
.requestMatchers(
70+
"/swagger-ui/**",
71+
"/v3/api-docs/**",
72+
"/swagger-resources/**",
73+
"/webjars/**",
74+
"/actuator/health",
75+
"/error"
76+
).permitAll()
77+
.anyRequest().authenticated()
5178
);
5279
5380
return http.build();
5481
}
82+
*/
5583

84+
/**
85+
* CORS configuration
86+
*/
5687
@Bean
5788
public CorsConfigurationSource corsConfigurationSource() {
5889
logger.info("Configuring CORS with allowed origins: {}", allowedOrigins);
5990

6091
CorsConfiguration configuration = new CorsConfiguration();
6192
configuration.setAllowedOrigins(allowedOrigins);
62-
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
63-
configuration.setAllowedHeaders(Arrays.asList(
64-
"Authorization",
65-
"Content-Type",
66-
"X-API-KEY",
67-
"X-Requested-With",
68-
"Accept",
69-
"Origin",
70-
"Access-Control-Request-Method",
71-
"Access-Control-Request-Headers"
72-
));
73-
configuration.setExposedHeaders(Arrays.asList(
74-
"X-API-KEY",
75-
"X-Rate-Limit-Remaining",
76-
"X-Rate-Limit-Reset"
77-
));
93+
configuration.setAllowedMethods(
94+
Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
95+
);
96+
configuration.setAllowedHeaders(
97+
Arrays.asList(
98+
"Authorization",
99+
"Content-Type",
100+
"X-API-KEY",
101+
"X-Requested-With",
102+
"Accept",
103+
"Origin",
104+
"Access-Control-Request-Method",
105+
"Access-Control-Request-Headers"
106+
)
107+
);
108+
configuration.setExposedHeaders(
109+
Arrays.asList(
110+
"X-API-KEY",
111+
"X-Rate-Limit-Remaining",
112+
"X-Rate-Limit-Reset"
113+
)
114+
);
78115
configuration.setAllowCredentials(true);
79116
configuration.setMaxAge(3600L);
80117

src/main/java/com/countyhospital/healthapi/encounter/dto/request/EncounterRequest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ public class EncounterRequest {
1818
private Long patientId;
1919

2020
@NotNull(message = "Start date time is required")
21-
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
22-
@Schema(description = "Encounter start date time", example = "2024-01-15 09:30:00", required = true)
21+
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
22+
@Schema(description = "Encounter start date time", example = "2024-01-15T09:30:00.000Z", required = true)
2323
private LocalDateTime startDateTime;
2424

25-
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
26-
@Schema(description = "Encounter end date time", example = "2024-01-15 10:15:00")
25+
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
26+
@Schema(description = "Encounter end date time", example = "2024-01-15T10:15:00.000Z")
2727
private LocalDateTime endDateTime;
2828

2929
@NotBlank(message = "Encounter class is required")

src/main/resources/application.properties

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ server.error.include-exception=false
1515
spring.application.name=health-api
1616
spring.mvc.throw-exception-if-no-handler-found=true
1717
spring.mvc.format.date=yyyy-MM-dd
18-
spring.mvc.format.date-time=yyyy-MM-dd HH:mm:ss
18+
spring.mvc.format.date-time=yyyy-MM-dd'T'HH:mm:ss
19+
20+
# Date/Time Configuration for JSON
21+
spring.jackson.serialization.write-dates-as-timestamps=false
22+
spring.jackson.deserialization.adjust-dates-to-context-time-zone=true
23+
spring.jackson.date-format=com.fasterxml.jackson.databind.util.StdDateFormat
1924

2025
# H2 Configuration
2126
spring.datasource.url=jdbc:h2:mem:healthdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE

src/test/java/com/countyhospital/healthapi/repository/EncounterRepositoryTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ class EncounterRepositoryTest {
3232
private Encounter encounter3;
3333

3434
@BeforeEach
35-
void setUp() {
36-
// Clear any existing data
35+
public void setUp() {
36+
3737
entityManager.clear();
3838

3939
// Create test patients

src/test/java/com/countyhospital/healthapi/service/EncounterServiceTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class EncounterServiceTest {
4949
private Encounter encounter2;
5050

5151
@BeforeEach
52-
void setUp() {
52+
public void setUp() {
5353
patient = new Patient("PAT-SERVICE-001", "John", "Doe",
5454
java.time.LocalDate.of(1985, 5, 15), "MALE");
5555
patient.setId(1L);

0 commit comments

Comments
 (0)