From cb950145d2d33c848462b8f5542f2d04e8ef68d3 Mon Sep 17 00:00:00 2001 From: Felipe Lauksas Date: Wed, 6 Sep 2023 21:25:08 -0300 Subject: [PATCH] some server refactorings and tests with reactive webclient for learning --- pom.xml | 14 +- .../rest/ResourceREST.java | 12 +- .../security/JWTUtil.java | 10 +- .../security/WebSecurityConfig.java | 29 ++-- .../security/model/AuthRequest.java | 9 +- .../service/UserService.java | 10 +- ...itional-spring-configuration-metadata.json | 29 ++++ .../SpringBootWebfluxJjwtApplicationTest.java | 26 ++++ .../springbootwebfluxjjwt/rest/LoginTest.java | 55 +++++++ .../rest/ResourceAccessTest.java | 98 ++++++++++++ .../utils/BaseRestTest.java | 143 ++++++++++++++++++ ...SpringBootWebfluxJjwtApplicationTests.java | 13 -- 12 files changed, 403 insertions(+), 45 deletions(-) create mode 100644 src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 src/test/java/com/ard333/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTest.java create mode 100644 src/test/java/com/ard333/springbootwebfluxjjwt/rest/LoginTest.java create mode 100644 src/test/java/com/ard333/springbootwebfluxjjwt/rest/ResourceAccessTest.java create mode 100644 src/test/java/com/ard333/springbootwebfluxjjwt/utils/BaseRestTest.java delete mode 100644 src/test/java/id/web/ard/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTests.java diff --git a/pom.xml b/pom.xml index 2873108..b45c308 100644 --- a/pom.xml +++ b/pom.xml @@ -14,14 +14,14 @@ org.springframework.boot spring-boot-starter-parent - 2.5.0 + 3.1.3 UTF-8 UTF-8 - 11 + 17 0.11.2 @@ -62,11 +62,21 @@ spring-boot-devtools runtime + + org.springframework.boot + spring-boot-configuration-processor + true + org.springframework.boot spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + io.projectreactor reactor-test diff --git a/src/main/java/com/ard333/springbootwebfluxjjwt/rest/ResourceREST.java b/src/main/java/com/ard333/springbootwebfluxjjwt/rest/ResourceREST.java index 96c4407..180a2fd 100644 --- a/src/main/java/com/ard333/springbootwebfluxjjwt/rest/ResourceREST.java +++ b/src/main/java/com/ard333/springbootwebfluxjjwt/rest/ResourceREST.java @@ -1,28 +1,32 @@ package com.ard333.springbootwebfluxjjwt.rest; -import com.ard333.springbootwebfluxjjwt.model.Message; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; + +import com.ard333.springbootwebfluxjjwt.model.Message; + import reactor.core.publisher.Mono; @RestController +@RequestMapping("/resource") public class ResourceREST { - @GetMapping("/resource/user") + @GetMapping("/user") @PreAuthorize("hasRole('USER')") public Mono> user() { return Mono.just(ResponseEntity.ok(new Message("Content for user"))); } - @GetMapping("/resource/admin") + @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public Mono> admin() { return Mono.just(ResponseEntity.ok(new Message("Content for admin"))); } - @GetMapping("/resource/user-or-admin") + @GetMapping("/user-or-admin") @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") public Mono> userOrAdmin() { return Mono.just(ResponseEntity.ok(new Message("Content for user or admin"))); diff --git a/src/main/java/com/ard333/springbootwebfluxjjwt/security/JWTUtil.java b/src/main/java/com/ard333/springbootwebfluxjjwt/security/JWTUtil.java index 41f08ac..13b5397 100644 --- a/src/main/java/com/ard333/springbootwebfluxjjwt/security/JWTUtil.java +++ b/src/main/java/com/ard333/springbootwebfluxjjwt/security/JWTUtil.java @@ -1,19 +1,19 @@ package com.ard333.springbootwebfluxjjwt.security; -import com.ard333.springbootwebfluxjjwt.model.User; import java.security.Key; import java.util.Date; import java.util.HashMap; import java.util.Map; -import javax.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.ard333.springbootwebfluxjjwt.model.User; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; @Component public class JWTUtil { diff --git a/src/main/java/com/ard333/springbootwebfluxjjwt/security/WebSecurityConfig.java b/src/main/java/com/ard333/springbootwebfluxjjwt/security/WebSecurityConfig.java index 41c4667..07a2564 100644 --- a/src/main/java/com/ard333/springbootwebfluxjjwt/security/WebSecurityConfig.java +++ b/src/main/java/com/ard333/springbootwebfluxjjwt/security/WebSecurityConfig.java @@ -1,6 +1,7 @@ package com.ard333.springbootwebfluxjjwt.security; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; @@ -14,6 +15,7 @@ @AllArgsConstructor @EnableWebFluxSecurity @EnableReactiveMethodSecurity +@Configuration public class WebSecurityConfig { private AuthenticationManager authenticationManager; @@ -22,21 +24,20 @@ public class WebSecurityConfig { @Bean public SecurityWebFilterChain securitygWebFilterChain(ServerHttpSecurity http) { return http - .exceptionHandling() - .authenticationEntryPoint((swe, e) -> - Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)) - ).accessDeniedHandler((swe, e) -> - Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)) - ).and() - .csrf().disable() - .formLogin().disable() - .httpBasic().disable() + .exceptionHandling(handling -> handling + .authenticationEntryPoint((swe, e) -> + Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)) + ).accessDeniedHandler((swe, e) -> + Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)) + )) + .csrf(csrf -> csrf.disable()) + .formLogin(formLogin -> formLogin.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) .authenticationManager(authenticationManager) .securityContextRepository(securityContextRepository) - .authorizeExchange() - .pathMatchers(HttpMethod.OPTIONS).permitAll() - .pathMatchers("/login").permitAll() - .anyExchange().authenticated() - .and().build(); + .authorizeExchange(exchange -> exchange + .pathMatchers(HttpMethod.OPTIONS).permitAll() + .pathMatchers("/login").permitAll() + .anyExchange().authenticated()).build(); } } diff --git a/src/main/java/com/ard333/springbootwebfluxjjwt/security/model/AuthRequest.java b/src/main/java/com/ard333/springbootwebfluxjjwt/security/model/AuthRequest.java index 9b01930..c14b202 100644 --- a/src/main/java/com/ard333/springbootwebfluxjjwt/security/model/AuthRequest.java +++ b/src/main/java/com/ard333/springbootwebfluxjjwt/security/model/AuthRequest.java @@ -1,6 +1,7 @@ package com.ard333.springbootwebfluxjjwt.security.model; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; @@ -9,8 +10,12 @@ * * @author ard333 */ -@Data @NoArgsConstructor @AllArgsConstructor @ToString -public class AuthRequest { +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Builder(toBuilder = true) +public class AuthRequest { private String username; private String password; diff --git a/src/main/java/com/ard333/springbootwebfluxjjwt/service/UserService.java b/src/main/java/com/ard333/springbootwebfluxjjwt/service/UserService.java index 38a43ca..fce91d6 100644 --- a/src/main/java/com/ard333/springbootwebfluxjjwt/service/UserService.java +++ b/src/main/java/com/ard333/springbootwebfluxjjwt/service/UserService.java @@ -1,15 +1,15 @@ package com.ard333.springbootwebfluxjjwt.service; -import com.ard333.springbootwebfluxjjwt.model.User; -import com.ard333.springbootwebfluxjjwt.model.security.Role; - import java.util.Arrays; import java.util.HashMap; import java.util.Map; -import javax.annotation.PostConstruct; - import org.springframework.stereotype.Service; + +import com.ard333.springbootwebfluxjjwt.model.User; +import com.ard333.springbootwebfluxjjwt.model.security.Role; + +import jakarta.annotation.PostConstruct; import reactor.core.publisher.Mono; /** diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..e5fd518 --- /dev/null +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,29 @@ +{ + "properties": [ + { + "name": "springbootwebfluxjjwt.password.encoder.secret", + "type": "java.lang.String", + "description": "Encoder secret" + }, + { + "name": "springbootwebfluxjjwt.password.encoder.iteration", + "type": "java.lang.Integer", + "description": "Iteration numbers for generating the key" + }, + { + "name": "springbootwebfluxjjwt.password.encoder.keylength", + "type": "java.lang.Integer", + "description": "The key length'" + }, + { + "name": "springbootwebfluxjjwt.jjwt.secret", + "type": "java.lang.String", + "description": "This is secret for JWTHS512 signature algorithm that MUST have 64 byte length" + }, + { + "name": "springbootwebfluxjjwt.jjwt.expiration", + "type": "java.lang.String", + "description": "Expiration time for token in seconds" + } + ] +} diff --git a/src/test/java/com/ard333/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTest.java b/src/test/java/com/ard333/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTest.java new file mode 100644 index 0000000..4439841 --- /dev/null +++ b/src/test/java/com/ard333/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTest.java @@ -0,0 +1,26 @@ +package com.ard333.springbootwebfluxjjwt; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.ard333.springbootwebfluxjjwt.rest.AuthenticationREST; +import com.ard333.springbootwebfluxjjwt.rest.ResourceREST; + +@SpringBootTest +class SpringBootWebfluxJjwtApplicationTest { + + @Autowired + AuthenticationREST authenticationREST; + + @Autowired + ResourceREST resourceREST; + + @Test + void contextLoads() { + Assertions.assertThat(authenticationREST).isNotNull(); + Assertions.assertThat(resourceREST).isNotNull(); + } + +} diff --git a/src/test/java/com/ard333/springbootwebfluxjjwt/rest/LoginTest.java b/src/test/java/com/ard333/springbootwebfluxjjwt/rest/LoginTest.java new file mode 100644 index 0000000..637ba7b --- /dev/null +++ b/src/test/java/com/ard333/springbootwebfluxjjwt/rest/LoginTest.java @@ -0,0 +1,55 @@ +package com.ard333.springbootwebfluxjjwt.rest; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatusCode; + +import com.ard333.springbootwebfluxjjwt.model.security.AuthResponse; +import com.ard333.springbootwebfluxjjwt.security.model.AuthRequest; +import com.ard333.springbootwebfluxjjwt.utils.BaseRestTest; + +public class LoginTest extends BaseRestTest { + + @Test + public void whenLoginWithBadPassword_shouldRespondWith4xx() { + + webClient() + .post() + .uri("/login") + .bodyValue(AuthRequest.builder() + .username("user") + .password("wrong") + .build()) + .exchangeToMono(res -> { + assertThat(res.statusCode()).isEqualTo(HttpStatusCode.valueOf(401)); + return res.bodyToMono(AuthRequest.class); + }) + .doOnError(e -> Assertions.fail("should not throw", e)) + .block(); + + } + + @Test + public void whenLoginWithUser_shouldRespondWith2xx() { + webClient() + .post() + .uri("/login") + .bodyValue(AuthRequest.builder() + .username("user") + .password("user") + .build()) + .exchangeToMono(res -> { + + assertThat(res.statusCode().is2xxSuccessful()).isTrue(); + return res.bodyToMono(AuthResponse.class); + + }) + .doOnError(e -> Assertions.fail("should not throw", e)) + .doOnSuccess(authRes -> { + assertThat(authRes.getToken()).isNotEmpty(); + }) + .block(); + } +} diff --git a/src/test/java/com/ard333/springbootwebfluxjjwt/rest/ResourceAccessTest.java b/src/test/java/com/ard333/springbootwebfluxjjwt/rest/ResourceAccessTest.java new file mode 100644 index 0000000..f22365c --- /dev/null +++ b/src/test/java/com/ard333/springbootwebfluxjjwt/rest/ResourceAccessTest.java @@ -0,0 +1,98 @@ +package com.ard333.springbootwebfluxjjwt.rest; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; + +import com.ard333.springbootwebfluxjjwt.model.Message; +import com.ard333.springbootwebfluxjjwt.model.security.AuthResponse; +import com.ard333.springbootwebfluxjjwt.utils.BaseRestTest; + +public class ResourceAccessTest extends BaseRestTest { + private static String REQ_MAPPING = "/resource"; + private static String USER_URL = REQ_MAPPING + "/user"; + private static String ADMIN_URL = REQ_MAPPING + "/admin"; + private static String USER_OR_ADMIN_URL = REQ_MAPPING + "/user-or-admin"; + + @Test + public void whenInvalidToken_shouldRespondWith401() { + + webClient() + .get() + .uri(USER_URL) + .exchangeToMono(res -> { + assertTrue(res.statusCode().is4xxClientError()); + return res.bodyToMono(AuthResponse.class); + }) + .doOnError(e -> fail("error on test", e)) + .block(); + } + + @Test + public void whenTokenValid_and_userHasAccess_responseShouldBe2xx() { + + userWebClient() + .transform(m -> m.flatMap(c -> c.get() + .uri(USER_URL) + .exchangeToMono(res -> { + assertTrue(res.statusCode().is2xxSuccessful()); + return res.bodyToMono(Message.class); + }))) + .doOnError(e -> fail("error on test", e)) + .block(); + } + + @Test + public void whenTokenValid_and_doNotHasAccess_responseShouldBe4xx() { + + userWebClient() + .transform(m -> m.flatMap(c -> c.get() + .uri(ADMIN_URL) + .exchangeToMono(res -> { + assertTrue(res.statusCode().is4xxClientError()); + return res.bodyToMono(Message.class); + }))) + .doOnError(e -> fail("error on test", e)) + .block(); + } + + @Test + public void whenTokenValid_and_adminHasAccess_responseShouldBe2xx() { + + adminWebClient() + .transform(m -> m.flatMap(c -> c.get() + .uri(ADMIN_URL) + .exchangeToMono(res -> { + assertTrue(res.statusCode().is2xxSuccessful()); + return res.bodyToMono(Message.class); + }))) + .doOnError(e -> fail("error on test", e)) + .block(); + } + + @Test + public void whenTokenValid_and_adminAndUserHasAccess_responseShouldBe2xx() { + + adminWebClient() + .transform(m -> m.flatMap(c -> c.get() + .uri(USER_OR_ADMIN_URL) + .exchangeToMono(res -> { + assertTrue(res.statusCode().is2xxSuccessful()); + return res.bodyToMono(Message.class); + }))) + .doOnError(e -> fail("error on test", e)) + .block(); + + userWebClient() + .transform(m -> m.flatMap(c -> c.get() + .uri(USER_OR_ADMIN_URL) + .exchangeToMono(res -> { + assertTrue(res.statusCode().is2xxSuccessful()); + return res.bodyToMono(Message.class); + }))) + .doOnError(e -> fail("error on test", e)) + .block(); + } + +} diff --git a/src/test/java/com/ard333/springbootwebfluxjjwt/utils/BaseRestTest.java b/src/test/java/com/ard333/springbootwebfluxjjwt/utils/BaseRestTest.java new file mode 100644 index 0000000..96ca853 --- /dev/null +++ b/src/test/java/com/ard333/springbootwebfluxjjwt/utils/BaseRestTest.java @@ -0,0 +1,143 @@ +package com.ard333.springbootwebfluxjjwt.utils; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Assertions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.reactive.function.client.WebClient; + +import com.ard333.springbootwebfluxjjwt.model.security.AuthResponse; +import com.ard333.springbootwebfluxjjwt.security.model.AuthRequest; + +import reactor.core.publisher.Mono; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Configuration +public abstract class BaseRestTest { + + protected static String LOGIN_URL = "/login"; + + protected Map tokenMap = new HashMap<>(); + + @Value(value = "${local.server.port}") + protected int port; + + @Autowired + protected TestRestTemplate restTemplate; + + @Autowired + protected TestRestTemplate userRestTemplate; + + @Bean + public TestRestTemplate userRestTemplate() { + RestTemplateBuilder builder = new RestTemplateBuilder(rt -> rt.getInterceptors().add((request, body, execution) -> { + request.getHeaders().add("Authorization", new StringBuilder("Bearer ").append(getUserToken()) + .toString()); + return execution.execute(request, body); + })); + return new TestRestTemplate(builder); + } + + public StringBuilder createURL(String resource) { + return new StringBuilder("http://localhost:") + .append(port) + .append(resource); + } + + protected ResponseEntity userLogin() { + AuthRequest req = AuthRequest.builder() + .username("user") + .password("user") + .build(); + + ResponseEntity res = restTemplate.postForEntity( + createURL(LOGIN_URL).toString(), + req, + AuthResponse.class); + return res; + } + + @SuppressWarnings("null") + protected String getUserToken() { + ResponseEntity userLogin = userLogin(); + String token = ""; + try { + token = userLogin.getBody().getToken(); + } catch (Exception e) { + } + return token; + } + + protected HttpEntity> getHttpEntyWithToken(String userToken) { + return new HttpEntity<>(Map.of("Authorization", new StringBuilder("Bearer ").append(userToken).toString())); + } + + protected WebClient webClient() { + return WebClient.create("http://localhost:" + port); + } + + protected Mono getToken(String userName, String password) { + return Mono.fromSupplier(() -> { + String result = ""; + if (tokenMap.containsKey(userName)) { + result = tokenMap.get(userName); + } else { + result = authWebClient(userName, password).block().getToken(); + tokenMap.put(userName, result); + } + return result; + }); + } + + protected Mono userWebClient() { + return getToken("user", "user") + .map(token -> webClient() + .mutate() + .defaultHeader("Authorization", + new StringBuilder("Bearer ").append(token).toString()) + .build()) + .cache(); + } + protected Mono adminWebClient() { + return getToken("admin", "admin") + .map(token -> webClient() + .mutate() + .defaultHeader("Authorization", + new StringBuilder("Bearer ").append(token).toString()) + .build()) + .cache(); + } + + private Mono authWebClient(String userName, String password) { + return webClient() + .post() + .uri("/login") + .bodyValue(AuthRequest.builder() + .username(userName) + .password(password) + .build()) + .exchangeToMono(res -> { + + assertThat(res.statusCode().is2xxSuccessful()).isTrue(); + return res.bodyToMono(AuthResponse.class); + + }) + .doOnError(e -> Assertions.fail("should not throw", e)) + .doOnSuccess(authRes -> { + assertThat(authRes.getToken()).isNotEmpty(); + }); + } + +} diff --git a/src/test/java/id/web/ard/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTests.java b/src/test/java/id/web/ard/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTests.java deleted file mode 100644 index 021b6df..0000000 --- a/src/test/java/id/web/ard/springbootwebfluxjjwt/SpringBootWebfluxJjwtApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package id.web.ard.springbootwebfluxjjwt; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DemoApplicationTests { - - @Test - void contextLoads() { - } - -}