Ver Fonte

added basic OAuth 2.0 Authorization Code Flow + PKCE impl

Sebastian Stenzel há 4 anos atrás
pai
commit
f9c2807ce1

+ 209 - 0
src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlow.java

@@ -0,0 +1,209 @@
+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.io.CharStreams;
+import com.google.common.net.PercentEscaper;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+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;
+	private final URI tokenEndpoint;
+	private final String clientId;
+
+	private AuthFlow(AuthFlowReceiver receiver, URI authEndpoint, URI tokenEndpoint, String clientId) {
+		this.receiver = receiver;
+		this.authEndpoint = authEndpoint;
+		this.tokenEndpoint = tokenEndpoint;
+		this.clientId = clientId;
+	}
+
+	/**
+	 * Prepares an Authorization Code Flow with PKCE.
+	 * <p>
+	 * This will start a loopback server, so make sure to {@link #close()} this resource.
+	 *
+	 * @param authEndpoint Address of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.1">Authorization Endpoint</a>
+	 * @param tokenEndpoint Address of the <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.2">Token Endpoint</a>
+	 * @param clientId The <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1"><code>client_id</code></a>
+	 * @return An authorization
+	 * @throws Exception
+	 */
+	public static AuthFlow init(URI authEndpoint, URI tokenEndpoint, String clientId) throws Exception {
+		var receiver = AuthFlowReceiver.start();
+		return new AuthFlow(receiver, authEndpoint, tokenEndpoint, clientId);
+	}
+
+	/**
+	 * 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", "cryptomator-hub", // TODO
+				"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 = parseBody(response);
+			return json.getAsJsonObject().get("access_token").getAsString();
+		} else {
+			LOG.error("Unexpected HTTP response {}: {}", response.statusCode(), readBody(response));
+			throw new IOException("Unexpected HTTP response code " + response.statusCode());
+		}
+	}
+
+	private String readBody(HttpResponse<InputStream> response) throws IOException {
+		try (var in = response.body(); var reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
+			return CharStreams.toString(reader);
+		}
+	}
+
+	private 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);
+		}
+	}
+
+	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.");
+		}
+	}
+
+}

+ 105 - 0
src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowReceiver.java

@@ -0,0 +1,105 @@
+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}/success</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 static final String HTML_SUCCESS = """
+			<html>
+			<head>
+				<title>OAuth 2.0 Authentication Token Received</title>
+			</head>
+			<body>
+				<p>Received verification code. You may now close this window.</p>
+			</body>
+			</html>
+			""";
+
+	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() throws Exception {
+		var server = new Server();
+		var context = new ServletContextHandler();
+
+//		var corsFilter = new FilterHolder(new CrossOriginFilter());
+//		corsFilter.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*"); // TODO restrict to hub host
+//		context.addFilter(corsFilter, "/*", EnumSet.of(DispatcherType.REQUEST));
+
+		var servlet = new CallbackServlet();
+		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<>();
+
+		@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");
+
+			// TODO 302 use redirect to configurable site
+			res.setContentType("text/html;charset=utf-8");
+			res.getWriter().write(HTML_SUCCESS);
+			res.getWriter().flush();
+
+			callback.add(new Callback(error, code, state));
+		}
+
+	}
+
+}

+ 34 - 0
src/test/java/org/cryptomator/ui/keyloading/hub/AuthFlowIntegrationTest.java

@@ -0,0 +1,34 @@
+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;
+
+import java.net.URI;
+
+public class AuthFlowIntegrationTest {
+
+	static {
+		System.setProperty("LOGLEVEL", "INFO");
+	}
+
+	private static final Logger LOG = LoggerFactory.getLogger(AuthFlowIntegrationTest.class);
+	private static final URI AUTH_URI = URI.create("http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/auth");
+	private static final URI TOKEN_URI = URI.create("http://localhost:8080/auth/realms/cryptomator/protocol/openid-connect/token");
+	private static final String CLIENT_ID = "cryptomator-hub";
+
+	@Test
+	@Disabled // only to be run manually
+	public void testRetrieveToken() throws Exception {
+		try (var authFlow = AuthFlow.init(AUTH_URI, TOKEN_URI, CLIENT_ID)) {
+			var token = authFlow.run(uri -> {
+				LOG.info("Visit {} to authenticate", uri);
+			});
+			LOG.info("Received token {}", token);
+			Assertions.assertNotNull(token);
+		}
+	}
+
+}