From c3d38e2d11d59c466bef1a23b4fb1a1880f841b9 Mon Sep 17 00:00:00 2001 From: Stephan Schnabel Date: Thu, 20 Aug 2020 19:40:48 +0200 Subject: [PATCH] Add request filter. --- README.md | 41 ++++++++++ pom.xml | 15 ++++ .../request/HeaderLoggingHttpFilter.java | 75 +++++++++++++++++++ .../request/HeaderLoggingTurboFilter.java | 32 ++++++++ .../logging/request/PrincipalHttpFilter.java | 55 ++++++++++++++ .../logging/request/CompositeTest.java | 41 ++++++++++ .../logging/request/RequestHeaderTest.java | 57 ++++++++++++++ .../logging/request/RequestPrincipalTest.java | 44 +++++++++++ .../micronaut/logging/request/TestClient.java | 64 ++++++++++++++++ .../logging/request/TestController.java | 52 +++++++++++++ .../resources/application-test-composite.yaml | 8 ++ src/test/resources/application-test.yaml | 16 ++++ 12 files changed, 500 insertions(+) create mode 100644 src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingHttpFilter.java create mode 100644 src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingTurboFilter.java create mode 100644 src/main/java/io/kokuwa/micronaut/logging/request/PrincipalHttpFilter.java create mode 100644 src/test/java/io/kokuwa/micronaut/logging/request/CompositeTest.java create mode 100644 src/test/java/io/kokuwa/micronaut/logging/request/RequestHeaderTest.java create mode 100644 src/test/java/io/kokuwa/micronaut/logging/request/RequestPrincipalTest.java create mode 100644 src/test/java/io/kokuwa/micronaut/logging/request/TestClient.java create mode 100644 src/test/java/io/kokuwa/micronaut/logging/request/TestController.java create mode 100644 src/test/resources/application-test-composite.yaml create mode 100644 src/test/resources/application-test.yaml diff --git a/README.md b/README.md index 30645e4..378e985 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,44 @@ logger: user: {} ``` +### Set log level based on HTTP request header + +Confguration: + * *enabled*: enable HTTP request filter (`true` is default) + * *order*: order for [Ordered](https://github.com/micronaut-projects/micronaut-core/blob/master/core/src/main/java/io/micronaut/core/order/Ordered.java) (highest is default) + * *pattern*: filter pattern (`/**` is default) + * *header*: name of HTTP header (`x-log-level` is default) + +Example with default configuration: +``` +logger: + request: + header: + enabled: true + order: -2147483648 + pattern: /** + header: x-log-level +``` + +### Add princial for request to MDC + +Confguration: + * *enabled*: enable HTTP principal filter (`true` is default) + * *order*: order for [Ordered](https://github.com/micronaut-projects/micronaut-core/blob/master/core/src/main/java/io/micronaut/core/order/Ordered.java) ([ServerFilterPhase.SECURITY.after()](https://github.com/micronaut-projects/micronaut-core/blob/v2.0.1/http/src/main/java/io/micronaut/http/filter/ServerFilterPhase.java#L54) is default) + * *pattern*: filter pattern (`/**` is default) + * *key*: name of MDC header (`principal` is default) + +Example with default configuration: +``` +logger: + request: + principal: + enabled: true + order: 39250 + pattern: /** + key: principal +``` + ## Build & Release ### Dependency updates @@ -88,3 +126,6 @@ mvn release:prepare release:perform release:clean -B ## Open Topics * configure mdc on refresh event + * add stackdriver per configuration + * add fluent per configuration + * read **serviceName** and **serviceVersion** from yaml diff --git a/pom.xml b/pom.xml index a053834..84db594 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,21 @@ micronaut-test-junit5 test + + io.micronaut + micronaut-http-client + test + + + io.micronaut + micronaut-http-server-netty + test + + + io.micronaut.security + micronaut-security-jwt + test + diff --git a/src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingHttpFilter.java b/src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingHttpFilter.java new file mode 100644 index 0000000..bed5fae --- /dev/null +++ b/src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingHttpFilter.java @@ -0,0 +1,75 @@ +package io.kokuwa.micronaut.logging.request; + +import java.util.Optional; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +import org.reactivestreams.Publisher; +import org.slf4j.MDC; + +import ch.qos.logback.classic.turbo.TurboFilter; +import io.kokuwa.micronaut.logging.LogbackUtil; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.filter.ServerFilterPhase; +import io.micronaut.runtime.server.EmbeddedServer; + +/** + * Http request logging filter. + * + * @author Stephan Schnabel + */ +@Requires(beans = EmbeddedServer.class) +@Requires(property = HeaderLoggingHttpFilter.ENABLED, notEquals = "false") +@Filter("${" + HeaderLoggingHttpFilter.PREFIX + ".pattern:" + HeaderLoggingHttpFilter.DEFAULT_PATTERN + ":/**}") +public class HeaderLoggingHttpFilter implements HttpServerFilter { + + public static final String PREFIX = "logger.request.header"; + public static final String ENABLED = PREFIX + ".enabled"; + public static final String MDC_FILTER = PREFIX + ".filter"; + public static final String MDC_KEY = "level"; + + public static final String DEFAULT_HEADER = "x-log-level"; + public static final String DEFAULT_PATTERN = "/**"; + public static final int DEFAULT_ORDER = ServerFilterPhase.FIRST.before(); + + private final LogbackUtil logback; + private final String header; + private final int order; + + public HeaderLoggingHttpFilter( + LogbackUtil logback, + @Value("${" + PREFIX + ".header:" + DEFAULT_HEADER + "}") String header, + @Value("${" + PREFIX + ".order}") Optional order) { + this.logback = logback; + this.header = header; + this.order = order.orElse(DEFAULT_ORDER); + } + + @PostConstruct + void startTurbofilter() { + logback.getTurboFilter(HeaderLoggingTurboFilter.class, MDC_FILTER, HeaderLoggingTurboFilter::new).start(); + } + + @PreDestroy + void stopTurbofilter() { + logback.getTurboFilter(HeaderLoggingTurboFilter.class, MDC_FILTER).ifPresent(TurboFilter::stop); + } + + @Override + public int getOrder() { + return order; + } + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + request.getHeaders().getFirst(header).ifPresent(level -> MDC.put(MDC_KEY, level)); + return chain.proceed(request); + } +} diff --git a/src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingTurboFilter.java b/src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingTurboFilter.java new file mode 100644 index 0000000..17e42ea --- /dev/null +++ b/src/main/java/io/kokuwa/micronaut/logging/request/HeaderLoggingTurboFilter.java @@ -0,0 +1,32 @@ +package io.kokuwa.micronaut.logging.request; + +import org.slf4j.MDC; +import org.slf4j.Marker; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; + +/** + * Filter for log levels based on MDC. + * + * @author Stephan Schnabel + */ +public class HeaderLoggingTurboFilter extends TurboFilter { + + @Override + public FilterReply decide(Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) { + + if (!isStarted()) { + return FilterReply.NEUTRAL; + } + + var value = MDC.get(HeaderLoggingHttpFilter.MDC_KEY); + if (value == null) { + return FilterReply.NEUTRAL; + } + + return level.isGreaterOrEqual(Level.valueOf(value)) ? FilterReply.ACCEPT : FilterReply.NEUTRAL; + } +} diff --git a/src/main/java/io/kokuwa/micronaut/logging/request/PrincipalHttpFilter.java b/src/main/java/io/kokuwa/micronaut/logging/request/PrincipalHttpFilter.java new file mode 100644 index 0000000..80848e2 --- /dev/null +++ b/src/main/java/io/kokuwa/micronaut/logging/request/PrincipalHttpFilter.java @@ -0,0 +1,55 @@ +package io.kokuwa.micronaut.logging.request; + +import java.util.Optional; + +import org.reactivestreams.Publisher; +import org.slf4j.MDC; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.filter.ServerFilterPhase; +import io.micronaut.runtime.server.EmbeddedServer; + +/** + * Http request principal filter. + * + * @author Stephan Schnabel + */ +@Requires(beans = EmbeddedServer.class) +@Requires(property = PrincipalHttpFilter.ENABLED, notEquals = "false") +@Filter("${" + PrincipalHttpFilter.PREFIX + ".pattern:" + PrincipalHttpFilter.DEFAULT_PATTERN + ":/**}") +public class PrincipalHttpFilter implements HttpServerFilter { + + public static final String PREFIX = "logger.request.principal"; + public static final String ENABLED = PREFIX + ".enabled"; + + public static final String DEFAULT_KEY = "principal"; + public static final String DEFAULT_PATTERN = "/**"; + public static final int DEFAULT_ORDER = ServerFilterPhase.SECURITY.after(); + + private final String key; + private final int order; + + public PrincipalHttpFilter( + @Value("${" + PREFIX + ".key:" + DEFAULT_KEY + "}") String key, + @Value("${" + PREFIX + ".order}") Optional order) { + this.key = key; + this.order = order.orElse(DEFAULT_ORDER); + } + + @Override + public int getOrder() { + return order; + } + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + request.getUserPrincipal().ifPresent(princial -> MDC.put(key, princial.getName())); + return chain.proceed(request); + } +} diff --git a/src/test/java/io/kokuwa/micronaut/logging/request/CompositeTest.java b/src/test/java/io/kokuwa/micronaut/logging/request/CompositeTest.java new file mode 100644 index 0000000..26e766d --- /dev/null +++ b/src/test/java/io/kokuwa/micronaut/logging/request/CompositeTest.java @@ -0,0 +1,41 @@ +package io.kokuwa.micronaut.logging.request; + +import javax.inject.Inject; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import ch.qos.logback.classic.Level; +import io.kokuwa.micronaut.logging.AbstractTest; +import io.micronaut.test.annotation.MicronautTest; + +/** + * Test for MDC and request filter combined. + * + * @author Stephan Schnabel + */ +@DisplayName("request-composite") +@MicronautTest(environments = "test-composite") +public class CompositeTest extends AbstractTest { + + @Inject + TestClient client; + + @DisplayName("default level") + @Test + void defaultLogging() { + client.assertLevel(Level.INFO, client.token("somebody"), null); + } + + @DisplayName("level set by mdc") + @Test + void headerFromMdc() { + client.assertLevel(Level.DEBUG, client.token("horst"), null); + } + + @DisplayName("level set by header (overriding mdc)") + @Test + void headerFromHeader() { + client.assertLevel(Level.TRACE, client.token("horst"), "TRACE"); + } +} diff --git a/src/test/java/io/kokuwa/micronaut/logging/request/RequestHeaderTest.java b/src/test/java/io/kokuwa/micronaut/logging/request/RequestHeaderTest.java new file mode 100644 index 0000000..b939b95 --- /dev/null +++ b/src/test/java/io/kokuwa/micronaut/logging/request/RequestHeaderTest.java @@ -0,0 +1,57 @@ +package io.kokuwa.micronaut.logging.request; + +import javax.inject.Inject; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import ch.qos.logback.classic.Level; +import io.kokuwa.micronaut.logging.AbstractTest; + +/** + * Test for {@link HeaderLoggingHttpFilter}. + * + * @author Stephan Schnabel + */ +@DisplayName("request-header") +public class RequestHeaderTest extends AbstractTest { + + @Inject + TestClient client; + + @DisplayName("header missing") + @Test + void headerMissing() { + client.assertLevel(Level.INFO, null, null); + } + + @DisplayName("header invalid, use DEBUG as default from logback") + @Test + void headerInvalid() { + client.assertLevel(Level.DEBUG, null, "TRCE"); + } + + @DisplayName("level trace (below default)") + @Test + void headerLevelTrace() { + client.assertLevel(Level.TRACE, null, "TRACE"); + } + + @DisplayName("level debug (below default)") + @Test + void headerLevelDebug() { + client.assertLevel(Level.DEBUG, null, "DEBUG"); + } + + @DisplayName("level info (is default)") + @Test + void headerLevelInfo() { + client.assertLevel(Level.INFO, null, "INFO"); + } + + @DisplayName("level warn (above default)") + @Test + void headerLevelWarn() { + client.assertLevel(Level.INFO, null, "WARN"); + } +} diff --git a/src/test/java/io/kokuwa/micronaut/logging/request/RequestPrincipalTest.java b/src/test/java/io/kokuwa/micronaut/logging/request/RequestPrincipalTest.java new file mode 100644 index 0000000..f8f1fcc --- /dev/null +++ b/src/test/java/io/kokuwa/micronaut/logging/request/RequestPrincipalTest.java @@ -0,0 +1,44 @@ +package io.kokuwa.micronaut.logging.request; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import javax.inject.Inject; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.kokuwa.micronaut.logging.AbstractTest; + +/** + * Test for {@link PrincipalHttpFilter}. + * + * @author Stephan Schnabel + */ +@DisplayName("request-principal") +public class RequestPrincipalTest extends AbstractTest { + + @Inject + TestClient client; + + @DisplayName("token missing") + @Test + void tokenMissing() { + assertPrincipal(null, null); + } + + @DisplayName("token invalid") + @Test + void tokenInvalid() { + assertPrincipal(null, "meh"); + } + + @DisplayName("token valid") + @Test + void tokenValid() { + assertPrincipal("meh", client.token("meh")); + } + + private void assertPrincipal(String expectedPrincipal, String actualTokenValue) { + assertEquals(expectedPrincipal, client.get(actualTokenValue, null).getPrincipal()); + } +} diff --git a/src/test/java/io/kokuwa/micronaut/logging/request/TestClient.java b/src/test/java/io/kokuwa/micronaut/logging/request/TestClient.java new file mode 100644 index 0000000..b44495a --- /dev/null +++ b/src/test/java/io/kokuwa/micronaut/logging/request/TestClient.java @@ -0,0 +1,64 @@ +package io.kokuwa.micronaut.logging.request; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jwt.JWTClaimsSet; + +import ch.qos.logback.classic.Level; +import io.kokuwa.micronaut.logging.request.TestController.TestResponse; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.security.token.jwt.signature.SignatureGeneratorConfiguration; + +/** + * Contoller for testing {@link HeaderLoggingHttpFilter} and {@link PrincipalHttpFilter}. + * + * @author Stephan Schnabel + */ +@Singleton +public class TestClient { + + @Inject + @Client("/") + HttpClient client; + @Inject + SignatureGeneratorConfiguration signature; + + String token(String subject) { + try { + return signature.sign(new JWTClaimsSet.Builder().subject(subject).build()).serialize(); + } catch (JOSEException e) { + fail("failed to create token"); + return null; + } + } + + TestResponse get(String token, String header) { + + var request = HttpRequest.GET("/"); + if (token != null) { + request.bearerAuth(token); + } + if (header != null) { + request.getHeaders().add(HeaderLoggingHttpFilter.DEFAULT_HEADER, header); + } + + var response = client.toBlocking().exchange(request, TestResponse.class); + assertEquals(HttpStatus.OK, response.getStatus(), "status"); + assertTrue(response.getBody().isPresent(), "body"); + + return response.body(); + } + + void assertLevel(Level expectedLevel, String actualTokenValue, String actualHeaderValue) { + assertEquals(expectedLevel.toString(), get(actualTokenValue, actualHeaderValue).getLevel()); + } +} diff --git a/src/test/java/io/kokuwa/micronaut/logging/request/TestController.java b/src/test/java/io/kokuwa/micronaut/logging/request/TestController.java new file mode 100644 index 0000000..3d3d6c9 --- /dev/null +++ b/src/test/java/io/kokuwa/micronaut/logging/request/TestController.java @@ -0,0 +1,52 @@ +package io.kokuwa.micronaut.logging.request; + +import org.slf4j.MDC; + +import ch.qos.logback.classic.Level; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Controller for testing {@link HeaderLoggingHttpFilter} and {@link PrincipalHttpFilter}. + * + * @author Stephan Schnabel + */ +@Secured({ SecurityRule.IS_ANONYMOUS, SecurityRule.IS_AUTHENTICATED }) +@Controller +@Slf4j +public class TestController { + + @Get("/") + TestResponse run() { + + var principal = MDC.get(PrincipalHttpFilter.DEFAULT_KEY); + var level = Level.OFF; + if (log.isTraceEnabled()) { + level = Level.TRACE; + } else if (log.isDebugEnabled()) { + level = Level.DEBUG; + } else if (log.isInfoEnabled()) { + level = Level.INFO; + } else if (log.isWarnEnabled()) { + level = Level.WARN; + } else if (log.isErrorEnabled()) { + level = Level.ERROR; + } + + return new TestResponse(level.toString(), principal); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class TestResponse { + private String level; + private String principal; + } +} diff --git a/src/test/resources/application-test-composite.yaml b/src/test/resources/application-test-composite.yaml new file mode 100644 index 0000000..7ba34db --- /dev/null +++ b/src/test/resources/application-test-composite.yaml @@ -0,0 +1,8 @@ +logger: + mdc: + principal: + level: DEBUG + loggers: + - io.kokuwa + values: + - horst diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..42aa67e --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,16 @@ +micronaut: + security: + token: + jwt: + signatures: + secret: + generator: + secret: pleaseChangeThisSecretForANewOne + jws-algorithm: HS256 + http: + client: + logger-name: io.kokuwa.Test + +logger: + levels: + io.kokuwa.Test: TRACE