From 6740cb9faf4674ede148a74393c4fbd5cda8b0e4 Mon Sep 17 00:00:00 2001 From: djmil Date: Fri, 28 Jul 2023 11:44:25 +0200 Subject: [PATCH] SpringSecurity - secure endpoint - login page - in-memory user credentials - tests --- README.md | 189 +++++++++++++++++- build.gradle | 3 + .../djmil/hellomvc/GreetingController.java | 16 ++ .../java/djmil/hellomvc/SecurityConfig.java | 54 +++++ src/main/resources/static/index.html | 1 + src/main/resources/templates/login.html | 22 ++ src/main/resources/templates/secret.html | 11 + .../hellomvc/CreetingControllerTests.java | 16 +- 8 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 src/main/java/djmil/hellomvc/SecurityConfig.java create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/secret.html diff --git a/README.md b/README.md index 028b329..0cf9223 100644 --- a/README.md +++ b/README.md @@ -355,4 +355,191 @@ public class CreetingControllerTests { } ``` -Notice the `@MockBean` annotation, which is essentially used to obtain an interface of a mocked class. Later, `when` function used to define an actual mock. \ No newline at end of file +Notice the `@MockBean` annotation, which is essentially used to obtain an interface of a mocked class. Later, `when` function used to define an actual mock. + +# Security + +Let's say, that we want to have `secret` endpoint, available only for for some users. [SpringSecurity](https://docs.spring.io/spring-security/reference/index.html) can be used to achieve this. + +*`build.gradle`* +```yaml +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' +} +``` + +## Security config + +At the core of Spring Security [architecture](https://spring.io/guides/topicals/spring-security-architecture/) - lays concept of filters. Common practice is to use dedicated class as provider of desired security configuration. + +*`src/main/java/djmil/hellomvc/SecurityConfig.java`* +```java +package djmil.hellomvc; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; + +@Configuration +public class SecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authz -> authz + .requestMatchers("/secret").authenticated() + .anyRequest().permitAll() + ); + + http + .formLogin(form -> form + .loginPage("/login") + .permitAll() + ); + + return http.build(); + } +``` + +`SecurityFilterChain` [is](https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-securityfilterchain) used by [FilterChainProxy](https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-filterchainproxy) to determine which Spring Security `Filter` instances should be invoked for the current request. In this code snipped, we've defined: +- only `authenticated` users are allowed to access `/secret` endpoint +- all other request would be server without any restrictions +- login form is accessible at `/login` endpoint + At this point, we shall make sure that this endpoint is known by our Controller + +## Login page + +Basic idea is well defined in official [documentation](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html). + +![Basic Flow](https://docs.spring.io/spring-security/reference/_images/servlet/authentication/unpwd/loginurlauthenticationentrypoint.png) +1. First, a user makes an unauthenticated request to the resource (`/private`) for which it is not authorized. +2. Spring Security’s [`AuthorizationFilter`](https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html) indicates that the unauthenticated request is _Denied_ by throwing an `AccessDeniedException`. +3. Since the user is not authenticated, [`ExceptionTranslationFilter`](https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-exceptiontranslationfilter) initiates _Start Authentication_ and sends a redirect to the login page with the configured [`AuthenticationEntryPoint`](https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-authenticationentrypoint). In most cases, the `AuthenticationEntryPoint` is an instance of [`LoginUrlAuthenticationEntryPoint`](https://docs.spring.io/spring-security/site/docs/6.1.2/api/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.html). +4. The browser requests the login page to which it was redirected. +5. Something within the application, must [render the login page](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html#servlet-authentication-form-custom). + +*`Login Form - src/main/resources/templates/login.html`* +```html + + + + Please Log In + + +

Please Log In

