Skip to content

Commit

Permalink
Add Spring Boot Admin to give users a simple monitoring dashboard exa…
Browse files Browse the repository at this point in the history
…mple

- Introduces HTTP Basic Authentication: Some endpoints (such as a subset of
  Spring Actuator endpoints) are now protected and require a username and
  password. For example, `/actuator/mappings` is now protected.
  The default usernames and passwords are configured in
  `application.properties`.
- Add landing page at `/` with info on endpoints and default user
  credentials.
- Update info on Docker image size, which increased with addition of
  Spring Boot Admin.
  • Loading branch information
miguno committed Sep 9, 2024
1 parent 43db6f1 commit 6dc246c
Show file tree
Hide file tree
Showing 8 changed files with 408 additions and 2 deletions.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Features:
- The Docker build uses a
[multi-stage build setup](https://docs.docker.com/build/building/multi-stage/)
including a downsized JRE (built inside Docker via `jlink`)
to minimize the size of the generated Docker image, which is **130MB**.
to minimize the size of the generated Docker image, which is **157MB**.
- Supports [Docker BuildKit](https://docs.docker.com/build/)
- Java 22 (Eclipse Temurin)
- [JUnit 5](https://github.com/junit-team/junit5) for testing,
Expand All @@ -29,6 +29,13 @@ Features:
at endpoint [/actuator](http://localhost:8123/actuator), e.g. for
[healthchecks](http://localhost:8123/actuator/health) or [Prometheus
metrics](http://localhost:8123/actuator/prometheus)
- Integrates [Spring Boot
Admin](https://github.com/codecentric/spring-boot-admin) at endpoint
[`/admin`](http://localhost:8123/admin) to inspect the running application.
Note that, in production, [it is not recommended to
co-locate](https://docs.spring-boot-admin.com/current/faq.html) the SBA
server with your applications (the SBA clients) as this
project does for demonstration purposes.
- Maven for build management (see [pom.xml](pom.xml)), using
[Maven Wrapper](https://github.com/apache/maven-wrapper)
- [GitHub Actions workflows](https://github.com/miguno/java-docker-build-tutorial/actions) for
Expand All @@ -51,7 +58,7 @@ Java JDK or Maven installed.

**Step 1:** Create the Docker image according to [Dockerfile](Dockerfile).
This step uses Maven to build, test, and package the Java application according
to [pom.xml](pom.xml). The resulting image is 131MB in size, of which 44MB are
to [pom.xml](pom.xml). The resulting image is 157MB in size, of which 44MB are
the underlying `alpine` image.

```shell
Expand Down
29 changes: 29 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<!-- Dependencies (other than Maven plugins) -->
<micrometer.version>1.13.3</micrometer.version>
<springdoc.version>2.6.0</springdoc.version>
<spring-boot-admin.version>3.3.3</spring-boot-admin.version>
<!-- Maven plugins -->
<maven-project-info-reports-plugin.version>3.7.0</maven-project-info-reports-plugin.version>
<maven-enforcer-plugin.version>3.5.0</maven-enforcer-plugin.version>
Expand Down Expand Up @@ -100,6 +101,11 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
Expand All @@ -112,6 +118,29 @@
<scope>test</scope>
</dependency>

<dependency>
<!--
Server for https://github.com/codecentric/spring-boot-admin/.
In this example project, the server and the client of Spring
Boot Admin run in the same application. In production, however,
you will typically run the server (think: central monitoring
process) separately from the clients, which are your "actual"
applications.
-->
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
<version>${spring-boot-admin.version}</version>
</dependency>

<dependency>
<!--
Client for https://github.com/codecentric/spring-boot-admin/.
-->
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${spring-boot-admin.version}</version>
</dependency>

<dependency>
<!--
Exposes metrics via a Prometheus actuator endpoint.
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/miguno/javadockerbuild/App.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.miguno.javadockerbuild;

import de.codecentric.boot.admin.server.config.EnableAdminServer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/** An example application that exposes an HTTP endpoint. */
@SpringBootApplication
@EnableAdminServer
public class App {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.miguno.javadockerbuild.admin;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

/**
* Redirects <code>/admin/</code> requests to <code>/admin</code>.
*
* <p>Spring treats paths like <code>foo/</code> vs. `<code>foo</code>` differently. This controller
* ensures that a user is correctly redirected after a login.
*/
@SuppressFBWarnings("SPRING_ENDPOINT")
@Controller
@RequestMapping("/${spring.boot.admin.context-path}")
public class AdminRedirector {

@Value("${spring.boot.admin.context-path}")
private String adminPath;

@GetMapping("/${spring.boot.admin.context-path}/")
@SuppressFBWarnings("SPRING_FILE_DISCLOSURE")
public ModelAndView redirectWithUsingRedirectPrefix(ModelMap model) {
return new ModelAndView(String.format("redirect:/%s", adminPath), model);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.miguno.javadockerbuild.admin;

import java.io.IOException;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.WebUtils;

/** A custom CSRF Filter, derived from the Spring Boot Admin documentation. */
public class CustomCsrfFilter extends OncePerRequestFilter {

public static final String CSRF_COOKIE_NAME = "XSRF-TOKEN";

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrf != null) {
Cookie cookie = WebUtils.getCookie(request, CSRF_COOKIE_NAME);
String token = csrf.getToken();

if (cookie == null || token != null && !token.equals(cookie.getValue())) {
cookie = new Cookie(CSRF_COOKIE_NAME, token);
cookie.setPath("/");
response.addCookie(cookie);
}
}
filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package com.miguno.javadockerbuild.admin;

import java.util.UUID;

import de.codecentric.boot.admin.server.config.AdminServerProperties;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.servlet.DispatcherType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
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;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import static org.springframework.http.HttpMethod.DELETE;
import static org.springframework.http.HttpMethod.POST;

/** Secures the endpoints of this application. */
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecuritySecureConfig {

private final AdminServerProperties adminServer;

@Value("${app.spring-boot-admin.role.user.name}")
private String roleUserName;

@Value("${app.spring-boot-admin.role.user.password}")
private String roleUserPassword;

@Value("${app.spring-boot-admin.role.admin.name}")
private String roleAdminName;

@Value("${app.spring-boot-admin.role.admin.password}")
private String roleAdminPassword;

@SuppressFBWarnings("EI_EXPOSE_REP2")
public SecuritySecureConfig(AdminServerProperties adminServer, SecurityProperties security) {
this.adminServer = adminServer;
}

/**
* Applies security policies such as authentication requirements to endpoints.
*
* @param http Supplied by Spring.
* @return The applications' security filter chain.
* @throws Exception Unclear when that happens.
*/
@Bean
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
SavedRequestAwareAuthenticationSuccessHandler successHandler =
new SavedRequestAwareAuthenticationSuccessHandler();
successHandler.setTargetUrlParameter("redirectTo");
successHandler.setDefaultTargetUrl(this.adminServer.path("/"));

// NOTE: In this project, the Spring Boot Admin server and client are colocated in the same
// application for demonstration purposes. In production, you would typically not do that
// and instead separate the code and functionality. See the recommendations of Spring Boot
// Admin at https://docs.spring-boot-admin.com/current/faq.html.
// The effect of this colocation is that this application contains endpoints for both
// server and client, and the authorization settings below also apply to both: if you
// permit access to a URL in the "for the server" section you also permit access for the
// client, and vice versa. Again, this would be different in production where the server
// and the clients would be separate applications and processes.
http.authorizeHttpRequests(
(authorizeRequests) ->
authorizeRequests
//// For the Spring Boot Admin server.
.requestMatchers(
// Permit public access to all static assets.
new AntPathRequestMatcher(this.adminServer.path("/assets/**")),
// Permit public access to the login page.
new AntPathRequestMatcher(this.adminServer.path("/login")))
.permitAll()
// Permit asynchronous processing of a request without requiring authentication.
// FIXME: Permitting any async requests as a workaround appears dangerous.
// https://github.com/spring-projects/spring-security/issues/11027 (from 2022)
.dispatcherTypeMatchers(DispatcherType.ASYNC)
.permitAll()

//// For the Spring Boot Admin client (the "real" app being developed).
.requestMatchers(
new AntPathRequestMatcher("/"),
// Permit public access to this app's example endpoint at `/status`.
new AntPathRequestMatcher("/status"),
// Permit public access to Swagger.
new AntPathRequestMatcher("/swagger-ui.html"),
new AntPathRequestMatcher("/v3/api-docs"),
// Permit public access to a subset of actuator endpoints.
new AntPathRequestMatcher("/actuator/health"),
new AntPathRequestMatcher("/actuator/info"),
new AntPathRequestMatcher("/actuator/prometheus"))
.permitAll()

//// Applies to both SBA server and clients.
// All other requests must be authenticated.
.anyRequest()
.authenticated())
// For Spring Boot Admin server: enables form-based login and logout.
.formLogin(
(formLogin) ->
formLogin.loginPage(this.adminServer.path("/login")).successHandler(successHandler))
.logout((logout) -> logout.logoutUrl(this.adminServer.path("/logout")))
// Enables HTTP Basic Authentication support.
.httpBasic(Customizer.withDefaults());

// Enables CSRF-Protection using cookies.
http.addFilterAfter(new CustomCsrfFilter(), BasicAuthenticationFilter.class)
.csrf(
(csrf) ->
csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
.ignoringRequestMatchers(
//// For the Spring Boot Admin server.
// Disables CSRF-Protection for the SBA server's endpoints that the SBA
// client uses to (de-)register.
new AntPathRequestMatcher(
this.adminServer.path("/instances"), POST.toString()),
new AntPathRequestMatcher(
this.adminServer.path("/instances/*"), DELETE.toString()),

//// For the Spring Boot Admin client.
// Disables CSRF-Protection for the SBA client's actuator endpoints that
// the SBA server uses to collect metrics.
new AntPathRequestMatcher("/actuator/**")));

http.rememberMe(
(rememberMe) -> rememberMe.key(UUID.randomUUID().toString()).tokenValiditySeconds(1209600));

return http.build();
}

/** Required to provide UserDetailsService for "remember functionality". */
@Bean
public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) {
// NOTE: Because this example project runs the Spring Boot Admin server and client in the same
// application, both the server's secured (with HTTP Basic Authentication) SBA API
// endpoint and the client's Spring actuator endpoints coincidentally require exactly the
// same username/password combination.
// In production, this is not recommended. See the recommendations of Spring Boot Admin at
// https://docs.spring-boot-admin.com/current/faq.html.
// Instead, in production you would separate clients from the server, and thus different
// username/password combinations can be used.
// NOTE: HTTP Basic Authentication itself is not recommended for production.
UserDetails user =
User.withUsername(roleUserName)
.password(passwordEncoder.encode(roleUserPassword))
.roles("USER")
.build();
UserDetails admin =
User.withUsername(roleAdminName)
.password(passwordEncoder.encode(roleAdminPassword))
.roles("ADMIN", "USER")
.build();
return new InMemoryUserDetailsManager(user, admin);
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.miguno.javadockerbuild.controllers;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/** Implements a basic landing page at endpoint `/`. */
@SuppressFBWarnings("SPRING_ENDPOINT")
@RestController
public class RootController {

@Value("${app.spring-boot-admin.role.user.name}")
private String roleUserName;

@Value("${spring.application.name}")
private String appName;

@Value("${app.spring-boot-admin.role.user.password}")
private String roleUserPassword;

@Value("${app.spring-boot-admin.role.admin.name}")
private String roleAdminName;

@Value("${app.spring-boot-admin.role.admin.password}")
private String roleAdminPassword;

/**
* Returns a basic landing page for this application.
*
* @return Basic landing page in HTML format.
*/
@GetMapping("/")
@SuppressFBWarnings("VA_FORMAT_STRING_USES_NEWLINE")
public String root() {
return String.format(
"""
<h1>Welcome to %s</h1>
<p>Enjoy playing around with this application!</p>
<h2>Example Endpoints</h2>
<ul>
<li><a href="/status"><code>/status</code></a> &mdash; this app's example endpoint</li>
<li><a href="/actuator/health"><code>/actuator/health</code></a> &mdash; Spring built-in feature</li>
<li><a href="/actuator/prometheus"><code>/actuator/prometheus</code></a> &mdash; Spring built-in feature</li>
<li><a href="/admin">Spring Boot Admin dashboard</a> <strong>(requires login, see below)</strong></li>
<li><a href="/swagger-ui.html">Swagger UI</a></li>
</ul>
<h2>User Accounts</h2>
<p>For endpoints that require login.</p>
<ul>
<li>Admin user: <strong><code>%s</code></strong> with password <strong><code>%s</code></strong></li>
<li>Regular user: <strong><code>%s</code></strong> with password <strong><code>%s</code></strong></li>
</p>
<ul>
</ul>
""",
appName, roleAdminName, roleAdminPassword, roleUserName, roleUserPassword);
}
}
Loading

0 comments on commit 6dc246c

Please sign in to comment.