mirror of https://github.com/hyperledger/besu
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
parent
157b42ed19
commit
721c713fa8
@ -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"] |
@ -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…
Reference in new issue