mirror of
https://github.com/cryptomator/cryptomator.git
synced 2024-11-23 12:09:45 +00:00
outsourced authorization flow to https://github.com/coffeelibs/tiny-oauth2-client
This commit is contained in:
parent
d1c4eda072
commit
9d4f9c12b9
20
pom.xml
20
pom.xml
@ -48,7 +48,6 @@
|
||||
<zxcvbn.version>1.6.0</zxcvbn.version>
|
||||
<slf4j.version>1.7.36</slf4j.version>
|
||||
<logback.version>1.2.11</logback.version>
|
||||
<jetty.version>10.0.6</jetty.version>
|
||||
|
||||
<!-- test dependencies -->
|
||||
<junit.jupiter.version>5.8.1</junit.jupiter.version>
|
||||
@ -140,23 +139,12 @@
|
||||
<version>${commons-lang3.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OAuth/JWT -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-server</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
<groupId>io.github.coffeelibs</groupId>
|
||||
<artifactId>tiny-oauth2-client</artifactId>
|
||||
<version>0.1.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-webapp</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-servlets</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>com.auth0</groupId>
|
||||
<artifactId>java-jwt</artifactId>
|
||||
|
@ -28,11 +28,9 @@ module org.cryptomator.desktop {
|
||||
requires com.nulabinc.zxcvbn;
|
||||
requires com.tobiasdiez.easybind;
|
||||
requires dagger;
|
||||
requires io.github.coffeelibs.tinyoauth2client;
|
||||
requires org.slf4j;
|
||||
requires org.apache.commons.lang3;
|
||||
requires org.eclipse.jetty.server;
|
||||
requires org.eclipse.jetty.webapp;
|
||||
requires org.eclipse.jetty.servlets;
|
||||
|
||||
/* TODO: filename-based modules: */
|
||||
requires static javax.inject; /* ugly dagger/guava crap */
|
||||
|
@ -1,186 +0,0 @@
|
||||
package org.cryptomator.ui.keyloading.hub;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.Streams;
|
||||
import com.google.common.escape.Escaper;
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.google.common.net.PercentEscaper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Simple OAuth 2.0 Authentication Code Flow with {@link PKCE}.
|
||||
* <p>
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc8252">RFC 8252</a>
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</a>
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a>
|
||||
*/
|
||||
class AuthFlow implements AutoCloseable {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthFlow.class);
|
||||
private static final SecureRandom CSPRNG = new SecureRandom();
|
||||
private static final BaseEncoding BASE64URL = BaseEncoding.base64Url().omitPadding();
|
||||
public static final Escaper QUERY_STRING_ESCAPER = new PercentEscaper("-_.!~*'()@:$,;/?", false);
|
||||
|
||||
private final AuthFlowReceiver receiver;
|
||||
private final URI authEndpoint; // see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
|
||||
private final URI tokenEndpoint; // see https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
|
||||
private final String clientId; // see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
||||
|
||||
private AuthFlow(AuthFlowReceiver receiver, HubConfig hubConfig) {
|
||||
this.receiver = receiver;
|
||||
this.authEndpoint = URI.create(hubConfig.authEndpoint);
|
||||
this.tokenEndpoint = URI.create(hubConfig.tokenEndpoint);
|
||||
this.clientId = hubConfig.clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares an Authorization Code Flow with PKCE.
|
||||
* <p>
|
||||
* This will start a loopback server, so make sure to {@link #close()} this resource.
|
||||
*
|
||||
* @param hubConfig A hub config object containing parameters required for this auth flow
|
||||
* @return An authorization flow
|
||||
* @throws Exception In case of any problems starting the server
|
||||
*/
|
||||
public static AuthFlow init(HubConfig hubConfig, AuthFlowContext authFlowContext) throws Exception {
|
||||
var receiver = AuthFlowReceiver.start(hubConfig, authFlowContext);
|
||||
return new AuthFlow(receiver, hubConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs this Authorization Code Flow. This will take a long time and should be done in a background thread.
|
||||
*
|
||||
* @param browser A callback that will open the auth URI in a browser
|
||||
* @return The access token
|
||||
* @throws IOException In case of any errors, including failed authentication.
|
||||
* @throws InterruptedException If this method is interrupted while waiting for responses from the authorization server
|
||||
*/
|
||||
public String run(Consumer<URI> browser) throws IOException, InterruptedException {
|
||||
var pkce = new PKCE();
|
||||
var authCode = auth(pkce, browser);
|
||||
return token(pkce, authCode);
|
||||
}
|
||||
|
||||
private String auth(PKCE pkce, Consumer<URI> browser) throws IOException, InterruptedException {
|
||||
var state = BASE64URL.encode(randomBytes(16));
|
||||
var params = Map.of("response_type", "code", //
|
||||
"client_id", clientId, //
|
||||
"redirect_uri", receiver.getRedirectUri(), //
|
||||
"state", state, //
|
||||
"code_challenge", pkce.challenge, //
|
||||
"code_challenge_method", PKCE.METHOD //
|
||||
);
|
||||
var uri = appendQueryParams(this.authEndpoint, params);
|
||||
|
||||
// open browser and wait for response
|
||||
LOG.debug("waiting for user to log into {}", uri);
|
||||
browser.accept(uri);
|
||||
var callback = receiver.receive();
|
||||
|
||||
if (!state.equals(callback.state())) {
|
||||
throw new IOException("Invalid CSRF Token");
|
||||
} else if (callback.error() != null) {
|
||||
throw new IOException("Authentication failed " + callback.error());
|
||||
} else if (callback.code() == null) {
|
||||
throw new IOException("Received neither authentication code nor error");
|
||||
}
|
||||
return callback.code();
|
||||
}
|
||||
|
||||
private String token(PKCE pkce, String authCode) throws IOException, InterruptedException {
|
||||
var params = Map.of("grant_type", "authorization_code", //
|
||||
"client_id", clientId, //
|
||||
"redirect_uri", receiver.getRedirectUri(), //
|
||||
"code", authCode, //
|
||||
"code_verifier", pkce.verifier //
|
||||
);
|
||||
var paramStr = paramString(params).collect(Collectors.joining("&"));
|
||||
var request = HttpRequest.newBuilder(this.tokenEndpoint) //
|
||||
.header("Content-Type", "application/x-www-form-urlencoded") //
|
||||
.POST(HttpRequest.BodyPublishers.ofString(paramStr)) //
|
||||
.build();
|
||||
HttpResponse<InputStream> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
if (response.statusCode() == 200) {
|
||||
var json = HttpHelper.parseBody(response);
|
||||
return json.getAsJsonObject().get("access_token").getAsString();
|
||||
} else {
|
||||
LOG.error("Unexpected HTTP response {}: {}", response.statusCode(), HttpHelper.readBody(response));
|
||||
throw new IOException("Unexpected HTTP response code " + response.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
private URI appendQueryParams(URI uri, Map<String, String> params) {
|
||||
var oldParams = Splitter.on("&").omitEmptyStrings().splitToStream(Strings.nullToEmpty(uri.getQuery()));
|
||||
var newParams = paramString(params);
|
||||
var query = Streams.concat(oldParams, newParams).collect(Collectors.joining("&"));
|
||||
try {
|
||||
return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), query, uri.getFragment());
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("Unable to create URI from given", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Stream<String> paramString(Map<String, String> params) {
|
||||
return params.entrySet().stream().map(param -> {
|
||||
var key = QUERY_STRING_ESCAPER.escape(param.getKey());
|
||||
var value = QUERY_STRING_ESCAPER.escape(param.getValue());
|
||||
return key + "=" + value;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
receiver.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a>
|
||||
*/
|
||||
private static record PKCE(String challenge, String verifier) {
|
||||
|
||||
public static final String METHOD = "S256";
|
||||
|
||||
public PKCE(String verifier) {
|
||||
this(BASE64URL.encode(sha256(verifier.getBytes(StandardCharsets.US_ASCII))), verifier);
|
||||
}
|
||||
|
||||
public PKCE() {
|
||||
this(BASE64URL.encode(randomBytes(32)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int len) {
|
||||
byte[] bytes = new byte[len];
|
||||
CSPRNG.nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] input) {
|
||||
try {
|
||||
var digest = MessageDigest.getInstance("SHA-256");
|
||||
return digest.digest(input);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Every implementation of the Java platform is required to support SHA-256.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -11,6 +11,7 @@ import org.cryptomator.ui.keyloading.KeyLoadingScoped;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Named;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.StringBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
@ -75,8 +76,10 @@ public class AuthFlowController implements FxController {
|
||||
}
|
||||
|
||||
private void setAuthUri(URI uri) {
|
||||
authUri.set(uri);
|
||||
browse();
|
||||
Platform.runLater(() -> {
|
||||
authUri.set(uri);
|
||||
browse();
|
||||
});
|
||||
}
|
||||
|
||||
private void windowClosed(WindowEvent windowEvent) {
|
||||
|
@ -1,101 +0,0 @@
|
||||
package org.cryptomator.ui.keyloading.hub;
|
||||
|
||||
import org.eclipse.jetty.server.Connector;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.server.ServerConnector;
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
import org.eclipse.jetty.servlet.ServletHolder;
|
||||
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
/**
|
||||
* A basic implementation for RFC 8252, Section 7.3:
|
||||
* <p>
|
||||
* We're spawning a local http server on a system-assigned high port and
|
||||
* use <code>http://127.0.0.1:{PORT}/callback</code> as a redirect URI.
|
||||
* <p>
|
||||
* Furthermore, we can deliver a html response to inform the user that the
|
||||
* auth workflow finished and she can close the browser tab.
|
||||
*/
|
||||
class AuthFlowReceiver implements AutoCloseable {
|
||||
|
||||
private static final String LOOPBACK_ADDR = "127.0.0.1";
|
||||
private static final String CALLBACK_PATH = "/callback";
|
||||
|
||||
private final Server server;
|
||||
private final ServerConnector connector;
|
||||
private final CallbackServlet servlet;
|
||||
|
||||
private AuthFlowReceiver(Server server, ServerConnector connector, CallbackServlet servlet) {
|
||||
this.server = server;
|
||||
this.connector = connector;
|
||||
this.servlet = servlet;
|
||||
}
|
||||
|
||||
public static AuthFlowReceiver start(HubConfig hubConfig, AuthFlowContext authFlowContext) throws Exception {
|
||||
var server = new Server();
|
||||
var context = new ServletContextHandler();
|
||||
|
||||
var servlet = new CallbackServlet(hubConfig, authFlowContext);
|
||||
context.addServlet(new ServletHolder(servlet), CALLBACK_PATH);
|
||||
|
||||
var connector = new ServerConnector(server);
|
||||
connector.setPort(0);
|
||||
connector.setHost(LOOPBACK_ADDR);
|
||||
server.setConnectors(new Connector[]{connector});
|
||||
server.setHandler(context);
|
||||
server.start();
|
||||
return new AuthFlowReceiver(server, connector, servlet);
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
return "http://" + LOOPBACK_ADDR + ":" + connector.getLocalPort() + CALLBACK_PATH;
|
||||
}
|
||||
|
||||
public Callback receive() throws InterruptedException {
|
||||
return servlet.callback.take();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
public static record Callback(String error, String code, String state) {
|
||||
|
||||
}
|
||||
|
||||
private static class CallbackServlet extends HttpServlet {
|
||||
|
||||
private final BlockingQueue<Callback> callback = new LinkedBlockingQueue<>();
|
||||
private final HubConfig hubConfig;
|
||||
private final AuthFlowContext authFlowContext;
|
||||
|
||||
public CallbackServlet(HubConfig hubConfig, AuthFlowContext authFlowContext) {
|
||||
this.hubConfig = hubConfig;
|
||||
this.authFlowContext = authFlowContext;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
|
||||
var error = req.getParameter("error");
|
||||
var code = req.getParameter("code");
|
||||
var state = req.getParameter("state");
|
||||
|
||||
res.setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY);
|
||||
if (error == null && code != null) {
|
||||
res.setHeader("Location", hubConfig.authSuccessUrl + "&device=" + authFlowContext.deviceId());
|
||||
} else {
|
||||
res.setHeader("Location", hubConfig.authErrorUrl + "&device=" + authFlowContext.deviceId());
|
||||
}
|
||||
|
||||
callback.add(new Callback(error, code, state));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,12 +1,16 @@
|
||||
package org.cryptomator.ui.keyloading.hub;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.github.coffeelibs.tinyoauth2client.AuthFlow;
|
||||
|
||||
import javafx.concurrent.Task;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
class AuthFlowTask extends Task<String> {
|
||||
|
||||
private final HubConfig hubConfig;
|
||||
private final AuthFlowContext authFlowContext;
|
||||
private final Consumer<URI> redirectUriConsumer;
|
||||
|
||||
@ -23,11 +27,13 @@ class AuthFlowTask extends Task<String> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String call() throws Exception {
|
||||
try (var authFlow = AuthFlow.init(hubConfig, authFlowContext)) {
|
||||
return authFlow.run(uri -> Platform.runLater(() -> redirectUriConsumer.accept(uri)));
|
||||
}
|
||||
protected String call() throws IOException, InterruptedException {
|
||||
// TODO configure redirectURIs with deviceId from authFlowContext
|
||||
var response = AuthFlow.asClient(hubConfig.clientId) //
|
||||
.authorize(URI.create(hubConfig.authEndpoint), redirectUriConsumer) //
|
||||
.getAccessToken(URI.create(hubConfig.tokenEndpoint));
|
||||
var json = JsonParser.parseString(response);
|
||||
return json.getAsJsonObject().get("access_token").getAsString();
|
||||
}
|
||||
|
||||
private final HubConfig hubConfig;
|
||||
}
|
||||
|
@ -20,12 +20,4 @@ class HttpHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonElement parseBody(HttpResponse<InputStream> response) throws IOException {
|
||||
try (InputStream in = response.body(); Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
|
||||
return JsonParser.parseReader(reader);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Failed to parse JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import org.cryptomator.ui.common.FxmlFile;
|
||||
import org.cryptomator.ui.common.FxmlScene;
|
||||
import org.cryptomator.ui.keyloading.KeyLoading;
|
||||
import org.cryptomator.ui.keyloading.KeyLoadingScoped;
|
||||
import org.eclipse.jetty.io.RuntimeIOException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@ -82,7 +81,7 @@ public class ReceiveKeyController implements FxController {
|
||||
default -> throw new IOException("Unexpected response " + response.statusCode());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeIOException(e);
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,36 +0,0 @@
|
||||
package org.cryptomator.ui.keyloading.hub;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class AuthFlowIntegrationTest {
|
||||
|
||||
static {
|
||||
System.setProperty("LOGLEVEL", "INFO");
|
||||
}
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthFlowIntegrationTest.class);
|
||||
|
||||
@Test
|
||||
@Disabled // only to be run manually
|
||||
public void testRetrieveToken() throws Exception {
|
||||
var hubConfig = new HubConfig();
|
||||
hubConfig.authEndpoint = "http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/auth";
|
||||
hubConfig.tokenEndpoint = "http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/token";
|
||||
hubConfig.clientId = "cryptomator-hub";
|
||||
hubConfig.authSuccessUrl = "http://localhost:3000/#/unlock-success?vault=vaultId";
|
||||
hubConfig.authErrorUrl = "http://localhost:3000/#/unlock-error?vault=vaultId";
|
||||
|
||||
try (var authFlow = AuthFlow.init(hubConfig, new AuthFlowContext("deviceId"))) {
|
||||
var token = authFlow.run(uri -> {
|
||||
LOG.info("Visit {} to authenticate", uri);
|
||||
});
|
||||
LOG.info("Received token {}", token);
|
||||
Assertions.assertNotNull(token);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user