From 1df5da2b7d7fd35e3d72ecac46e82ac5965c316d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Leb=C3=A8gue?= Date: Thu, 23 Apr 2026 16:30:27 +0200 Subject: [PATCH 1/6] feat(springSecurity): add spring security in app --- .env.template | 4 ++ pom.xml | 4 ++ .../config/SecurityConfig.java | 37 +++++++++++++++++++ src/main/resources/application.properties | 5 +++ .../UserGetByIdContainerTest.java | 18 ++++++--- .../java/feature/SpringIntegrationTest.java | 15 ++++++-- .../resources/application-test.properties | 9 +++-- 7 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java diff --git a/.env.template b/.env.template index a1e3ad5..36df26f 100644 --- a/.env.template +++ b/.env.template @@ -3,6 +3,10 @@ POSTGRES_PASSWORD=your_postgres_password POSTGRES_DB=your_postgres_db POSTGRES_PORT=5432 +# Spring Security +APP_SECURITY_USER=your_api_username +APP_SECURITY_PASSWORD=your_api_password + # Liquibase configuration LB_CHANGELOG=your_changelog_file.yaml LB_OUTPUT_CHANGELOG=your_output_changelog.yaml diff --git a/pom.xml b/pom.xml index 4927ad8..80f07a0 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,10 @@ org.springframework.boot spring-boot-starter-webmvc + + org.springframework.boot + spring-boot-starter-security + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java new file mode 100644 index 0000000..d85618b --- /dev/null +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java @@ -0,0 +1,37 @@ +package com.xpeho.spring_boot_java_random_user.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(Customizer.withDefaults()) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health" + ).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ec2a53..4f7989a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,10 @@ spring.application.name=spring_boot_java_random_user +# Security +spring.security.user.name=${APP_SECURITY_USER} +spring.security.user.password=${APP_SECURITY_PASSWORD} +spring.security.user.roles=USER + # Swagger UI custom path springdoc.swagger-ui.path=/api diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java index 85d78bf..30287ce 100644 --- a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java @@ -26,11 +26,17 @@ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "spring.sql.init.mode=never", - "spring.jpa.hibernate.ddl-auto=create-drop" + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.security.user.name=testuser", + "spring.security.user.password=testpass", + "spring.security.user.roles=USER" } ) class UserGetByIdContainerTest { + private static final String TEST_USERNAME = "testuser"; + private static final String TEST_PASSWORD = "testpass"; + @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine"); @@ -61,8 +67,8 @@ void shouldReturnUserByIdWhenUserExists() { User saved = userRepository.saveAndFlush(user); - ResponseEntity response = restTemplate.getForEntity( - "/random-users/{id}", + ResponseEntity response = restTemplate.withBasicAuth(TEST_USERNAME, TEST_PASSWORD).getForEntity( + "/random-users/{id}", UserEntity.class, saved.getId() ); @@ -77,10 +83,10 @@ void shouldReturnUserByIdWhenUserExists() { @Test @DisplayName("GET /random-users/{id} should return 404 when user does not exist") void shouldReturnNotFoundWhenUserDoesNotExist() { - ResponseEntity response = restTemplate.getForEntity( - "/random-users/{id}", + ResponseEntity response = restTemplate.withBasicAuth(TEST_USERNAME, TEST_PASSWORD).getForEntity( + "/random-users/{id}", UserEntity.class, - -1 + -1 ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); diff --git a/src/test/java/feature/SpringIntegrationTest.java b/src/test/java/feature/SpringIntegrationTest.java index ecfe69b..9ad754d 100644 --- a/src/test/java/feature/SpringIntegrationTest.java +++ b/src/test/java/feature/SpringIntegrationTest.java @@ -27,6 +27,9 @@ ) public class SpringIntegrationTest { + private static final String TEST_USERNAME = "testuser"; + private static final String TEST_PASSWORD = "testpass"; + @Autowired protected TestRestTemplate restTemplate; @@ -37,7 +40,9 @@ public class SpringIntegrationTest { protected void executeGet(String path) { String url = "http://localhost:" + port + path; - latestResponse = restTemplate.getForEntity(url, String.class); + latestResponse = restTemplate + .withBasicAuth(TEST_USERNAME, TEST_PASSWORD) + .getForEntity(url, String.class); } protected void executePost(String path, Object payload) { @@ -45,12 +50,16 @@ protected void executePost(String path, Object payload) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity request = new HttpEntity<>(payload, headers); - latestResponse = restTemplate.postForEntity(url, request, String.class); + latestResponse = restTemplate + .withBasicAuth(TEST_USERNAME, TEST_PASSWORD) + .postForEntity(url, request, String.class); } protected void executeDelete(String path) { String url = "http://localhost:" + port + path; - latestResponse = restTemplate.exchange(url, HttpMethod.DELETE, HttpEntity.EMPTY, String.class); + latestResponse = restTemplate + .withBasicAuth(TEST_USERNAME, TEST_PASSWORD) + .exchange(url, HttpMethod.DELETE, HttpEntity.EMPTY, String.class); } protected void executePut(String path, Object payload) { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 0f7b4dc..9bd281b 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,6 +1,9 @@ -spring.datasource.url=${H2_URL};MODE=PostgreSQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.url=${H2_URL:jdbc:h2:mem:mydb};MODE=PostgreSQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=${H2_USERNAME} -spring.datasource.password=${H2_PASSWORD} +spring.datasource.username=${H2_USERNAME:myusername} +spring.datasource.password=${H2_PASSWORD:mypassword} +spring.security.user.name=testuser +spring.security.user.password=testpass +spring.security.user.roles=USER spring.sql.init.mode=never spring.docker.compose.enabled=false From 339e7ee6bd92a4791da5e74a4003342fe284fb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Leb=C3=A8gue?= Date: Thu, 23 Apr 2026 17:36:15 +0200 Subject: [PATCH 2/6] feat(security): enhance security configuration with multiple user roles and credentials Co-authored-by: Copilot --- .env.template | 4 ++ README.md | 24 ++++++++ .../config/SecurityConfig.java | 57 ++++++++++++++++++- src/main/resources/application.properties | 9 ++- .../UserGetByIdContainerTest.java | 11 ++-- .../java/feature/SpringIntegrationTest.java | 4 +- .../resources/application-test.properties | 9 ++- 7 files changed, 104 insertions(+), 14 deletions(-) diff --git a/.env.template b/.env.template index 36df26f..7c68119 100644 --- a/.env.template +++ b/.env.template @@ -4,8 +4,12 @@ POSTGRES_DB=your_postgres_db POSTGRES_PORT=5432 # Spring Security +APP_SECURITY_ADMIN_USER=your_admin_username +APP_SECURITY_ADMIN_PASSWORD=your_admin_password APP_SECURITY_USER=your_api_username APP_SECURITY_PASSWORD=your_api_password +APP_SECURITY_TEST_USER=your_test_username +APP_SECURITY_TEST_PASSWORD=your_test_password # Liquibase configuration LB_CHANGELOG=your_changelog_file.yaml diff --git a/README.md b/README.md index 37f5c89..97ed51d 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,36 @@ POSTGRES_PASSWORD=your_password POSTGRES_DB=your_database POSTGRES_PORT=5432 +# Spring Security +APP_SECURITY_ADMIN_USER=admin +APP_SECURITY_ADMIN_PASSWORD=admin123 +APP_SECURITY_USER=apiuser +APP_SECURITY_PASSWORD=changeit +APP_SECURITY_TEST_USER=testuser +APP_SECURITY_TEST_PASSWORD=testpass + # Liquibase (optional, defaults provided) LB_CHANGELOG=db/changelog/db.changelog-master.yaml LB_SCHEMA=public SPRING_LIQUIBASE_ENABLED=true ``` +### Spring Security + +The API uses HTTP Basic authentication with three in-memory roles: + +- `ADMIN`: can read, create, update and delete users +- `USER`: can read users +- `TEST`: can read users and is used by automated tests + +Protected endpoints require credentials. Example: + +```bash +curl -u apiuser:changeit "http://localhost:8080/random-users" +``` + +Swagger UI remains publicly accessible, while API endpoints are protected according to the role rules above. + ### External API Configuration `application.properties` uses: diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java index d85618b..4852f6d 100644 --- a/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java @@ -2,22 +2,46 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpMethod; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { + @Value("${app.security.admin.username}") + private String adminUsername; + + @Value("${app.security.admin.password}") + private String adminPassword; + + @Value("${app.security.user.username}") + private String userUsername; + + @Value("${app.security.user.password}") + private String userPassword; + + @Value("${app.security.test.username}") + private String testUsername; + + @Value("${app.security.test.password}") + private String testPassword; + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) + .csrf(csrf -> csrf.ignoringRequestMatchers("/random-users/**")) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .httpBasic(Customizer.withDefaults()) .authorizeHttpRequests(auth -> auth @@ -29,9 +53,38 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/actuator/health" ).permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.GET, "/random-users/**").hasAnyRole("ADMIN", "USER", "TEST") + .requestMatchers(HttpMethod.POST, "/random-users/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.PUT, "/random-users/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/random-users/**").hasRole("ADMIN") .anyRequest().authenticated() ); return http.build(); } + + @Bean + UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { + UserDetails admin = User.withUsername(adminUsername) + .password(passwordEncoder.encode(adminPassword)) + .roles("ADMIN") + .build(); + + UserDetails user = User.withUsername(userUsername) + .password(passwordEncoder.encode(userPassword)) + .roles("USER") + .build(); + + UserDetails test = User.withUsername(testUsername) + .password(passwordEncoder.encode(testPassword)) + .roles("TEST") + .build(); + + return new InMemoryUserDetailsManager(admin, user, test); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4f7989a..62f00f1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,9 +1,12 @@ spring.application.name=spring_boot_java_random_user # Security -spring.security.user.name=${APP_SECURITY_USER} -spring.security.user.password=${APP_SECURITY_PASSWORD} -spring.security.user.roles=USER +app.security.admin.username=${APP_SECURITY_ADMIN_USER} +app.security.admin.password=${APP_SECURITY_ADMIN_PASSWORD} +app.security.user.username=${APP_SECURITY_USER} +app.security.user.password=${APP_SECURITY_PASSWORD} +app.security.test.username=${APP_SECURITY_TEST_USER} +app.security.test.password=${APP_SECURITY_TEST_PASSWORD} # Swagger UI custom path springdoc.swagger-ui.path=/api diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java index 30287ce..e94bde1 100644 --- a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java @@ -26,10 +26,13 @@ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "spring.sql.init.mode=never", - "spring.jpa.hibernate.ddl-auto=create-drop", - "spring.security.user.name=testuser", - "spring.security.user.password=testpass", - "spring.security.user.roles=USER" + "spring.jpa.hibernate.ddl-auto=create-drop", + "app.security.admin.username=testadmin", + "app.security.admin.password=testadminpass", + "app.security.user.username=testuser", + "app.security.user.password=testpass", + "app.security.test.username=testviewer", + "app.security.test.password=testviewerpass" } ) class UserGetByIdContainerTest { diff --git a/src/test/java/feature/SpringIntegrationTest.java b/src/test/java/feature/SpringIntegrationTest.java index 9ad754d..d79840f 100644 --- a/src/test/java/feature/SpringIntegrationTest.java +++ b/src/test/java/feature/SpringIntegrationTest.java @@ -27,8 +27,8 @@ ) public class SpringIntegrationTest { - private static final String TEST_USERNAME = "testuser"; - private static final String TEST_PASSWORD = "testpass"; + private static final String TEST_USERNAME = "testadmin"; + private static final String TEST_PASSWORD = "testadminpass"; @Autowired protected TestRestTemplate restTemplate; diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 9bd281b..455b797 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -2,8 +2,11 @@ spring.datasource.url=${H2_URL:jdbc:h2:mem:mydb};MODE=PostgreSQL;DATABASE_TO_UPP spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=${H2_USERNAME:myusername} spring.datasource.password=${H2_PASSWORD:mypassword} -spring.security.user.name=testuser -spring.security.user.password=testpass -spring.security.user.roles=USER +app.security.admin.username=testadmin +app.security.admin.password=testadminpass +app.security.user.username=testuser +app.security.user.password=testpass +app.security.test.username=testviewer +app.security.test.password=testviewerpass spring.sql.init.mode=never spring.docker.compose.enabled=false From ea7f383ebca13f2e67293783cdba9f21ec4c35b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Leb=C3=A8gue?= Date: Fri, 24 Apr 2026 11:28:27 +0200 Subject: [PATCH 3/6] feat(test): refactor authentication to use values from application properties Co-authored-by: Copilot --- .github/workflows/sonar.yaml | 5 +++-- .github/workflows/tests.yaml | 5 +++-- src/test/java/feature/SpringIntegrationTest.java | 14 +++++++++----- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 3a3f774..8015e4f 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -39,7 +39,8 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} run: | - mkdir -p src/test/resources - echo ${{ secrets.APPLICATION_TEST_PROPERTIES }} | base64 -d > src/test/resources/application-test.properties + if [ -n "${{ secrets.APPLICATION_TEST_PROPERTIES }}" ]; then + printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties + fi echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true -Dit.test="!CucumberIntegrationTest" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 60e2234..2850908 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -31,8 +31,9 @@ jobs: - name: Prepare test properties run: | - mkdir -p src/test/resources - echo ${{ secrets.APPLICATION_TEST_PROPERTIES }} | base64 -d > src/test/resources/application-test.properties + if [ -n "${{ secrets.APPLICATION_TEST_PROPERTIES }}" ]; then + printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties + fi echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties - name: Prepare Docker .env for CI tests diff --git a/src/test/java/feature/SpringIntegrationTest.java b/src/test/java/feature/SpringIntegrationTest.java index d79840f..b55c65f 100644 --- a/src/test/java/feature/SpringIntegrationTest.java +++ b/src/test/java/feature/SpringIntegrationTest.java @@ -7,6 +7,7 @@ import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -27,8 +28,11 @@ ) public class SpringIntegrationTest { - private static final String TEST_USERNAME = "testadmin"; - private static final String TEST_PASSWORD = "testadminpass"; + @Value("${app.security.admin.username}") + private String testUsername; + + @Value("${app.security.admin.password}") + private String testPassword; @Autowired protected TestRestTemplate restTemplate; @@ -41,7 +45,7 @@ public class SpringIntegrationTest { protected void executeGet(String path) { String url = "http://localhost:" + port + path; latestResponse = restTemplate - .withBasicAuth(TEST_USERNAME, TEST_PASSWORD) + .withBasicAuth(testUsername, testPassword) .getForEntity(url, String.class); } @@ -51,14 +55,14 @@ protected void executePost(String path, Object payload) { headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity request = new HttpEntity<>(payload, headers); latestResponse = restTemplate - .withBasicAuth(TEST_USERNAME, TEST_PASSWORD) + .withBasicAuth(testUsername, testPassword) .postForEntity(url, request, String.class); } protected void executeDelete(String path) { String url = "http://localhost:" + port + path; latestResponse = restTemplate - .withBasicAuth(TEST_USERNAME, TEST_PASSWORD) + .withBasicAuth(testUsername, testPassword) .exchange(url, HttpMethod.DELETE, HttpEntity.EMPTY, String.class); } From e856fe697eac9dacd66f7b66d86dc16270d37202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Leb=C3=A8gue?= Date: Fri, 24 Apr 2026 12:06:06 +0200 Subject: [PATCH 4/6] feat(security): enhance security filter chain and add custom exception handling Co-authored-by: Copilot --- .github/workflows/sonar.yaml | 2 +- .../config/SecurityConfig.java | 68 +++++---- .../SecurityConfigurationException.java | 8 ++ .../presentation/handlers/UserHandler.java | 4 +- .../config/SecurityConfigTest.java | 129 ++++++++++++++++++ .../SecurityConfigurationExceptionTest.java | 18 +++ .../UserGetByIdContainerTest.java | 4 +- .../GlobalExceptionHandlerTest.java | 6 +- .../resources/application-test.properties | 2 + .../application-test.properties.template | 2 + 10 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationException.java create mode 100644 src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigTest.java create mode 100644 src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationExceptionTest.java diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 8015e4f..d85bba7 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -43,4 +43,4 @@ jobs: printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties fi echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties - mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true -Dit.test="!CucumberIntegrationTest" + mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true -Dit.test="!CucumberIntegrationTest diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java index 4852f6d..ae3d469 100644 --- a/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfig.java @@ -1,8 +1,10 @@ package com.xpeho.spring_boot_java_random_user.config; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; @@ -15,11 +17,16 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.NullSecurityContextRepository; @Configuration @EnableWebSecurity public class SecurityConfig { + private static final String RANDOM_USERS_PATH = "/random-users/**"; + private static final String RANDOM_USERS_PREFIX = "/random-users"; + private static final String ADMIN_ROLE = "ADMIN"; + @Value("${app.security.admin.username}") private String adminUsername; @@ -39,35 +46,50 @@ public class SecurityConfig { private String testPassword; @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.ignoringRequestMatchers("/random-users/**")) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .httpBasic(Customizer.withDefaults()) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/v3/api-docs/**", - "/actuator/health" - ).permitAll() - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.GET, "/random-users/**").hasAnyRole("ADMIN", "USER", "TEST") - .requestMatchers(HttpMethod.POST, "/random-users/**").hasRole("ADMIN") - .requestMatchers(HttpMethod.PUT, "/random-users/**").hasRole("ADMIN") - .requestMatchers(HttpMethod.DELETE, "/random-users/**").hasRole("ADMIN") - .anyRequest().authenticated() - ); - - return http.build(); + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + try { + return http + .csrf(csrf -> csrf.ignoringRequestMatchers(this::isBasicAuthRequest)) + .securityContext(context -> context.securityContextRepository(new NullSecurityContextRepository())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(Customizer.withDefaults()) + .authorizeHttpRequests(auth -> auth + .requestMatchers(getPublicEndpoints()).permitAll() + .requestMatchers(HttpMethod.GET, RANDOM_USERS_PATH).hasAnyRole(ADMIN_ROLE, "USER", "TEST") + .requestMatchers(HttpMethod.POST, RANDOM_USERS_PATH).hasRole(ADMIN_ROLE) + .requestMatchers(HttpMethod.PUT, RANDOM_USERS_PATH).hasRole(ADMIN_ROLE) + .requestMatchers(HttpMethod.DELETE, RANDOM_USERS_PATH).hasRole(ADMIN_ROLE) + .anyRequest().authenticated() + ) + .build(); + } catch (Exception e) { + throw new SecurityConfigurationException("Failed to build Spring Security filter chain", e); + } + } + + + private boolean isBasicAuthRequest(HttpServletRequest request) { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String servletPath = request.getServletPath(); + boolean isRandomUsersPath = servletPath != null && servletPath.startsWith(RANDOM_USERS_PREFIX); + return isRandomUsersPath && authHeader != null && authHeader.startsWith("Basic "); + } + + private String[] getPublicEndpoints() { + return new String[]{ + "/api/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health" + }; } @Bean UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { UserDetails admin = User.withUsername(adminUsername) .password(passwordEncoder.encode(adminPassword)) - .roles("ADMIN") + .roles(ADMIN_ROLE) .build(); UserDetails user = User.withUsername(userUsername) diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationException.java b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationException.java new file mode 100644 index 0000000..470bb5a --- /dev/null +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationException.java @@ -0,0 +1,8 @@ +package com.xpeho.spring_boot_java_random_user.config; + +public class SecurityConfigurationException extends RuntimeException { + + public SecurityConfigurationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java b/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java index ae2f80e..c6d81c2 100644 --- a/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java +++ b/src/main/java/com/xpeho/spring_boot_java_random_user/presentation/handlers/UserHandler.java @@ -27,7 +27,7 @@ public class UserHandler implements UserController { private static final Logger logger = LoggerFactory.getLogger(UserHandler.class); - private static final String USER_NOT_FOUND_LOG = "warning: the requested user does not exist : {}"; + private static final String USER_NOT_FOUND_LOG = "warning: the requested user does not exist: {}"; private final FetchAndSaveRandomUsersUseCase fetchAndSaveRandomUsersUseCase; private final UpdateRandomUserUseCase updateRandomUserUseCase; @@ -119,6 +119,6 @@ public void deleteUserById(int id) { } private void logUserNotFound(UserNotFoundException e) { - logger.warn(USER_NOT_FOUND_LOG, e); + logger.warn(USER_NOT_FOUND_LOG, e.getMessage()); } } diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigTest.java new file mode 100644 index 0000000..bed902f --- /dev/null +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigTest.java @@ -0,0 +1,129 @@ +package com.xpeho.spring_boot_java_random_user.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SecurityConfigTest { + + private final SecurityConfig securityConfig = new SecurityConfig(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(securityConfig, "adminUsername", "admin"); + ReflectionTestUtils.setField(securityConfig, "adminPassword", "admin123"); + ReflectionTestUtils.setField(securityConfig, "userUsername", "apiuser"); + ReflectionTestUtils.setField(securityConfig, "userPassword", "changeit"); + ReflectionTestUtils.setField(securityConfig, "testUsername", "testuser"); + ReflectionTestUtils.setField(securityConfig, "testPassword", "testpass"); + } + + @Test + void shouldEncodePasswordsWithBcrypt() { + PasswordEncoder passwordEncoder = securityConfig.passwordEncoder(); + + assertThat(passwordEncoder).isInstanceOf(BCryptPasswordEncoder.class); + assertThat(passwordEncoder.matches("admin123", passwordEncoder.encode("admin123"))).isTrue(); + } + + @Test + void shouldCreateInMemoryUsersWithExpectedRoles() { + PasswordEncoder passwordEncoder = securityConfig.passwordEncoder(); + + UserDetailsService userDetailsService = securityConfig.userDetailsService(passwordEncoder); + + UserDetails admin = userDetailsService.loadUserByUsername("admin"); + UserDetails user = userDetailsService.loadUserByUsername("apiuser"); + UserDetails test = userDetailsService.loadUserByUsername("testuser"); + + assertThat(admin.getAuthorities()).extracting("authority").containsExactly("ROLE_ADMIN"); + assertThat(user.getAuthorities()).extracting("authority").containsExactly("ROLE_USER"); + assertThat(test.getAuthorities()).extracting("authority").containsExactly("ROLE_TEST"); + } + + @Test + void shouldRecognizeBasicAuthRequestsOnRandomUsersPath() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/random-users/123"); + request.addHeader("Authorization", "Basic dGVzdDp0ZXN0"); + + boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request); + + assertThat(result).isTrue(); + } + + @Test + void shouldRejectNonBasicAuthOrNonRandomUsersRequests() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/health"); + request.addHeader("Authorization", "Bearer token"); + + boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request); + + assertThat(result).isFalse(); + } + + @Test + void shouldRejectRandomUsersRequestWithoutAuthHeader() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/random-users/123"); + + boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request); + + assertThat(result).isFalse(); + } + + @Test + void shouldRejectRandomUsersRequestWithNonBasicAuthHeader() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/random-users/123"); + request.addHeader("Authorization", "Bearer token"); + + boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request); + + assertThat(result).isFalse(); + } + + @Test + void shouldRejectWhenServletPathIsNull() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getServletPath()).thenReturn(null); + when(request.getHeader("Authorization")).thenReturn("Basic dGVzdDp0ZXN0"); + + boolean result = ReflectionTestUtils.invokeMethod(securityConfig, "isBasicAuthRequest", request); + + assertThat(result).isFalse(); + } + + @Test + void shouldExposePublicEndpoints() { + String[] endpoints = ReflectionTestUtils.invokeMethod(securityConfig, "getPublicEndpoints"); + + assertThat(endpoints).contains( + "/api/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health" + ); + } + + @Test + void shouldWrapFilterChainConfigurationException() { + assertThatThrownBy(() -> securityConfig.securityFilterChain(null)) + .isInstanceOf(SecurityConfigurationException.class) + .hasMessage("Failed to build Spring Security filter chain") + .hasCauseInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationExceptionTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationExceptionTest.java new file mode 100644 index 0000000..4c96579 --- /dev/null +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/config/SecurityConfigurationExceptionTest.java @@ -0,0 +1,18 @@ +package com.xpeho.spring_boot_java_random_user.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityConfigurationExceptionTest { + + @Test + void shouldExposeMessageAndCause() { + IllegalStateException cause = new IllegalStateException("boom"); + SecurityConfigurationException exception = new SecurityConfigurationException("Failed to build Spring Security filter chain", cause); + + assertThat(exception) + .hasMessage("Failed to build Spring Security filter chain") + .hasCause(cause); + } +} \ No newline at end of file diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java index e94bde1..1468bd9 100644 --- a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/UserGetByIdContainerTest.java @@ -32,7 +32,9 @@ "app.security.user.username=testuser", "app.security.user.password=testpass", "app.security.test.username=testviewer", - "app.security.test.password=testviewerpass" + "app.security.test.password=testviewerpass", + "logging.level.com.xpeho.spring_boot_java_random_user.presentation.handlers=OFF", + "logging.level.com.zaxxer.hikari.pool.PoolBase=ERROR" } ) class UserGetByIdContainerTest { diff --git a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/exceptions/GlobalExceptionHandlerTest.java b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/exceptions/GlobalExceptionHandlerTest.java index 2c27fa2..f916a73 100644 --- a/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/exceptions/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/xpeho/spring_boot_java_random_user/presentation/exceptions/GlobalExceptionHandlerTest.java @@ -79,9 +79,9 @@ void shouldReturnNotFoundWhenUserNotFoundException() { } @Test - @DisplayName("Should return 500 INTERNAL_SERVER_ERROR for generic exceptions") - void shouldReturnInternalServerErrorForGenericException() { - Exception ex = new Exception("Something went wrong"); + @DisplayName("Should return 500 INTERNAL_SERVER_ERROR for runtime exceptions") + void shouldReturnInternalServerErrorForRuntimeException() { + RuntimeException ex = new RuntimeException("Something went wrong"); ResponseEntity response = handler.handleGenericException(ex); assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 455b797..45e847f 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -10,3 +10,5 @@ app.security.test.username=testviewer app.security.test.password=testviewerpass spring.sql.init.mode=never spring.docker.compose.enabled=false +logging.level.com.xpeho.spring_boot_java_random_user.presentation.handlers=OFF +logging.level.com.zaxxer.hikari.pool.PoolBase=ERROR diff --git a/src/test/resources/application-test.properties.template b/src/test/resources/application-test.properties.template index bd145a7..b9bd7d4 100644 --- a/src/test/resources/application-test.properties.template +++ b/src/test/resources/application-test.properties.template @@ -4,3 +4,5 @@ spring.datasource.username=myusername spring.datasource.password=mypassword spring.sql.init.mode=never spring.docker.compose.enabled=false +logging.level.com.xpeho.spring_boot_java_random_user.presentation.handlers=OFF +logging.level.com.zaxxer.hikari.pool.PoolBase=ERROR From f7bddb9800fc0accde97de08be0637d4ce94ad4b Mon Sep 17 00:00:00 2001 From: Martho Evan Date: Mon, 4 May 2026 10:39:38 +0200 Subject: [PATCH 5/6] feat(springSecurity): add spring security in app --- .github/workflows/sonar.yaml | 2 +- .github/workflows/tests.yaml | 11 +++++------ src/test/java/feature/SpringIntegrationTest.java | 16 +++++++++------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index d85bba7..1f29665 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -43,4 +43,4 @@ jobs: printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties fi echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties - mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true -Dit.test="!CucumberIntegrationTest + mvn clean verify sonar:sonar -DskipDocker=true -Dsonar.qualitygate.wait=true "-Dit.test=!CucumberIntegrationTest" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 2850908..13c29ba 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -38,12 +38,11 @@ jobs: - name: Prepare Docker .env for CI tests run: | - cat > .env < .env - name: Install Docker Compose run: | diff --git a/src/test/java/feature/SpringIntegrationTest.java b/src/test/java/feature/SpringIntegrationTest.java index b55c65f..a3d8d66 100644 --- a/src/test/java/feature/SpringIntegrationTest.java +++ b/src/test/java/feature/SpringIntegrationTest.java @@ -66,11 +66,13 @@ protected void executeDelete(String path) { .exchange(url, HttpMethod.DELETE, HttpEntity.EMPTY, String.class); } - protected void executePut(String path, Object payload) { - String url = "http://localhost:" + port + path; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity request = new HttpEntity<>(payload, headers); - latestResponse = restTemplate.exchange(url, HttpMethod.PUT, request, String.class); - } + protected void executePut(String path, Object payload) { + String url = "http://localhost:" + port + path; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(payload, headers); + latestResponse = restTemplate + .withBasicAuth(testUsername, testPassword) + .exchange(url, HttpMethod.PUT, request, String.class); + } } From b80f964ec638356e9b967bc8f15675533d2c4d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Leb=C3=A8gue?= Date: Mon, 4 May 2026 11:08:33 +0200 Subject: [PATCH 6/6] fix(addSpace): space --- README.md | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/README.md b/README.md index 97ed51d..09356b1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A REST API built with Spring Boot that fetches users from [DummyJSON Users API]( --- -## 📦 Prerequisites +## 📦 Prerequisites - Java 25+ - Docker Desktop @@ -120,36 +120,12 @@ POSTGRES_PASSWORD=your_password POSTGRES_DB=your_database POSTGRES_PORT=5432 -# Spring Security -APP_SECURITY_ADMIN_USER=admin -APP_SECURITY_ADMIN_PASSWORD=admin123 -APP_SECURITY_USER=apiuser -APP_SECURITY_PASSWORD=changeit -APP_SECURITY_TEST_USER=testuser -APP_SECURITY_TEST_PASSWORD=testpass - # Liquibase (optional, defaults provided) LB_CHANGELOG=db/changelog/db.changelog-master.yaml LB_SCHEMA=public SPRING_LIQUIBASE_ENABLED=true ``` -### Spring Security - -The API uses HTTP Basic authentication with three in-memory roles: - -- `ADMIN`: can read, create, update and delete users -- `USER`: can read users -- `TEST`: can read users and is used by automated tests - -Protected endpoints require credentials. Example: - -```bash -curl -u apiuser:changeit "http://localhost:8080/random-users" -``` - -Swagger UI remains publicly accessible, while API endpoints are protected according to the role rules above. - ### External API Configuration `application.properties` uses: