Skip to content

Commit b57bea2

Browse files
Theo-lbgCopilotMayuriXx
authored
feat(springSecurity): add spring security in app (#79)
* feat(springSecurity): add spring security in app * feat(security): enhance security configuration with multiple user roles and credentials Co-authored-by: Copilot <copilot@github.com> * feat(test): refactor authentication to use values from application properties Co-authored-by: Copilot <copilot@github.com> * feat(security): enhance security filter chain and add custom exception handling Co-authored-by: Copilot <copilot@github.com> * feat(springSecurity): add spring security in app * fix(addSpace): space --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Martho Evan <martho.evan@gmail.com>
1 parent f8bf26b commit b57bea2

16 files changed

Lines changed: 360 additions & 36 deletions

File tree

.env.template

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ POSTGRES_PASSWORD=your_postgres_password
33
POSTGRES_DB=your_postgres_db
44
POSTGRES_PORT=5432
55

6+
# Spring Security
7+
APP_SECURITY_ADMIN_USER=your_admin_username
8+
APP_SECURITY_ADMIN_PASSWORD=your_admin_password
9+
APP_SECURITY_USER=your_api_username
10+
APP_SECURITY_PASSWORD=your_api_password
11+
APP_SECURITY_TEST_USER=your_test_username
12+
APP_SECURITY_TEST_PASSWORD=your_test_password
13+
614
# Liquibase configuration
715
LB_CHANGELOG=your_changelog_file.yaml
816
LB_OUTPUT_CHANGELOG=your_output_changelog.yaml

.github/workflows/sonar.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ jobs:
3939
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
4040
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
4141
run: |
42-
mkdir -p src/test/resources
43-
echo ${{ secrets.APPLICATION_TEST_PROPERTIES }} | base64 -d > src/test/resources/application-test.properties
42+
if [ -n "${{ secrets.APPLICATION_TEST_PROPERTIES }}" ]; then
43+
printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties
44+
fi
4445
echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties
45-
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"

.github/workflows/tests.yaml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@ jobs:
3131

3232
- name: Prepare test properties
3333
run: |
34-
mkdir -p src/test/resources
35-
echo ${{ secrets.APPLICATION_TEST_PROPERTIES }} | base64 -d > src/test/resources/application-test.properties
34+
if [ -n "${{ secrets.APPLICATION_TEST_PROPERTIES }}" ]; then
35+
printf '%s' "${{ secrets.APPLICATION_TEST_PROPERTIES }}" | base64 -d >> src/test/resources/application-test.properties
36+
fi
3637
echo "spring.sql.init.mode=never" >> src/test/resources/application-test.properties
3738
3839
- name: Prepare Docker .env for CI tests
3940
run: |
40-
cat > .env <<EOF
41-
POSTGRES_USER=${{ secrets.POSTGRES_USER }}
42-
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
43-
POSTGRES_DB=${{ secrets.POSTGRES_DB }}
44-
POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}
45-
EOF
41+
printf '%s\n' \
42+
"POSTGRES_USER=${{ secrets.POSTGRES_USER }}" \
43+
"POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" \
44+
"POSTGRES_DB=${{ secrets.POSTGRES_DB }}" \
45+
"POSTGRES_PORT=${{ secrets.POSTGRES_PORT }}" > .env
4646
4747
- name: Install Docker Compose
4848
run: |

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A REST API built with Spring Boot that fetches users from [DummyJSON Users API](
1515

1616
---
1717

18-
## 📦 Prerequisites
18+
## 📦 Prerequisites
1919

2020
- Java 25+
2121
- Docker Desktop

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@
6969
<groupId>org.springframework.boot</groupId>
7070
<artifactId>spring-boot-starter-webmvc</artifactId>
7171
</dependency>
72+
<dependency>
73+
<groupId>org.springframework.boot</groupId>
74+
<artifactId>spring-boot-starter-security</artifactId>
75+
</dependency>
7276
<dependency>
7377
<groupId>org.springdoc</groupId>
7478
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.xpeho.spring_boot_java_random_user.config;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.http.HttpHeaders;
8+
import org.springframework.http.HttpMethod;
9+
import org.springframework.security.core.userdetails.User;
10+
import org.springframework.security.core.userdetails.UserDetails;
11+
import org.springframework.security.core.userdetails.UserDetailsService;
12+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
13+
import org.springframework.security.crypto.password.PasswordEncoder;
14+
import org.springframework.security.config.Customizer;
15+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
16+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
17+
import org.springframework.security.config.http.SessionCreationPolicy;
18+
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
19+
import org.springframework.security.web.SecurityFilterChain;
20+
import org.springframework.security.web.context.NullSecurityContextRepository;
21+
22+
@Configuration
23+
@EnableWebSecurity
24+
public class SecurityConfig {
25+
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+
30+
@Value("${app.security.admin.username}")
31+
private String adminUsername;
32+
33+
@Value("${app.security.admin.password}")
34+
private String adminPassword;
35+
36+
@Value("${app.security.user.username}")
37+
private String userUsername;
38+
39+
@Value("${app.security.user.password}")
40+
private String userPassword;
41+
42+
@Value("${app.security.test.username}")
43+
private String testUsername;
44+
45+
@Value("${app.security.test.password}")
46+
private String testPassword;
47+
48+
@Bean
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+
};
86+
}
87+
88+
@Bean
89+
UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
90+
UserDetails admin = User.withUsername(adminUsername)
91+
.password(passwordEncoder.encode(adminPassword))
92+
.roles(ADMIN_ROLE)
93+
.build();
94+
95+
UserDetails user = User.withUsername(userUsername)
96+
.password(passwordEncoder.encode(userPassword))
97+
.roles("USER")
98+
.build();
99+
100+
UserDetails test = User.withUsername(testUsername)
101+
.password(passwordEncoder.encode(testPassword))
102+
.roles("TEST")
103+
.build();
104+
105+
return new InMemoryUserDetailsManager(admin, user, test);
106+
}
107+
108+
@Bean
109+
PasswordEncoder passwordEncoder() {
110+
return new BCryptPasswordEncoder();
111+
}
112+
}
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
}

src/main/resources/application.properties

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
spring.application.name=spring_boot_java_random_user
22

3+
# Security
4+
app.security.admin.username=${APP_SECURITY_ADMIN_USER}
5+
app.security.admin.password=${APP_SECURITY_ADMIN_PASSWORD}
6+
app.security.user.username=${APP_SECURITY_USER}
7+
app.security.user.password=${APP_SECURITY_PASSWORD}
8+
app.security.test.username=${APP_SECURITY_TEST_USER}
9+
app.security.test.password=${APP_SECURITY_TEST_PASSWORD}
10+
311
# Swagger UI custom path
412
springdoc.swagger-ui.path=/api
513

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+
}

0 commit comments

Comments
 (0)