From 562251638e4901fd716015f4a3d2b8887ecb19c1 Mon Sep 17 00:00:00 2001 From: Chris Mckay Date: Mon, 4 Feb 2019 12:40:52 +1000 Subject: [PATCH] [NC-2044] jsonrpc authentication login (#753) Signed-off-by: Adrian Sutton --- ethereum/jsonrpc/build.gradle | 1 + .../ethereum/jsonrpc/JsonRpcHttpService.java | 122 +++++++- .../jsonrpc/JsonRpcHttpServiceLoginTest.java | 287 ++++++++++++++++++ .../jsonrpc/JsonRpcHttpServiceTest.java | 11 + 4 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java diff --git a/ethereum/jsonrpc/build.gradle b/ethereum/jsonrpc/build.gradle index c74cbaa8ca..18f0b38efb 100644 --- a/ethereum/jsonrpc/build.gradle +++ b/ethereum/jsonrpc/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation 'io.vertx:vertx-web' implementation 'net.consensys.cava:cava-toml' implementation 'org.springframework.security:spring-security-crypto' + implementation 'io.vertx:vertx-auth-jwt:3.6.2' testImplementation project(':config') testImplementation project(path: ':config', configuration: 'testSupportArtifacts') 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 f8d82362d9..6c6b27991b 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 @@ -58,6 +58,11 @@ import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.AuthProvider; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.jwt.JWTAuth; +import io.vertx.ext.auth.jwt.JWTAuthOptions; +import io.vertx.ext.jwt.JWTOptions; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; @@ -80,14 +85,66 @@ public class JsonRpcHttpService { private final Path dataDir; private final LabelledMetric requestTimer; + private final Optional jwtAuthProvider; + private final Optional credentialAuthProvider; + private HttpServer httpServer; + /** + * Construct a JsonRpcHttpService handler that has authentication enabled + * + * @param vertx The vertx process that will be running this service + * @param dataDir The data directory where requests can be buffered + * @param config Configuration for the rpc methods being loaded + * @param metricsSystem The metrics service that activities should be reported to + * @param methods The json rpc methods that should be enabled + * @param jwtOptions The configuration for the jwt auth provider + * @param credentialAuthProvider An auth provider that is backed by a credentials store + */ + public JsonRpcHttpService( + final Vertx vertx, + final Path dataDir, + final JsonRpcConfiguration config, + final MetricsSystem metricsSystem, + final Map methods, + final JWTAuthOptions jwtOptions, + final AuthProvider credentialAuthProvider) { + this( + vertx, + dataDir, + config, + metricsSystem, + methods, + Optional.of(jwtOptions), + Optional.of(credentialAuthProvider)); + } + + /** + * Construct a JsonRpcHttpService handler that doesn't have authentication enabled + * + * @param vertx The vertx process that will be running this service + * @param dataDir The data directory where requests can be buffered + * @param config Configuration for the rpc methods being loaded + * @param metricsSystem The metrics service that activities should be reported to + * @param methods The json rpc methods that should be enabled + */ public JsonRpcHttpService( final Vertx vertx, final Path dataDir, final JsonRpcConfiguration config, final MetricsSystem metricsSystem, final Map methods) { + this(vertx, dataDir, config, metricsSystem, methods, Optional.empty(), Optional.empty()); + } + + private JsonRpcHttpService( + final Vertx vertx, + final Path dataDir, + final JsonRpcConfiguration config, + final MetricsSystem metricsSystem, + final Map methods, + final Optional jwtOptions, + final Optional credentialAuthProvider) { this.dataDir = dataDir; requestTimer = metricsSystem.createLabelledTimer( @@ -99,6 +156,8 @@ public class JsonRpcHttpService { this.config = config; this.vertx = vertx; this.jsonRpcMethods = methods; + this.credentialAuthProvider = credentialAuthProvider; + jwtAuthProvider = jwtOptions.map(options -> JWTAuth.create(vertx, options)); } private void validateConfig(final JsonRpcConfiguration config) { @@ -139,6 +198,11 @@ public class JsonRpcHttpService { .method(HttpMethod.POST) .produces(APPLICATION_JSON) .handler(this::handleJsonRPCRequest); + router + .route("/login") + .method(HttpMethod.POST) + .produces(APPLICATION_JSON) + .handler(this::handleLogin); final CompletableFuture resultFuture = new CompletableFuture<>(); httpServer @@ -173,7 +237,7 @@ public class JsonRpcHttpService { return event -> { final Optional hostHeader = getAndValidateHostHeader(event); if (config.getHostsWhitelist().contains("*") - || (hostHeader.isPresent() && hostIsInWhitelist(hostHeader))) { + || (hostHeader.isPresent() && hostIsInWhitelist(hostHeader.get()))) { event.next(); } else { event @@ -197,12 +261,11 @@ public class JsonRpcHttpService { return Optional.ofNullable(Iterables.get(splitHostHeader, 0)); } - private boolean hostIsInWhitelist(final Optional hostHeader) { + private boolean hostIsInWhitelist(final String hostHeader) { return config .getHostsWhitelist() .stream() - .anyMatch( - whitelistEntry -> whitelistEntry.toLowerCase().equals(hostHeader.get().toLowerCase())); + .anyMatch(whitelistEntry -> whitelistEntry.toLowerCase().equals(hostHeader.toLowerCase())); } public CompletableFuture stop() { @@ -284,6 +347,57 @@ public class JsonRpcHttpService { }); } + private void handleLogin(final RoutingContext routingContext) { + if (!jwtAuthProvider.isPresent() || !credentialAuthProvider.isPresent()) { + routingContext + .response() + .setStatusCode(HttpResponseStatus.BAD_REQUEST.code()) + .setStatusMessage("Authentication not enabled") + .end(); + return; + } + + final JsonObject requestBody = routingContext.getBodyAsJson(); + + if (requestBody == null) { + routingContext + .response() + .setStatusCode(HttpResponseStatus.BAD_REQUEST.code()) + .setStatusMessage(HttpResponseStatus.BAD_REQUEST.reasonPhrase()) + .end(); + return; + } + + // Check user + final JsonObject authParams = new JsonObject(); + authParams.put("username", requestBody.getValue("username")); + authParams.put("password", requestBody.getValue("password")); + credentialAuthProvider + .get() + .authenticate( + authParams, + (r) -> { + if (r.failed()) { + routingContext + .response() + .setStatusCode(HttpResponseStatus.UNAUTHORIZED.code()) + .setStatusMessage(HttpResponseStatus.UNAUTHORIZED.reasonPhrase()) + .end(); + } else { + final User user = r.result(); + + final JWTOptions options = new JWTOptions().setExpiresInMinutes(5); + final String token = jwtAuthProvider.get().generateToken(user.principal(), options); + + final JsonObject responseBody = new JsonObject().put("token", token); + final HttpServerResponse response = routingContext.response(); + response.setStatusCode(200); + response.putHeader("Content-Type", APPLICATION_JSON); + response.end(responseBody.encode()); + } + }); + } + private HttpResponseStatus status(final JsonRpcResponse response) { switch (response.getType()) { 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 new file mode 100644 index 0000000000..49ab60172d --- /dev/null +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java @@ -0,0 +1,287 @@ +/* + * 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.ethereum.jsonrpc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import tech.pegasys.pantheon.config.StubGenesisConfigOptions; +import tech.pegasys.pantheon.ethereum.blockcreation.EthHashMiningCoordinator; +import tech.pegasys.pantheon.ethereum.core.PrivacyParameters; +import tech.pegasys.pantheon.ethereum.core.Synchronizer; +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.JsonRpcMethod; +import tech.pegasys.pantheon.ethereum.jsonrpc.internal.queries.BlockchainQueries; +import tech.pegasys.pantheon.ethereum.mainnet.MainnetProtocolSchedule; +import tech.pegasys.pantheon.ethereum.p2p.api.P2PNetwork; +import tech.pegasys.pantheon.ethereum.p2p.wire.Capability; +import tech.pegasys.pantheon.ethereum.permissioning.AccountWhitelistController; +import tech.pegasys.pantheon.ethereum.privacy.PrivateTransactionHandler; +import tech.pegasys.pantheon.metrics.noop.NoOpMetricsSystem; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.AuthProvider; +import io.vertx.ext.auth.PubSecKeyOptions; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.jwt.JWTAuth; +import io.vertx.ext.auth.jwt.JWTAuthOptions; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.StrictStubs.class) +public class JsonRpcHttpServiceLoginTest { + @ClassRule public static final TemporaryFolder folder = new TemporaryFolder(); + + private static final Vertx vertx = Vertx.vertx(); + + protected static Map rpcMethods; + protected static JsonRpcHttpService service; + protected static OkHttpClient client; + protected static String baseUrl; + protected static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + protected static final String CLIENT_VERSION = "TestClientVersion/0.1.0"; + protected static final int CHAIN_ID = 123; + protected static P2PNetwork peerDiscoveryMock; + protected static BlockchainQueries blockchainQueries; + protected static Synchronizer synchronizer; + protected static final Collection JSON_RPC_APIS = + Arrays.asList(RpcApis.ETH, RpcApis.NET, RpcApis.WEB3, RpcApis.ADMIN); + private static StubAuthProvider stubCredentialProvider; + private static final JWTAuthOptions jwtOptions = + new JWTAuthOptions() + .setPermissionsClaimKey("permissions") + .addPubSecKey( + new PubSecKeyOptions() + .setAlgorithm("HS256") + .setPublicKey("keyboard cat") + .setSymmetric(true)); + + private static class StubAuthProvider implements AuthProvider { + private Optional respondUser = Optional.empty(); + private Optional respondError = Optional.empty(); + + @Override + public void authenticate( + final JsonObject authInfo, final Handler> resultHandler) { + if (respondUser.isPresent()) { + resultHandler.handle(Future.succeededFuture(respondUser.get())); + } else if (respondError.isPresent()) { + resultHandler.handle(Future.failedFuture(respondError.get())); + } else { + throw new IllegalStateException("Setup your auth provider stub"); + } + } + + public void setRespondUser(final User respondUser) { + this.respondError = Optional.empty(); + this.respondUser = Optional.of(respondUser); + } + + public void setRespondError(final String respondError) { + this.respondUser = Optional.empty(); + this.respondError = Optional.of(respondError); + } + } + + @BeforeClass + public static void initServerAndClient() throws Exception { + stubCredentialProvider = new StubAuthProvider(); + peerDiscoveryMock = mock(P2PNetwork.class); + blockchainQueries = mock(BlockchainQueries.class); + synchronizer = mock(Synchronizer.class); + + final Set supportedCapabilities = new HashSet<>(); + supportedCapabilities.add(EthProtocol.ETH62); + supportedCapabilities.add(EthProtocol.ETH63); + + rpcMethods = + spy( + new JsonRpcMethodsFactory() + .methods( + CLIENT_VERSION, + peerDiscoveryMock, + blockchainQueries, + synchronizer, + MainnetProtocolSchedule.fromConfig( + new StubGenesisConfigOptions().constantinopleBlock(0).chainId(CHAIN_ID), + PrivacyParameters.noPrivacy()), + mock(FilterManager.class), + mock(TransactionPool.class), + mock(EthHashMiningCoordinator.class), + new NoOpMetricsSystem(), + supportedCapabilities, + mock(AccountWhitelistController.class), + JSON_RPC_APIS, + mock(PrivateTransactionHandler.class))); + service = createJsonRpcHttpService(); + service.start().join(); + + // Build an OkHttp client. + client = new OkHttpClient(); + baseUrl = service.url(); + } + + private static JsonRpcHttpService createJsonRpcHttpService() throws Exception { + return new JsonRpcHttpService( + vertx, + folder.newFolder().toPath(), + createJsonRpcConfig(), + new NoOpMetricsSystem(), + rpcMethods, + jwtOptions, + stubCredentialProvider); + } + + private static JsonRpcConfiguration createJsonRpcConfig() { + final JsonRpcConfiguration config = JsonRpcConfiguration.createDefault(); + config.setPort(0); + config.setHostsWhitelist(Collections.singletonList("*")); + return config; + } + + /** Tears down the HTTP server. */ + @AfterClass + public static void shutdownServer() { + service.stop().join(); + } + + @Test + public void loginWithBadCredentials() throws IOException { + stubCredentialProvider.setRespondError("Invalid password"); + + final RequestBody body = + RequestBody.create(JSON, "{\"username\":\"user\",\"password\":\"pass\"}"); + final Request request = new Request.Builder().post(body).url(baseUrl + "/login").build(); + try (final Response resp = client.newCall(request).execute()) { + assertThat(resp.code()).isEqualTo(401); + assertThat(resp.message()).isEqualTo("Unauthorized"); + } + } + + @Test + public void loginWithGoodCredentials() throws IOException { + final User mockUser = mock(User.class); + stubCredentialProvider.setRespondUser(mockUser); + when(mockUser.principal()).thenReturn(new JsonObject()); + + final RequestBody body = + RequestBody.create(JSON, "{\"username\":\"user\",\"password\":\"pass\"}"); + 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(); + + final JWTAuth auth = JWTAuth.create(vertx, jwtOptions); + + auth.authenticate( + new JsonObject().put("jwt", token), + (r) -> { + assertThat(r.succeeded()).isTrue(); + final User user = r.result(); + user.isAuthorized( + "noauths", + (authed) -> { + assertThat(authed.succeeded()).isTrue(); + assertThat(authed.result()).isFalse(); + }); + }); + } + } + + @Test + public void loginWithGoodCredentialsAndPermissions() throws IOException { + final User mockUser = mock(User.class); + stubCredentialProvider.setRespondUser(mockUser); + when(mockUser.principal()) + .thenReturn( + new JsonObject() + .put("permissions", new JsonArray(Collections.singletonList("fakePermission")))); + + final RequestBody body = + RequestBody.create(JSON, "{\"username\":\"user\",\"password\":\"pass\"}"); + 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(); + + final JWTAuth auth = JWTAuth.create(vertx, jwtOptions); + + auth.authenticate( + new JsonObject().put("jwt", token), + (r) -> { + assertThat(r.succeeded()).isTrue(); + final User user = r.result(); + user.isAuthorized( + "noauths", + (authed) -> { + assertThat(authed.succeeded()).isTrue(); + assertThat(authed.result()).isFalse(); + }); + user.isAuthorized( + "fakePermission", + (authed) -> { + assertThat(authed.succeeded()).isTrue(); + assertThat(authed.result()).isTrue(); + }); + }); + } + } +} diff --git a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceTest.java b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceTest.java index eb67f2716e..9fcc666c5e 100644 --- a/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceTest.java +++ b/ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceTest.java @@ -165,6 +165,17 @@ public class JsonRpcHttpServiceTest { service.stop().join(); } + @Test + public void handleLoginRequestWithAuthDisabled() throws Exception { + final RequestBody body = + RequestBody.create(JSON, "{\"username\":\"user\",\"password\":\"pass\"}"); + final Request request = new Request.Builder().post(body).url(baseUrl + "/login").build(); + try (final Response resp = client.newCall(request).execute()) { + assertThat(resp.code()).isEqualTo(400); + assertThat(resp.message()).isEqualTo("Authentication not enabled"); + } + } + @Test public void invalidCallToStart() { service