Compare commits
2 Commits
0ddd30a245
...
4b8e9ed65a
Author | SHA1 | Date | |
---|---|---|---|
4b8e9ed65a | |||
6740cb9faf |
192
README.md
192
README.md
@ -113,8 +113,9 @@ Let's add minimal interactivity to the page by introducing an input field and a
|
|||||||
...
|
...
|
||||||
<body>
|
<body>
|
||||||
<p th:text="'Hello, ' + ${name} + '!'" />
|
<p th:text="'Hello, ' + ${name} + '!'" />
|
||||||
<input placeholder="Enter username.."/>
|
<p th:text="'Length of your name is ' + ${nameLength} + ' characters.'" />
|
||||||
<button>Go!</button>
|
<input placeholder="Enter username.."/>
|
||||||
|
<button>Again!</button>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@ -356,3 +357,190 @@ 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 {
|
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') {
|
||||||
|
@ -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";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
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>
|
</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>
|
@ -6,9 +6,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<p th:text="'Hello, ' + ${name} + '!'" />
|
<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.."/>
|
<input placeholder="Enter username.."/>
|
||||||
<button>Go!</button>
|
<button>Again!</button>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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.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!")));
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user