Compare commits
2 Commits
0ddd30a245
...
4b8e9ed65a
Author | SHA1 | Date | |
---|---|---|---|
4b8e9ed65a | |||
6740cb9faf |
194
README.md
194
README.md
@ -113,8 +113,9 @@ Let's add minimal interactivity to the page by introducing an input field and a
|
||||
...
|
||||
<body>
|
||||
<p th:text="'Hello, ' + ${name} + '!'" />
|
||||
<input placeholder="Enter username.."/>
|
||||
<button>Go!</button>
|
||||
<p th:text="'Length of your name is ' + ${nameLength} + ' characters.'" />
|
||||
<input placeholder="Enter username.."/>
|
||||
<button>Again!</button>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -355,4 +356,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 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
|
||||
<!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.
|
@ -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') {
|
||||
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
54
src/main/java/djmil/hellomvc/SecurityConfig.java
Normal file
54
src/main/java/djmil/hellomvc/SecurityConfig.java
Normal 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);
|
||||
}
|
||||
}
|
@ -6,5 +6,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<p>Get your greeting <a href="/greeting">here</a></p>
|
||||
<p>Get your secret <a href="/secret">here</a></p>
|
||||
</body>
|
||||
</html>
|
@ -6,9 +6,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<p th:text="'Hello, ' + ${name} + '!'" />
|
||||
<p th:text="'Length of a given name is ' + ${nameLength} + ' characters.'" />
|
||||
<p th:text="'Length of your name is ' + ${nameLength} + ' characters.'" />
|
||||
<input placeholder="Enter username.."/>
|
||||
<button>Go!</button>
|
||||
<button>Again!</button>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
22
src/main/resources/templates/login.html
Normal file
22
src/main/resources/templates/login.html
Normal 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>
|
11
src/main/resources/templates/secret.html
Normal file
11
src/main/resources/templates/secret.html
Normal 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>
|
@ -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!")));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user