Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ POSTGRES_PASSWORD=your_postgres_password
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
LB_OUTPUT_CHANGELOG=your_output_changelog.yaml
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/sonar.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
MayuriXx marked this conversation as resolved.
fi
Comment thread
Theo-lbg marked this conversation as resolved.
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"
16 changes: 8 additions & 8 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ 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
Comment thread
MayuriXx marked this conversation as resolved.
fi
echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties
Comment thread
Theo-lbg marked this conversation as resolved.
Comment on lines +35 to 37

- name: Prepare Docker .env for CI tests
run: |
cat > .env <<EOF
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB=${{ secrets.POSTGRES_DB }}
POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}
EOF
printf '%s\n' \
"POSTGRES_USER=${{ secrets.POSTGRES_USER }}" \
"POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" \
"POSTGRES_DB=${{ secrets.POSTGRES_DB }}" \
"POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}" > .env

- name: Install Docker Compose
run: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A REST API built with Spring Boot that fetches users from [DummyJSON Users API](

---

## 📦 Prerequisites
## 📦 Prerequisites

- Java 25+
- Docker Desktop
Expand Down
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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;
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.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;

@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
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
try {
return http
.csrf(csrf -> csrf.ignoringRequestMatchers(this::isBasicAuthRequest))
.securityContext(context -> context.securityContextRepository(new NullSecurityContextRepository()))
Comment thread
Theo-lbg marked this conversation as resolved.
.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")
Comment thread
Theo-lbg marked this conversation as resolved.
.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)
Comment thread
MayuriXx marked this conversation as resolved.
Comment on lines +58 to +61
.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) {
Comment thread
Theo-lbg marked this conversation as resolved.
UserDetails admin = User.withUsername(adminUsername)
.password(passwordEncoder.encode(adminPassword))
.roles(ADMIN_ROLE)
.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() {
Comment thread
Theo-lbg marked this conversation as resolved.
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}";

Comment thread
Theo-lbg marked this conversation as resolved.
private final FetchAndSaveRandomUsersUseCase fetchAndSaveRandomUsersUseCase;
private final UpdateRandomUserUseCase updateRandomUserUseCase;
Expand Down Expand Up @@ -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());
}
Comment thread
Theo-lbg marked this conversation as resolved.
}
8 changes: 8 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
spring.application.name=spring_boot_java_random_user

# Security
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}
Comment thread
MayuriXx marked this conversation as resolved.

# Swagger UI custom path
springdoc.swagger-ui.path=/api

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading