Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement simple password-based gRPC authentication #4189

Merged
merged 40 commits into from
Apr 29, 2020
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fe9e57b
Implement simple cleartext gRPC authentication
ghubstan Apr 22, 2020
506e12d
Inject Config directly into BisqGrpcServer
cbeams Apr 23, 2020
3fba97c
Favor final fields declared one per line
cbeams Apr 23, 2020
6490e97
Do not declare local variables as final
cbeams Apr 23, 2020
864bd9a
Wrap method parameters according to convention
cbeams Apr 23, 2020
bc88080
Simplify implementation of authenticate method
cbeams Apr 23, 2020
24c245c
Polish BisqCallCredentials style
cbeams Apr 23, 2020
ca06582
Rename AuthenticationInterceptor => TokenAuthInterceptor
cbeams Apr 23, 2020
1a133f4
Use a single auth token vs username:password
cbeams Apr 23, 2020
31aed4b
Simplify implementation to a single main class
cbeams Apr 23, 2020
04defcb
Update comments and console output
cbeams Apr 23, 2020
3fe7848
Use var declarations where appropriate
cbeams Apr 25, 2020
e84123c
Refactor auth infrastructure naming
cbeams Apr 25, 2020
d19581a
Reduce AuthHeaderCallCredentials visibility to package-private
cbeams Apr 25, 2020
0b338bb
Rename/repackage bisq.cli.{app.Bisq=>CliMain}
cbeams Apr 25, 2020
423b2ad
Handle OptionException gracefully
cbeams Apr 25, 2020
b67ad6c
Print help text when no command is specified
cbeams Apr 25, 2020
c6e5670
Print nested exception error message on connect failure
cbeams Apr 25, 2020
7bf854c
Improve error handling
cbeams Apr 25, 2020
c1931d2
Use 'method' vs 'command' naming
cbeams Apr 25, 2020
42e9bb1
Refine help output
cbeams Apr 25, 2020
653856f
Refactor 'auth*' naming to 'password'
cbeams Apr 25, 2020
20f563a
Reduce PasswordAuthInterceptor visibility to package-private
cbeams Apr 25, 2020
d923d07
Fix typo in help output
cbeams Apr 25, 2020
16c2efc
Allow double-quoted multiword apiPassword
ghubstan Apr 25, 2020
56f2923
Remove redundant text from console err msg
ghubstan Apr 25, 2020
dee5e4c
Revert 16c2efc
ghubstan Apr 26, 2020
e10e29a
Handle OptionException immediately
cbeams Apr 26, 2020
0a2aac0
Shutdown channel using a JVM shutdown hook
cbeams Apr 26, 2020
b7fda8d
Declare channel outside try/catch
cbeams Apr 26, 2020
510e84d
Add expect-based cli test suite
cbeams Apr 26, 2020
ceaf20a
Clean up whitespace in cli-test.sh
cbeams Apr 26, 2020
a3f9faf
Move cli-test.sh => cli/test.sh
cbeams Apr 26, 2020
822e681
Touch up test descriptions and parameter naming
cbeams Apr 26, 2020
5fa7939
Set --password as a required option in JOpt parser
cbeams Apr 26, 2020
f4b4f5a
Update cli/test.sh instructions
cbeams Apr 26, 2020
f580349
Add comment explaining exception message mangling
cbeams Apr 26, 2020
a6a8702
Touch up help output
cbeams Apr 26, 2020
312ef30
Revert marking password as required in JOpt parser
cbeams Apr 26, 2020
cfb7e32
Remove note to self
ghubstan Apr 26, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ configure(project(':core')) {
}

