Skip to content

Commit e856fe6

Browse files
Theo-lbgCopilot
authored andcommitted
feat(security): enhance security filter chain and add custom exception handling
Co-authored-by: Copilot <copilot@github.com>
1 parent ea7f383 commit e856fe6

10 files changed

Lines changed: 213 additions & 30 deletions

File tree

.github/workflows/sonar.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ jobs:
4343
printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties
4444
fi
4545
echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties
46-
mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true -Dit.test="!CucumberIntegrationTest"
46+
mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true -Dit.test="!CucumberIntegrationTest

src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.xpeho.spring_boot_java_random_user.config;
22

3+
import jakarta.servlet.http.HttpServletRequest;
34
import org.springframework.context.annotation.Bean;
45
import org.springframework.context.annotation.Configuration;
56
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.http.HttpHeaders;
68
import org.springframework.http.HttpMethod;
79
import org.springframework.security.core.userdetails.User;
810
import org.springframework.security.core.userdetails.UserDetails;
@@ -15,11 +17,16 @@
1517
import org.springframework.security.config.http.SessionCreationPolicy;
1618
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
1719
import org.springframework.security.web.SecurityFilterChain;
20+
import org.springframework.security.web.context.NullSecurityContextRepository;
1821

1922
@Configuration
2023
@EnableWebSecurity
2124
public class SecurityConfig {
2225

26+
private static final String RANDOM_USERS_PATH = "/random-users/**";
27+
private static final String RANDOM_USERS_PREFIX = "/random-users";
28+
private static final String ADMIN_ROLE = "ADMIN";
29+
2330
@Value("${app.security.admin.username}")
2431
private String adminUsername;
2532

@@ -39,35 +46,50 @@ public class SecurityConfig {
3946
private String testPassword;
4047

4148
@Bean
42-
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
43-
http
44-
.csrf(csrf -> csrf.ignoringRequestMatchers("/random-users/**"))
45-
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
46-
.httpBasic(Customizer.withDefaults())
47-
.authorizeHttpRequests(auth -> auth
48-
.requestMatchers(
49-
"/api/**",
50-
"/swagger-ui/**",
51-
"/swagger-ui.html",
52-
"/v3/api-docs/**",
53-
"/actuator/health"
54-
).permitAll()
55-
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
56-
.requestMatchers(HttpMethod.GET, "/random-users/**").hasAnyRole("ADMIN", "USER", "TEST")
57-
.requestMatchers(HttpMethod.POST, "/random-users/**").hasRole("ADMIN")
58-
.requestMatchers(HttpMethod.PUT, "/random-users/**").hasRole("ADMIN")
59-
.requestMatchers(HttpMethod.DELETE, "/random-users/**").hasRole("ADMIN")
60-
.anyRequest().authenticated()
61-
);
62-
63-
return http.build();
49+
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
50+
try {
51+
return http
52+
.csrf(csrf -> csrf.ignoringRequestMatchers(this::isBasicAuthRequest))
53+
.securityContext(context -> context.securityContextRepository(new NullSecurityContextRepository()))
54+
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
55+
.httpBasic(Customizer.withDefaults())
56+
.authorizeHttpRequests(auth -> auth
57+
.requestMatchers(getPublicEndpoints()).permitAll()
58+
.requestMatchers(HttpMethod.GET, RANDOM_USERS_PATH).hasAnyRole(ADMIN_ROLE, "USER", "TEST")
59+
.requestMatchers(HttpMethod.POST, RANDOM_USERS_PATH).hasRole(ADMIN_ROLE)
60+
.requestMatchers(HttpMethod.PUT, RANDOM_USERS_PATH).hasRole(ADMIN_ROLE)
61+
.requestMatchers(HttpMethod.DELETE, RANDOM_USERS_PATH).hasRole(ADMIN_ROLE)
62+
.anyRequest().authenticated()
63+
)
64+
.build();
65+
} catch (Exception e) {
66+
throw new SecurityConfigurationException("Failed to build Spring Security filter chain", e);
67+
}
68+
}
69+
70+
71+
private boolean isBasicAuthRequest(HttpServletRequest request) {
72+
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
73+
String servletPath = request.getServletPath();
74+
boolean isRandomUsersPath = servletPath != null && servletPath.startsWith(RANDOM_USERS_PREFIX);
75+
return isRandomUsersPath && authHeader != null && authHeader.startsWith("Basic ");
76+
}
77+
78+
private String[] getPublicEndpoints() {
79+
return new String[]{
80+
"/api/**",
81+
"/swagger-ui/**",
82+
"/swagger-ui.html",
83+
"/v3/api-docs/**",
84+
"/actuator/health"
85+
};
6486
}
6587

6688
@Bean
6789
UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
6890
UserDetails admin = User.withUsername(adminUsername)
6991
.password(passwordEncoder.encode(adminPassword))
70-
.roles("ADMIN")
92+
.roles(ADMIN_ROLE)
7193
.build();
7294

7395
UserDetails user = User.withUsername(userUsername)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.xpeho.spring_boot_java_random_user.config;
2+
3+
public class SecurityConfigurationException extends RuntimeException {
4+
5+
public SecurityConfigurationException(String message, Throwable cause) {
6+
super(message, cause);
7+
}
8+
}

src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
public class UserHandler implements UserController {
2828

2929
private static final Logger logger = LoggerFactory.getLogger(UserHandler.class);
30-
private static final String USER_NOT_FOUND_LOG = "warning: the requested user does not exist : {}";
30+
private static final String USER_NOT_FOUND_LOG = "warning: the requested user does not exist: {}";
3131

3232
private final FetchAndSaveRandomUsersUseCase fetchAndSaveRandomUsersUseCase;
3333
private final UpdateRandomUserUseCase updateRandomUserUseCase;
@@ -119,6 +119,6 @@ public void deleteUserById(int id) {
119119
}
120120

121121
private void logUserNotFound(UserNotFoundException e) {
122-
logger.warn(USER_NOT_FOUND_LOG, e);
122+
logger.warn(USER_NOT_FOUND_LOG, e.getMessage());
123123
}
124124
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.xpeho.spring_boot_java_random_user.config;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import org.junit.jupiter.api.BeforeEach;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.mock.web.MockHttpServletRequest;
7+
import org.springframework.security.core.userdetails.UserDetails;
8+
import org.springframework.security.core.userdetails.UserDetailsService;
9+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
10+
import org.springframework.security.crypto.password.PasswordEncoder;
11+
import org.springframework.test.util.ReflectionTestUtils;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
15+
import static org.mockito.Mockito.mock;
16+
import static org.mockito.Mockito.when;
17+
18+
class SecurityConfigTest {
19+
20+
private final SecurityConfig securityConfig = new SecurityConfig();
21+
22+
@BeforeEach
23+
void setUp() {
24+
ReflectionTestUtils.setField(securityConfig, "adminUsername", "admin");
25+
ReflectionTestUtils.setField(securityConfig, "adminPassword", "admin123");
26+
ReflectionTestUtils.setField(securityConfig, "userUsername", "apiuser");
27+
ReflectionTestUtils.setField(securityConfig, "userPassword", "changeit");
28+
ReflectionTestUtils.setField(securityConfig, "testUsername", "testuser");
29+
ReflectionTestUtils.setField(securityConfig, "testPassword", "testpass");
30+
}
31+
32+
@Test
33+
void shouldEncodePasswordsWithBcrypt() {
34+
PasswordEncoder passwordEncoder = securityConfig.passwordEncoder();
35+
36+
assertThat(passwordEncoder).isInstanceOf(BCryptPasswordEncoder.class);
37+
assertThat(passwordEncoder.matches("admin123", passwordEncoder.encode("admin123"))).isTrue();
38+
}
39+
40+
@Test
41+
void shouldCreateInMemoryUsersWithExpectedRoles() {
42+
PasswordEncoder passwordEncoder = securityConfig.passwordEncoder();
43+
44+
UserDetailsService userDetailsService = securityConfig.userDetailsService(passwordEncoder);
45+
46+
UserDetails admin = userDetailsService.loadUserByUsername("admin");
47+
UserDetails user = userDetailsService.loadUserByUsername("apiuser");
48+
UserDetails test = userDetailsService.loadUserByUsername("testuser");
49+
50+
assertThat(admin.getAuthorities()).extracting("authority").containsExactly("ROLE_ADMIN");
51+
assertThat(user.getAuthorities()).extracting("authority").containsExactly("ROLE_USER");
52+
assertThat(test.getAuthorities()).extracting("authority").containsExactly("ROLE_TEST");
53+
}
54+
55+
@Test
56+
void shouldRecognizeBasicAuthRequestsOnRandomUsersPath() {
57+
MockHttpServletRequest request = new MockHttpServletRequest();
58+
request.setServletPath("/random-users/123");
59+
request.addHeader("Authorization", "Basic dGVzdDp0ZXN0");
60+
61+
boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request);
62+
63+
assertThat(result).isTrue();
64+
}
65+
66+
@Test
67+
void shouldRejectNonBasicAuthOrNonRandomUsersRequests() {
68+
MockHttpServletRequest request = new MockHttpServletRequest();
69+
request.setServletPath("/health");
70+
request.addHeader("Authorization", "Bearer token");
71+
72+
boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request);
73+
74+
assertThat(result).isFalse();
75+
}
76+
77+
@Test
78+
void shouldRejectRandomUsersRequestWithoutAuthHeader() {
79+
MockHttpServletRequest request = new MockHttpServletRequest();
80+
request.setServletPath("/random-users/123");
81+
82+
boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request);
83+
84+
assertThat(result).isFalse();
85+
}
86+
87+
@Test
88+
void shouldRejectRandomUsersRequestWithNonBasicAuthHeader() {
89+
MockHttpServletRequest request = new MockHttpServletRequest();
90+
request.setServletPath("/random-users/123");
91+
request.addHeader("Authorization", "Bearer token");
92+
93+
boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request);
94+
95+
assertThat(result).isFalse();
96+
}
97+
98+
@Test
99+
void shouldRejectWhenServletPathIsNull() {
100+
HttpServletRequest request = mock(HttpServletRequest.class);
101+
when(request.getServletPath()).thenReturn(null);
102+
when(request.getHeader("Authorization")).thenReturn("Basic dGVzdDp0ZXN0");
103+
104+
boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request);
105+
106+
assertThat(result).isFalse();
107+
}
108+
109+
@Test
110+
void shouldExposePublicEndpoints() {
111+
String[] endpoints = ReflectionTestUtils.invokeMethod(securityConfig, "getPublicEndpoints");
112+
113+
assertThat(endpoints).contains(
114+
"/api/**",
115+
"/swagger-ui/**",
116+
"/swagger-ui.html",
117+
"/v3/api-docs/**",
118+
"/actuator/health"
119+
);
120+
}
121+
122+
@Test
123+
void shouldWrapFilterChainConfigurationException() {
124+
assertThatThrownBy(() -> securityConfig.securityFilterChain(null))
125+
.isInstanceOf(SecurityConfigurationException.class)
126+
.hasMessage("Failed to build Spring Security filter chain")
127+
.hasCauseInstanceOf(NullPointerException.class);
128+
}
129+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.xpeho.spring_boot_java_random_user.config;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
class SecurityConfigurationExceptionTest {
8+
9+
@Test
10+
void shouldExposeMessageAndCause() {
11+
IllegalStateException cause = new IllegalStateException("boom");
12+
SecurityConfigurationException exception = new SecurityConfigurationException("Failed to build Spring Security filter chain", cause);
13+
14+
assertThat(exception)
15+
.hasMessage("Failed to build Spring Security filter chain")
16+
.hasCause(cause);
17+
}
18+
}

