Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

FIX-5017 Add authorization metrics #5074

Merged
merged 11 commits into from
Mar 28, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import static java.util.Objects.requireNonNull;

import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import org.slf4j.Logger;
Expand All @@ -28,11 +29,17 @@
import com.linecorp.armeria.common.HttpRequest;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.metric.MeterIdPrefix;
import com.linecorp.armeria.common.metric.MoreMeters;
import com.linecorp.armeria.common.util.Exceptions;
import com.linecorp.armeria.server.HttpService;
import com.linecorp.armeria.server.ServiceConfig;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.SimpleDecoratingHttpService;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;

/**
* Decorates an {@link HttpService} to provide HTTP authorization functionality.
*
Expand Down Expand Up @@ -75,33 +82,52 @@ public static AuthServiceBuilder builder() {
private final Authorizer<HttpRequest> authorizer;
private final AuthSuccessHandler defaultSuccessHandler;
private final AuthFailureHandler defaultFailureHandler;
@Nullable
private Timer successTimer;
@Nullable
private Timer failureTimer;
private final MeterIdPrefix meterIdPrefix;

AuthService(HttpService delegate, Authorizer<HttpRequest> authorizer,
AuthSuccessHandler defaultSuccessHandler, AuthFailureHandler defaultFailureHandler) {
AuthSuccessHandler defaultSuccessHandler, AuthFailureHandler defaultFailureHandler,
MeterIdPrefix meterIdPrefix) {
super(delegate);
this.authorizer = authorizer;
this.defaultSuccessHandler = defaultSuccessHandler;
this.defaultFailureHandler = defaultFailureHandler;
this.meterIdPrefix = meterIdPrefix;
}

@Override
public void serviceAdded(ServiceConfig cfg) throws Exception {
super.serviceAdded(cfg);
final MeterRegistry meterRegistry = cfg.server().meterRegistry();
successTimer = MoreMeters.newTimer(meterRegistry, meterIdPrefix.name(),
meterIdPrefix.tags("result", "success"));
failureTimer = MoreMeters.newTimer(meterRegistry, meterIdPrefix.name(),
meterIdPrefix.tags("result", "failure"));
}

@Override
public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception {
final long startNanos = System.nanoTime();

return HttpResponse.of(AuthorizerUtil.authorizeAndSupplyHandlers(authorizer, ctx, req)
.handleAsync((result, cause) -> {
try {
final HttpService delegate = (HttpService) unwrap();
if (cause == null) {
if (result != null) {
if (!result.isAuthorized()) {
return handleFailure(delegate, result.failureHandler(), ctx, req, null);
return handleFailure(delegate, result.failureHandler(), ctx, req, null, startNanos);
}
return handleSuccess(delegate, result.successHandler(), ctx, req);
return handleSuccess(delegate, result.successHandler(), ctx, req, startNanos);
}
cause = AuthorizerUtil.newNullResultException(authorizer);
}

return handleFailure(delegate, result != null ? result.failureHandler() : null,
ctx, req, cause);
ctx, req, cause, startNanos);
} catch (Exception e) {
return Exceptions.throwUnsafely(e);
}
Expand All @@ -110,22 +136,39 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exc

private HttpResponse handleSuccess(HttpService delegate,
@Nullable AuthSuccessHandler authorizerSuccessHandler,
ServiceRequestContext ctx, HttpRequest req)
ServiceRequestContext ctx, HttpRequest req,
long startNanos)
throws Exception {
final AuthSuccessHandler handler = authorizerSuccessHandler == null ? defaultSuccessHandler
: authorizerSuccessHandler;
return handler.authSucceeded(delegate, ctx, req);
try {
return handler.authSucceeded(delegate, ctx, req);
} finally {
maybeRecordTimer(successTimer, startNanos);
yashmurty marked this conversation as resolved.
Show resolved Hide resolved
}
}

private HttpResponse handleFailure(HttpService delegate,
@Nullable AuthFailureHandler authorizerFailureHandler,
ServiceRequestContext ctx, HttpRequest req,
@Nullable Throwable cause) throws Exception {
@Nullable Throwable cause,
long startNanos) throws Exception {
final AuthFailureHandler handler = authorizerFailureHandler == null ? defaultFailureHandler
: authorizerFailureHandler;
if (cause != null) {
cause = Exceptions.peel(cause);
}
return handler.authFailed(delegate, ctx, req, cause);
try {
return handler.authFailed(delegate, ctx, req, cause);
} finally {
maybeRecordTimer(failureTimer, startNanos);
}
}

private static void maybeRecordTimer(@Nullable Timer timer, long startNanos) {
if (timer == null) {
return;
}
timer.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import com.linecorp.armeria.common.auth.BasicToken;
import com.linecorp.armeria.common.auth.OAuth1aToken;
import com.linecorp.armeria.common.auth.OAuth2Token;
import com.linecorp.armeria.common.metric.MeterIdPrefix;
import com.linecorp.armeria.server.HttpService;
import com.linecorp.armeria.server.Service;

Expand All @@ -45,6 +46,7 @@ public final class AuthServiceBuilder {
}
return HttpResponse.of(HttpStatus.UNAUTHORIZED);
};
private MeterIdPrefix meterIdPrefix = new MeterIdPrefix("armeria.server.auth");

/**
* Creates a new instance.
Expand Down Expand Up @@ -153,17 +155,41 @@ public AuthServiceBuilder onFailure(AuthFailureHandler failureHandler) {
return this;
}

/**
* Sets the {@link MeterIdPrefix} pattern to which metrics will be collected.
* By default, {@code armeria.server.auth} will be used as the metric name.
* <table>
* <caption>Metrics that will be generated by this class</caption>
* <tr>
* <th>metric name</th>
* <th>description</th>
* </tr>
* <tr>
* <td>{@code <name>#count{result="success"}}</td>
* <td>The number of successful authentication requests.</td>
* </tr>
* <tr>
* <td>{@code <name>#count{result="failure"}}</td>
* <td>The number of failed authentication requests.</td>
* </tr>
* </table>
*/
public AuthServiceBuilder meterIdPrefix(MeterIdPrefix meterIdPrefix) {
this.meterIdPrefix = requireNonNull(meterIdPrefix, "meterIdPrefix");
return this;
}

