diff --git a/src/main/java/io/cryostat/net/AgentClient.java b/src/main/java/io/cryostat/net/AgentClient.java index 3f25696077..13fd81a13f 100644 --- a/src/main/java/io/cryostat/net/AgentClient.java +++ b/src/main/java/io/cryostat/net/AgentClient.java @@ -81,8 +81,10 @@ import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.codec.BodyCodec; import org.apache.commons.lang3.tuple.Pair; +import org.apache.http.auth.InvalidCredentialsException; -class AgentClient { +public class AgentClient { + public static final String NULL_CREDENTIALS = "No credentials found for agent"; private final Vertx vertx; private final Gson gson; @@ -212,41 +214,56 @@ Future> eventTemplates() { private Future> invoke(HttpMethod mtd, String path, BodyCodec codec) { return Future.fromCompletionStage( CompletableFuture.supplyAsync( - () -> { - logger.info("{} {} {}", mtd, agentUri, path); - HttpRequest req = - webClient - .request( - mtd, - agentUri.getPort(), - agentUri.getHost(), - path) - .ssl("https".equals(agentUri.getScheme())) - .timeout(Duration.ofSeconds(httpTimeout).toMillis()) - .followRedirects(true) - .as(codec); - try { - Credentials credentials = - credentialsManager.getCredentialsByTargetId( - agentUri.toString()); - req = - req.authentication( - new UsernamePasswordCredentials( - credentials.getUsername(), - credentials.getPassword())); - } catch (ScriptException e) { - logger.error(e); - throw new RuntimeException(e); - } - - try { - return req.send().toCompletionStage().toCompletableFuture().get(); - } catch (InterruptedException | ExecutionException e) { - logger.error(e); - throw new RuntimeException(e); - } - }, - ForkJoinPool.commonPool())); + () -> { + logger.info("{} {} {}", mtd, agentUri, path); + HttpRequest req = + webClient + .request( + mtd, + agentUri.getPort(), + agentUri.getHost(), + path) + .ssl("https".equals(agentUri.getScheme())) + .timeout( + Duration.ofSeconds(httpTimeout) + .toMillis()) + .followRedirects(true) + .as(codec); + try { + Credentials credentials = + credentialsManager.getCredentialsByTargetId( + agentUri.toString()); + if (credentials == null + || credentials.getUsername() == null + || credentials.getPassword() == null) { + throw new InvalidCredentialsException( + NULL_CREDENTIALS + " " + agentUri); + } + req = + req.authentication( + new UsernamePasswordCredentials( + credentials.getUsername(), + credentials.getPassword())); + } catch (ScriptException | InvalidCredentialsException e) { + logger.error(e); + throw new RuntimeException(e); + } + + try { + return req.send() + .toCompletionStage() + .toCompletableFuture() + .get(); + } catch (InterruptedException | ExecutionException e) { + logger.error(e); + throw new RuntimeException(e); + } + }, + ForkJoinPool.commonPool()) + .exceptionally( + t -> { + throw new RuntimeException(t); + })); } static class Factory { diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/CredentialTestPostBodyHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/CredentialTestPostBodyHandler.java new file mode 100644 index 0000000000..34abf72898 --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/CredentialTestPostBodyHandler.java @@ -0,0 +1,95 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.cryostat.net.web.http.api.beta; + +import java.util.Set; + +import javax.inject.Inject; + +import io.cryostat.configuration.CredentialsManager; +import io.cryostat.core.log.Logger; +import io.cryostat.net.AuthManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; +import io.cryostat.net.web.http.api.ApiVersion; + +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; + +public class CredentialTestPostBodyHandler extends AbstractAuthenticatedRequestHandler { + + static final BodyHandler BODY_HANDLER = BodyHandler.create(true).setHandleFileUploads(false); + + @Inject + CredentialTestPostBodyHandler( + AuthManager auth, CredentialsManager credentialsManager, Logger logger) { + super(auth, credentialsManager, logger); + } + + @Override + public int getPriority() { + return DEFAULT_PRIORITY - 1; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.POST; + } + + @Override + public Set resourceActions() { + return ResourceAction.NONE; + } + + @Override + public String path() { + return basePath() + CredentialTestPostHandler.PATH; + } + + @Override + public void handleAuthenticated(RoutingContext ctx) throws Exception { + BODY_HANDLER.handle(ctx); + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/CredentialTestPostHandler.java b/src/main/java/io/cryostat/net/web/http/api/beta/CredentialTestPostHandler.java new file mode 100644 index 0000000000..10fa606b1e --- /dev/null +++ b/src/main/java/io/cryostat/net/web/http/api/beta/CredentialTestPostHandler.java @@ -0,0 +1,192 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.cryostat.net.web.http.api.beta; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import org.openjdk.jmc.rjmx.ConnectionException; + +import io.cryostat.configuration.CredentialsManager; +import io.cryostat.core.net.Credentials; +import io.cryostat.net.AgentClient; +import io.cryostat.net.AuthManager; +import io.cryostat.net.ConnectionDescriptor; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.web.http.AbstractAuthenticatedRequestHandler; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.net.web.http.api.beta.CredentialTestPostHandler.CredentialTestResult; +import io.cryostat.net.web.http.api.v2.AbstractV2RequestHandler; +import io.cryostat.net.web.http.api.v2.ApiException; +import io.cryostat.net.web.http.api.v2.IntermediateResponse; +import io.cryostat.net.web.http.api.v2.RequestParameters; + +import com.google.gson.Gson; +import io.vertx.core.http.HttpMethod; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; + +public class CredentialTestPostHandler extends AbstractV2RequestHandler { + + static final String PATH = "credentials/:targetId"; + + private final TargetConnectionManager tcm; + + @Inject + CredentialTestPostHandler( + AuthManager auth, + CredentialsManager credentialsManager, + Gson gson, + TargetConnectionManager tcm) { + super(auth, credentialsManager, gson); + this.tcm = tcm; + } + + @Override + public boolean requiresAuthentication() { + return true; + } + + @Override + public ApiVersion apiVersion() { + return ApiVersion.BETA; + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.POST; + } + + @Override + public Set resourceActions() { + return EnumSet.of(ResourceAction.READ_TARGET); + } + + @Override + public String path() { + return basePath() + PATH; + } + + @Override + public List produces() { + return List.of(HttpMimeType.JSON); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public IntermediateResponse handle(RequestParameters params) + throws Exception { + String targetId = params.getPathParams().get("targetId"); + String username = params.getFormAttributes().get("username"); + String password = params.getFormAttributes().get("password"); + if (StringUtils.isAnyBlank(targetId, username, password)) { + StringBuilder sb = new StringBuilder(); + if (StringUtils.isBlank(targetId)) { + sb.append("\"targetId\" is required."); + } + if (StringUtils.isBlank(username)) { + sb.append("\"username\" is required."); + } + if (StringUtils.isBlank(password)) { + sb.append(" \"password\" is required."); + } + + throw new ApiException(400, sb.toString().trim()); + } + ConnectionDescriptor noCreds = new ConnectionDescriptor(targetId, null); + + try { + return new IntermediateResponse() + .body( + tcm.executeConnectedTask( + noCreds, + (conn) -> { + conn.connect(); + return CredentialTestResult.NA; + })); + } catch (Exception e1) { + if (AbstractAuthenticatedRequestHandler.isJmxAuthFailure(e1) + || isAgentAuthFailure(e1)) { + ConnectionDescriptor creds = + new ConnectionDescriptor(targetId, new Credentials(username, password)); + try { + return new IntermediateResponse() + .body( + tcm.executeConnectedTask( + creds, + (conn) -> { + conn.connect(); + return CredentialTestResult.SUCCESS; + })); + } catch (Exception e2) { + if (AbstractAuthenticatedRequestHandler.isJmxAuthFailure(e2) + || isAgentAuthFailure(e2)) { + return new IntermediateResponse() + .body(CredentialTestResult.FAILURE); + } + throw e2; + } + } + throw e1; + } + } + + boolean isAgentAuthFailure(Exception e) { + int index = ExceptionUtils.indexOfType(e, ConnectionException.class); + if (index >= 0) { + Throwable ce = ExceptionUtils.getThrowableList(e).get(index); + return ce.getMessage().contains(AgentClient.NULL_CREDENTIALS); + } + return false; + } + + static enum CredentialTestResult { + SUCCESS, + FAILURE, + NA; + } +} diff --git a/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java b/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java index 125b8cda7e..afa1f7a7ae 100644 --- a/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java +++ b/src/main/java/io/cryostat/net/web/http/api/beta/HttpApiBetaModule.java @@ -161,4 +161,12 @@ abstract RequestHandler bindMatchExpressionsPostBodyHandler( @Binds @IntoSet abstract RequestHandler bindMatchExpressionDeleteHandler(MatchExpressionDeleteHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindCredentialTestPostHandler(CredentialTestPostHandler handler); + + @Binds + @IntoSet + abstract RequestHandler bindCredentialTestGetBodyHandler(CredentialTestPostBodyHandler handler); } diff --git a/src/test/java/io/cryostat/net/web/http/api/beta/CredentialTestPostHandlerTest.java b/src/test/java/io/cryostat/net/web/http/api/beta/CredentialTestPostHandlerTest.java new file mode 100644 index 0000000000..2d72ecf23b --- /dev/null +++ b/src/test/java/io/cryostat/net/web/http/api/beta/CredentialTestPostHandlerTest.java @@ -0,0 +1,199 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.cryostat.net.web.http.api.beta; + +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.cryostat.MainModule; +import io.cryostat.configuration.CredentialsManager; +import io.cryostat.core.log.Logger; +import io.cryostat.core.net.JFRConnection; +import io.cryostat.net.AuthManager; +import io.cryostat.net.TargetConnectionManager; +import io.cryostat.net.security.ResourceAction; +import io.cryostat.net.web.http.HttpMimeType; +import io.cryostat.net.web.http.api.ApiVersion; +import io.cryostat.net.web.http.api.beta.CredentialTestPostHandler.CredentialTestResult; +import io.cryostat.net.web.http.api.v2.IntermediateResponse; +import io.cryostat.net.web.http.api.v2.RequestParameters; + +import com.google.gson.Gson; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpMethod; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CredentialTestPostHandlerTest { + CredentialTestPostHandler handler; + @Mock AuthManager auth; + @Mock CredentialsManager credentialsManager; + @Mock Logger logger; + @Mock TargetConnectionManager tcm; + Gson gson = MainModule.provideGson(logger); + + @BeforeEach + void setup() { + this.handler = new CredentialTestPostHandler(auth, credentialsManager, gson, tcm); + } + + @Nested + class BasicHandlerDefinition { + @Test + void shouldBePOSTHandler() { + MatcherAssert.assertThat(handler.httpMethod(), Matchers.equalTo(HttpMethod.POST)); + } + + @Test + void shouldBeAPIBeta() { + MatcherAssert.assertThat(handler.apiVersion(), Matchers.equalTo(ApiVersion.BETA)); + } + + @Test + void shouldHaveExpectedPath() { + MatcherAssert.assertThat( + handler.path(), Matchers.equalTo("/api/beta/credentials/:targetId")); + } + + @Test + void shouldHaveExpectedRequiredPermissions() { + MatcherAssert.assertThat( + handler.resourceActions(), + Matchers.equalTo(Set.of(ResourceAction.READ_TARGET))); + } + + @Test + void shouldProduceJson() { + MatcherAssert.assertThat( + handler.produces(), Matchers.equalTo(List.of(HttpMimeType.JSON))); + } + + @Test + void shouldRequireAuthentication() { + MatcherAssert.assertThat(handler.requiresAuthentication(), Matchers.is(true)); + } + } + + @Nested + class RequestHandling { + @Mock RequestParameters requestParams; + @Mock JFRConnection connection; + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + String username = "user"; + String password = "pass"; + String targetId = "targetId"; + + @BeforeEach + void setup() { + this.form.set("username", username); + this.form.set("password", password); + } + + @Test + void shouldRespondNA() throws Exception { + when(requestParams.getFormAttributes()).thenReturn(form); + when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); + + when(tcm.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenAnswer( + arg0 -> + ((TargetConnectionManager.ConnectedTask) + arg0.getArgument(1)) + .execute(connection)); + + IntermediateResponse response = handler.handle(requestParams); + + MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(200)); + MatcherAssert.assertThat(response.getBody(), Matchers.equalTo(CredentialTestResult.NA)); + } + + @Test + void shouldRespondSUCCESS() throws Exception { + when(requestParams.getFormAttributes()).thenReturn(form); + when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); + + when(tcm.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenThrow( + new Exception( + new SecurityException("first failure without credentials"))) + .thenAnswer( + arg0 -> + ((TargetConnectionManager.ConnectedTask) + arg0.getArgument(1)) + .execute(connection)); + + IntermediateResponse response = handler.handle(requestParams); + + MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(200)); + MatcherAssert.assertThat( + response.getBody(), Matchers.equalTo(CredentialTestResult.SUCCESS)); + } + + @Test + void shouldRespondFAILURE() throws Exception { + when(requestParams.getFormAttributes()).thenReturn(form); + when(requestParams.getPathParams()).thenReturn(Map.of("targetId", targetId)); + + when(tcm.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenThrow( + new Exception( + new SecurityException("first failure without credentials"))) + .thenThrow( + new Exception( + new SecurityException("second failure with credentials"))); + + IntermediateResponse response = handler.handle(requestParams); + + MatcherAssert.assertThat(response.getStatusCode(), Matchers.equalTo(200)); + MatcherAssert.assertThat( + response.getBody(), Matchers.equalTo(CredentialTestResult.FAILURE)); + } + } +}