Compare commits
No commits in common. "4b8e9ed65ac97e207ff79a14cda515a32f82a2f9" and "0ddd30a24502cb84c52c02a025e6cdf180276e55" have entirely different histories.
4b8e9ed65a
...
0ddd30a245
190
README.md
190
README.md
@ -113,9 +113,8 @@ 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} + '!'" />
|
||||||
<p th:text="'Length of your name is ' + ${nameLength} + ' characters.'" />
|
|
||||||
<input placeholder="Enter username.."/>
|
<input placeholder="Enter username.."/>
|
||||||
<button>Again!</button>
|
<button>Go!</button>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@ -357,190 +356,3 @@ 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,11 +18,8 @@ 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,7 +1,5 @@
|
|||||||
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;
|
||||||
@ -26,18 +24,4 @@ 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
@ -1,54 +0,0 @@
|
|||||||
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,6 +6,5 @@
|
|||||||
</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 your name is ' + ${nameLength} + ' characters.'" />
|
<p th:text="'Length of a given name is ' + ${nameLength} + ' characters.'" />
|
||||||
<input placeholder="Enter username.."/>
|
<input placeholder="Enter username.."/>
|
||||||
<button>Again!</button>
|
<button>Go!</button>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
<!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>
|
|
@ -1,11 +0,0 @@
|
|||||||
<!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,9 +12,8 @@ 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(controllers = GreetingController.class)
|
@WebMvcTest(GreetingController.class)
|
||||||
public class CreetingControllerTests {
|
public class CreetingControllerTests {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@ -32,22 +31,11 @@ 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 your name is 7 characters.")));
|
.andExpect(content().string(containsString("Length of a given 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