+
+ Invalid username and password.
+
+ You have been logged out.
+
+
+ +
+
+ +
+ +
+ + +``` + +*`LoginController`* +```java +@Controller +class LoginController { + @GetMapping("/login") + String login() { + return "login"; + } +} +``` + +## User Authentication + +Spring Security provides comprehensive support for [Authentication](https://docs.spring.io/spring-security/reference/features/authentication/index.html#authentication). We are going to use [In-Memory](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/in-memory.html) [Authentication Mechanism](https://docs.spring.io/spring-security/reference/servlet/authentication/index.html#servlet-authentication-mechanisms) + +Add these `Beans` to *`SecurityConfig.java`* +```java +@Bean +public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); +} + +@Bean +public UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) { + User.UserBuilder users = User.builder(); + + UserDetails sarah = users + .username("agent") + .password(passwordEncoder.encode("qaz123")) + //.roles("CARD-OWNER") // no roles for now + .build(); + + return new InMemoryUserDetailsManager(sarah); +} +``` + +Essentially, this means that our app would authorise only one hard-coded user. There are other ways to [manage](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/storage.html) user login credentials. + +## Test + +So far so good, in a best TDD fashion, let's write a test to check our secure endpoint security: + +*`CreetingControllerTests.java`* +```java +import org.springframework.security.test.context.support.WithMockUser; +... + +@WebMvcTest(controllers = GreetingController.class) +public class CreetingControllerTests { +... + @Test + @WithMockUser(username = "user1", password = "pwd", roles = "USER") + public void secret() throws Exception { + when(service.nameLength("World")).thenReturn(7); + this.mockMvc.perform(get("/secret")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Greetings user1!"))); + } +} +``` + +`@WithMockUser` injects mock user into Controller's test instance from `WebMvcTest`. + +> [!note] Mock user from tests has nothing to do with In-Memory stored user credentials from [[README#User Authentication]] + +## Secure endpoint + +Finally, everything is ready for creation of `/secret` Secure Endpoint. + +*`GreetingsController.java`* +```java +import java.security.Principal; +... + + @GetMapping("/secret") + public String secret(Principal principal, Model model) { + + model.addAttribute("name", principal.getName()); + model.addAttribute("secret", 42); + + return "secret"; + } +``` + +SpringSecurity will make sure to process all our previously defined security routines (alongside with applying some common protection techniques and utilities like Cookies) and only if the request was qualified - it will be served to the specified endpoint with all the security related info stored in convenient `Principal` class. + +Also, let's update our home page, so users would be aware of existence of our secret endpoint. +*`index.html`* +```html + +

Get your greeting here

+

Get your secret here

+ +``` + +Upon attempt to visit `/secret` endpoint, unauthenticated users would be redirected to `/login` page, and if that was successful, they'd be redirected back to the `/secret`. Spring framework is handling all that complexity for us. \ No newline at end of file diff --git a/build.gradle b/build.gradle index e883eac..c11305e 100644 --- a/build.gradle +++ b/build.gradle @@ -18,8 +18,11 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/src/main/java/djmil/hellomvc/GreetingController.java b/src/main/java/djmil/hellomvc/GreetingController.java index 3a467dc..d05e423 100644 --- a/src/main/java/djmil/hellomvc/GreetingController.java +++ b/src/main/java/djmil/hellomvc/GreetingController.java @@ -1,5 +1,7 @@ package djmil.hellomvc; +import java.security.Principal; + import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -24,4 +26,18 @@ public class GreetingController { return "greeting"; // view tempalte } + @GetMapping("/login") + String login() { + return "login"; + } + + @GetMapping("/secret") + public String secret(Principal principal, Model model) { + + model.addAttribute("name", principal.getName()); + model.addAttribute("secret", 42); + + return "secret"; + } + } \ No newline at end of file diff --git a/src/main/java/djmil/hellomvc/SecurityConfig.java b/src/main/java/djmil/hellomvc/SecurityConfig.java new file mode 100644 index 0000000..0048aa1 --- /dev/null +++ b/src/main/java/djmil/hellomvc/SecurityConfig.java @@ -0,0 +1,54 @@ +package djmil.hellomvc; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .authorizeHttpRequests(authz -> authz + .requestMatchers("/secret").authenticated() + //.hasRole("CARD-OWNER") // <<-- enable RBAC + .anyRequest().permitAll() + ); + + http + .formLogin(form -> form + .loginPage("/login") + .permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) { + User.UserBuilder users = User.builder(); + + UserDetails sarah = users + .username("agent") + .password(passwordEncoder.encode("qaz123")) + //.roles("CARD-OWNER") // no roles for now + .build(); + + return new InMemoryUserDetailsManager(sarah); + } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index b585f41..918716b 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -6,5 +6,6 @@

Get your greeting here

+

Get your secret here

\ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..ef3cee1 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,22 @@ + + + + Please Log In + + +

Please Log In

+
+ Invalid username and password.
+
+ You have been logged out.
+
+
+ +
+
+ +
+ +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/secret.html b/src/main/resources/templates/secret.html new file mode 100644 index 0000000..748f83f --- /dev/null +++ b/src/main/resources/templates/secret.html @@ -0,0 +1,11 @@ + + + + TOP SECRET + + + +

+

+ + \ No newline at end of file diff --git a/src/test/java/djmil/hellomvc/CreetingControllerTests.java b/src/test/java/djmil/hellomvc/CreetingControllerTests.java index c3e4cc8..253d0d8 100644 --- a/src/test/java/djmil/hellomvc/CreetingControllerTests.java +++ b/src/test/java/djmil/hellomvc/CreetingControllerTests.java @@ -12,8 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.security.test.context.support.WithMockUser; -@WebMvcTest(GreetingController.class) +@WebMvcTest(controllers = GreetingController.class) public class CreetingControllerTests { @Autowired @@ -31,11 +32,22 @@ public class CreetingControllerTests { // } @Test + @WithMockUser public void greetingShouldReturnMessageFromService() throws Exception { when(service.nameLength("World")).thenReturn(7); this.mockMvc.perform(get("/greeting")) .andDo(print()) .andExpect(status().isOk()) - .andExpect(content().string(containsString("Length of a given name is 7 characters."))); + .andExpect(content().string(containsString("Length of your name is 7 characters."))); + } + + @Test + @WithMockUser(username = "user1", password = "pwd", roles = "USER") + public void secret() throws Exception { + when(service.nameLength("World")).thenReturn(7); + this.mockMvc.perform(get("/secret")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Greetings user1!"))); } } \ No newline at end of file