diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/login/AwaitLoginResponse.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/login/AwaitLoginResponse.java new file mode 100644 index 0000000000..72a2c2935e --- /dev/null +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/login/AwaitLoginResponse.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.tests.acceptance.dsl.condition.login; + +import static org.assertj.core.api.Assertions.assertThat; + +import tech.pegasys.pantheon.tests.acceptance.dsl.WaitUtils; +import tech.pegasys.pantheon.tests.acceptance.dsl.condition.Condition; +import tech.pegasys.pantheon.tests.acceptance.dsl.httptransaction.LoginResponds; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.Node; + +public class AwaitLoginResponse implements Condition { + + private final LoginResponds transaction; + + public AwaitLoginResponse(final LoginResponds transaction) { + this.transaction = transaction; + } + + @Override + public void verify(final Node node) { + WaitUtils.waitFor(() -> assertThat(node.executeHttpTransaction(transaction)).isNotNull()); + } +} diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/net/ExpectNetVersionPermissionException.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/net/ExpectNetVersionPermissionException.java new file mode 100644 index 0000000000..0be49e8635 --- /dev/null +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/net/ExpectNetVersionPermissionException.java @@ -0,0 +1,44 @@ +/* + * Copyright 2018 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.tests.acceptance.dsl.condition.net; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import tech.pegasys.pantheon.tests.acceptance.dsl.condition.Condition; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.Node; +import tech.pegasys.pantheon.tests.acceptance.dsl.transaction.net.NetVersionTransaction; + +import org.web3j.protocol.exceptions.ClientConnectionException; + +public class ExpectNetVersionPermissionException implements Condition { + + private final NetVersionTransaction transaction; + private final String expectedMessage; + + public ExpectNetVersionPermissionException( + final NetVersionTransaction transaction, final String expectedMessage) { + this.transaction = transaction; + this.expectedMessage = expectedMessage; + } + + @Override + public void verify(final Node node) { + final Throwable thrown = catchThrowable(() -> node.execute(transaction)); + assertThat(thrown).isInstanceOf(RuntimeException.class); + + final Throwable cause = thrown.getCause(); + assertThat(cause).isInstanceOf(ClientConnectionException.class); + assertThat(cause.getMessage()).contains(expectedMessage); + } +} diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/HttpRequestFactory.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/HttpRequestFactory.java index 45e43670e5..4817f74952 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/HttpRequestFactory.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/HttpRequestFactory.java @@ -66,4 +66,16 @@ public void loginUnauthorized(final String username, final String password) thro assertThat(response.message()).isEqualTo("Unauthorized"); } } + + public String loginResponds(final String username, final String password) throws IOException { + final RequestBody requestBody = + RequestBody.create( + JSON, "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}"); + final Request request = new Request.Builder().post(requestBody).url(uri + "/login").build(); + try (final Response response = client.newCall(request).execute()) { + assertThat(response).isNotNull(); + assertThat(response.message()).isNotNull(); + return response.message(); + } + } } diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/LoginResponds.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/LoginResponds.java new file mode 100644 index 0000000000..bece998a54 --- /dev/null +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/httptransaction/LoginResponds.java @@ -0,0 +1,37 @@ +/* + * Copyright 2019 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.pantheon.tests.acceptance.dsl.httptransaction; + +import static org.junit.Assert.fail; + +import java.io.IOException; + +public class LoginResponds implements HttpTransaction { + private final String username; + private final String password; + + public LoginResponds(final String username, final String password) { + this.username = username; + this.password = password; + } + + @Override + public String execute(final HttpRequestFactory httpFactory) { + try { + return httpFactory.loginResponds(username, password); + } catch (IOException e) { + fail("Login request failed with exception: " + e.toString()); + return null; + } + } +} diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Login.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Login.java index 114483909e..9664a86e66 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Login.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Login.java @@ -15,8 +15,11 @@ import static org.assertj.core.api.Assertions.assertThat; import tech.pegasys.pantheon.tests.acceptance.dsl.condition.Condition; +import tech.pegasys.pantheon.tests.acceptance.dsl.condition.login.AwaitLoginResponse; +import tech.pegasys.pantheon.tests.acceptance.dsl.httptransaction.LoginResponds; import tech.pegasys.pantheon.tests.acceptance.dsl.httptransaction.LoginTransaction; import tech.pegasys.pantheon.tests.acceptance.dsl.httptransaction.LoginUnauthorizedTransaction; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.PantheonNode; public class Login { @@ -32,4 +35,18 @@ public Condition loginFails(final String username, final String password) { n.executeHttpTransaction(new LoginUnauthorizedTransaction(username, password)); }; } + + public Condition loginSucceedsAndSetsAuthenticationToken( + final String username, final String password) { + + return (n) -> { + final String token = n.executeHttpTransaction(new LoginTransaction(username, password)); + assertThat(token).isNotBlank(); + ((PantheonNode) n).useAuthenticationTokenInHeaderForJsonRpc(token); + }; + } + + public Condition awaitLoginResponse(final String username, final String password) { + return new AwaitLoginResponse(new LoginResponds(username, password)); + } } diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Net.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Net.java index 5c651b6d19..001b33aad4 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Net.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/jsonrpc/Net.java @@ -18,6 +18,7 @@ import tech.pegasys.pantheon.tests.acceptance.dsl.condition.net.ExpectNetVersionConnectionException; import tech.pegasys.pantheon.tests.acceptance.dsl.condition.net.ExpectNetVersionConnectionExceptionWithCause; import tech.pegasys.pantheon.tests.acceptance.dsl.condition.net.ExpectNetVersionIsNotBlank; +import tech.pegasys.pantheon.tests.acceptance.dsl.condition.net.ExpectNetVersionPermissionException; import tech.pegasys.pantheon.tests.acceptance.dsl.transaction.net.NetTransactions; import java.math.BigInteger; @@ -46,6 +47,10 @@ public Condition netVersionExceptional(final Class cause) { return new ExpectNetVersionConnectionExceptionWithCause(transactions.netVersion(), cause); } + public Condition netVersionUnauthorizedExceptional(final String expectedMessage) { + return new ExpectNetVersionPermissionException(transactions.netVersion(), expectedMessage); + } + public Condition awaitPeerCountExceptional() { return new AwaitNetPeerCountException(transactions.peerCount()); } diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/NodeConfiguration.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/NodeConfiguration.java index bde4baf19c..5cc22d0d79 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/NodeConfiguration.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/NodeConfiguration.java @@ -25,6 +25,8 @@ public interface NodeConfiguration { void useWebSocketsForJsonRpc(); + void useAuthenticationTokenInHeaderForJsonRpc(String token); + Optional jsonRpcWebSocketPort(); String hostName(); diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/PantheonNode.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/PantheonNode.java index 5d7f5e3276..e472ba7222 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/PantheonNode.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/PantheonNode.java @@ -90,6 +90,7 @@ public class PantheonNode implements Node, NodeConfiguration, RunnableNode, Auto private HttpRequestFactory httpRequestFactory; private Optional ethNetworkConfig = Optional.empty(); private boolean useWsForJsonRpc = false; + private String token = null; public PantheonNode( final String name, @@ -201,6 +202,10 @@ private JsonRequestFactories jsonRequestFactories() { .map(url -> new HttpService(url)) .orElse(new HttpService("http://" + LOCALHOST + ":" + port)); + if (token != null) { + ((HttpService) web3jService).addHeader("Bearer", token); + } + jsonRequestFactories = new JsonRequestFactories( new JsonRpc2_0Web3j(web3jService, 2000, Async.defaultExecutorService()), @@ -255,6 +260,21 @@ public void useWebSocketsForJsonRpc() { useWsForJsonRpc = true; } + /** All future JSON-RPC calls will include the authentication token. */ + @Override + public void useAuthenticationTokenInHeaderForJsonRpc(final String token) { + + if (jsonRequestFactories != null) { + jsonRequestFactories.shutdown(); + } + + if (httpRequestFactory != null) { + httpRequestFactory = null; + } + + this.token = token; + } + private void checkIfWebSocketEndpointIsAvailable(final String url) { final WebSocketClient webSocketClient = new WebSocketClient(URI.create(url)); // Web3j implementation always invoke the listener (even when one hasn't been set). We are using diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/transaction/net/NetVersionTransaction.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/transaction/net/NetVersionTransaction.java index 9217a60d48..7b4d7a92b3 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/transaction/net/NetVersionTransaction.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/transaction/net/NetVersionTransaction.java @@ -17,8 +17,6 @@ import tech.pegasys.pantheon.tests.acceptance.dsl.transaction.JsonRequestFactories; import tech.pegasys.pantheon.tests.acceptance.dsl.transaction.Transaction; -import java.io.IOException; - import org.web3j.protocol.core.methods.response.NetVersion; public class NetVersionTransaction implements Transaction { @@ -32,7 +30,7 @@ public String execute(final JsonRequestFactories node) { assertThat(result).isNotNull(); assertThat(result.hasError()).isFalse(); return result.getNetVersion(); - } catch (final IOException e) { + } catch (final Exception e) { throw new RuntimeException(e); } } diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/jsonrpc/HttpServiceLoginAcceptanceTest.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/jsonrpc/HttpServiceLoginAcceptanceTest.java index 01170f4317..4b9cf79d87 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/jsonrpc/HttpServiceLoginAcceptanceTest.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/jsonrpc/HttpServiceLoginAcceptanceTest.java @@ -14,6 +14,9 @@ import tech.pegasys.pantheon.tests.acceptance.dsl.AcceptanceTestBase; import tech.pegasys.pantheon.tests.acceptance.dsl.node.Node; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.cluster.Cluster; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.cluster.ClusterConfiguration; +import tech.pegasys.pantheon.tests.acceptance.dsl.node.cluster.ClusterConfigurationBuilder; import java.io.IOException; import java.net.URISyntaxException; @@ -22,12 +25,18 @@ import org.junit.Test; public class HttpServiceLoginAcceptanceTest extends AcceptanceTestBase { + private Cluster authenticatedCluster; private Node node; @Before public void setUp() throws IOException, URISyntaxException { + final ClusterConfiguration clusterConfiguration = + new ClusterConfigurationBuilder().setAwaitPeerDiscovery(false).build(); + + authenticatedCluster = new Cluster(clusterConfiguration, net); node = pantheon.createArchiveNodeWithAuthentication("node1"); - cluster.start(node); + authenticatedCluster.start(node); + node.verify(login.awaitLoginResponse("user", "badpassword")); } @Test @@ -39,4 +48,17 @@ public void shouldFailLoginWithWrongCredentials() { public void shouldSucceedLoginWithCorrectCredentials() { node.verify(login.loginSucceeds("user", "pegasys")); } + + @Test + public void jsonRpcMethodShouldSucceedWithAuthenticatedUserAndPermission() { + node.verify(login.loginSucceedsAndSetsAuthenticationToken("user", "pegasys")); + node.verify(net.awaitPeerCount(0)); + node.verify(net.netVersionUnauthorizedExceptional("Unauthorized")); + } + + @Override + public void tearDownAcceptanceTestBase() { + authenticatedCluster.stop(); + super.tearDownAcceptanceTestBase(); + } } diff --git a/acceptance-tests/src/test/resources/authentication/auth.toml b/acceptance-tests/src/test/resources/authentication/auth.toml index f36672b9ba..7e226025c5 100644 --- a/acceptance-tests/src/test/resources/authentication/auth.toml +++ b/acceptance-tests/src/test/resources/authentication/auth.toml @@ -1,3 +1,3 @@ [Users.user] password = "$2a$10$l3GA7K8g6rJ/Yv.YFSygCuI9byngpEzxgWS9qEg5emYDZomQW7fGC" -permissions = ["fakePermission"] +permissions = ["fakePermission", "net/peerCount"] diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpService.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpService.java index ade407e6ec..e2bc7694da 100644 --- a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpService.java +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpService.java @@ -42,6 +42,7 @@ import java.util.Optional; import java.util.StringJoiner; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Splitter; @@ -59,6 +60,7 @@ import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.User; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; @@ -228,6 +230,66 @@ private Handler checkWhitelistHostHeader() { }; } + private boolean requiresAuthentication(final RoutingContext routingContext) { + return authenticationService.isPresent(); + } + + @VisibleForTesting + public boolean isPermitted(final Optional optionalUser, final JsonRpcMethod jsonRpcMethod) { + + AtomicBoolean foundMatchingPermission = new AtomicBoolean(); + + if (optionalUser.isPresent()) { + User user = optionalUser.get(); + for (String perm : jsonRpcMethod.getPermissions()) { + user.isAuthorized( + perm, + (authed) -> { + if (authed.result()) { + LOG.trace( + "user {} authorized : {} via permission {}", + user, + jsonRpcMethod.getName(), + perm); + foundMatchingPermission.set(true); + } + }); + } + } else { + // no user means no auth provider configured thus anything is permitted + foundMatchingPermission.set(true); + } + + if (!foundMatchingPermission.get()) { + LOG.trace("user NOT authorized : {}", jsonRpcMethod.getName()); + } + return foundMatchingPermission.get(); + } + + private String getToken(final RoutingContext routingContext) { + return routingContext.request().getHeader("Bearer"); + } + + private void getUser(final String token, final Handler> handler) { + try { + if (!authenticationService.isPresent()) { + handler.handle(Optional.empty()); + } else { + authenticationService + .get() + .getJwtAuthProvider() + .authenticate( + new JsonObject().put("jwt", token), + (r) -> { + final User user = r.result(); + handler.handle(Optional.of(user)); + }); + } + } catch (Exception e) { + handler.handle(Optional.empty()); + } + } + private Optional getAndValidateHostHeader(final RoutingContext event) { final Iterable splitHostHeader = Splitter.on(':').split(event.request().host()); final long hostPieces = stream(splitHostHeader).count(); @@ -279,21 +341,36 @@ public String url() { } private void handleJsonRPCRequest(final RoutingContext routingContext) { - // Parse json - try { - final String json = routingContext.getBodyAsString().trim(); - if (!json.isEmpty() && json.charAt(0) == '{') { - handleJsonSingleRequest(routingContext, new JsonObject(json)); - } else { - final JsonArray array = new JsonArray(json); - if (array.size() < 1) { - handleJsonRpcError(routingContext, null, JsonRpcError.INVALID_REQUEST); - return; + // first check token if authentication is required + String token = getToken(routingContext); + if (requiresAuthentication(routingContext) && token == null) { + // no auth token when auth required + handleJsonRpcUnauthorizedError(routingContext, null, JsonRpcError.UNAUTHORIZED); + } else { + // Parse json + try { + final String json = routingContext.getBodyAsString().trim(); + if (!json.isEmpty() && json.charAt(0) == '{') { + getUser( + token, + user -> { + handleJsonSingleRequest(routingContext, new JsonObject(json), user); + }); + } else { + final JsonArray array = new JsonArray(json); + if (array.size() < 1) { + handleJsonRpcError(routingContext, null, JsonRpcError.INVALID_REQUEST); + return; + } + getUser( + token, + user -> { + handleJsonBatchRequest(routingContext, array, user); + }); } - handleJsonBatchRequest(routingContext, array); + } catch (final DecodeException ex) { + handleJsonRpcError(routingContext, null, JsonRpcError.PARSE_ERROR); } - } catch (final DecodeException ex) { - handleJsonRpcError(routingContext, null, JsonRpcError.PARSE_ERROR); } } @@ -303,11 +380,11 @@ private void handleEmptyRequest(final RoutingContext routingContext) { } private void handleJsonSingleRequest( - final RoutingContext routingContext, final JsonObject request) { + final RoutingContext routingContext, final JsonObject request, final Optional user) { final HttpServerResponse response = routingContext.response(); vertx.executeBlocking( future -> { - final JsonRpcResponse jsonRpcResponse = process(request); + final JsonRpcResponse jsonRpcResponse = process(request, user); future.complete(jsonRpcResponse); }, false, @@ -347,7 +424,7 @@ private String serialise(final JsonRpcResponse response) { @SuppressWarnings("rawtypes") private void handleJsonBatchRequest( - final RoutingContext routingContext, final JsonArray jsonArray) { + final RoutingContext routingContext, final JsonArray jsonArray, final Optional user) { // Interpret json as rpc request final List responses = jsonArray.stream() @@ -361,7 +438,7 @@ private void handleJsonBatchRequest( final JsonObject req = (JsonObject) obj; final Future fut = Future.future(); vertx.executeBlocking( - future -> future.complete(process(req)), + future -> future.complete(process(req, user)), false, ar -> { if (ar.failed()) { @@ -398,7 +475,7 @@ private boolean isNonEmptyResponses(final JsonRpcResponse result) { return result.getType() != JsonRpcResponseType.NONE; } - private JsonRpcResponse process(final JsonObject requestJson) { + private JsonRpcResponse process(final JsonObject requestJson, final Optional user) { final JsonRpcRequest request; Object id = null; try { @@ -420,12 +497,16 @@ private JsonRpcResponse process(final JsonObject requestJson) { return errorResponse(id, JsonRpcError.METHOD_NOT_FOUND); } - // Generate response - try (final TimingContext ignored = requestTimer.labels(request.getMethod()).startTimer()) { - return method.response(request); - } catch (final InvalidJsonRpcParameters e) { - LOG.debug(e); - return errorResponse(id, JsonRpcError.INVALID_PARAMS); + if (isPermitted(user, method)) { + // Generate response + try (final TimingContext ignored = requestTimer.labels(request.getMethod()).startTimer()) { + return method.response(request); + } catch (final InvalidJsonRpcParameters e) { + LOG.debug(e); + return errorResponse(id, JsonRpcError.INVALID_PARAMS); + } + } else { + return errorResponse(id, JsonRpcError.UNAUTHORIZED); } } @@ -437,6 +518,14 @@ private void handleJsonRpcError( .end(Json.encode(new JsonRpcErrorResponse(id, error))); } + private void handleJsonRpcUnauthorizedError( + final RoutingContext routingContext, final Object id, final JsonRpcError error) { + routingContext + .response() + .setStatusCode(HttpResponseStatus.UNAUTHORIZED.code()) + .end(Json.encode(new JsonRpcErrorResponse(id, error))); + } + private JsonRpcResponse errorResponse(final Object id, final JsonRpcError error) { return new JsonRpcErrorResponse(id, error); } diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/JsonRpcMethod.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/JsonRpcMethod.java index 11b7b776c4..5db810f3b2 100644 --- a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/JsonRpcMethod.java +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/JsonRpcMethod.java @@ -15,6 +15,9 @@ import tech.pegasys.pantheon.ethereum.jsonrpc.internal.JsonRpcRequest; import tech.pegasys.pantheon.ethereum.jsonrpc.internal.response.JsonRpcResponse; +import java.util.ArrayList; +import java.util.List; + public interface JsonRpcMethod { /** @@ -31,4 +34,17 @@ public interface JsonRpcMethod { * @return output from applying the JSON-RPC method to the input. */ JsonRpcResponse response(JsonRpcRequest request); + + /** + * The list of Permissions that correspond to this JSON-RPC method. e.g. [net/*, net/listening] + * + * @return list of permissions that match this method. + */ + default List getPermissions() { + List permissions = new ArrayList<>(); + permissions.add("*/*"); + permissions.add(this.getName().replace('_', '/')); + permissions.add(this.getName().substring(0, this.getName().indexOf('_')) + "/*"); + return permissions; + }; } diff --git a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/response/JsonRpcError.java b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/response/JsonRpcError.java index f25f01eb1f..761371e4a2 100644 --- a/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/response/JsonRpcError.java +++ b/ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/response/JsonRpcError.java @@ -50,7 +50,7 @@ public enum JsonRpcError { // Wallet errors COINBASE_NOT_SPECIFIED(-32000, "Coinbase must be explicitly specified"), - // Account whitelist errors + // Permissioning/Account whitelist errors ACCOUNT_WHITELIST_NOT_ENABLED(-32000, "Account whitelisting has not been enabled"), ACCOUNT_WHITELIST_EMPTY_ENTRY(-32000, "Request contains an empty list of accounts"), ACCOUNT_WHITELIST_INVALID_ENTRY(-32000, "Request contains an invalid account"), @@ -58,7 +58,7 @@ public enum JsonRpcError { ACCOUNT_WHITELIST_EXISTING_ENTRY(-32000, "Cannot add an existing account to whitelist"), ACCOUNT_WHITELIST_ABSENT_ENTRY(-32000, "Cannot remove an absent account from whitelist"), - // Node whitelist errors + // Permissioning/Node whitelist errors NODE_WHITELIST_NOT_ENABLED(-32000, "Node whitelisting has not been enabled"), NODE_WHITELIST_EMPTY_ENTRY(-32000, "Request contains an empty list of nodes"), NODE_WHITELIST_INVALID_ENTRY(-32000, "Request contains an invalid node"), @@ -66,7 +66,7 @@ public enum JsonRpcError { NODE_WHITELIST_EXISTING_ENTRY(-32000, "Cannot add an existing node to whitelist"), NODE_WHITELIST_MISSING_ENTRY(-32000, "Cannot remove an absent node from whitelist"), - // Permissioning errors + // Permissioning/persistence errors WHITELIST_PERSIST_FAILURE( -32000, "Unable to persist changes to whitelist configuration file. Changes reverted"), WHITELIST_FILE_SYNC( @@ -77,6 +77,9 @@ public enum JsonRpcError { "Error reloading permissions file. Please use perm_getAccountsWhitelist and perm_getNodesWhitelist to review the current state of the whitelists."), PERMISSIONING_NOT_ENABLED(-32000, "Node/Account whitelisting has not been enabled"), + // Permissioning/Authorization errors + UNAUTHORIZED(-40100, "Unauthorized"), + // Private transaction errors ENCLAVE_IS_DOWN(-32000, "Enclave is down"); diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java index 4d942f15f8..05b09f4401 100644 --- a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java @@ -23,7 +23,12 @@ import tech.pegasys.pantheon.ethereum.core.TransactionPool; import tech.pegasys.pantheon.ethereum.eth.EthProtocol; import tech.pegasys.pantheon.ethereum.jsonrpc.internal.filter.FilterManager; +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.EthAccounts; +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.EthBlockNumber; import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.JsonRpcMethod; +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.NetVersion; +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.Web3ClientVersion; +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.methods.Web3Sha3; import tech.pegasys.pantheon.ethereum.jsonrpc.internal.queries.BlockchainQueries; import tech.pegasys.pantheon.ethereum.mainnet.MainnetProtocolSchedule; import tech.pegasys.pantheon.ethereum.p2p.api.P2PNetwork; @@ -91,7 +96,8 @@ public class JsonRpcHttpServiceLoginTest { protected static Synchronizer synchronizer; protected static final Collection JSON_RPC_APIS = Arrays.asList(RpcApis.ETH, RpcApis.NET, RpcApis.WEB3, RpcApis.ADMIN); - private static JWTAuth jwtAuth; + protected static JWTAuth jwtAuth; + protected static String authPermissionsConfigFilePath = "JsonRpcHttpService/auth.toml"; @BeforeClass public static void initServerAndClient() throws Exception { @@ -133,7 +139,7 @@ public static void initServerAndClient() throws Exception { private static JsonRpcHttpService createJsonRpcHttpService() throws Exception { final String authTomlPath = - Paths.get(ClassLoader.getSystemResource("JsonRpcHttpService/auth.toml").toURI()) + Paths.get(ClassLoader.getSystemResource(authPermissionsConfigFilePath).toURI()) .toAbsolutePath() .toString(); @@ -327,4 +333,54 @@ public void loginDoesntPopulateJWTPayloadWithPassword() assertThat(jwtPayloadString.contains("pegasys")).isFalse(); } } + + @Test + public void checkJsonRpcMethodsAvailableWithGoodCredentialsAndPermissions() throws IOException { + final RequestBody body = + RequestBody.create(JSON, "{\"username\":\"user\",\"password\":\"pegasys\"}"); + final Request request = new Request.Builder().post(body).url(baseUrl + "/login").build(); + try (final Response resp = client.newCall(request).execute()) { + assertThat(resp.code()).isEqualTo(200); + assertThat(resp.message()).isEqualTo("OK"); + assertThat(resp.body().contentType()).isNotNull(); + assertThat(resp.body().contentType().type()).isEqualTo("application"); + assertThat(resp.body().contentType().subtype()).isEqualTo("json"); + final String bodyString = resp.body().string(); + assertThat(bodyString).isNotNull(); + assertThat(bodyString).isNotBlank(); + + final JsonObject respBody = new JsonObject(bodyString); + final String token = respBody.getString("token"); + assertThat(token).isNotNull(); + + JsonRpcMethod ethAccounts = new EthAccounts(); + JsonRpcMethod netVersion = new NetVersion(123); + JsonRpcMethod ethBlockNumber = new EthBlockNumber(blockchainQueries); + JsonRpcMethod web3Sha3 = new Web3Sha3(); + JsonRpcMethod web3ClientVersion = new Web3ClientVersion("777"); + + jwtAuth.authenticate( + new JsonObject().put("jwt", token), + (r) -> { + assertThat(r.succeeded()).isTrue(); + final User user = r.result(); + // single eth/blockNumber method permitted + assertThat(service.isPermitted(Optional.of(user), ethBlockNumber)).isTrue(); + // eth/accounts not permitted + assertThat(service.isPermitted(Optional.of(user), ethAccounts)).isFalse(); + // allowed by web3/* + assertThat(service.isPermitted(Optional.of(user), web3ClientVersion)).isTrue(); + assertThat(service.isPermitted(Optional.of(user), web3Sha3)).isTrue(); + // no net permissions + assertThat(service.isPermitted(Optional.of(user), netVersion)).isFalse(); + }); + } + } + + @Test + public void checkPermissionsWithEmptyUser() { + JsonRpcMethod ethAccounts = new EthAccounts(); + + assertThat(service.isPermitted(Optional.empty(), ethAccounts)).isTrue(); + } } diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/NetListeningTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/NetListeningTest.java index d41d526401..56399e2d04 100644 --- a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/NetListeningTest.java +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/internal/methods/NetListeningTest.java @@ -20,6 +20,8 @@ import tech.pegasys.pantheon.ethereum.jsonrpc.internal.response.JsonRpcSuccessResponse; import tech.pegasys.pantheon.ethereum.p2p.api.P2PNetwork; +import java.util.List; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -58,6 +60,12 @@ public void shouldReturnFalseWhenNetworkIsNotListening() { assertThat(method.response(request)).isEqualToComparingFieldByField(expectedResponse); } + @Test + public void getPermissions() { + List permissions = method.getPermissions(); + assertThat(permissions).containsExactlyInAnyOrder("net/*", "net/listening", "*/*"); + } + private JsonRpcRequest netListeningRequest() { return new JsonRpcRequest("2.0", "net_listening", new Object[] {}); } diff --git a/ethereum/jsonrpc/src/test/resources/JsonRpcHttpService/auth.toml b/ethereum/jsonrpc/src/test/resources/JsonRpcHttpService/auth.toml index f36672b9ba..f654161945 100644 --- a/ethereum/jsonrpc/src/test/resources/JsonRpcHttpService/auth.toml +++ b/ethereum/jsonrpc/src/test/resources/JsonRpcHttpService/auth.toml @@ -1,3 +1,3 @@ [Users.user] password = "$2a$10$l3GA7K8g6rJ/Yv.YFSygCuI9byngpEzxgWS9qEg5emYDZomQW7fGC" -permissions = ["fakePermission"] +permissions = ["fakePermission","eth/blockNumber", "web3/*"]