SpringSecurity

- secure endpoint
- login page
- in-memory user credentials
- tests
This commit is contained in:
djmil 2023-07-28 11:44:25 +02:00
parent 0ddd30a245
commit 6740cb9faf
8 changed files with 309 additions and 3 deletions

189
README.md
View File

@ -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. 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 Securitys [`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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.</div>
<div th:if="${param.logout}">
You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>
```
*`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
<body>
<p>Get your greeting <a href="/greeting">here</a></p>
<p>Get your secret <a href="/secret">here</a></p>
</body>
```
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.

View File

@ -18,8 +18,11 @@ repositories {
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
} }
tasks.named('test') { tasks.named('test') {

View File

@ -1,5 +1,7 @@
package djmil.hellomvc; package djmil.hellomvc;
import java.security.Principal;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -24,4 +26,18 @@ public class GreetingController {
return "greeting"; // view tempalte 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";
}
} }

View File

@ -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);
}
}

View File

@ -6,5 +6,6 @@
</head> </head>
<body> <body>
<p>Get your greeting <a href="/greeting">here</a></p> <p>Get your greeting <a href="/greeting">here</a></p>
<p>Get your secret <a href="/secret">here</a></p>
</body> </body>
</html> </html>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.</div>
<div th:if="${param.logout}">
You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>

View File

@ -0,0 +1,11 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>TOP SECRET</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="'Greetings ' + ${name} + '!'" />
<p th:text="' the seecret you are looking for is [' + ${secret} + ']. Use this knowledge wisely.'" />
</body>
</html>

View File

@ -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.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc; 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 { public class CreetingControllerTests {
@Autowired @Autowired
@ -31,11 +32,22 @@ public class CreetingControllerTests {
// } // }
@Test @Test
@WithMockUser
public void greetingShouldReturnMessageFromService() throws Exception { public void greetingShouldReturnMessageFromService() throws Exception {
when(service.nameLength("World")).thenReturn(7); when(service.nameLength("World")).thenReturn(7);
this.mockMvc.perform(get("/greeting")) this.mockMvc.perform(get("/greeting"))
.andDo(print()) .andDo(print())
.andExpect(status().isOk()) .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!")));
} }
} }