Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Comment thread
MayuriXx marked this conversation as resolved.
Outdated
5 changes: 3 additions & 2 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
Theo-lbg marked this conversation as resolved.
Outdated
# 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

Comment thread
Theo-lbg marked this conversation as resolved.
Outdated
Protected endpoints require credentials. Example:

```bash
curl -u apiuser:changeit "http://localhost:8080/random-users"
```

Comment thread
MayuriXx marked this conversation as resolved.
Outdated
Swagger UI remains publicly accessible, while API endpoints are protected according to the role rules above.

### External API Configuration

`application.properties` uses:
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