Skip to content

Commit

Permalink
Merge pull request #20481 from michalszynkiewicz/grpc-security
Browse files Browse the repository at this point in the history
Quarkus gRPC security interceptor + non-blocking security check
  • Loading branch information
michalszynkiewicz authored Oct 6, 2021
2 parents 623a957 + eafcc01 commit 3990936
Show file tree
Hide file tree
Showing 13 changed files with 604 additions and 6 deletions.
5 changes: 5 additions & 0 deletions extensions/grpc/deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-elytron-security-properties-file-deployment</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
import io.quarkus.arc.processor.BeanInfo;
import io.quarkus.arc.processor.BuiltinScope;
import io.quarkus.deployment.ApplicationArchive;
import io.quarkus.deployment.Capabilities;
import io.quarkus.deployment.Capability;
import io.quarkus.deployment.IsDevelopment;
import io.quarkus.deployment.IsNormal;
import io.quarkus.deployment.annotations.BuildProducer;
Expand All @@ -71,6 +73,8 @@
import io.quarkus.gizmo.MethodCreator;
import io.quarkus.gizmo.MethodDescriptor;
import io.quarkus.grpc.GrpcService;
import io.quarkus.grpc.auth.DefaultAuthExceptionHandlerProvider;
import io.quarkus.grpc.auth.GrpcSecurityInterceptor;
import io.quarkus.grpc.deployment.devmode.FieldDefinalizingVisitor;
import io.quarkus.grpc.protoc.plugin.MutinyGrpcGenerator;
import io.quarkus.grpc.runtime.GrpcContainer;
Expand Down Expand Up @@ -334,6 +338,7 @@ KubernetesPortBuildItem registerGrpcServiceInKubernetes(List<BindableServiceBuil

@BuildStep
void registerBeans(BuildProducer<AdditionalBeanBuildItem> beans,
Capabilities capabilities,
List<BindableServiceBuildItem> bindables, BuildProducer<FeatureBuildItem> features) {
// @GrpcService is a CDI qualifier
beans.produce(new AdditionalBeanBuildItem(GrpcService.class));
Expand All @@ -345,15 +350,25 @@ void registerBeans(BuildProducer<AdditionalBeanBuildItem> beans,
// Global interceptors are invoked before any of the per-service interceptors
beans.produce(AdditionalBeanBuildItem.unremovableOf(GrpcRequestContextGrpcInterceptor.class));
features.produce(new FeatureBuildItem(GRPC_SERVER));

if (capabilities.isPresent(Capability.SECURITY)) {
beans.produce(AdditionalBeanBuildItem.unremovableOf(GrpcSecurityInterceptor.class));
beans.produce(AdditionalBeanBuildItem.unremovableOf(DefaultAuthExceptionHandlerProvider.class));
}
} else {
log.debug("Unable to find beans exposing the `BindableService` interface - not starting the gRPC server");
}
}

@BuildStep
void registerAdditionalInterceptors(BuildProducer<AdditionalGlobalInterceptorBuildItem> additionalInterceptors) {
void registerAdditionalInterceptors(BuildProducer<AdditionalGlobalInterceptorBuildItem> additionalInterceptors,
Capabilities capabilities) {
additionalInterceptors
.produce(new AdditionalGlobalInterceptorBuildItem(GrpcRequestContextGrpcInterceptor.class.getName()));
if (capabilities.isPresent(Capability.SECURITY)) {
additionalInterceptors
.produce(new AdditionalGlobalInterceptorBuildItem(GrpcSecurityInterceptor.class.getName()));
}
}

@BuildStep
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package io.quarkus.grpc.auth;

import static com.example.security.Security.ThreadInfo.newBuilder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javax.annotation.security.RolesAllowed;
import javax.inject.Singleton;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import com.example.security.SecuredService;
import com.example.security.Security;

import io.grpc.Metadata;
import io.quarkus.grpc.GrpcClient;
import io.quarkus.grpc.GrpcClientUtils;
import io.quarkus.grpc.GrpcService;
import io.quarkus.security.credential.PasswordCredential;
import io.quarkus.security.identity.request.AuthenticationRequest;
import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest;
import io.quarkus.test.QuarkusUnitTest;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.vertx.core.Context;

public class GrpcAuthTest {

public static final Metadata.Key<String> AUTHORIZATION = Metadata.Key.of("Authorization",
Metadata.ASCII_STRING_MARSHALLER);

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest().setArchiveProducer(
() -> ShrinkWrap.create(JavaArchive.class)
.addClasses(Service.class, BasicGrpcSecurityMechanism.class)
.addPackage(SecuredService.class.getPackage())
.add(new StringAsset("quarkus.security.users.embedded.enabled=true\n" +
"quarkus.security.users.embedded.users.john=john\n" +
"quarkus.security.users.embedded.roles.john=employees\n" +
"quarkus.security.users.embedded.users.paul=paul\n" +
"quarkus.security.users.embedded.roles.paul=interns\n" +
"quarkus.security.users.embedded.plain-text=true\n" +
"quarkus.http.auth.basic=true"), "application.properties"));
public static final String JOHN_BASIC_CREDS = "am9objpqb2hu";
public static final String PAUL_BASIC_CREDS = "cGF1bDpwYXVs";

@GrpcClient
SecuredService securityClient;

@Test
void shouldSecureUniEndpoint() {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, "Basic " + JOHN_BASIC_CREDS);
SecuredService client = GrpcClientUtils.attachHeaders(securityClient, headers);
AtomicInteger resultCount = new AtomicInteger();
client.unaryCall(Security.Container.newBuilder().setText("woo-hoo").build())
.subscribe().with(e -> resultCount.incrementAndGet());

await().atMost(5, TimeUnit.SECONDS)
.until(() -> resultCount.get() == 1);
}

@Test
void shouldSecureMultiEndpoint() {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, "Basic " + PAUL_BASIC_CREDS);
SecuredService client = GrpcClientUtils.attachHeaders(securityClient, headers);
List<Boolean> results = new CopyOnWriteArrayList<>();
client.streamCall(Multi.createBy().repeating()
.supplier(() -> (Security.Container.newBuilder().setText("woo-hoo").build())).atMost(4))
.subscribe().with(e -> results.add(e.getIsOnEventLoop()));

await().atMost(5, TimeUnit.SECONDS)
.until(() -> results.size() == 5);

assertThat(results.stream().filter(e -> !e)).isEmpty();
}

@Test
void shouldFailWithInvalidCredentials() {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, "Basic invalid creds");
SecuredService client = GrpcClientUtils.attachHeaders(securityClient, headers);

AtomicReference<Throwable> error = new AtomicReference<>();

AtomicInteger resultCount = new AtomicInteger();
client.unaryCall(Security.Container.newBuilder().setText("woo-hoo").build())
.onFailure().invoke(error::set)
.subscribe().with(e -> resultCount.incrementAndGet());

await().atMost(5, TimeUnit.SECONDS)
.until(() -> error.get() != null);
}

