From d40e1d2f56c989bd0b7e718d76b491f16ae23724 Mon Sep 17 00:00:00 2001 From: Bernard Labno Date: Wed, 23 Jan 2019 15:21:39 +0100 Subject: [PATCH] Security framework for the API --- api/README.md | 33 +- .../java/bisq/api/http/HttpApiModule.java | 4 + .../api/http/exceptions/ExceptionMappers.java | 46 +++ .../exceptions/UnauthorizedException.java | 4 + .../java/bisq/api/http/model/AuthForm.java | 17 + .../java/bisq/api/http/model/AuthResult.java | 14 + .../bisq/api/http/model/ChangePassword.java | 16 + .../api/http/service/HttpApiInterfaceV1.java | 22 +- .../bisq/api/http/service/HttpApiServer.java | 27 +- .../http/service/auth/ApiPasswordManager.java | 135 ++++++ .../api/http/service/auth/AuthFilter.java | 60 +++ .../api/http/service/auth/TokenRegistry.java | 63 +++ .../http/service/endpoint/UserEndpoint.java | 73 ++++ .../service/auth/ApiPasswordManagerTest.java | 391 ++++++++++++++++++ .../api/http/service/auth/AuthFilterTest.java | 162 ++++++++ .../http/service/auth/TokenRegistryTest.java | 116 ++++++ .../java/bisq/api/http/UserEndpointIT.java | 317 ++++++++++++++ build.gradle | 7 +- 18 files changed, 1495 insertions(+), 12 deletions(-) create mode 100644 api/src/main/java/bisq/api/http/exceptions/ExceptionMappers.java create mode 100644 api/src/main/java/bisq/api/http/exceptions/UnauthorizedException.java create mode 100644 api/src/main/java/bisq/api/http/model/AuthForm.java create mode 100644 api/src/main/java/bisq/api/http/model/AuthResult.java create mode 100644 api/src/main/java/bisq/api/http/model/ChangePassword.java create mode 100644 api/src/main/java/bisq/api/http/service/auth/ApiPasswordManager.java create mode 100644 api/src/main/java/bisq/api/http/service/auth/AuthFilter.java create mode 100644 api/src/main/java/bisq/api/http/service/auth/TokenRegistry.java create mode 100644 api/src/main/java/bisq/api/http/service/endpoint/UserEndpoint.java create mode 100644 api/src/test/java/bisq/api/http/service/auth/ApiPasswordManagerTest.java create mode 100644 api/src/test/java/bisq/api/http/service/auth/AuthFilterTest.java create mode 100644 api/src/test/java/bisq/api/http/service/auth/TokenRegistryTest.java create mode 100644 api/src/testIntegration/java/bisq/api/http/UserEndpointIT.java diff --git a/api/README.md b/api/README.md index 2916638bd25..b4dbc392032 100644 --- a/api/README.md +++ b/api/README.md @@ -9,18 +9,20 @@ You can run it either as the desktop application or as a headless application. On that branch we start to implement feature by feature starting with the most simple one - `version`. -_**Known issues**: Wallet password protection is not supported at the moment for the headless version. So if you have set +**Known issues**: Wallet password protection is not supported at the moment for the headless version. So if you have set a wallet password when running the Desktop version and afterwards run the headless version it will get stuck at startup expecting the wallet password. This feature will be implemented soon._ -_**Note**: -If you have a Bisq application with BTC already set up it is recommended to use the optional `appName` argument to +**Note**: If you have a Bisq application with BTC already set up it is recommended to use the optional `appName` argument to provide a different name and data directory so that the default Bisq application is not exposed via the API. That way your data and wallet from your default Bisq application are completely isolated from the API application. In the below commands we use the argument `--appName=bisq-API` to ensure you are not mixing up your default Bisq setup when experimenting with the API branch. You cannot run the desktop and the headless version in parallel as they would access the same data. +**Security**: Api uses HTTP transport which is not encrypted. Use the API only locally and do not expose it over +public network interfaces. + ## Run the API as Desktop application cd desktop @@ -39,6 +41,31 @@ Documentation is available at http://localhost:8080/docs/ Sample call: curl http://localhost:8080/api/v1/version + +#### Authentication + +By default there is no password required for the API. We recommend that you set password as soon as possible: + + curl -X POST "http://localhost:8080/api/v1/user/password" -H "Content-Type: application/json" -d "{\"newPassword\":\"string\"}" + +Password digest and salt are stored in a `apipasswd` in Bisq data directory. +If you forget your password, just delete that file and restart Bisq. + +Once you set the password you need to trade it for access token. + + curl -X POST "http://localhost:8080/api/v1/user/authenticate" -H "Content-Type: application/json" -d "{\"password\":\"string\"}" + +You should get the token in response looking like this: + + { + "token": "5130bcb6-bee5-47ae-ac78-ee31d6557ed5" + } + +Now you can access other endpoints by adding `Authorization` header to the request: + + curl -X GET "http://localhost:8080/api/v1/version" -H "authorization: 5130bcb6-bee5-47ae-ac78-ee31d6557ed5" + +**NOTE**: Tokens expire after 30 minutes. ## Run the API as headless application diff --git a/api/src/main/java/bisq/api/http/HttpApiModule.java b/api/src/main/java/bisq/api/http/HttpApiModule.java index af199a24c7d..47a6e2441c1 100644 --- a/api/src/main/java/bisq/api/http/HttpApiModule.java +++ b/api/src/main/java/bisq/api/http/HttpApiModule.java @@ -18,6 +18,8 @@ package bisq.api.http; import bisq.api.http.service.HttpApiServer; +import bisq.api.http.service.auth.ApiPasswordManager; +import bisq.api.http.service.auth.TokenRegistry; import bisq.api.http.service.endpoint.VersionEndpoint; import bisq.core.app.AppOptionKeys; @@ -38,6 +40,8 @@ public HttpApiModule(Environment environment) { @Override protected void configure() { bind(HttpApiServer.class).in(Singleton.class); + bind(TokenRegistry.class).in(Singleton.class); + bind(ApiPasswordManager.class).in(Singleton.class); bind(VersionEndpoint.class).in(Singleton.class); String httpApiHost = environment.getProperty(AppOptionKeys.HTTP_API_HOST, String.class, "127.0.0.1"); diff --git a/api/src/main/java/bisq/api/http/exceptions/ExceptionMappers.java b/api/src/main/java/bisq/api/http/exceptions/ExceptionMappers.java new file mode 100644 index 00000000000..9580da65202 --- /dev/null +++ b/api/src/main/java/bisq/api/http/exceptions/ExceptionMappers.java @@ -0,0 +1,46 @@ +package bisq.api.http.exceptions; + +import com.fasterxml.jackson.core.JsonParseException; + +import lombok.extern.slf4j.Slf4j; + + + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import org.eclipse.jetty.io.EofException; +import org.glassfish.jersey.server.ResourceConfig; + +@Slf4j +public final class ExceptionMappers { + + private ExceptionMappers() { + } + + public static void register(ResourceConfig environment) { + environment.register(new ExceptionMappers.EofExceptionMapper(), 1); + environment.register(new ExceptionMappers.JsonParseExceptionMapper(), 1); + environment.register(new ExceptionMappers.UnauthorizedExceptionMapper()); + } + + public static class EofExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(EofException e) { + return Response.status(Response.Status.BAD_REQUEST).build(); + } + } + + public static class JsonParseExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(JsonParseException e) { + return Response.status(Response.Status.BAD_REQUEST).build(); + } + } + + public static class UnauthorizedExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(UnauthorizedException exception) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + } +} diff --git a/api/src/main/java/bisq/api/http/exceptions/UnauthorizedException.java b/api/src/main/java/bisq/api/http/exceptions/UnauthorizedException.java new file mode 100644 index 00000000000..76f032e6efe --- /dev/null +++ b/api/src/main/java/bisq/api/http/exceptions/UnauthorizedException.java @@ -0,0 +1,4 @@ +package bisq.api.http.exceptions; + +public class UnauthorizedException extends RuntimeException { +} diff --git a/api/src/main/java/bisq/api/http/model/AuthForm.java b/api/src/main/java/bisq/api/http/model/AuthForm.java new file mode 100644 index 00000000000..46eaf0d62ed --- /dev/null +++ b/api/src/main/java/bisq/api/http/model/AuthForm.java @@ -0,0 +1,17 @@ +package bisq.api.http.model; + +import org.hibernate.validator.constraints.NotEmpty; + +public class AuthForm { + + @NotEmpty + public String password; + + @SuppressWarnings("unused") + public AuthForm() { + } + + public AuthForm(String password) { + this.password = password; + } +} diff --git a/api/src/main/java/bisq/api/http/model/AuthResult.java b/api/src/main/java/bisq/api/http/model/AuthResult.java new file mode 100644 index 00000000000..a526528b719 --- /dev/null +++ b/api/src/main/java/bisq/api/http/model/AuthResult.java @@ -0,0 +1,14 @@ +package bisq.api.http.model; + +public class AuthResult { + + public String token; + + @SuppressWarnings("unused") + public AuthResult() { + } + + public AuthResult(String token) { + this.token = token; + } +} diff --git a/api/src/main/java/bisq/api/http/model/ChangePassword.java b/api/src/main/java/bisq/api/http/model/ChangePassword.java new file mode 100644 index 00000000000..934a0d7f1b5 --- /dev/null +++ b/api/src/main/java/bisq/api/http/model/ChangePassword.java @@ -0,0 +1,16 @@ +package bisq.api.http.model; + +public class ChangePassword { + + public String newPassword; + public String oldPassword; + + @SuppressWarnings("unused") + public ChangePassword() { + } + + public ChangePassword(String newPassword, String oldPassword) { + this.newPassword = newPassword; + this.oldPassword = oldPassword; + } +} diff --git a/api/src/main/java/bisq/api/http/service/HttpApiInterfaceV1.java b/api/src/main/java/bisq/api/http/service/HttpApiInterfaceV1.java index 20da4d5e46a..b0e92e3580b 100644 --- a/api/src/main/java/bisq/api/http/service/HttpApiInterfaceV1.java +++ b/api/src/main/java/bisq/api/http/service/HttpApiInterfaceV1.java @@ -1,5 +1,6 @@ package bisq.api.http.service; +import bisq.api.http.service.endpoint.UserEndpoint; import bisq.api.http.service.endpoint.VersionEndpoint; import javax.inject.Inject; @@ -7,25 +8,44 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; import io.swagger.v3.oas.annotations.tags.Tag; import javax.ws.rs.Path; +@SecurityScheme( + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + name = "authorization", + paramName = "authorization" +) @OpenAPIDefinition( info = @Info(version = "0.0.1", title = "Bisq HTTP API"), + security = @SecurityRequirement(name = "authorization"), tags = { + @Tag(name = "user"), @Tag(name = "version") } ) @Path("/api/v1") public class HttpApiInterfaceV1 { + private final UserEndpoint userEndpoint; private final VersionEndpoint versionEndpoint; @Inject - public HttpApiInterfaceV1(VersionEndpoint versionEndpoint) { + public HttpApiInterfaceV1(UserEndpoint userEndpoint, VersionEndpoint versionEndpoint) { + this.userEndpoint = userEndpoint; this.versionEndpoint = versionEndpoint; } + @Path("user") + public UserEndpoint getUserEndpoint() { + return userEndpoint; + } + @Path("version") public VersionEndpoint getVersionEndpoint() { return versionEndpoint; diff --git a/api/src/main/java/bisq/api/http/service/HttpApiServer.java b/api/src/main/java/bisq/api/http/service/HttpApiServer.java index 9822ab499ac..02aac14a4dc 100644 --- a/api/src/main/java/bisq/api/http/service/HttpApiServer.java +++ b/api/src/main/java/bisq/api/http/service/HttpApiServer.java @@ -1,11 +1,20 @@ package bisq.api.http.service; +import bisq.api.http.exceptions.ExceptionMappers; +import bisq.api.http.service.auth.ApiPasswordManager; +import bisq.api.http.service.auth.AuthFilter; +import bisq.api.http.service.auth.TokenRegistry; + import bisq.core.app.BisqEnvironment; +import javax.servlet.DispatcherType; + import javax.inject.Inject; import java.net.InetSocketAddress; +import java.util.EnumSet; + import lombok.extern.slf4j.Slf4j; @@ -16,6 +25,7 @@ import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.server.handler.ContextHandlerCollection; import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.servlet.FilterHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.glassfish.jersey.server.ResourceConfig; @@ -26,12 +36,16 @@ public class HttpApiServer { private final HttpApiInterfaceV1 httpApiInterfaceV1; private final BisqEnvironment bisqEnvironment; + private final TokenRegistry tokenRegistry; + private final ApiPasswordManager apiPasswordManager; @Inject - public HttpApiServer(HttpApiInterfaceV1 httpApiInterfaceV1, - BisqEnvironment bisqEnvironment) { - this.httpApiInterfaceV1 = httpApiInterfaceV1; + public HttpApiServer(ApiPasswordManager apiPasswordManager, BisqEnvironment bisqEnvironment, HttpApiInterfaceV1 httpApiInterfaceV1, + TokenRegistry tokenRegistry) { + this.apiPasswordManager = apiPasswordManager; this.bisqEnvironment = bisqEnvironment; + this.httpApiInterfaceV1 = httpApiInterfaceV1; + this.tokenRegistry = tokenRegistry; } public void startServer() { @@ -52,11 +66,13 @@ public void startServer() { private ContextHandler buildAPIHandler() { ResourceConfig resourceConfig = new ResourceConfig(); + ExceptionMappers.register(resourceConfig); resourceConfig.register(httpApiInterfaceV1); resourceConfig.packages("io.swagger.v3.jaxrs2.integration.resources"); ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.NO_SESSIONS | ServletContextHandler.NO_SECURITY); servletContextHandler.setContextPath("/"); servletContextHandler.addServlet(new ServletHolder(new ServletContainer(resourceConfig)), "/*"); + setupAuth(servletContextHandler); return servletContextHandler; } @@ -77,4 +93,9 @@ private ContextHandler buildSwaggerUIHandler() throws Exception { swaggerUIContext.setHandler(swaggerUIResourceHandler); return swaggerUIContext; } + + private void setupAuth(ServletContextHandler appContextHandler) { + AuthFilter authFilter = new AuthFilter(apiPasswordManager, tokenRegistry); + appContextHandler.addFilter(new FilterHolder(authFilter), "/*", EnumSet.allOf(DispatcherType.class)); + } } diff --git a/api/src/main/java/bisq/api/http/service/auth/ApiPasswordManager.java b/api/src/main/java/bisq/api/http/service/auth/ApiPasswordManager.java new file mode 100644 index 00000000000..c8db1a40bf6 --- /dev/null +++ b/api/src/main/java/bisq/api/http/service/auth/ApiPasswordManager.java @@ -0,0 +1,135 @@ +package bisq.api.http.service.auth; + +import bisq.api.http.exceptions.UnauthorizedException; + +import bisq.core.app.BisqEnvironment; + +import bisq.common.crypto.Hash; + +import com.google.inject.Inject; + +import java.security.SecureRandom; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import java.io.IOException; + +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + + +@Slf4j +public class ApiPasswordManager { + + private static final String SEPARATOR = ":"; + + private final Path passwordFilePath; + private final TokenRegistry tokenRegistry; + private final SecureRandom secureRandom; + private boolean passwordSet; + private String salt; + private byte[] passwordDigest; + + @Inject + public ApiPasswordManager(BisqEnvironment bisqEnvironment, TokenRegistry tokenRegistry) { + this.tokenRegistry = tokenRegistry; + String appDataDir = bisqEnvironment.getAppDataDir(); + this.passwordFilePath = Paths.get(appDataDir).resolve("apipasswd"); + this.secureRandom = new SecureRandom(); + readPasswordFromFile(); + } + + public boolean isPasswordSet() { + return passwordSet; + } + + public String authenticate(String password) { + final boolean passwordValid = isPasswordValid(password); + if (!passwordValid) + throw new UnauthorizedException(); + + return tokenRegistry.generateToken(); + } + + @Nullable + public String changePassword(@Nullable String oldPassword, @Nullable String newPassword) { + if (passwordSet && (oldPassword == null || !isPasswordValid(oldPassword))) throw new UnauthorizedException(); + if (newPassword != null && newPassword.length() > 0) { + salt = "" + secureRandom.nextLong(); + passwordDigest = getBytesForSaltedPassword(newPassword); + passwordSet = true; + writePasswordFile(); + tokenRegistry.clear(); + return tokenRegistry.generateToken(); + } else { + passwordSet = false; + removePasswordFile(); + } + return null; + } + + private void removePasswordFile() { + try { + Files.delete(passwordFilePath); + } catch (IOException e) { + throw new RuntimeException("Unable to remove password file: " + passwordFilePath, e); + } + } + + private boolean isPasswordValid(String password) { + final byte[] sha256Hash = getBytesForSaltedPassword(password); + return Arrays.equals(sha256Hash, passwordDigest); + } + + private byte[] getBytesForSaltedPassword(String password) { + final StringBuilder stringBuilder = new StringBuilder(password); + if (salt != null) { + stringBuilder.append(salt); + } + return Hash.getSha256Hash(stringBuilder.toString()); + } + + private void writePasswordFile() { + final String line = salt + SEPARATOR + new String(Base64.getEncoder().encode(passwordDigest)); + try { + Files.write(passwordFilePath, Collections.singleton(line)); + } catch (IOException e) { + throw new RuntimeException("Unable to write password file: " + passwordFilePath, e); + } + } + + private void readPasswordFromFile() { + passwordSet = false; + if (!Files.exists(this.passwordFilePath)) { + return; + } + try { + final List lines = Files.readAllLines(this.passwordFilePath); + final int linesCount = lines.size(); + if (linesCount != 1) { + log.warn("API password file is corrupt. Expected to have 1 line, found {}", linesCount); + return; + } + final String line = lines.get(0); + final String[] segments = line.split(SEPARATOR); + if (segments.length != 2) { + log.warn("API password file is corrupt. Expected 2 segments, found {}", segments.length); + return; + } + passwordSet = true; + this.salt = segments[0]; + this.passwordDigest = Base64.getDecoder().decode(segments[1]); + + } catch (IOException e) { + throw new RuntimeException("Unable to read api password file", e); + } + } +} diff --git a/api/src/main/java/bisq/api/http/service/auth/AuthFilter.java b/api/src/main/java/bisq/api/http/service/auth/AuthFilter.java new file mode 100644 index 00000000000..e086a79308b --- /dev/null +++ b/api/src/main/java/bisq/api/http/service/auth/AuthFilter.java @@ -0,0 +1,60 @@ +package bisq.api.http.service.auth; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class AuthFilter implements Filter { + private final TokenRegistry tokenRegistry; + private final ApiPasswordManager apiPasswordManager; + + + public AuthFilter(ApiPasswordManager apiPasswordManager, TokenRegistry tokenRegistry) { + this.apiPasswordManager = apiPasswordManager; + this.tokenRegistry = tokenRegistry; + } + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; + HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse; + String pathInfo = httpServletRequest.getPathInfo(); + if (!pathInfo.startsWith("/api") || pathInfo.endsWith("/user/authenticate") || pathInfo.endsWith("/user/password")) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + if (!apiPasswordManager.isPasswordSet()) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + String authorizationHeader = httpServletRequest.getHeader("authorization"); + if (authorizationHeader == null) { + respondWithUnauthorizedStatus(httpServletResponse); + return; + } + if (tokenRegistry.isValidToken(authorizationHeader)) + filterChain.doFilter(servletRequest, servletResponse); + else + respondWithUnauthorizedStatus(httpServletResponse); + } + + @Override + public void destroy() { + } + + private void respondWithUnauthorizedStatus(HttpServletResponse httpServletResponse) { + httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/api/src/main/java/bisq/api/http/service/auth/TokenRegistry.java b/api/src/main/java/bisq/api/http/service/auth/TokenRegistry.java new file mode 100644 index 00000000000..9c620eacfc4 --- /dev/null +++ b/api/src/main/java/bisq/api/http/service/auth/TokenRegistry.java @@ -0,0 +1,63 @@ +package bisq.api.http.service.auth; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class TokenRegistry { + + public static final long TTL = 30 * 60 * 1000; + + private final TimeProvider timeProvider; + private final RandomStringGenerator randomStringGenerator; + private Map tokens = new HashMap<>(); + + public TokenRegistry() { + this(() -> UUID.randomUUID().toString(), System::currentTimeMillis); + } + + public TokenRegistry(RandomStringGenerator randomStringGenerator, TimeProvider timeProvider) { + this.timeProvider = timeProvider; + this.randomStringGenerator = randomStringGenerator; + } + + public String generateToken() { + String uuid; + do { + uuid = randomStringGenerator.generateRandomString(); + } while (tokens.containsKey(uuid)); + tokens.put(uuid, timeProvider.getTime()); + removeTimeoutTokens(); + return uuid; + } + + boolean isValidToken(String token) { + Long createDate = tokens.get(token); + if (createDate == null || isTimeout(createDate)) { + tokens.remove(token); + return false; + } else { + return true; + } + } + + private boolean isTimeout(Long createDate) { + return timeProvider.getTime() - createDate > TTL; + } + + private void removeTimeoutTokens() { + tokens.values().removeIf(this::isTimeout); + } + + public void clear() { + tokens.clear(); + } + + public interface TimeProvider { + Long getTime(); + } + + public interface RandomStringGenerator { + String generateRandomString(); + } +} diff --git a/api/src/main/java/bisq/api/http/service/endpoint/UserEndpoint.java b/api/src/main/java/bisq/api/http/service/endpoint/UserEndpoint.java new file mode 100644 index 00000000000..be19f212187 --- /dev/null +++ b/api/src/main/java/bisq/api/http/service/endpoint/UserEndpoint.java @@ -0,0 +1,73 @@ +package bisq.api.http.service.endpoint; + +import bisq.api.http.model.AuthForm; +import bisq.api.http.model.AuthResult; +import bisq.api.http.model.ChangePassword; +import bisq.api.http.service.auth.ApiPasswordManager; + +import bisq.common.UserThread; + +import javax.inject.Inject; + + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.container.AsyncResponse; +import javax.ws.rs.container.Suspended; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + + +@Tag(name = "user") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class UserEndpoint { + + private final ApiPasswordManager apiPasswordManager; + + @Inject + public UserEndpoint(ApiPasswordManager apiPasswordManager) { + this.apiPasswordManager = apiPasswordManager; + } + + @Operation(summary = "Exchange password for access token", responses = @ApiResponse(content = @Content(schema = @Schema(implementation = AuthResult.class)))) + @POST + @Path("/authenticate") + public void authenticate(@Suspended AsyncResponse asyncResponse, @Valid AuthForm authForm) { + UserThread.execute(() -> { + try { + final String token = apiPasswordManager.authenticate(authForm.password); + asyncResponse.resume(new AuthResult(token)); + } catch (Throwable e) { + asyncResponse.resume(e); + } + }); + } + + @Operation(summary = "Change password", responses = @ApiResponse(content = @Content(schema = @Schema(implementation = AuthResult.class)))) + @POST + @Path("/password") + public void changePassword(@Suspended AsyncResponse asyncResponse, @Valid ChangePassword data) { + UserThread.execute(() -> { + try { + final String token = apiPasswordManager.changePassword(data.oldPassword, data.newPassword); + if (token == null) { + asyncResponse.resume(Response.noContent().build()); + } else { + asyncResponse.resume(new AuthResult(token)); + } + } catch (Throwable e) { + asyncResponse.resume(e); + } + }); + } +} diff --git a/api/src/test/java/bisq/api/http/service/auth/ApiPasswordManagerTest.java b/api/src/test/java/bisq/api/http/service/auth/ApiPasswordManagerTest.java new file mode 100644 index 00000000000..d62efe13744 --- /dev/null +++ b/api/src/test/java/bisq/api/http/service/auth/ApiPasswordManagerTest.java @@ -0,0 +1,391 @@ +package bisq.api.http.service.auth; + +import bisq.api.http.exceptions.UnauthorizedException; + +import bisq.core.app.BisqEnvironment; + +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.IOException; + +import org.jetbrains.annotations.NotNull; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + + +import com.github.javafaker.Faker; + +public class ApiPasswordManagerTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private String dataDir; + private BisqEnvironment bisqEnvironmentMock; + private TokenRegistry tokenRegistryMock; + private ApiPasswordManager apiPasswordManager; + + private static String getRandomPasswordDifferentThan(String otherPassword) { + String newPassword; + do { + newPassword = Faker.instance().internet().password(); + } while (otherPassword.equals(newPassword)); + return newPassword; + } + + @Before + public void setUp() throws Exception { + this.bisqEnvironmentMock = mock(BisqEnvironment.class); + this.tokenRegistryMock = new TokenRegistry(); + this.dataDir = createTempDirectory(); + when(bisqEnvironmentMock.getAppDataDir()).thenReturn(dataDir); + assertPasswordFileDoesNotExist(); + } + + @After + public void tearDown() throws IOException { + FileUtils.deleteDirectory(new File(this.dataDir)); + } + + @Test + public void constructor_passwordFileNotReadable_throwsException() { + // Given + File invalidPasswordFile = getPasswordFile(); + assertTrue(invalidPasswordFile.mkdir()); + invalidPasswordFile.deleteOnExit(); + expectedException.expect(RuntimeException.class); + expectedException.expectMessage("Unable to read api password file"); + + // When + new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + } + + @Test + public void constructor_passwordFileContainsMoreThan2Lines_doesNotSetPassword() throws IOException { + // Given + writePasswordFile("a:b\nd:e"); + + // When + ApiPasswordManager apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + boolean passwordSet = apiPasswordManager.isPasswordSet(); + + // Then + assertFalse(passwordSet); + } + + @Test + public void constructor_passwordFileContainsMoreThan2Separators_doesNotSetPassword() throws IOException { + // Given + writePasswordFile("a:b:e"); + + // When + ApiPasswordManager apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + boolean passwordSet = apiPasswordManager.isPasswordSet(); + + // Then + assertFalse(passwordSet); + } + + @Test + public void isPasswordSet_noPasswordFile_returnsFalse() { + // Given + + // When + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + + // Then + assertFalse(apiPasswordManager.isPasswordSet()); + } + + @Test + public void isPasswordSet_passwordFileExists_returnsTrue() { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String newPassword = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, newPassword); + + // When + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + + // Then + assertTrue(this.apiPasswordManager.isPasswordSet()); + assertTrue(anotherPasswordManager.isPasswordSet()); + } + + @Test + public void authenticate_noPasswordFile_throwsUnauthorizedException() { + // Given + expectedException.expect(UnauthorizedException.class); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + assertPasswordFileDoesNotExist(); + + // When + apiPasswordManager.authenticate(getRandomPasswordDifferentThan("")); + } + + @Test + public void authenticate_passwordDoesNotMatch_throwsUnauthorizedException() { + // Given + expectedException.expect(UnauthorizedException.class); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + + // When + apiPasswordManager.authenticate(getRandomPasswordDifferentThan(password)); + } + + @Test + public void authenticate_passwordDoesNotMatchAndDifferentPasswordManagerInstance_throwsUnauthorizedException() { + // Given + expectedException.expect(UnauthorizedException.class); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + + // When + anotherPasswordManager.authenticate(getRandomPasswordDifferentThan(password)); + } + + @Test + public void authenticate_passwordMatches_returnsTokenFromTokenRegistry() { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + + // When + String token = anotherPasswordManager.authenticate(password); + + // Then + assertNotNull(token); + assertTrue(tokenRegistryMock.isValidToken(token)); + } + + @Test + public void changePassword_noPasswordFileAndNewPasswordSet_returnsTokenFromRegistry() { + // Given + assertPasswordFileDoesNotExist(); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + + // When + String token = apiPasswordManager.changePassword(null, password); + + // Then + assertNotNull(token); + assertTrue(tokenRegistryMock.isValidToken(token)); + assertNotNull(apiPasswordManager.authenticate(password)); + } + + @Test + public void changePassword_noPasswordFileAndNewPasswordSet_changesThePassword() { + // Given + assertPasswordFileDoesNotExist(); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + + // When + apiPasswordManager.changePassword(null, password); + String token = apiPasswordManager.authenticate(password); + + // Then + assertTrue(tokenRegistryMock.isValidToken(token)); + } + + @Test + public void changePassword_noPasswordFileAndNewPasswordSet_storesThePasswordInPasswordFile() { + // Given + assertPasswordFileDoesNotExist(); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + + // When + apiPasswordManager.changePassword(null, password); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String token = anotherPasswordManager.authenticate(password); + + // Then + assertPasswordFileExists(); + assertTrue(tokenRegistryMock.isValidToken(token)); + } + + @Test + public void changePassword_passwordDoesNotMatch_throwsUnauthorizedException() { + // Given + expectedException.expect(UnauthorizedException.class); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String invalidPassword = getRandomPasswordDifferentThan(password); + String newPassword = getRandomPasswordDifferentThan(password); + + // When + anotherPasswordManager.changePassword(invalidPassword, newPassword); + } + + @Test + public void changePassword_oldPasswordIsNull_throwsUnauthorizedException() { + // Given + expectedException.expect(UnauthorizedException.class); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String newPassword = getRandomPasswordDifferentThan(password); + + // When + anotherPasswordManager.changePassword(null, newPassword); + } + + @Test + public void changePassword_oldPasswordMatchesTheOneInPasswordFileAndNewPasswordSet_changesThePasswordInPasswordFile() { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + String newPassword = getRandomPasswordDifferentThan(password); + + // When + apiPasswordManager.changePassword(password, newPassword); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String token = anotherPasswordManager.authenticate(newPassword); + + // Then + assertTrue(tokenRegistryMock.isValidToken(token)); + } + + @Test + public void changePassword_oldPasswordMatchesTheOneInPasswordFileAndNewPasswordSet_oldPasswordBecomesInvalid() { + // Given + expectedException.expect(UnauthorizedException.class); + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + String newPassword = getRandomPasswordDifferentThan(password); + + // When + apiPasswordManager.changePassword(password, newPassword); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + anotherPasswordManager.authenticate(password); + } + + @Test + public void changePassword_oldPasswordMatchesTheOneInPasswordFileAndNewPasswordIsNull_unSetsPassword() { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + + // When + apiPasswordManager.changePassword(password, null); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + + // Then + assertFalse(apiPasswordManager.isPasswordSet()); + assertFalse(anotherPasswordManager.isPasswordSet()); + expectedException.expect(UnauthorizedException.class); + anotherPasswordManager.authenticate(password); + } + + @Test + public void changePassword_oldPasswordMatchesTheOneInPasswordFileAndNewPasswordIsEmptyString_unSetsPassword() { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + + // When + apiPasswordManager.changePassword(password, ""); + ApiPasswordManager anotherPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + + // Then + assertFalse(apiPasswordManager.isPasswordSet()); + assertFalse(anotherPasswordManager.isPasswordSet()); + expectedException.expect(UnauthorizedException.class); + anotherPasswordManager.authenticate(password); + } + + @Test + public void changePassword_newPasswordNullButPasswordFileNotWritable_throwsException() throws IOException { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + File passwordFile = getPasswordFile(); + assertTrue(passwordFile.delete()); + assertTrue(passwordFile.mkdir()); + assertNotNull(File.createTempFile("bisq", "api", passwordFile)); + expectedException.expect(RuntimeException.class); + expectedException.expectMessage("Unable to remove password file: " + passwordFile.getAbsolutePath()); + + // When + apiPasswordManager.changePassword(password, null); + } + + @Test + public void changePassword_passwordFileNotWritable_throwsException() throws IOException { + // Given + apiPasswordManager = new ApiPasswordManager(bisqEnvironmentMock, tokenRegistryMock); + String password = getRandomPasswordDifferentThan(""); + apiPasswordManager.changePassword(null, password); + File passwordFile = getPasswordFile(); + assertTrue(passwordFile.delete()); + assertTrue(passwordFile.mkdir()); + assertNotNull(File.createTempFile("bisq", "api", passwordFile)); + expectedException.expect(RuntimeException.class); + expectedException.expectMessage("Unable to write password file: " + passwordFile.getAbsolutePath()); + + // When + apiPasswordManager.changePassword(password, getRandomPasswordDifferentThan(password)); + } + + private boolean passwordFileExists() { + return getPasswordFile().exists(); + } + + private void assertPasswordFileDoesNotExist() { + assertFalse(passwordFileExists()); + } + + private void assertPasswordFileExists() { + assertTrue(passwordFileExists()); + } + + private String createTempDirectory() throws IOException { + File tempFile = File.createTempFile("bisq", "api"); + if (!tempFile.delete()) { + throw new RuntimeException("Unable to create temporary directory: " + tempFile.getAbsolutePath()); + } + if (!tempFile.mkdir()) { + throw new RuntimeException("Unable to create temporary directory: " + tempFile.getAbsolutePath()); + } + return tempFile.getAbsolutePath(); + } + + @NotNull + private File getPasswordFile() { + return new File(this.dataDir, "apipasswd"); + } + + private void writePasswordFile(String data) throws IOException { + File passwordFile = getPasswordFile(); + passwordFile.deleteOnExit(); + FileUtils.write(passwordFile, data, "UTF-8"); + } +} diff --git a/api/src/test/java/bisq/api/http/service/auth/AuthFilterTest.java b/api/src/test/java/bisq/api/http/service/auth/AuthFilterTest.java new file mode 100644 index 00000000000..43176903835 --- /dev/null +++ b/api/src/test/java/bisq/api/http/service/auth/AuthFilterTest.java @@ -0,0 +1,162 @@ +package bisq.api.http.service.auth; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.junit.Before; +import org.junit.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + + +import com.github.javafaker.Faker; + +public class AuthFilterTest { + + private TokenRegistry tokenRegistryMock; + private ApiPasswordManager apiPasswordManagerMock; + private AuthFilter authFilter; + private HttpServletRequest servletRequestMock; + private HttpServletResponse servletResponseMock; + private FilterChain filterChainMock; + + @Before + public void setUp() { + tokenRegistryMock = mock(TokenRegistry.class); + apiPasswordManagerMock = mock(ApiPasswordManager.class); + authFilter = new AuthFilter(apiPasswordManagerMock, tokenRegistryMock); + + servletRequestMock = mock(HttpServletRequest.class); + servletResponseMock = mock(HttpServletResponse.class); + filterChainMock = mock(FilterChain.class); + } + + @Test + public void doFilter_passwordNotSet_passThrough() throws Exception { + // Given + when(servletRequestMock.getPathInfo()).thenReturn("/api/version"); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock).doFilter(servletRequestMock, servletResponseMock); + } + + @Test + public void doFilter_invalidAuthorizationToken_forbid() throws Exception { + // Given + when(apiPasswordManagerMock.isPasswordSet()).thenReturn(true); + when(servletRequestMock.getPathInfo()).thenReturn("/api/version"); + String invalidToken = Faker.instance().crypto().md5(); + when(servletRequestMock.getHeader("authorization")).thenReturn(invalidToken); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock, never()).doFilter(any(), any()); + verify(servletResponseMock).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + @Test + public void doFilter_missingAuthorizationToken_forbid() throws Exception { + // Given + when(apiPasswordManagerMock.isPasswordSet()).thenReturn(true); + when(servletRequestMock.getPathInfo()).thenReturn("/api/version"); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock, never()).doFilter(any(), any()); + verify(servletResponseMock).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + @Test + public void doFilter_tokenInvalidButAuthenticationPath_passThrough() throws Exception { + // Given + when(apiPasswordManagerMock.isPasswordSet()).thenReturn(true); + when(servletRequestMock.getPathInfo()).thenReturn("/api/user/authenticate"); + String invalidToken = Faker.instance().crypto().md5(); + when(servletRequestMock.getHeader("authorization")).thenReturn(invalidToken); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock).doFilter(servletRequestMock, servletResponseMock); + } + + @Test + public void doFilter_tokenInvalidButPasswordChangePath_passThrough() throws Exception { + // Given + when(apiPasswordManagerMock.isPasswordSet()).thenReturn(true); + when(servletRequestMock.getPathInfo()).thenReturn("/api/user/password"); + String invalidToken = Faker.instance().crypto().md5(); + when(servletRequestMock.getHeader("authorization")).thenReturn(invalidToken); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock).doFilter(servletRequestMock, servletResponseMock); + } + + @Test + public void doFilter_tokenInvalidButNonApiPath_passThrough() throws Exception { + // Given + when(apiPasswordManagerMock.isPasswordSet()).thenReturn(true); + when(servletRequestMock.getPathInfo()).thenReturn("/docs"); + String invalidToken = Faker.instance().crypto().md5(); + when(servletRequestMock.getHeader("authorization")).thenReturn(invalidToken); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock).doFilter(servletRequestMock, servletResponseMock); + } + + @Test + public void doFilter_tokenValid_passThrough() throws Exception { + // Given + when(apiPasswordManagerMock.isPasswordSet()).thenReturn(true); + when(servletRequestMock.getPathInfo()).thenReturn("/api/version"); + String token = Faker.instance().crypto().md5(); + when(tokenRegistryMock.isValidToken(token)).thenReturn(true); + when(servletRequestMock.getHeader("authorization")).thenReturn(token); + + // When + authFilter.doFilter(servletRequestMock, servletResponseMock, filterChainMock); + + // Then + verify(filterChainMock).doFilter(servletRequestMock, servletResponseMock); + } + + @Test + public void destroy_always_doesNothing() { + // Given + + // When + authFilter.destroy(); + + // Then + } + + @Test + public void destroy_init_doesNothing() { + // Given + + // When + authFilter.init(null); + + // Then + } +} diff --git a/api/src/test/java/bisq/api/http/service/auth/TokenRegistryTest.java b/api/src/test/java/bisq/api/http/service/auth/TokenRegistryTest.java new file mode 100644 index 00000000000..a8083f7da30 --- /dev/null +++ b/api/src/test/java/bisq/api/http/service/auth/TokenRegistryTest.java @@ -0,0 +1,116 @@ +package bisq.api.http.service.auth; + +import java.util.UUID; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TokenRegistryTest { + + private TokenRegistry tokenRegistry; + + @Before + public void setUp() { + tokenRegistry = new TokenRegistry(); + } + + @Test + public void generateToken_always_returnsNewValidToken() { + // Given + + // When + String token1 = tokenRegistry.generateToken(); + String token2 = tokenRegistry.generateToken(); + + // Then + assertNotNull(token1); + assertNotNull(token2); + assertNotEquals(token1, token2); + assertTrue(tokenRegistry.isValidToken(token1)); + assertTrue(tokenRegistry.isValidToken(token2)); + } + + @Test + public void generateToken_always_returnsNewUniqueToken() { + // Given + TokenRegistry.RandomStringGenerator randomStringGeneratorMock = mock(TokenRegistry.RandomStringGenerator.class); + tokenRegistry = new TokenRegistry(randomStringGeneratorMock, System::currentTimeMillis); + when(randomStringGeneratorMock.generateRandomString()) + .thenReturn("a") + .thenReturn("a") + .thenReturn("b"); + + // When + String token1 = tokenRegistry.generateToken(); + String token2 = tokenRegistry.generateToken(); + + // Then + assertEquals("a", token1); + assertEquals("b", token2); + assertTrue(tokenRegistry.isValidToken(token1)); + assertTrue(tokenRegistry.isValidToken(token2)); + } + + @Test + public void generateToken_always_removesExpiredTokens() { + // Given + TokenRegistry.TimeProvider timeProviderMock = mock(TokenRegistry.TimeProvider.class); + tokenRegistry = new TokenRegistry(() -> UUID.randomUUID().toString(), timeProviderMock); + when(timeProviderMock.getTime()).thenReturn(0L); + String token1 = tokenRegistry.generateToken(); + when(timeProviderMock.getTime()).thenReturn(TokenRegistry.TTL + 1); + + // When + String token2 = tokenRegistry.generateToken(); + when(timeProviderMock.getTime()).thenReturn(TokenRegistry.TTL + 1 + TokenRegistry.TTL); + + // Then + assertTrue(tokenRegistry.isValidToken(token2)); + assertFalse(tokenRegistry.isValidToken(token1)); + } + + @Test + public void isValidToken_invalidToken_returnsFalse() { + // Given + + // When + boolean result = tokenRegistry.isValidToken(UUID.randomUUID().toString()); + + // Then + assertFalse(result); + } + + @Test + public void isValidToken_expiredToken_returnsFalse() { + // Given + TokenRegistry.TimeProvider timeProviderMock = mock(TokenRegistry.TimeProvider.class); + tokenRegistry = new TokenRegistry(() -> UUID.randomUUID().toString(), timeProviderMock); + when(timeProviderMock.getTime()).thenReturn(0L); + String token = tokenRegistry.generateToken(); + when(timeProviderMock.getTime()).thenReturn(TokenRegistry.TTL + 1); + + // When + boolean result = tokenRegistry.isValidToken(token); + + // Then + assertFalse(result); + } + + @Test + public void clear_always_removesAllTokens() { + // Given + String token1 = tokenRegistry.generateToken(); + String token2 = tokenRegistry.generateToken(); + + // When + tokenRegistry.clear(); + + // Then + assertFalse(tokenRegistry.isValidToken(token1)); + assertFalse(tokenRegistry.isValidToken(token2)); + } +} diff --git a/api/src/testIntegration/java/bisq/api/http/UserEndpointIT.java b/api/src/testIntegration/java/bisq/api/http/UserEndpointIT.java new file mode 100644 index 00000000000..4c2838c24ca --- /dev/null +++ b/api/src/testIntegration/java/bisq/api/http/UserEndpointIT.java @@ -0,0 +1,317 @@ +package bisq.api.http; + +import bisq.api.http.model.AuthForm; +import bisq.api.http.model.AuthResult; +import bisq.api.http.model.ChangePassword; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.isA; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + + + +import com.github.javafaker.Faker; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.arquillian.cube.docker.impl.client.containerobject.dsl.Container; +import org.arquillian.cube.docker.impl.client.containerobject.dsl.DockerContainer; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.junit.InSequence; + +@RunWith(Arquillian.class) +public class UserEndpointIT { + + private static String validPassword = new Faker().internet().password(); + private static String invalidPassword = getRandomPasswordDifferentThan(validPassword); + private static String accessToken; + @DockerContainer + Container alice = ContainerFactory.createApiContainer("alice", "8081->8080", 3333, false, false); + + private static String getRandomPasswordDifferentThan(String otherPassword) { + String newPassword; + do { + newPassword = new Faker().internet().password(); + } while (otherPassword.equals(newPassword)); + return newPassword; + } + + @InSequence + @Test + public void waitForAllServicesToBeReady() throws InterruptedException { + ApiTestHelper.waitForAllServicesToBeReady(); + verifyThatAuthenticationIsDisabled(); + } + + @InSequence(1) + @Test + public void authenticate_noPasswordSet_returns401() { + int alicePort = getAlicePort(); + given(). + port(alicePort). + body(new AuthForm(validPassword)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/authenticate"). +// + then(). + statusCode(401) + ; + } + + @InSequence(1) + @Test + public void authenticate_badJson_returns400() { + int alicePort = getAlicePort(); + given(). + port(alicePort). + body("{"). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/authenticate"). +// + then(). + statusCode(400) + ; + } + + @InSequence(2) + @Test + public void changePassword_settingFirstPassword_enablesAuthentication() { + int alicePort = getAlicePort(); + accessToken = given(). + port(alicePort). + body(new ChangePassword(validPassword, null)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/password"). +// + then(). + statusCode(200). + and().body("token", isA(String.class)). + extract().as(AuthResult.class).token; + verifyThatAuthenticationIsEnabled(); + verifyThatAccessTokenIsValid(accessToken); + } + + @InSequence(3) + @Test + public void changePassword_invalidOldPassword_returns401() { + int alicePort = getAlicePort(); + String newPassword = getRandomPasswordDifferentThan(validPassword); + given(). + port(alicePort). + body(new ChangePassword(newPassword, invalidPassword)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/password"). +// + then(). + statusCode(401) + ; + verifyThatAuthenticationIsEnabled(); + verifyThatAccessTokenIsValid(accessToken); + verifyThatPasswordIsValid(validPassword); + verifyThatPasswordIsInvalid(newPassword); + } + + @InSequence(3) + @Test + public void changePassword_emptyOldPassword_returns401() { + int alicePort = getAlicePort(); + String newPassword = getRandomPasswordDifferentThan(validPassword); + given(). + port(alicePort). + body(new ChangePassword(newPassword, null)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/password"). +// + then(). + statusCode(401) + ; + verifyThatAuthenticationIsEnabled(); + verifyThatAccessTokenIsValid(accessToken); + verifyThatPasswordIsValid(validPassword); + verifyThatPasswordIsInvalid(newPassword); + } + + @InSequence(4) + @Test + public void authenticate_invalidCredentials_returns401() { + int alicePort = getAlicePort(); + given(). + port(alicePort). + body(new AuthForm(invalidPassword)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/authenticate"). +// + then(). + statusCode(401) + ; + } + + @InSequence(4) + @Test + public void authenticate_invalidCredentials_returnsNoAccessToken() { + int alicePort = getAlicePort(); + String responseBody = given(). + port(alicePort). + body(new AuthForm(invalidPassword)). + accept(ContentType.JSON). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/authenticate"). +// + then(). + extract().asString(); + assertEquals("", responseBody); + } + + @InSequence(5) + @Test + public void authenticate_validCredentials_returnsAccessToken() { + String token = authenticate(validPassword); + verifyThatAccessTokenIsValid(token); + String anotherToken = authenticate(validPassword); + + verifyThatAccessTokenIsValid(accessToken); + verifyThatAccessTokenIsValid(token); + verifyThatAccessTokenIsValid(anotherToken); + } + + @InSequence(5) + @Test + public void authenticate_validCredentials_returnsDifferentAccessTokenEachTime() { + String token = authenticate(validPassword); + String anotherToken = authenticate(validPassword); + + assertNotEquals(accessToken, token); + assertNotEquals(accessToken, anotherToken); + assertNotEquals(token, anotherToken); + } + + @InSequence(6) + @Test + public void changePassword_settingAnotherPassword_keepsAuthenticationEnabled() { + int alicePort = getAlicePort(); + String oldPassword = validPassword; + String newPassword = getRandomPasswordDifferentThan(validPassword); + validPassword = newPassword; + invalidPassword = getRandomPasswordDifferentThan(validPassword); + String oldAccessToken = accessToken; + accessToken = given(). + port(alicePort). + body(new ChangePassword(newPassword, oldPassword)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/password"). +// + then(). + statusCode(200). + and().body("token", isA(String.class)). + extract().as(AuthResult.class).token + ; + verifyThatPasswordIsInvalid(oldPassword); + verifyThatPasswordIsValid(newPassword); + verifyThatAuthenticationIsEnabled(); + verifyThatAccessTokenIsInvalid(oldAccessToken); + verifyThatAccessTokenIsValid(accessToken); + } + + @InSequence(7) + @Test + public void changePassword_validOldPasswordAndNoNewPassword_disablesAuthentication() { + int alicePort = getAlicePort(); + given(). + port(alicePort). + body(new ChangePassword(null, validPassword)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/password"). +// + then(). + statusCode(204) + ; + verifyThatAuthenticationIsDisabled(); + } + + private String authenticate(String password) { + int alicePort = getAlicePort(); + return given(). + port(alicePort). + body(new AuthForm(password)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/authenticate"). +// + then(). + statusCode(200). + and().body("token", isA(String.class)). + extract().as(AuthResult.class).token; + } + + private void verifyThatAccessTokenIsValid(String accessToken) { + accessTokenVerificationRequest(accessToken).then().statusCode(200); + } + + private void verifyThatAccessTokenIsInvalid(String accessToken) { + accessTokenVerificationRequest(accessToken).then().statusCode(401); + } + + private void verifyThatAuthenticationIsDisabled() { + authenticationVerificationRequest().then().statusCode(200); + } + + private void verifyThatAuthenticationIsEnabled() { + authenticationVerificationRequest().then().statusCode(401); + } + + private void verifyThatPasswordIsInvalid(String password) { + int alicePort = getAlicePort(); + given(). + port(alicePort). + body(new AuthForm(password)). + contentType(ContentType.JSON). +// + when(). + post("/api/v1/user/authenticate"). +// + then(). + statusCode(401) + ; + } + + private void verifyThatPasswordIsValid(String password) { + authenticate(password); + } + + private Response authenticationVerificationRequest() { + int alicePort = getAlicePort(); + return given().port(alicePort).when().get("/api/v1/version"); + } + + private Response accessTokenVerificationRequest(String accessToken) { + int alicePort = getAlicePort(); + return given().port(alicePort).header("authorization", accessToken).when().get("/api/v1/version"); + } + + private int getAlicePort() { + return alice.getBindPort(8080); + } + +} diff --git a/build.gradle b/build.gradle index 29c41a91cd2..1aac221cced 100644 --- a/build.gradle +++ b/build.gradle @@ -303,14 +303,11 @@ configure(project(':api')) { annotationProcessor 'org.projectlombok:lombok:1.18.2' testCompile 'junit:junit:4.12' - testCompile('org.mockito:mockito-core:2.8.9') { - exclude(module: 'objenesis') - } + testCompile "org.mockito:mockito-core:$mockitoVersion" testCompileOnly 'org.projectlombok:lombok:1.18.2' testAnnotationProcessor 'org.projectlombok:lombok:1.18.2' - testCompile "junit:junit:4.12" - testCompile "org.mockito:mockito-core:2.7.5" testCompile "com.github.javafaker:javafaker:0.14" + testCompile "org.apache.commons:commons-lang3:$langVersion" testCompile "org.arquillian.universe:arquillian-junit:1.2.0.1" testCompile "org.arquillian.universe:arquillian-cube-docker:1.2.0.1" testCompile "org.arquillian.cube:arquillian-cube-docker:1.15.3"