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