Skip to content

Commit

Permalink
feat: implement mTLS policy
Browse files Browse the repository at this point in the history
  • Loading branch information
ytvnr committed Sep 5, 2024
1 parent f7cf64f commit 1e72fb4
Show file tree
Hide file tree
Showing 7 changed files with 583 additions and 31 deletions.
7 changes: 6 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

<properties>
<gravitee-bom.version>8.1.0</gravitee-bom.version>
<gravitee-gateway-api.version>3.5.1</gravitee-gateway-api.version>
<gravitee-gateway-api.version>3.7.0</gravitee-gateway-api.version>
<gravitee-reporter-api.version>1.31.1</gravitee-reporter-api.version>
<gravitee-policy-api.version>1.11.0</gravitee-policy-api.version>
<gravitee-apim.version>4.5.0-SNAPSHOT</gravitee-apim.version>
Expand Down Expand Up @@ -135,6 +135,11 @@
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
55 changes: 53 additions & 2 deletions src/main/java/io/gravitee/policy/mtls/MtlsPolicy.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@
* limitations under the License.
*/

import io.gravitee.common.http.HttpStatusCode;
import io.gravitee.gateway.reactive.api.ExecutionFailure;
import io.gravitee.gateway.reactive.api.context.HttpExecutionContext;
import io.gravitee.gateway.reactive.api.policy.SecurityPolicy;
import io.gravitee.gateway.reactive.api.policy.SecurityToken;
import io.gravitee.policy.mtls.configuration.MtlsPolicyConfiguration;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Maybe;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.DigestUtils;