src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
"app.security.user.username=testuser",
3333
"app.security.user.password=testpass",
3434
"app.security.test.username=testviewer",
35-
"app.security.test.password=testviewerpass"
35+
"app.security.test.password=testviewerpass",
36+
"logging.level.com.xpeho.spring_boot_java_random_user.presentation.handlers=OFF",
37+
"logging.level.com.zaxxer.hikari.pool.PoolBase=ERROR"
3638
}
3739
)
3840
class UserGetByIdContainerTest {

src/test/java/com/xpeho/spring_boot_java_random_user/presentation/exceptions/GlobalExceptionHandlerTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ void shouldReturnNotFoundWhenUserNotFoundException() {
7979
}
8080

8181
@Test
82-
@DisplayName("Should return 500 INTERNAL_SERVER_ERROR for generic exceptions")
83-
void shouldReturnInternalServerErrorForGenericException() {
84-
Exception ex = new Exception("Something went wrong");
82+
@DisplayName("Should return 500 INTERNAL_SERVER_ERROR for runtime exceptions")
83+
void shouldReturnInternalServerErrorForRuntimeException() {
84+
RuntimeException ex = new RuntimeException("Something went wrong");
8585
ResponseEntity<ErrorResponse> response = handler.handleGenericException(ex);
8686

8787
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());

src/test/resources/application-test.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ app.security.test.username=testviewer
1010
app.security.test.password=testviewerpass
1111
spring.sql.init.mode=never
1212
spring.docker.compose.enabled=false
13+
logging.level.com.xpeho.spring_boot_java_random_user.presentation.handlers=OFF
14+
logging.level.com.zaxxer.hikari.pool.PoolBase=ERROR

src/test/resources/application-test.properties.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ spring.datasource.username=myusername
44
spring.datasource.password=mypassword
55
spring.sql.init.mode=never
66
spring.docker.compose.enabled=false
7+
logging.level.com.xpeho.spring_boot_java_random_user.presentation.handlers=OFF
8+
logging.level.com.zaxxer.hikari.pool.PoolBase=ERROR

0 commit comments

Comments
 (0)