[NC-2044] jsonrpc authentication login (#753)

Signed-off-by: Adrian Sutton <adrian.sutton@consensys.net>
pull/2/head
Chris Mckay 6 years ago committed by GitHub
parent 61bdcc879a
commit 562251638e
  1. 1
      ethereum/jsonrpc/build.gradle
  2. 122
      ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpService.java
  3. 287
      ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceLoginTest.java
  4. 11
      ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/JsonRpcHttpServiceTest.java

@ -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')

@ -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<OperationTimer> requestTimer;
private final Optional<JWTAuth> jwtAuthProvider;
private final Optional<AuthProvider> 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<String, JsonRpcMethod> 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<String, JsonRpcMethod> 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<String, JsonRpcMethod> methods,
final Optional<JWTAuthOptions> jwtOptions,
final Optional<AuthProvider> 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<String> 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<String> 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()) {

@ -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<String, JsonRpcMethod> 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<RpcApi> 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<User> respondUser = Optional.empty();
private Optional<String> respondError = Optional.empty();
@Override
public void authenticate(
final JsonObject authInfo, final Handler<AsyncResult<User>> 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<Capability> 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();
});
});
}
}
}

@ -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

Loading…
Cancel
Save