/**
* @author Yann TAVERNIER (yann.tavernier at graviteesource.com)
Expand All @@ -46,6 +54,10 @@
@Slf4j
public class MtlsPolicy implements SecurityPolicy {

public static final String CLIENT_CERTIFICATE_MISSING = "CLIENT_CERTIFICATE_MISSING";
public static final String CLIENT_CERTIFICATE_INVALID = "CLIENT_CERTIFICATE_INVALID";
public static final String FAILURE_MESSAGE = "Unauthorized";
public static final String SSL_SESSION_REQUIRED = "SSL_SESSION_REQUIRED";
private final MtlsPolicyConfiguration configuration;

public MtlsPolicy(MtlsPolicyConfiguration configuration) {
Expand All @@ -58,8 +70,30 @@ public String id() {
}

@Override
public Maybe<SecurityToken> extractSecurityToken(HttpExecutionContext httpExecutionContext) {
return Maybe.just(SecurityToken.none());
public Maybe<SecurityToken> extractSecurityToken(HttpExecutionContext ctx) {
final SSLSession sslSession = ctx.request().sslSession();
if (sslSession == null) {
return Maybe.empty();
}

final Certificate[] peerCertificates;
try {
peerCertificates = sslSession.getPeerCertificates();
} catch (SSLPeerUnverifiedException e) {
return Maybe.empty();
}

if (peerCertificates == null || peerCertificates.length == 0) {
return Maybe.empty();
}

final String clientCertificate;
try {
clientCertificate = DigestUtils.md5DigestAsHex(peerCertificates[0].getEncoded());
} catch (CertificateEncodingException e) {
return Maybe.empty();
}
return Maybe.just(SecurityToken.forClientCertificate(clientCertificate));
}

@Override
Expand All @@ -79,6 +113,23 @@ public int order() {

@Override
public Completable onRequest(HttpExecutionContext ctx) {
final SSLSession sslSession = ctx.request().sslSession();
if (sslSession == null) {
return interruptWith401(ctx, SSL_SESSION_REQUIRED);
}
final Certificate[] certificates;
try {
certificates = sslSession.getPeerCertificates();
} catch (SSLPeerUnverifiedException e) {
return interruptWith401(ctx, CLIENT_CERTIFICATE_INVALID);
}
if (certificates == null || certificates.length == 0) {
return interruptWith401(ctx, CLIENT_CERTIFICATE_MISSING);
}
return Completable.complete();
}

private static Completable interruptWith401(HttpExecutionContext ctx, String errorKey) {
return ctx.interruptWith(new ExecutionFailure(HttpStatusCode.UNAUTHORIZED_401).key(errorKey).message(FAILURE_MESSAGE));
}
}
152 changes: 124 additions & 28 deletions src/test/java/io/gravitee/policy/mtls/MtlsPolicyIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,57 +24,153 @@
import io.gravitee.apim.gateway.tests.sdk.AbstractPolicyTest;
import io.gravitee.apim.gateway.tests.sdk.annotations.DeployApi;
import io.gravitee.apim.gateway.tests.sdk.annotations.GatewayTest;
import io.gravitee.apim.gateway.tests.sdk.configuration.GatewayConfigurationBuilder;
import io.gravitee.apim.gateway.tests.sdk.connector.EndpointBuilder;
import io.gravitee.apim.gateway.tests.sdk.connector.EntrypointBuilder;
import io.gravitee.common.http.HttpStatusCode;
import io.gravitee.node.api.certificate.KeyStoreLoader;
import io.gravitee.plugin.endpoint.EndpointConnectorPlugin;
import io.gravitee.plugin.endpoint.http.proxy.HttpProxyEndpointConnectorFactory;
import io.gravitee.plugin.entrypoint.EntrypointConnectorPlugin;
import io.gravitee.plugin.entrypoint.http.proxy.HttpProxyEntrypointConnectorFactory;
import io.gravitee.policy.mtls.configuration.MtlsPolicyConfiguration;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.net.PemKeyCertOptions;
import io.vertx.rxjava3.core.Vertx;
import io.vertx.rxjava3.core.buffer.Buffer;
import io.vertx.rxjava3.core.http.HttpClient;
import io.vertx.rxjava3.core.http.HttpClientRequest;
import java.net.URL;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
* @author Yann TAVERNIER (yann.tavernier at graviteesource.com)
* @author GraviteeSource Team
*/
@GatewayTest
@DeployApi({ "/apis/v4/api.json" })
public class MtlsPolicyIntegrationTest extends AbstractPolicyTest<MtlsPolicy, MtlsPolicyConfiguration> {
public class MtlsPolicyIntegrationTest {

@Override
public void configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) {
entrypoints.putIfAbsent("http-proxy", EntrypointBuilder.build("http-proxy", HttpProxyEntrypointConnectorFactory.class));
}
@Nested
@GatewayTest
@DeployApi({ "/apis/v4/api.json" })
class SecuredGateway extends AbstractPolicyTest<MtlsPolicy, MtlsPolicyConfiguration> {

@Override
public void configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) {
entrypoints.putIfAbsent("http-proxy", EntrypointBuilder.build("http-proxy", HttpProxyEntrypointConnectorFactory.class));
}

@Override
public void configureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints) {
endpoints.putIfAbsent("http-proxy", EndpointBuilder.build("http-proxy", HttpProxyEndpointConnectorFactory.class));
}

@SneakyThrows
@Override
protected void configureGateway(GatewayConfigurationBuilder config) {
config
.httpSecured(true)
.set("http.ssl.clientAuth", "request")
.set("http.ssl.keystore.type", KeyStoreLoader.CERTIFICATE_FORMAT_SELF_SIGNED)
.set("http.ssl.truststore.path", getUrl("ca.pem").getPath())
.set("http.ssl.truststore.type", "pem")
.set("http.ssl.truststore.password", "secret");
}

@Test
protected void should_be_unauthorized_if_no_certificate_on_request(Vertx vertx) {
wiremock.stubFor(get("/endpoint").willReturn(ok("backend response")));

createTrustedHttpClient(vertx, false)
.rxRequest(HttpMethod.GET, "/test")
.flatMap(HttpClientRequest::rxSend)
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(HttpStatusCode.UNAUTHORIZED_401);
return response.body();
})
.test()
.awaitDone(10, TimeUnit.SECONDS)
.assertComplete()
.assertValue(Buffer.buffer(MtlsPolicy.FAILURE_MESSAGE))
.assertNoErrors();