/**
* Returns a newly-created {@link AuthService} based on the {@link Authorizer}s added to this builder.
*/
public AuthService build(HttpService delegate) {
return new AuthService(requireNonNull(delegate, "delegate"), authorizer(),
successHandler, failureHandler);
successHandler, failureHandler, meterIdPrefix);
}

private AuthService build(HttpService delegate, Authorizer<HttpRequest> authorizer) {
return new AuthService(requireNonNull(delegate, "delegate"), authorizer,
successHandler, failureHandler);
successHandler, failureHandler, meterIdPrefix);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static com.linecorp.armeria.common.HttpHeaderNames.AUTHORIZATION;
import static com.linecorp.armeria.common.util.UnmodifiableFuture.completedFuture;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
Expand All @@ -42,6 +43,7 @@

import com.linecorp.armeria.client.BlockingWebClient;
import com.linecorp.armeria.client.WebClient;
import com.linecorp.armeria.common.AggregatedHttpResponse;
import com.linecorp.armeria.common.HttpHeaderNames;
import com.linecorp.armeria.common.HttpHeaders;
import com.linecorp.armeria.common.HttpMethod;
Expand All @@ -52,6 +54,7 @@
import com.linecorp.armeria.common.auth.BasicToken;
import com.linecorp.armeria.common.auth.OAuth1aToken;
import com.linecorp.armeria.common.auth.OAuth2Token;
import com.linecorp.armeria.common.metric.MoreMeters;
import com.linecorp.armeria.common.util.UnmodifiableFuture;
import com.linecorp.armeria.internal.testing.AnticipatedException;
import com.linecorp.armeria.server.AbstractHttpService;
Expand All @@ -61,6 +64,7 @@
import com.linecorp.armeria.server.logging.LoggingService;
import com.linecorp.armeria.testing.junit5.server.ServerExtension;

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.netty.util.AsciiString;

class AuthServiceTest {
Expand All @@ -75,13 +79,14 @@ String accessToken() {

private static final Function<HttpHeaders, InsecureToken> INSECURE_TOKEN_EXTRACTOR =
headers -> new InsecureToken();

private static final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
private static final AsciiString CUSTOM_TOKEN_HEADER = HttpHeaderNames.of("X-Custom-Authorization");

@RegisterExtension
static final ServerExtension server = new ServerExtension() {
@Override
protected void configure(ServerBuilder sb) throws Exception {
sb.meterRegistry(meterRegistry);
final HttpService ok = new AbstractHttpService() {
@Override
protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) {
Expand Down Expand Up @@ -293,9 +298,9 @@ void testOAuth1a() throws Exception {
@Test
void testOAuth2() throws Exception {
final BlockingWebClient webClient = WebClient.builder(server.httpUri())
.auth(AuthToken.ofOAuth2("dummy_oauth2_token"))
.build()
.blocking();
.auth(AuthToken.ofOAuth2("dummy_oauth2_token"))
.build()
.blocking();
assertThat(webClient.get("/oauth2").status()).isEqualTo(HttpStatus.OK);
try (CloseableHttpClient hc = HttpClients.createMinimal()) {
try (CloseableHttpResponse res = hc.execute(
Expand Down Expand Up @@ -436,6 +441,20 @@ void shouldPeelRedundantAuthorizerExceptions() throws Exception {
assertThat(causeRef.get()).isInstanceOf(AnticipatedException.class);
}



@Test
void shouldRecordMetrics() {
final double before = MoreMeters.measureAll(meterRegistry)
.getOrDefault("armeria.server.auth#count", 0.0);
final AggregatedHttpResponse res = server.blockingWebClient(cb -> cb.auth(AuthToken.ofBasic("brown",
"cony")))
.get("/basic");
assertThat(res.status().code()).isEqualTo(200);
await().untilAsserted(() -> assertThat(MoreMeters.measureAll(meterRegistry))
.containsEntry("armeria.server.auth#count{result=success}", before + 1));
}

private static HttpUriRequestBase getRequest(String path, String authorization) {
final HttpGet request = new HttpGet(server.httpUri().resolve(path));
request.addHeader("Authorization", authorization);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ public class GrpcHttpJsonTranscodingServiceAnnotatedAuthServiceTest {
@Override
protected void configure(ServerBuilder sb) throws Exception {
final GrpcService grpcService = GrpcService.builder()
.addService(new AuthenticatedHttpJsonTranscodingTestService())
.enableHttpJsonTranscoding(true)
.build();
.addService(new AuthenticatedHttpJsonTranscodingTestService())
.enableHttpJsonTranscoding(true)
.build();
sb.requestTimeoutMillis(5000);
sb.decorator(LoggingService.newDecorator());
sb.service(grpcService);
Expand All @@ -87,20 +87,20 @@ protected void configure(ServerBuilder sb) throws Exception {
@Test
void testAuthenticatedRpcMethod() throws Exception {
final Transcoding.GetMessageRequestV1 requestMessage = Transcoding.GetMessageRequestV1.newBuilder()
.setName("messages/1").build();
.setName("messages/1").build();
final Throwable exception = assertThrows(Throwable.class,
() -> grpcClient.getMessageV1(requestMessage).getText());
() -> grpcClient.getMessageV1(requestMessage).getText());
assertThat(exception).isInstanceOf(StatusRuntimeException.class);
assertThat(((StatusRuntimeException) exception).getStatus().getCode())
.isEqualTo(Status.UNAUTHENTICATED.getCode());

final Metadata metadata = new Metadata();
metadata.put(Metadata.Key.of(TEST_CREDENTIAL_KEY, Metadata.ASCII_STRING_MARSHALLER),
"some-credential-string");
"some-credential-string");
final Transcoding.Message result =
grpcClient.withInterceptors(
MetadataUtils.newAttachHeadersInterceptor(metadata)
).getMessageV1(requestMessage);
MetadataUtils.newAttachHeadersInterceptor(metadata)
).getMessageV1(requestMessage);
assertThat(result.getText()).isEqualTo("messages/1");
}

Expand All @@ -110,11 +110,11 @@ void testAuthenticatedHttpJsonTranscoding() throws Exception {
assertThat(failResponse.status()).isEqualTo(HttpStatus.UNAUTHORIZED);

final JsonNode root = webClient.prepare()
.get("/v1/messages/1")
.header(TEST_CREDENTIAL_KEY, "some-credential-string")
.asJson(JsonNode.class)
.execute()
.content();
.get("/v1/messages/1")
.header(TEST_CREDENTIAL_KEY, "some-credential-string")
.asJson(JsonNode.class)
.execute()
.content();
assertThat(root.get("text").asText()).isEqualTo("messages/1");
}

Expand Down