@Test
void shouldFailWithInvalidInsufficientRole() {
Metadata headers = new Metadata();
headers.put(AUTHORIZATION, PAUL_BASIC_CREDS);
SecuredService client = GrpcClientUtils.attachHeaders(securityClient, headers);

AtomicReference<Throwable> error = new AtomicReference<>();

AtomicInteger resultCount = new AtomicInteger();
client.unaryCall(Security.Container.newBuilder().setText("woo-hoo").build())
.onFailure().invoke(error::set)
.subscribe().with(e -> resultCount.incrementAndGet());

await().atMost(5, TimeUnit.SECONDS)
.until(() -> error.get() != null);
}

@GrpcService
public static class Service implements SecuredService {
@Override
@RolesAllowed("employees")
public Uni<Security.ThreadInfo> unaryCall(Security.Container request) {
return Uni.createFrom()
.item(newBuilder().setIsOnEventLoop(Context.isOnEventLoopThread()).build());
}

@Override
@RolesAllowed("interns")
public Multi<Security.ThreadInfo> streamCall(Multi<Security.Container> request) {
return Multi.createBy()
.repeating().supplier(() -> newBuilder().setIsOnEventLoop(Context.isOnEventLoopThread()).build())
.atMost(5);
}

}

@Singleton
public static class BasicGrpcSecurityMechanism implements GrpcSecurityMechanism {
@Override
public boolean handles(Metadata metadata) {
String authString = metadata.get(AUTHORIZATION);
return authString != null && authString.startsWith("Basic ");
}

@Override
public AuthenticationRequest createAuthenticationRequest(Metadata metadata) {
String authString = metadata.get(AUTHORIZATION);
authString = authString.substring("Basic ".length());
byte[] decode = Base64.getDecoder().decode(authString);
String plainChallenge = new String(decode, StandardCharsets.UTF_8);
int colonPos;
if ((colonPos = plainChallenge.indexOf(':')) > -1) {
String userName = plainChallenge.substring(0, colonPos);
char[] password = plainChallenge.substring(colonPos + 1).toCharArray();
return new UsernamePasswordAuthenticationRequest(userName, new PasswordCredential(password));
} else {
return null;
}
}
}
}
17 changes: 17 additions & 0 deletions extensions/grpc/deployment/src/test/proto/security.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
syntax = "proto3";

package security;
option java_package = "com.example.security";

service SecuredService {
rpc unaryCall(Container) returns (ThreadInfo);
rpc streamCall(stream Container) returns (stream ThreadInfo);
}

message ThreadInfo {
bool isOnEventLoop = 1;
}

message Container {
string text = 1;
}
4 changes: 4 additions & 0 deletions extensions/grpc/runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus.security</groupId>
<artifactId>quarkus-security</artifactId>
</dependency>

<dependency>
<groupId>io.quarkus</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package io.quarkus.grpc.auth;

import javax.enterprise.inject.spi.Prioritized;

import io.grpc.ForwardingServerCallListener;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.Status;
import io.quarkus.security.AuthenticationFailedException;

/**
* Exception mapper for authentication and authorization exceptions
*
* To alter mapping exceptions, create a subclass of this handler and create an appropriate
* {@link AuthExceptionHandlerProvider}
*/
public class AuthExceptionHandler<ReqT, RespT>
extends ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT> implements Prioritized {

private final ServerCall<ReqT, RespT> serverCall;
private final Metadata metadata;

public AuthExceptionHandler(ServerCall.Listener<ReqT> listener, ServerCall<ReqT, RespT> serverCall,
Metadata metadata) {
super(listener);
this.metadata = metadata;
this.serverCall = serverCall;
}

@Override
public void onMessage(ReqT message) {
try {
super.onMessage(message);
} catch (RuntimeException e) {
handleException(e, serverCall, metadata);
}
}

@Override
public void onHalfClose() {
try {
super.onHalfClose();
} catch (RuntimeException e) {
handleException(e, serverCall, metadata);
}
}

@Override
public void onReady() {
try {
super.onReady();
} catch (RuntimeException e) {
handleException(e, serverCall, metadata);
}
}

/**
* Maps exception to a gRPC error. Override this method to customize the mapping
*
* @param exception exception thrown
* @param serverCall server call to close with error
* @param metadata call metadata
*/
protected void handleException(RuntimeException exception, ServerCall<ReqT, RespT> serverCall, Metadata metadata) {
if (exception instanceof AuthenticationFailedException) {
serverCall.close(Status.UNAUTHENTICATED.withDescription(exception.getMessage()), metadata);
} else if (exception instanceof SecurityException) {
serverCall.close(Status.PERMISSION_DENIED.withDescription(exception.getMessage()), metadata);
} else {
throw exception;
}
}

@Override
public int getPriority() {
return 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.quarkus.grpc.auth;

import javax.enterprise.inject.spi.Prioritized;

import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCall.Listener;

/**
* Provider for AuthExceptionHandler.
*
* To use a custom AuthExceptionHandler, extend {@link AuthExceptionHandler} and implement
* an {@link AuthExceptionHandlerProvider} with priority greater than the default one.
*/
public interface AuthExceptionHandlerProvider extends Prioritized {
int DEFAULT_PRIORITY = 0;

<ReqT, RespT> AuthExceptionHandler<ReqT, RespT> createHandler(Listener<ReqT> listener,
ServerCall<ReqT, RespT> serverCall, Metadata metadata);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.quarkus.grpc.auth;

import javax.inject.Singleton;

import io.grpc.Metadata;
import io.grpc.ServerCall;

@Singleton
public class DefaultAuthExceptionHandlerProvider implements AuthExceptionHandlerProvider {

@Override
public int getPriority() {
return DEFAULT_PRIORITY;
}

@Override
public <ReqT, RespT> AuthExceptionHandler<ReqT, RespT> createHandler(ServerCall.Listener<ReqT> listener,
ServerCall<ReqT, RespT> serverCall, Metadata metadata) {
return new AuthExceptionHandler<>(listener, serverCall, metadata);
}
}
Loading

0 comments on commit 3990936

Please sign in to comment.