wiremock.verify(0, getRequestedFor(urlPathEqualTo("/endpoint")));
}

@Test
protected void should_call_the_api_with_a_certificate_on_request(Vertx vertx) {
wiremock.stubFor(get("/endpoint").willReturn(ok("backend response")));

createTrustedHttpClient(vertx, true)
.rxRequest(HttpMethod.GET, "/test")
.flatMap(HttpClientRequest::rxSend)
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(HttpStatusCode.OK_200);
return response.body();
})
.test()
.awaitDone(10, TimeUnit.SECONDS)
.assertComplete()
.assertValue(Buffer.buffer("backend response"))
.assertNoErrors();

@Override
public void configureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints) {
endpoints.putIfAbsent("http-proxy", EndpointBuilder.build("http-proxy", HttpProxyEndpointConnectorFactory.class));
wiremock.verify(1, getRequestedFor(urlPathEqualTo("/endpoint")));
}

HttpClient createTrustedHttpClient(Vertx vertx, boolean withCert) {
var options = new HttpClientOptions().setSsl(true).setTrustAll(true).setDefaultPort(gatewayPort()).setDefaultHost("localhost");
if (withCert) {
options =
options.setPemKeyCertOptions(
new PemKeyCertOptions().addCertPath(getUrl("client.cer").getPath()).addKeyPath(getUrl("client.key").getPath())
);
}

return vertx.createHttpClient(options);
}

URL getUrl(String name) {
return getClass().getClassLoader().getResource(name);
}
}

@Test
protected void should_call_api_with_policy_on_request(HttpClient httpClient) {
wiremock.stubFor(get("/endpoint").willReturn(ok("backend response")));

httpClient
.rxRequest(HttpMethod.GET, "/test")
.flatMap(HttpClientRequest::rxSend)
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(HttpStatusCode.OK_200);
return response.body();
})
.test()
.awaitDone(10, TimeUnit.SECONDS)
.assertComplete()
.assertValue(Buffer.buffer("backend response"))
.assertNoErrors();

wiremock.verify(1, getRequestedFor(urlPathEqualTo("/endpoint")));
@Nested
@GatewayTest
@DeployApi({ "/apis/v4/api.json" })
class UnsecuredGateway extends AbstractPolicyTest<MtlsPolicy, MtlsPolicyConfiguration> {

@Override
public void configureEntrypoints(Map<String, EntrypointConnectorPlugin<?, ?>> entrypoints) {
entrypoints.putIfAbsent("http-proxy", EntrypointBuilder.build("http-proxy", HttpProxyEntrypointConnectorFactory.class));
}

@Override
public void configureEndpoints(Map<String, EndpointConnectorPlugin<?, ?>> endpoints) {
endpoints.putIfAbsent("http-proxy", EndpointBuilder.build("http-proxy", HttpProxyEndpointConnectorFactory.class));
}

@Test
protected void should_be_unauthorized_if_no_certificate_on_request(HttpClient client) {
wiremock.stubFor(get("/endpoint").willReturn(ok("backend response")));

client
.rxRequest(HttpMethod.GET, "/test")
.flatMap(HttpClientRequest::rxSend)
.flatMap(response -> {
assertThat(response.statusCode()).isEqualTo(HttpStatusCode.UNAUTHORIZED_401);
return response.body();
})
.test()
.awaitDone(10, TimeUnit.SECONDS)
.assertComplete()
.assertValue(Buffer.buffer(MtlsPolicy.FAILURE_MESSAGE))
.assertNoErrors();

wiremock.verify(0, getRequestedFor(urlPathEqualTo("/endpoint")));
}
}
}
Loading

0 comments on commit 1e72fb4

Please sign in to comment.