Add config options to add authentication attributes as mdc.

This commit is contained in:
Stephan Schnabel 2021-12-01 10:25:11 +01:00
parent a350698f52
commit 7ad1ee0add
Signed by: stephan.schnabel
GPG key ID: F74FE2422AA07290
32 changed files with 964 additions and 541 deletions

View file

@ -2,7 +2,9 @@ package io.kokuwa.micronaut.logging;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer.DisplayName;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.TestClassOrder;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.MDC;
@ -14,7 +16,8 @@ import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
* @author Stephan Schnabel
*/
@MicronautTest
@TestMethodOrder(DisplayName.class)
@TestClassOrder(ClassOrderer.DisplayName.class)
@TestMethodOrder(MethodOrderer.DisplayName.class)
public abstract class AbstractTest {
@BeforeEach

View file

@ -0,0 +1,133 @@
package io.kokuwa.micronaut.logging.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.function.Consumer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import com.nimbusds.jwt.JWTClaimsSet;
import ch.qos.logback.classic.Level;
import io.kokuwa.micronaut.logging.AbstractTest;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.http.HttpHeaderValues;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.client.DefaultHttpClientConfiguration;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.filter.HttpServerFilter;
import io.micronaut.runtime.server.EmbeddedServer;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.security.token.jwt.signature.SignatureGeneratorConfiguration;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
/**
* Test for {@link HttpServerFilter}.
*
* @author Stephan Schnabel
*/
@MicronautTest(rebuildContext = true)
public abstract class AbstractFilterTest extends AbstractTest {
private static boolean INIT = false;
@Inject
SignatureGeneratorConfiguration signature;
@Inject
EmbeddedServer embeddedServer;
@DisplayName("0 init")
@Test
@BeforeEach
void refresh() {
// https://github.com/micronaut-projects/micronaut-core/issues/5453#issuecomment-864594741
if (INIT) {
embeddedServer.refresh();
} else {
INIT = true;
}
}
// security
public String token(String subject) {
return token(subject, claims -> {});
}
@SneakyThrows
public String token(String subject, Consumer<JWTClaimsSet.Builder> manipulator) {
var claims = new JWTClaimsSet.Builder().subject(subject);
manipulator.accept(claims);
return HttpHeaderValues.AUTHORIZATION_PREFIX_BEARER + " " + signature.sign(claims.build()).serialize();
}
// request
@SneakyThrows
public TestResponse get(Map<String, String> headers) {
var request = HttpRequest.GET("/");
headers.forEach((name, value) -> request.header(name, value));
var configuration = new DefaultHttpClientConfiguration();
configuration.setLoggerName("io.kokuwa.TestClient");
var response = HttpClient
.create(embeddedServer.getURL(), configuration)
.toBlocking().exchange(request, TestResponse.class);
assertEquals(HttpStatus.OK, response.getStatus(), "status");
assertTrue(response.getBody().isPresent(), "body");
assertTrue(CollectionUtils.isEmpty(MDC.getCopyOfContextMap()), "mdc leaked: " + MDC.getCopyOfContextMap());
return response.body();
}
@Secured({ SecurityRule.IS_ANONYMOUS, SecurityRule.IS_AUTHENTICATED })
@Controller
@Slf4j
public static class TestController {
@Get("/")
TestResponse run() {
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;
}
var mdc = MDC.getCopyOfContextMap();
log.info("Found MDC: {}", mdc);
return new TestResponse(level.toString(), mdc == null ? Map.of() : mdc);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TestResponse {
private String level;
private Map<String, String> context = Map.of();
}
}

View file

@ -0,0 +1,80 @@
package io.kokuwa.micronaut.logging.http.level;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import ch.qos.logback.classic.Level;
import io.kokuwa.micronaut.logging.http.AbstractFilterTest;
import io.micronaut.context.annotation.Property;
/**
* Test for {@link LogLevelServerFilter}.
*
* @author Stephan Schnabel
*/
@DisplayName("http: set log level via http request")
public class LogLevelServerFilterTest extends AbstractFilterTest {
@DisplayName("noop: disabled")
@Test
@Property(name = "logger.http.level.enabled", value = "false")
void noopDisabled() {
assertLevel(Level.INFO, "TRACE");
}
@DisplayName("noop: header missing")
@Test
void noopHeaderMissing() {
assertLevel(Level.INFO, null);
}
@DisplayName("noop: header invalid, use DEBUG as default from logback")
@Test
void noopHeaderInvalid() {
assertLevel(Level.DEBUG, "TRCE");
}
@DisplayName("level: trace (below default)")
@Test
void levelTrace() {
assertLevel(Level.TRACE, "TRACE");
}
@DisplayName("level: debug (below default)")
@Test
void levelDebug() {
assertLevel(Level.DEBUG, "DEBUG");
}
@DisplayName("level: info (is default)")
@Test
void levelInfo() {
assertLevel(Level.INFO, "INFO");
}
@DisplayName("level: warn (above default)")
@Test
void levelWarn() {
assertLevel(Level.INFO, "WARN");
}
@DisplayName("config: custom header name")
@Test
@Property(name = "logger.http.level.header", value = "FOO")
void configHeaderWarn() {
assertLevel(Level.TRACE, "FOO", "TRACE");
}
private void assertLevel(Level expectedLevel, String value) {
assertLevel(expectedLevel, LogLevelServerFilter.DEFAULT_HEADER, value);
}
private void assertLevel(Level expectedLevel, String name, String value) {
var headers = value == null ? Map.<String, String>of() : Map.of(name, value);
assertEquals(expectedLevel.toString(), get(headers).getLevel());
}
}

View file

@ -0,0 +1,73 @@
package io.kokuwa.micronaut.logging.http.mdc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import io.kokuwa.micronaut.logging.http.AbstractFilterTest;
import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpHeaders;
/**
* Test for {@link AuthenticationMdcFilter}.
*
* @author Stephan Schnabel
*/
@DisplayName("http: mdc from authentication")
public class AuthenticationMdcFilterTest extends AbstractFilterTest {
@DisplayName("noop: disabled")
@Test
@Property(name = "logger.http.authentication.enabled", value = "false")
void noopDisabled() {
assertEquals(Map.of(), getContext(true));
}
@DisplayName("noop: token missing")
@Test
void noopTokenMissing() {
assertEquals(Map.of(), getContext(false));
}
@DisplayName("mdc: default config")
@Test
void mdcWithDefault() {
assertEquals(Map.of("principal", "mySubject"), getContext(true));
}
@DisplayName("mdc: with name")
@Test
@Property(name = "logger.http.authentication.name", value = "sub")
void mdcWithName() {
assertEquals(Map.of("sub", "mySubject"), getContext(true));
}
@DisplayName("mdc: with attribute keys")
@Test
@Property(name = "logger.http.authentication.attributes", value = "azp,aud")
void mdcWithAttributes() {
assertEquals(Map.of("principal", "mySubject", "aud", "[a, b]", "azp", "myAzp"), getContext(true));
}
@DisplayName("mdc: with prefix")
@Test
@Property(name = "logger.http.authentication.name", value = "sub")
@Property(name = "logger.http.authentication.attributes", value = "azp")
@Property(name = "logger.http.authentication.prefix", value = "auth.")
void mdcWithPrefix() {
assertEquals(Map.of("auth.sub", "mySubject", "auth.azp", "myAzp"), getContext(true));
}
private Map<String, String> getContext(boolean token) {
return get(token
? Map.of(HttpHeaders.AUTHORIZATION, token("mySubject", claims -> claims
.issuer("nope")
.claim("azp", "myAzp")
.audience(List.of("a", "b"))))
: Map.of()).getContext();
}
}

View file

@ -0,0 +1,60 @@
package io.kokuwa.micronaut.logging.http.mdc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import io.kokuwa.micronaut.logging.http.AbstractFilterTest;
import io.micronaut.context.annotation.Property;
/**
* Test for {@link HttpHeadersMdcFilter}.
*
* @author Stephan Schnabel
*/
@DisplayName("http: mdc from headers")
public class HttpHeadersMdcFilterTest extends AbstractFilterTest {
@DisplayName("noop: empty configuration")
@Test
void noopEmptyConfiguration() {
assertContext(Map.of(), Map.of("foo", "bar"));
}
@DisplayName("noop: disabled")
@Test
@Property(name = "logger.http.headers.enabled", value = "false")
@Property(name = "logger.http.headers.names", value = "foo")
void noopDisabled() {
assertContext(Map.of(), Map.of("foo", "bar"));
}
@DisplayName("mdc: mismatch")
@Test
@Property(name = "logger.http.headers.names", value = "foo")
void mdcMismatch() {
assertContext(Map.of(), Map.of("nope", "bar"));
}
@DisplayName("mdc: match without prefix")
@Test
@Property(name = "logger.http.headers.names", value = "foo")
void mdcMatchWithoutPrefix() {
assertContext(Map.of("foo", "bar"), Map.of("foo", "bar", "nope", "bar"));
}
@DisplayName("mdc: match with prefix")
@Test
@Property(name = "logger.http.headers.names", value = "foo")
@Property(name = "logger.http.headers.prefix", value = "header.")
void mdcMatchWithPrefix() {
assertContext(Map.of("header.foo", "bar"), Map.of("foo", "bar", "nope", "bar"));
}
private void assertContext(Map<String, String> expectedMdcs, Map<String, String> headers) {
assertEquals(expectedMdcs, get(headers).getContext());
}
}

View file

@ -17,7 +17,7 @@ import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
*
* @author Stephan Schnabel
*/
@DisplayName("mdc")
@DisplayName("mdc based log levels")
@MicronautTest(environments = "test-mdc")
public class MDCTurboFilterTest extends AbstractTest {

View file

@ -1,40 +0,0 @@
package io.kokuwa.micronaut.logging.request;
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.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
/**
* 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");
}
}

View file

@ -1,56 +0,0 @@
package io.kokuwa.micronaut.logging.request;
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 jakarta.inject.Inject;
/**
* Test for {@link HeaderLoggingServerHttpFilter}.
*
* @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");
}
}

View file

@ -1,43 +0,0 @@
package io.kokuwa.micronaut.logging.request;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import io.kokuwa.micronaut.logging.AbstractTest;
import jakarta.inject.Inject;
/**
* 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());
}
}

View file

@ -1,63 +0,0 @@
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 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;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Contoller for testing {@link HeaderLoggingServerHttpFilter} 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(HeaderLoggingServerHttpFilter.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());
}
}

View file

@ -1,54 +0,0 @@
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 HeaderLoggingServerHttpFilter} 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;
}
log.info("Test log for MDC inclusion, expected: {}", principal);
return new TestResponse(level.toString(), principal);
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class TestResponse {
private String level;
private String principal;
}
}