NC-2043: Toml authentication provider (#689)

* NC-2043: Toml authentication provider

* NC-2043: CLI option to generate hashed password

* Renaming test

* Removing unnecessary TomlAuth interface

Signed-off-by: Adrian Sutton <adrian.sutton@consensys.net>
pull/2/head
Lucas Saldanha 6 years ago committed by GitHub
parent 157b42ed19
commit 721c713fa8
  1. 2
      ethereum/jsonrpc/build.gradle
  2. 154
      ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuth.java
  3. 50
      ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthOptions.java
  4. 86
      ethereum/jsonrpc/src/main/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlUser.java
  5. 128
      ethereum/jsonrpc/src/test/java/tech/pegasys/pantheon/ethereum/jsonrpc/authentication/TomlAuthTest.java
  6. 17
      ethereum/jsonrpc/src/test/resources/authentication/auth.toml
  7. 2
      gradle/versions.gradle
  8. 1
      pantheon/build.gradle
  9. 4
      pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java
  10. 57
      pantheon/src/main/java/tech/pegasys/pantheon/cli/PasswordSubCommand.java
  11. 59
      pantheon/src/test/java/tech/pegasys/pantheon/cli/PasswordSubCommandTest.java

@ -38,6 +38,8 @@ dependencies {
implementation 'com.google.guava:guava' implementation 'com.google.guava:guava'
implementation 'io.vertx:vertx-core' implementation 'io.vertx:vertx-core'
implementation 'io.vertx:vertx-web' implementation 'io.vertx:vertx-web'
implementation 'net.consensys.cava:cava-toml'
implementation 'org.springframework.security:spring-security-crypto'
testImplementation project(':config') testImplementation project(':config')
testImplementation project(path: ':config', configuration: 'testSupportArtifacts') testImplementation project(path: ':config', configuration: 'testSupportArtifacts')

@ -0,0 +1,154 @@
/*
* 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.authentication;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
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.JsonObject;
import io.vertx.ext.auth.AuthProvider;
import io.vertx.ext.auth.User;
import net.consensys.cava.toml.Toml;
import net.consensys.cava.toml.TomlParseResult;
import net.consensys.cava.toml.TomlTable;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.security.crypto.bcrypt.BCrypt;
public class TomlAuth implements AuthProvider {
private static final Logger LOG = LogManager.getLogger();
private final Vertx vertx;
private final TomlAuthOptions options;
public TomlAuth(final Vertx vertx, final TomlAuthOptions options) {
this.vertx = vertx;
this.options = options;
}
@Override
public void authenticate(
final JsonObject authInfo, final Handler<AsyncResult<User>> resultHandler) {
final String username = authInfo.getString("username");
if (username == null) {
resultHandler.handle(Future.failedFuture("No username provided"));
return;
}
final String password = authInfo.getString("password");
if (password == null) {
resultHandler.handle(Future.failedFuture("No password provided"));
return;
}
LOG.debug("Authenticating user {} with password {}", username, password);
readUser(
username,
rs -> {
if (rs.succeeded()) {
TomlUser user = rs.result();
checkPasswordHash(
password,
user.getPassword(),
rs2 -> {
if (rs2.succeeded()) {
resultHandler.handle(Future.succeededFuture(user));
} else {
resultHandler.handle(Future.failedFuture(rs2.cause()));
}
});
} else {
resultHandler.handle(Future.failedFuture(rs.cause()));
}
});
}
private void readUser(final String username, final Handler<AsyncResult<TomlUser>> resultHandler) {
vertx.executeBlocking(
f -> {
TomlParseResult parseResult;
try {
parseResult = Toml.parse(options.getTomlPath());
} catch (IOException e) {
f.fail(e);
return;
}
final TomlTable userData = parseResult.getTableOrEmpty("Users." + username);
if (userData.isEmpty()) {
f.fail("User not found");
return;
}
final TomlUser tomlUser = readTomlUserFromTable(username, userData);
if ("".equals(tomlUser.getPassword())) {
f.fail("No password set for user");
return;
}
f.complete(tomlUser);
},
res -> {
if (res.succeeded()) {
resultHandler.handle(Future.succeededFuture((TomlUser) res.result()));
} else {
resultHandler.handle(Future.failedFuture(res.cause()));
}
});
}
private TomlUser readTomlUserFromTable(final String username, final TomlTable userData) {
final String saltedAndHashedPassword = userData.getString("password", () -> "");
final List<String> groups =
userData
.getArrayOrEmpty("groups")
.toList()
.stream()
.map(Object::toString)
.collect(Collectors.toList());
final List<String> permissions =
userData
.getArrayOrEmpty("permissions")
.toList()
.stream()
.map(Object::toString)
.collect(Collectors.toList());
final List<String> roles =
userData
.getArrayOrEmpty("roles")
.toList()
.stream()
.map(Object::toString)
.collect(Collectors.toList());
return new TomlUser(username, saltedAndHashedPassword, groups, permissions, roles);
}
private void checkPasswordHash(
final String password,
final String passwordHash,
final Handler<AsyncResult<Void>> resultHandler) {
boolean passwordMatches = BCrypt.checkpw(password, passwordHash);
if (passwordMatches) {
resultHandler.handle(Future.succeededFuture());
} else {
resultHandler.handle(Future.failedFuture("Invalid password"));
}
}
}

@ -0,0 +1,50 @@
/*
* 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.authentication;
import java.nio.file.Path;
import java.nio.file.Paths;
import io.vertx.core.Vertx;
import io.vertx.ext.auth.AuthOptions;
import io.vertx.ext.auth.AuthProvider;
public class TomlAuthOptions implements AuthOptions {
private Path tomlPath;
public TomlAuthOptions() {}
public TomlAuthOptions(final TomlAuthOptions that) {
tomlPath = that.tomlPath;
}
@Override
public AuthProvider createProvider(final Vertx vertx) {
return new TomlAuth(vertx, this);
}
@Override
public AuthOptions clone() {
return new TomlAuthOptions(this);
}
public TomlAuthOptions setTomlPath(final String tomlPath) {
this.tomlPath = Paths.get(tomlPath);
return this;
}
public Path getTomlPath() {
return tomlPath;
}
}

@ -0,0 +1,86 @@
/*
* 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.authentication;
import java.util.List;
import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.AbstractUser;
import io.vertx.ext.auth.AuthProvider;
public class TomlUser extends AbstractUser {
private final String username;
private final String password;
private final List<String> groups;
private final List<String> permissions;
private final List<String> roles;
TomlUser(
final String username,
final String password,
final List<String> groups,
final List<String> permissions,
final List<String> roles) {
this.username = username;
this.password = password;
this.groups = groups;
this.permissions = permissions;
this.roles = roles;
}
@Override
public JsonObject principal() {
return new JsonObject()
.put("username", username)
.put("password", password)
.put("groups", groups)
.put("permissions", permissions)
.put("roles", roles);
}
@Override
public void setAuthProvider(final AuthProvider authProvider) {
// we only use Toml for authentication
throw new UnsupportedOperationException("Not implemented");
}
@Override
protected void doIsPermitted(
final String permission, final Handler<AsyncResult<Boolean>> resultHandler) {
// we only use Toml for authentication
throw new UnsupportedOperationException("Not implemented");
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public List<String> getGroups() {
return groups;
}
public List<String> getPermissions() {
return permissions;
}
public List<String> getRoles() {
return roles;
}
}

@ -0,0 +1,128 @@
/*
* 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.authentication;
import java.net.URISyntaxException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Paths;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.unit.TestContext;
import io.vertx.ext.unit.junit.VertxUnitRunner;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(VertxUnitRunner.class)
public class TomlAuthTest {
private Vertx vertx;
private JsonObject validAuthInfo;
private TomlAuth tomlAuth;
@Before
public void before(final TestContext context) throws URISyntaxException {
vertx = Vertx.vertx();
tomlAuth =
new TomlAuth(
vertx, new TomlAuthOptions().setTomlPath(getTomlPath("authentication/auth.toml")));
validAuthInfo = new JsonObject().put("username", "userA").put("password", "pegasys");
}
@Test
public void authInfoWithoutUsernameShouldFailAuthentication(final TestContext context) {
JsonObject authInfo = new JsonObject().put("password", "foo");
tomlAuth.authenticate(
authInfo,
context.asyncAssertFailure(
th -> context.assertEquals("No username provided", th.getMessage())));
}
@Test
public void authInfoWithoutPasswordShouldFailAuthentication(final TestContext context) {
JsonObject authInfo = new JsonObject().put("username", "foo");
tomlAuth.authenticate(
authInfo,
context.asyncAssertFailure(
th -> context.assertEquals("No password provided", th.getMessage())));
}
@Test
public void parseFailureWithIOExceptionShouldFailAuthentication(final TestContext context) {
tomlAuth = new TomlAuth(vertx, new TomlAuthOptions().setTomlPath("invalid_path"));
tomlAuth.authenticate(
validAuthInfo,
context.asyncAssertFailure(
th -> {
context.assertEquals(th.getClass(), NoSuchFileException.class);
}));
}
@Test
public void authInfoWithAbsentUserShouldFailAuthentication(final TestContext context) {
JsonObject authInfo = new JsonObject().put("username", "foo").put("password", "foo");
tomlAuth.authenticate(
authInfo,
context.asyncAssertFailure(th -> context.assertEquals("User not found", th.getMessage())));
}
@Test
public void userWithoutPasswordSetShouldFailAuthentication(final TestContext context) {
JsonObject authInfo = new JsonObject().put("username", "noPasswordUser").put("password", "foo");
tomlAuth.authenticate(
authInfo,
context.asyncAssertFailure(
th -> context.assertEquals("No password set for user", th.getMessage())));
}
@Test
public void passwordMismatchShouldFailAuthentication(final TestContext context) {
JsonObject authInfo = new JsonObject().put("username", "userA").put("password", "foo");
tomlAuth.authenticate(
authInfo,
context.asyncAssertFailure(
th -> context.assertEquals("Invalid password", th.getMessage())));
}
@Test
public void validPasswordShouldAuthenticateSuccessfully(final TestContext context) {
JsonObject expectedPrincipal =
new JsonObject()
.put("username", "userA")
.put("password", "$2a$10$l3GA7K8g6rJ/Yv.YFSygCuI9byngpEzxgWS9qEg5emYDZomQW7fGC")
.put("groups", new JsonArray().add("admin"))
.put("permissions", new JsonArray().add("eth:*").add("perm:*"))
.put("roles", new JsonArray().add("net"));
JsonObject authInfo = new JsonObject().put("username", "userA").put("password", "pegasys");
tomlAuth.authenticate(
authInfo,
context.asyncAssertSuccess(
res -> context.assertEquals(expectedPrincipal, res.principal())));
}
private String getTomlPath(final String tomlFileName) throws URISyntaxException {
return Paths.get(ClassLoader.getSystemResource(tomlFileName).toURI())
.toAbsolutePath()
.toString();
}
}

@ -0,0 +1,17 @@
[Users.userA]
password = "$2a$10$l3GA7K8g6rJ/Yv.YFSygCuI9byngpEzxgWS9qEg5emYDZomQW7fGC"
groups = ["admin"]
permissions = ["eth:*", "perm:*"]
roles = ["net"]
[Groups.admins]
roles = ["admin"]
[Roles.admin]
permissions = ["admin:*"]
[Roles.net]
permissions = ["net:*"]
[Users.noPasswordUser]
groups = ["admin"]

@ -72,6 +72,8 @@ dependencyManagement {
dependency 'org.rocksdb:rocksdbjni:5.17.2' dependency 'org.rocksdb:rocksdbjni:5.17.2'
dependency 'org.springframework.security:spring-security-crypto:5.1.3.RELEASE'
dependency 'org.web3j:abi:4.1.0' dependency 'org.web3j:abi:4.1.0'
dependency 'org.web3j:core:4.1.0' dependency 'org.web3j:core:4.1.0'
dependency 'org.web3j:crypto:4.1.0' dependency 'org.web3j:crypto:4.1.0'

@ -48,6 +48,7 @@ dependencies {
implementation 'io.vertx:vertx-web' implementation 'io.vertx:vertx-web'
implementation 'net.consensys.cava:cava-toml' implementation 'net.consensys.cava:cava-toml'
implementation 'org.apache.logging.log4j:log4j-api' implementation 'org.apache.logging.log4j:log4j-api'
implementation 'org.springframework.security:spring-security-crypto'
runtime 'org.apache.logging.log4j:log4j-core' runtime 'org.apache.logging.log4j:log4j-core'

@ -372,7 +372,7 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
description = description =
"Set if the metrics push gateway integration should be started (default: ${DEFAULT-VALUE})" "Set if the metrics push gateway integration should be started (default: ${DEFAULT-VALUE})"
) )
private Boolean isMetricsPushEnabled = false; private final Boolean isMetricsPushEnabled = false;
@Option( @Option(
names = {"--metrics-push-host"}, names = {"--metrics-push-host"},
@ -528,6 +528,8 @@ public class PantheonCommand implements DefaultCommandValues, Runnable {
BlocksSubCommand.COMMAND_NAME, new BlocksSubCommand(blockImporter, resultHandler.out())); BlocksSubCommand.COMMAND_NAME, new BlocksSubCommand(blockImporter, resultHandler.out()));
commandLine.addSubcommand( commandLine.addSubcommand(
PublicKeySubCommand.COMMAND_NAME, new PublicKeySubCommand(resultHandler.out())); PublicKeySubCommand.COMMAND_NAME, new PublicKeySubCommand(resultHandler.out()));
commandLine.addSubcommand(
PasswordSubCommand.COMMAND_NAME, new PasswordSubCommand(resultHandler.out()));
commandLine.registerConverter(Address.class, Address::fromHexString); commandLine.registerConverter(Address.class, Address::fromHexString);
commandLine.registerConverter(BytesValue.class, BytesValue::fromHexString); commandLine.registerConverter(BytesValue.class, BytesValue::fromHexString);

@ -0,0 +1,57 @@
/*
* 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.cli;
import static tech.pegasys.pantheon.cli.PasswordSubCommand.COMMAND_NAME;
import java.io.PrintStream;
import org.springframework.security.crypto.bcrypt.BCrypt;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.ParentCommand;
import picocli.CommandLine.Spec;
@Command(
name = COMMAND_NAME,
description = "This command generates the hash of a given password.",
mixinStandardHelpOptions = true
)
class PasswordSubCommand implements Runnable {
static final String COMMAND_NAME = "password-hash";
@SuppressWarnings("unused")
@ParentCommand
private PantheonCommand parentCommand; // Picocli injects reference to parent command
@SuppressWarnings("unused")
@Spec
private CommandSpec spec; // Picocli injects reference to command spec
final PrintStream out;
@SuppressWarnings("FieldMustBeFinal")
@Parameters(arity = "1..1", description = "The password input")
private String password = null;
PasswordSubCommand(final PrintStream out) {
this.out = out;
}
@Override
public void run() {
out.print(BCrypt.hashpw(password, BCrypt.gensalt()));
}
}

@ -0,0 +1,59 @@
/*
* 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.cli;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.Test;
import picocli.CommandLine.Model.CommandSpec;
public class PasswordSubCommandTest extends CommandTestAbstract {
@Test
public void passwordHashSubCommandExists() {
CommandSpec spec = parseCommand();
assertThat(spec.subcommands()).containsKeys("password-hash");
assertThat(commandOutput.toString()).isEmpty();
assertThat(commandErrorOutput.toString()).isEmpty();
}
@Test
public void callingPasswordHashWithoutPasswordParameterMustDisplayUsage() {
final String expectedUsage =
"Missing required parameter: <password>"
+ System.lineSeparator()
+ "Usage: pantheon password-hash [-hV] <password>"
+ System.lineSeparator()
+ "This command generates the hash of a given password."
+ System.lineSeparator()
+ " <password> The password input"
+ System.lineSeparator()
+ " -h, --help Show this help message and exit."
+ System.lineSeparator()
+ " -V, --version Print version information and exit."
+ System.lineSeparator();
parseCommand("password-hash");
assertThat(commandOutput.toString()).isEmpty();
assertThat(commandErrorOutput.toString()).startsWith(expectedUsage);
}
@Test
public void publicKeySubCommandExistAnbHaveSubCommands() {
parseCommand("password-hash", "foo");
// we can't predict the final value so we are only checking if it starts with the hash marker
assertThat(commandOutput.toString()).startsWith("$2");
assertThat(commandErrorOutput.toString()).isEmpty();
}
}
Loading…
Cancel
Save