001package org.syncany.plugins.transfer.oauth;
002
003import java.io.IOException;
004import java.net.InetAddress;
005import java.net.Socket;
006import java.net.URI;
007import java.net.UnknownHostException;
008import java.util.List;
009import java.util.Random;
010import java.util.UUID;
011import java.util.concurrent.Callable;
012import java.util.concurrent.Executors;
013import java.util.concurrent.Future;
014import java.util.concurrent.SynchronousQueue;
015import java.util.concurrent.TimeUnit;
016import java.util.logging.Level;
017import java.util.logging.Logger;
018
019import org.syncany.plugins.transfer.oauth.OAuthTokenExtractors.NamedQueryTokenExtractor;
020import org.syncany.plugins.transfer.oauth.OAuthTokenInterceptors.RedirectTokenInterceptor;
021import com.google.common.collect.Lists;
022import com.google.common.collect.Queues;
023import com.google.common.collect.Range;
024import io.undertow.Handlers;
025import io.undertow.Undertow;
026import io.undertow.server.HttpServerExchange;
027import io.undertow.server.handlers.IPAddressAccessControlHandler;
028import io.undertow.util.Headers;
029
030/**
031 * This class creates a server handling the OAuth callback URLs. It has two tasks. First it is responsible for executing
032 * {@link OAuthTokenInterceptor} depending on a path defined by the interceptor itself. Furthermore it does the token
033 * parsing in the URL using a {@link OAuthTokenExtractor}.
034 *
035 * @author Christian Roth (christian.roth@port17.de)
036 */
037
038public class OAuthTokenWebListener implements Callable<OAuthTokenFinish> {
039
040        private static final Logger logger = Logger.getLogger(OAuthTokenWebListener.class.getName());
041        private static final Range<Integer> VALID_PORT_RANGE = Range.openClosed(0x0000, 0xFFFF);
042        private static final int PORT_LOWER = 55500;
043        private static final int PORT_UPPER = 55599;
044
045        private final int port;
046        private final String id;
047        private final SynchronousQueue<Object> ioQueue = Queues.newSynchronousQueue();
048        private final OAuthTokenInterceptor interceptor;
049        private final OAuthTokenExtractor extractor;
050        private final List<InetAddress> allowedClients;
051
052        private Undertow server;
053
054        /**
055         * Create a new {@link OAuthTokenWebListener} with some clever defaults for a {@link OAuthMode}.
056         *
057         * @param mode  {@link OAuthMode} supported by the {@link org.syncany.plugins.transfer.TransferPlugin}.
058         * @return A ready to use {@link OAuthTokenWebListener}.
059         */
060        public static Builder forMode(OAuthMode mode) {
061                return new Builder(mode);
062        }
063
064        public static class Builder {
065
066                private final List<InetAddress> allowedClients = Lists.newArrayList();
067
068                private OAuthTokenInterceptor interceptor;
069                private OAuthTokenExtractor extractor;
070                private int port;
071                private String id;
072
073                private Builder(OAuthMode mode) {
074                        this.interceptor = OAuthTokenInterceptors.newTokenInterceptorForMode(mode);
075                        this.extractor = OAuthTokenExtractors.newTokenExtractorForMode(mode);
076
077                        this.id = UUID.randomUUID().toString();
078                        this.port = new Random().nextInt((PORT_UPPER - PORT_LOWER) + 1) + PORT_LOWER;
079
080                        try {
081                                this.addAllowedClient(InetAddress.getByName("127.0.0.1"));
082                        }
083                        catch (UnknownHostException e) {
084                                throw new RuntimeException("127.0.0.1 is unknown. This should NEVER happen", e);
085                        }
086                }
087
088                /**
089                 * Use a custom plugin id instead of a randomly generated one. Might be needed if the service provider does not
090                 * allow wildcard redirect URLs.
091                 */
092                public Builder setId(String id) {
093                        if (id != null) {
094                                this.id = id;
095                        }
096
097                        return this;
098                }
099
100                /**
101                 * Use a custom interceptor (default {@link RedirectTokenInterceptor})
102                 */
103                public Builder setTokenInterceptor(OAuthTokenInterceptor interceptor) {
104                        if (interceptor != null) {
105                                this.interceptor = interceptor;
106                        }
107
108                        return this;
109                }
110
111                /**
112                 * Use a custom extractor (default {@link NamedQueryTokenExtractor})
113                 */
114                public Builder setTokenExtractor(OAuthTokenExtractor extractor) {
115                        if (extractor != null) {
116                                this.extractor = extractor;
117                        }
118
119                        return this;
120                }
121
122                /**
123                 * Use a fixed port, otherwise the port is randomly chosen from a range of {@link OAuthTokenWebListener#PORT_LOWER}
124                 * and {@link OAuthTokenWebListener#PORT_UPPER}.
125                 *
126                 * @param port Fixed port to use
127                 *
128                 * @throws IllegalArgumentException Thrown if the chosen port is not in the valid port range (1-65535).
129                 * @throws RuntimeException Thrown if the chosen port is already taken.
130                 */
131                public Builder setPort(int port) {
132                        if (!VALID_PORT_RANGE.contains(port)) {
133                                throw new IllegalArgumentException("Invalid port number " + port);
134                        }
135
136                        if (!isPortAvailable(port)) {
137                                throw new RuntimeException("Token listener tried to use a defined but already taken port " + port);
138                        }
139
140                        this.port = port;
141                        return this;
142                }
143
144                public Builder addAllowedClient(InetAddress... clientIp) {
145                        allowedClients.addAll(Lists.newArrayList(clientIp));
146                        return this;
147                }
148
149                /**
150                 * Build an immutable {@link OAuthTokenWebListener}.
151                 */
152                public OAuthTokenWebListener build() {
153                        return new OAuthTokenWebListener(id, port, interceptor, extractor, allowedClients);
154                }
155
156                private static boolean isPortAvailable(int port) {
157                        try (Socket ignored = new Socket("localhost", port)) {
158                                return false;
159                        } catch (IOException ignored) {
160                                return true;
161                        }
162                }
163        }
164
165        private OAuthTokenWebListener(String id, int port, OAuthTokenInterceptor interceptor, OAuthTokenExtractor extractor, List<InetAddress> allowedClients) {
166                this.id = id;
167                this.port = port;
168                this.interceptor = interceptor;
169                this.extractor = extractor;
170                this.allowedClients = allowedClients;
171        }
172
173        /**
174         * Start the server created by the @{link Builder}.
175         *
176         * @return A callback URI which should be used during the OAuth process.
177         */
178        public URI start() {
179                createServer();
180                return URI.create(String.format("http://localhost:%d/%s/", port, id));
181        }
182
183        /**
184         * Get the token generated by the OAuth process. In fact, this class returns a {@link Future} because the token may not
185         * be received by the server when this method is called.
186         *
187         * @return Returns an {@link OAuthTokenFinish} wrapped in a {@link Future}. The {@link OAuthTokenFinish} should at least
188         * contain a token
189         */
190        public Future<OAuthTokenFinish> getToken() {
191                return Executors.newFixedThreadPool(1).submit(this);
192        }
193
194        @Override
195        public OAuthTokenFinish call() throws Exception {
196                logger.log(Level.INFO, "Waiting for token response");
197                final String urlWithIdAndToken = (String) ioQueue.take();
198
199                logger.log(Level.INFO, "Parsing token response " + urlWithIdAndToken);
200
201                OAuthTokenFinish tokenResponse = null; // null if parsing failed (user canceled, api error, ...
202                try {
203                        tokenResponse = extractor.parse(urlWithIdAndToken);
204                        ioQueue.put(OAuthWebResponses.createValidResponse());
205                }
206                catch (NoSuchFieldException e) {
207                        logger.log(Level.SEVERE, "Unable to find token in response", e);
208                        ioQueue.put(OAuthWebResponses.createBadResponse());
209                }
210
211                ioQueue.take(); // make sure undertow has send a response
212                stop();
213
214                logger.log(Level.INFO, tokenResponse != null ? "Returning token" : "No token received, returning null");
215                return tokenResponse;
216        }
217
218        /**
219         * Stop the listener server so the port becomes available again.
220         */
221        public void stop() {
222                if (server != null) {
223                        logger.log(Level.INFO, "Stopping server");
224                        server.stop();
225                        server = null;
226                }
227        }
228
229        @Override
230        public void finalize() throws Throwable {
231                super.finalize();
232                stop();
233        }
234
235        private void createServer() {
236                logger.log(Level.FINE, "Locked to build server...");
237
238                OAuthTokenInterceptor extractingHttpHandler = new ExtractingTokenInterceptor(ioQueue);
239
240                IPAddressAccessControlHandler ipAddressAccessControlHandler = new IPAddressAccessControlHandler();
241                ipAddressAccessControlHandler.setDefaultAllow(false);
242
243                for (InetAddress inetAddress : allowedClients) {
244                        ipAddressAccessControlHandler.addAllow(inetAddress.getHostAddress());
245                }
246
247                server = Undertow.builder()
248                                                .addHttpListener(port, "localhost")
249                                                .setHandler(ipAddressAccessControlHandler)
250                                                .setHandler(Handlers.path()
251                                                                                                                .addExactPath(createPath(extractingHttpHandler.getPathPrefix()), extractingHttpHandler)
252                                                                                                                .addExactPath(createPath(interceptor.getPathPrefix()), interceptor)
253                                                )
254                                                .build();
255
256                logger.log(Level.INFO, "Starting token web listener...");
257                server.start();
258        }
259
260        private String createPath(String prefix) {
261                return URI.create(String.format("/%s/%s", id, prefix)).normalize().toString();
262        }
263
264        /**
265         * Default {@link OAuthTokenInterceptor} which notifies the listener about an existing token. It also sends feedback
266         * to a user.
267         */
268        static final class ExtractingTokenInterceptor implements OAuthTokenInterceptor {
269
270                static final String PATH_PREFIX = "/extract";
271
272                private final SynchronousQueue<Object> queue;
273
274                private ExtractingTokenInterceptor(SynchronousQueue<Object> queue) {
275                        this.queue = queue;
276                }
277
278                @Override
279                public String getPathPrefix() {
280                        return PATH_PREFIX;
281                }
282
283                @Override
284                public void handleRequest(HttpServerExchange exchange) throws Exception {
285                        final String urlWithIdAndToken = exchange.getRequestURL() + "?" + exchange.getQueryString();
286                        logger.log(Level.INFO, "Got a request to " + urlWithIdAndToken);
287                        queue.add(urlWithIdAndToken);
288
289                        TimeUnit.SECONDS.sleep(2);
290
291                        final OAuthWebResponse oauthWebResponse = (OAuthWebResponse) queue.take();
292                        logger.log(Level.INFO, "Got an oauth response with code " + oauthWebResponse.getCode());
293                        exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html");
294                        exchange.setResponseCode(oauthWebResponse.getCode());
295                        exchange.getResponseSender().send(oauthWebResponse.getBody());
296                        exchange.endExchange();
297                        queue.add(Boolean.TRUE);
298                }
299        }
300}
301