configure(project(':cli')) {
mainClassName = 'bisq.cli.app.BisqCliMain'
mainClassName = 'bisq.cli.CliMain'

dependencies {
compile project(':proto')
Expand Down
189 changes: 189 additions & 0 deletions cli/src/main/java/bisq/cli/CliMain.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/

package bisq.cli;

import bisq.proto.grpc.GetBalanceGrpc;
import bisq.proto.grpc.GetBalanceRequest;
import bisq.proto.grpc.GetVersionGrpc;
import bisq.proto.grpc.GetVersionRequest;

import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;

import joptsimple.OptionException;
import joptsimple.OptionParser;
import joptsimple.OptionSet;

import java.text.DecimalFormat;

import java.io.IOException;
import java.io.PrintStream;

import java.math.BigDecimal;

import java.util.List;
import java.util.concurrent.TimeUnit;

import lombok.extern.slf4j.Slf4j;

import static java.lang.System.err;
import static java.lang.System.exit;
import static java.lang.System.out;

/**
* A command-line client for the Bisq gRPC API.
*/
@Slf4j
public class CliMain {

private static final int EXIT_SUCCESS = 0;
private static final int EXIT_FAILURE = 1;

private enum Method {
getversion,
getbalance
}

public static void main(String[] args) {
var parser = new OptionParser();

var helpOpt = parser.accepts("help", "Print this help text")
.forHelp();

var hostOpt = parser.accepts("host", "rpc server hostname or IP")
.withRequiredArg()
.defaultsTo("localhost");

var portOpt = parser.accepts("port", "rpc server port")
.withRequiredArg()
.ofType(Integer.class)
.defaultsTo(9998);

var passwordOpt = parser.accepts("password", "rpc server password")
.withRequiredArg();

OptionSet options = null;
try {
options = parser.parse(args);
} catch (OptionException ex) {
err.println("Error: " + ex.getMessage());
exit(EXIT_FAILURE);
}

if (options.has(helpOpt)) {
printHelp(parser, out);
exit(EXIT_SUCCESS);
}

@SuppressWarnings("unchecked")
var nonOptionArgs = (List<String>) options.nonOptionArguments();
if (nonOptionArgs.isEmpty()) {
printHelp(parser, err);
err.println("Error: no method specified");
exit(EXIT_FAILURE);
}

var methodName = nonOptionArgs.get(0);
Method method = null;
try {
method = Method.valueOf(methodName);
} catch (IllegalArgumentException ex) {
err.printf("Error: '%s' is not a supported method\n", methodName);
exit(EXIT_FAILURE);
}

var host = options.valueOf(hostOpt);
var port = options.valueOf(portOpt);
var password = options.valueOf(passwordOpt);
if (password == null) {
err.println("Error: missing required 'password' option");
exit(EXIT_FAILURE);
}

var credentials = new PasswordCallCredentials(password);

var channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
channel.shutdown().awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException ex) {
ex.printStackTrace(err);
exit(EXIT_FAILURE);
}
}));

try {
switch (method) {
case getversion: {
var stub = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials);
var request = GetVersionRequest.newBuilder().build();
var version = stub.getVersion(request).getVersion();
out.println(version);
exit(EXIT_SUCCESS);
}
case getbalance: {
var stub = GetBalanceGrpc.newBlockingStub(channel).withCallCredentials(credentials);
var request = GetBalanceRequest.newBuilder().build();
var balance = stub.getBalance(request).getBalance();
if (balance == -1) {
err.println("Error: server is still initializing");
exit(EXIT_FAILURE);
}
out.println(formatBalance(balance));
exit(EXIT_SUCCESS);
}
default: {
err.printf("Error: unhandled method '%s'\n", method);
exit(EXIT_FAILURE);
}
}
} catch (StatusRuntimeException ex) {
// This exception is thrown if the client-provided password credentials do not
// match the value set on the server. The actual error message is in a nested
// exception and we clean it up a bit to make it more presentable.
Throwable t = ex.getCause() == null ? ex : ex.getCause();
err.println("Error: " + t.getMessage().replace("UNAUTHENTICATED: ", ""));
exit(EXIT_FAILURE);
}
}

private static void printHelp(OptionParser parser, PrintStream stream) {
try {
stream.println("Bisq RPC Client");
stream.println();
stream.println("Usage: bisq-cli [options] <method>");
stream.println();
parser.printHelpOn(stream);
stream.println();
stream.println("Method Description");
stream.println("------ -----------");
stream.println("getversion Get server version");
stream.println("getbalance Get server wallet balance");
stream.println();
} catch (IOException ex) {
ex.printStackTrace(stream);
}
}

@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
private static String formatBalance(long satoshis) {
var btcFormat = new DecimalFormat("###,##0.00000000");
var satoshiDivisor = new BigDecimal(100000000);
return btcFormat.format(BigDecimal.valueOf(satoshis).divide(satoshiDivisor));
}
}
45 changes: 45 additions & 0 deletions cli/src/main/java/bisq/cli/PasswordCallCredentials.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package bisq.cli;

import io.grpc.CallCredentials;
import io.grpc.Metadata;
import io.grpc.Metadata.Key;

import java.util.concurrent.Executor;

import static io.grpc.Metadata.ASCII_STRING_MARSHALLER;
import static io.grpc.Status.UNAUTHENTICATED;
import static java.lang.String.format;

/**
* Sets the {@value PASSWORD_KEY} rpc call header to a given value.
*/
class PasswordCallCredentials extends CallCredentials {

public static final String PASSWORD_KEY = "password";

private final String passwordValue;

public PasswordCallCredentials(String passwordValue) {
if (passwordValue == null)
throw new IllegalArgumentException(format("'%s' value must not be null", PASSWORD_KEY));
this.passwordValue = passwordValue;
}

@Override
public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier metadataApplier) {
appExecutor.execute(() -> {
try {
var headers = new Metadata();
var passwordKey = Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER);
headers.put(passwordKey, passwordValue);
metadataApplier.apply(headers);
} catch (Throwable ex) {
metadataApplier.fail(UNAUTHENTICATED.withCause(ex));
}
});
}

@Override
public void thisUsesUnstableApi() {
}
}
116 changes: 0 additions & 116 deletions cli/src/main/java/bisq/cli/app/BisqCliMain.java

This file was deleted.

Loading