package org.syncany.plugins.transfer.oauth; import java.io.IOException; import java.net.InetAddress; import java.net.Socket; import java.net.URI; import java.net.UnknownHostException; import java.util.List; import java.util.Random; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import org.syncany.plugins.transfer.oauth.OAuthTokenExtractors.NamedQueryTokenExtractor; import org.syncany.plugins.transfer.oauth.OAuthTokenInterceptors.RedirectTokenInterceptor; import com.google.common.collect.Lists; import com.google.common.collect.Queues; import com.google.common.collect.Range; import io.undertow.Handlers; import io.undertow.Undertow; import io.undertow.server.HttpServerExchange; import io.undertow.server.handlers.IPAddressAccessControlHandler; import io.undertow.util.Headers; /** * This class creates a server handling the OAuth callback URLs. It has two tasks. First it is responsible for executing * {@link OAuthTokenInterceptor} depending on a path defined by the interceptor itself. Furthermore it does the token * parsing in the URL using a {@link OAuthTokenExtractor}. * * @author Christian Roth <christian.roth@port17.de> */ public class OAuthTokenWebListener implements Callable<OAuthTokenFinish> { private static final Logger logger = Logger.getLogger(OAuthTokenWebListener.class.getName()); private static final Range<Integer> VALID_PORT_RANGE = Range.openClosed(0x0000, 0xFFFF); private static final int PORT_LOWER = 55500; private static final int PORT_UPPER = 55599; private final int port; private final String id; private final SynchronousQueue<Object> ioQueue = Queues.newSynchronousQueue(); private final OAuthTokenInterceptor interceptor; private final OAuthTokenExtractor extractor; private final List<InetAddress> allowedClients; private Undertow server; /** * Create a new {@link OAuthTokenWebListener} with some clever defaults for a {@link OAuthMode}. * * @param mode {@link OAuthMode} supported by the {@link org.syncany.plugins.transfer.TransferPlugin}. * @return A ready to use {@link OAuthTokenWebListener}. */ public static Builder forMode(OAuthMode mode) { return new Builder(mode); } public static class Builder { private final List<InetAddress> allowedClients = Lists.newArrayList(); private OAuthTokenInterceptor interceptor; private OAuthTokenExtractor extractor; private int port; private String id; private Builder(OAuthMode mode) { this.interceptor = OAuthTokenInterceptors.newTokenInterceptorForMode(mode); this.extractor = OAuthTokenExtractors.newTokenExtractorForMode(mode); this.id = UUID.randomUUID().toString(); this.port = new Random().nextInt((PORT_UPPER - PORT_LOWER) + 1) + PORT_LOWER; try { this.addAllowedClient(InetAddress.getByName("127.0.0.1")); } catch (UnknownHostException e) { throw new RuntimeException("127.0.0.1 is unknown. This should NEVER happen", e); } } /** * Use a custom plugin id instead of a randomly generated one. Might be needed if the service provider does not * allow wildcard redirect URLs. */ public Builder setId(String id) { if (id != null) { this.id = id; } return this; } /** * Use a custom interceptor (default {@link RedirectTokenInterceptor}) */ public Builder setTokenInterceptor(OAuthTokenInterceptor interceptor) { if (interceptor != null) { this.interceptor = interceptor; } return this; } /** * Use a custom extractor (default {@link NamedQueryTokenExtractor}) */ public Builder setTokenExtractor(OAuthTokenExtractor extractor) { if (extractor != null) { this.extractor = extractor; } return this; } /** * Use a fixed port, otherwise the port is randomly chosen from a range of {@link OAuthTokenWebListener#PORT_LOWER} * and {@link OAuthTokenWebListener#PORT_UPPER}. * * @param port Fixed port to use * * @throws IllegalArgumentException Thrown if the chosen port is not in the valid port range (1-65535). * @throws RuntimeException Thrown if the chosen port is already taken. */ public Builder setPort(int port) { if (!VALID_PORT_RANGE.contains(port)) { throw new IllegalArgumentException("Invalid port number " + port); } if (!isPortAvailable(port)) { throw new RuntimeException("Token listener tried to use a defined but already taken port " + port); } this.port = port; return this; } public Builder addAllowedClient(InetAddress... clientIp) { allowedClients.addAll(Lists.newArrayList(clientIp)); return this; } /** * Build an immutable {@link OAuthTokenWebListener}. */ public OAuthTokenWebListener build() { return new OAuthTokenWebListener(id, port, interceptor, extractor, allowedClients); } private static boolean isPortAvailable(int port) { try (Socket ignored = new Socket("localhost", port)) { return false; } catch (IOException ignored) { return true; } } } private OAuthTokenWebListener(String id, int port, OAuthTokenInterceptor interceptor, OAuthTokenExtractor extractor, List<InetAddress> allowedClients) { this.id = id; this.port = port; this.interceptor = interceptor; this.extractor = extractor; this.allowedClients = allowedClients; } /** * Start the server created by the @{link Builder}. * * @return A callback URI which should be used during the OAuth process. */ public URI start() { createServer(); return URI.create(String.format("http://localhost:%d/%s/", port, id)); } /** * Get the token generated by the OAuth process. In fact, this class returns a {@link Future} because the token may not * be received by the server when this method is called. * * @return Returns an {@link OAuthTokenFinish} wrapped in a {@link Future}. The {@link OAuthTokenFinish} should at least * contain a token */ public Future<OAuthTokenFinish> getToken() { return Executors.newFixedThreadPool(1).submit(this); } @Override public OAuthTokenFinish call() throws Exception { logger.log(Level.INFO, "Waiting for token response"); final String urlWithIdAndToken = (String) ioQueue.take(); logger.log(Level.INFO, "Parsing token response " + urlWithIdAndToken); OAuthTokenFinish tokenResponse = null; // null if parsing failed (user canceled, api error, ... try { tokenResponse = extractor.parse(urlWithIdAndToken); ioQueue.put(OAuthWebResponses.createValidResponse()); } catch (NoSuchFieldException e) { logger.log(Level.SEVERE, "Unable to find token in response", e); ioQueue.put(OAuthWebResponses.createBadResponse()); } ioQueue.take(); // make sure undertow has send a response stop(); logger.log(Level.INFO, tokenResponse != null ? "Returning token" : "No token received, returning null"); return tokenResponse; } /** * Stop the listener server so the port becomes available again. */ public void stop() { if (server != null) { logger.log(Level.INFO, "Stopping server"); server.stop(); server = null; } } @Override public void finalize() throws Throwable { super.finalize(); stop(); } private void createServer() { logger.log(Level.FINE, "Locked to build server..."); OAuthTokenInterceptor extractingHttpHandler = new ExtractingTokenInterceptor(ioQueue); IPAddressAccessControlHandler ipAddressAccessControlHandler = new IPAddressAccessControlHandler(); ipAddressAccessControlHandler.setDefaultAllow(false); for (InetAddress inetAddress : allowedClients) { ipAddressAccessControlHandler.addAllow(inetAddress.getHostAddress()); } server = Undertow.builder() .addHttpListener(port, "localhost") .setHandler(ipAddressAccessControlHandler) .setHandler(Handlers.path() .addExactPath(createPath(extractingHttpHandler.getPathPrefix()), extractingHttpHandler) .addExactPath(createPath(interceptor.getPathPrefix()), interceptor) ) .build(); logger.log(Level.INFO, "Starting token web listener..."); server.start(); } private String createPath(String prefix) { return URI.create(String.format("/%s/%s", id, prefix)).normalize().toString(); } /** * Default {@link OAuthTokenInterceptor} which notifies the listener about an existing token. It also sends feedback * to a user. */ static final class ExtractingTokenInterceptor implements OAuthTokenInterceptor { static final String PATH_PREFIX = "/extract"; private final SynchronousQueue<Object> queue; private ExtractingTokenInterceptor(SynchronousQueue<Object> queue) { this.queue = queue; } @Override public String getPathPrefix() { return PATH_PREFIX; } @Override public void handleRequest(HttpServerExchange exchange) throws Exception { final String urlWithIdAndToken = exchange.getRequestURL() + "?" + exchange.getQueryString(); logger.log(Level.INFO, "Got a request to " + urlWithIdAndToken); queue.add(urlWithIdAndToken); TimeUnit.SECONDS.sleep(2); final OAuthWebResponse oauthWebResponse = (OAuthWebResponse) queue.take(); logger.log(Level.INFO, "Got an oauth response with code " + oauthWebResponse.getCode()); exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html"); exchange.setResponseCode(oauthWebResponse.getCode()); exchange.getResponseSender().send(oauthWebResponse.getBody()); exchange.endExchange(); queue.add(Boolean.TRUE); } } }