001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.oauth;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.BufferedReader;
007import java.io.DataOutputStream;
008import java.io.IOException;
009import java.io.InputStreamReader;
010import java.io.UnsupportedEncodingException;
011import java.lang.reflect.Field;
012import java.net.HttpURLConnection;
013import java.net.MalformedURLException;
014import java.net.URL;
015import java.net.URLEncoder;
016import java.util.HashMap;
017import java.util.Iterator;
018import java.util.List;
019import java.util.Map;
020import java.util.Map.Entry;
021import java.util.regex.Matcher;
022import java.util.regex.Pattern;
023
024import oauth.signpost.OAuth;
025import oauth.signpost.OAuthConsumer;
026import oauth.signpost.OAuthProvider;
027import oauth.signpost.basic.DefaultOAuthProvider;
028import oauth.signpost.exception.OAuthCommunicationException;
029import oauth.signpost.exception.OAuthException;
030
031import org.openstreetmap.josm.Main;
032import org.openstreetmap.josm.data.oauth.OAuthParameters;
033import org.openstreetmap.josm.data.oauth.OAuthToken;
034import org.openstreetmap.josm.data.oauth.OsmPrivileges;
035import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
036import org.openstreetmap.josm.gui.progress.ProgressMonitor;
037import org.openstreetmap.josm.io.OsmTransferCanceledException;
038import org.openstreetmap.josm.tools.CheckParameterUtil;
039import org.openstreetmap.josm.tools.Utils;
040
041/**
042 * An OAuth 1.0 authorization client.
043 * @since 2746
044 */
045public class OsmOAuthAuthorizationClient {
046    private final OAuthParameters oauthProviderParameters;
047    private final OAuthConsumer consumer;
048    private final OAuthProvider provider;
049    private boolean canceled;
050    private HttpURLConnection connection;
051
052    private static class SessionId {
053        String id;
054        String token;
055        String userName;
056    }
057
058    /**
059     * Creates a new authorisation client with default OAuth parameters
060     *
061     */
062    public OsmOAuthAuthorizationClient() {
063        oauthProviderParameters = OAuthParameters.createDefault(Main.pref.get("osm-server.url"));
064        consumer = oauthProviderParameters.buildConsumer();
065        provider = oauthProviderParameters.buildProvider(consumer);
066    }
067
068    /**
069     * Creates a new authorisation client with the parameters <code>parameters</code>.
070     *
071     * @param parameters the OAuth parameters. Must not be null.
072     * @throws IllegalArgumentException if parameters is null
073     */
074    public OsmOAuthAuthorizationClient(OAuthParameters parameters) throws IllegalArgumentException {
075        CheckParameterUtil.ensureParameterNotNull(parameters, "parameters");
076        oauthProviderParameters = new OAuthParameters(parameters);
077        consumer = oauthProviderParameters.buildConsumer();
078        provider = oauthProviderParameters.buildProvider(consumer);
079    }
080
081    /**
082     * Creates a new authorisation client with the parameters <code>parameters</code>
083     * and an already known Request Token.
084     *
085     * @param parameters the OAuth parameters. Must not be null.
086     * @param requestToken the request token. Must not be null.
087     * @throws IllegalArgumentException if parameters is null
088     * @throws IllegalArgumentException if requestToken is null
089     */
090    public OsmOAuthAuthorizationClient(OAuthParameters parameters, OAuthToken requestToken) throws IllegalArgumentException {
091        CheckParameterUtil.ensureParameterNotNull(parameters, "parameters");
092        oauthProviderParameters = new OAuthParameters(parameters);
093        consumer = oauthProviderParameters.buildConsumer();
094        provider = oauthProviderParameters.buildProvider(consumer);
095        consumer.setTokenWithSecret(requestToken.getKey(), requestToken.getSecret());
096    }
097
098    /**
099     * Cancels the current OAuth operation.
100     */
101    public void cancel() {
102        DefaultOAuthProvider p  = (DefaultOAuthProvider)provider;
103        canceled = true;
104        if (p != null) {
105            try {
106                Field f =  p.getClass().getDeclaredField("connection");
107                f.setAccessible(true);
108                HttpURLConnection con = (HttpURLConnection)f.get(p);
109                if (con != null) {
110                    con.disconnect();
111                }
112            } catch (NoSuchFieldException e) {
113                e.printStackTrace();
114                Main.warn(tr("Failed to cancel running OAuth operation"));
115            } catch (SecurityException e) {
116                e.printStackTrace();
117                Main.warn(tr("Failed to cancel running OAuth operation"));
118            } catch (IllegalAccessException e) {
119                e.printStackTrace();
120                Main.warn(tr("Failed to cancel running OAuth operation"));
121            }
122        }
123        synchronized(this) {
124            if (connection != null) {
125                connection.disconnect();
126            }
127        }
128    }
129
130    /**
131     * Submits a request for a Request Token to the Request Token Endpoint Url of the OAuth Service
132     * Provider and replies the request token.
133     *
134     * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
135     * @return the OAuth Request Token
136     * @throws OsmOAuthAuthorizationException if something goes wrong when retrieving the request token
137     * @throws OsmTransferCanceledException if the user canceled the request
138     */
139    public OAuthToken getRequestToken(ProgressMonitor monitor) throws OsmOAuthAuthorizationException, OsmTransferCanceledException {
140        if (monitor == null) {
141            monitor = NullProgressMonitor.INSTANCE;
142        }
143        try {
144            monitor.beginTask("");
145            monitor.indeterminateSubTask(tr("Retrieving OAuth Request Token from ''{0}''", oauthProviderParameters.getRequestTokenUrl()));
146            provider.retrieveRequestToken(consumer, "");
147            return OAuthToken.createToken(consumer);
148        } catch(OAuthCommunicationException e){
149            if (canceled)
150                throw new OsmTransferCanceledException();
151            throw new OsmOAuthAuthorizationException(e);
152        } catch(OAuthException e){
153            if (canceled)
154                throw new OsmTransferCanceledException();
155            throw new OsmOAuthAuthorizationException(e);
156        } finally {
157            monitor.finishTask();
158        }
159    }
160
161    /**
162     * Submits a request for an Access Token to the Access Token Endpoint Url of the OAuth Service
163     * Provider and replies the request token.
164     *
165     * You must have requested a Request Token using {@link #getRequestToken(ProgressMonitor)} first.
166     *
167     * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
168     * @return the OAuth Access Token
169     * @throws OsmOAuthAuthorizationException if something goes wrong when retrieving the request token
170     * @throws OsmTransferCanceledException if the user canceled the request
171     * @see #getRequestToken(ProgressMonitor)
172     */
173    public OAuthToken getAccessToken(ProgressMonitor monitor) throws OsmOAuthAuthorizationException, OsmTransferCanceledException {
174        if (monitor == null) {
175            monitor = NullProgressMonitor.INSTANCE;
176        }
177        try {
178            monitor.beginTask("");
179            monitor.indeterminateSubTask(tr("Retrieving OAuth Access Token from ''{0}''", oauthProviderParameters.getAccessTokenUrl()));
180            provider.retrieveAccessToken(consumer, null);
181            return OAuthToken.createToken(consumer);
182        } catch(OAuthCommunicationException e){
183            if (canceled)
184                throw new OsmTransferCanceledException();
185            throw new OsmOAuthAuthorizationException(e);
186        } catch(OAuthException e){
187            if (canceled)
188                throw new OsmTransferCanceledException();
189            throw new OsmOAuthAuthorizationException(e);
190        } finally {
191            monitor.finishTask();
192        }
193    }
194
195    /**
196     * Builds the authorise URL for a given Request Token. Users can be redirected to this URL.
197     * There they can login to OSM and authorise the request.
198     *
199     * @param requestToken  the request token
200     * @return  the authorise URL for this request
201     */
202    public String getAuthoriseUrl(OAuthToken requestToken) {
203        StringBuilder sb = new StringBuilder();
204
205        // OSM is an OAuth 1.0 provider and JOSM isn't a web app. We just add the oauth request token to
206        // the authorisation request, no callback parameter.
207        //
208        sb.append(oauthProviderParameters.getAuthoriseUrl()).append("?")
209        .append(OAuth.OAUTH_TOKEN).append("=").append(requestToken.getKey());
210        return sb.toString();
211    }
212
213    protected String extractToken(HttpURLConnection connection) {
214        BufferedReader r = null;
215        try {
216            r = new BufferedReader(new InputStreamReader(connection.getInputStream()));
217            String c;
218            Pattern p = Pattern.compile(".*authenticity_token.*value=\"([^\"]+)\".*");
219            while ((c = r.readLine()) != null) {
220                Matcher m = p.matcher(c);
221                if (m.find()) {
222                    return m.group(1);
223                }
224            }
225        } catch (IOException e) {
226            Main.error(e);
227            return null;
228        } finally {
229            Utils.close(r);
230        }
231        return null;
232    }
233
234    protected SessionId extractOsmSession(HttpURLConnection connection) {
235        List<String> setCookies = connection.getHeaderFields().get("Set-Cookie");
236        if (setCookies == null)
237            // no cookies set
238            return null;
239
240        for (String setCookie: setCookies) {
241            String[] kvPairs = setCookie.split(";");
242            if (kvPairs == null || kvPairs.length == 0) {
243                continue;
244            }
245            for (String kvPair : kvPairs) {
246                kvPair = kvPair.trim();
247                String [] kv = kvPair.split("=");
248                if (kv == null || kv.length != 2) {
249                    continue;
250                }
251                if (kv[0].equals("_osm_session")) {
252                    // osm session cookie found
253                    String token = extractToken(connection);
254                    if(token == null)
255                        return null;
256                    SessionId si = new SessionId();
257                    si.id = kv[1];
258                    si.token = token;
259                    return si;
260                }
261            }
262        }
263        return null;
264    }
265
266    protected String buildPostRequest(Map<String,String> parameters) throws OsmOAuthAuthorizationException {
267        try {
268            StringBuilder sb = new StringBuilder();
269
270            for(Iterator<Entry<String,String>> it = parameters.entrySet().iterator(); it.hasNext();) {
271                Entry<String,String> entry = it.next();
272                String value = entry.getValue();
273                value = (value == null) ? "" : value;
274                sb.append(entry.getKey()).append("=").append(URLEncoder.encode(value, "UTF-8"));
275                if (it.hasNext()) {
276                    sb.append("&");
277                }
278            }
279            return sb.toString();
280        } catch(UnsupportedEncodingException e) {
281            throw new OsmOAuthAuthorizationException(e);
282        }
283    }
284
285    /**
286     * Derives the OSM login URL from the OAuth Authorization Website URL
287     *
288     * @return the OSM login URL
289     * @throws OsmOAuthAuthorizationException if something went wrong, in particular if the
290     * URLs are malformed
291     */
292    public String buildOsmLoginUrl() throws OsmOAuthAuthorizationException{
293        try {
294            URL autUrl = new URL(oauthProviderParameters.getAuthoriseUrl());
295            URL url = new URL(Main.pref.get("oauth.protocol", "https"), autUrl.getHost(), autUrl.getPort(), "/login");
296            return url.toString();
297        } catch(MalformedURLException e) {
298            throw new OsmOAuthAuthorizationException(e);
299        }
300    }
301
302    /**
303     * Derives the OSM logout URL from the OAuth Authorization Website URL
304     *
305     * @return the OSM logout URL
306     * @throws OsmOAuthAuthorizationException if something went wrong, in particular if the
307     * URLs are malformed
308     */
309    protected String buildOsmLogoutUrl() throws OsmOAuthAuthorizationException{
310        try {
311            URL autUrl = new URL(oauthProviderParameters.getAuthoriseUrl());
312            URL url = new URL("http", autUrl.getHost(), autUrl.getPort(), "/logout");
313            return url.toString();
314        } catch(MalformedURLException e) {
315            throw new OsmOAuthAuthorizationException(e);
316        }
317    }
318
319    /**
320     * Submits a request to the OSM website for a login form. The OSM website replies a session ID in
321     * a cookie.
322     *
323     * @return the session ID structure
324     * @throws OsmOAuthAuthorizationException if something went wrong
325     */
326    protected SessionId fetchOsmWebsiteSessionId() throws OsmOAuthAuthorizationException {
327        try {
328            StringBuilder sb = new StringBuilder();
329            sb.append(buildOsmLoginUrl()).append("?cookie_test=true");
330            URL url = new URL(sb.toString());
331            synchronized(this) {
332                connection = Utils.openHttpConnection(url);
333            }
334            connection.setRequestMethod("GET");
335            connection.setDoInput(true);
336            connection.setDoOutput(false);
337            connection.connect();
338            SessionId sessionId = extractOsmSession(connection);
339            if (sessionId == null)
340                throw new OsmOAuthAuthorizationException(tr("OSM website did not return a session cookie in response to ''{0}'',", url.toString()));
341            return sessionId;
342        } catch(IOException e) {
343            throw new OsmOAuthAuthorizationException(e);
344        } finally {
345            synchronized(this) {
346                connection = null;
347            }
348        }
349    }
350
351    /**
352     * Submits a request to the OSM website for a OAuth form. The OSM website replies a session token in
353     * a hidden parameter.
354     *
355     * @throws OsmOAuthAuthorizationException if something went wrong
356     */
357    protected void fetchOAuthToken(SessionId sessionId, OAuthToken requestToken) throws OsmOAuthAuthorizationException {
358        try {
359            URL url = new URL(getAuthoriseUrl(requestToken));
360            synchronized(this) {
361                connection = Utils.openHttpConnection(url);
362            }
363            connection.setRequestMethod("GET");
364            connection.setDoInput(true);
365            connection.setDoOutput(false);
366            connection.setRequestProperty("Cookie", "_osm_session=" + sessionId.id + "; _osm_username=" + sessionId.userName);
367            connection.connect();
368            sessionId.token = extractToken(connection);
369            if (sessionId.token == null)
370                throw new OsmOAuthAuthorizationException(tr("OSM website did not return a session cookie in response to ''{0}'',", url.toString()));
371        } catch(IOException e) {
372            throw new OsmOAuthAuthorizationException(e);
373        } finally {
374            synchronized(this) {
375                connection = null;
376            }
377        }
378    }
379
380    protected void authenticateOsmSession(SessionId sessionId, String userName, String password) throws OsmLoginFailedException {
381        DataOutputStream dout = null;
382        try {
383            URL url = new URL(buildOsmLoginUrl());
384            synchronized(this) {
385                connection = Utils.openHttpConnection(url);
386            }
387            connection.setRequestMethod("POST");
388            connection.setDoInput(true);
389            connection.setDoOutput(true);
390            connection.setUseCaches(false);
391
392            Map<String,String> parameters = new HashMap<String, String>();
393            parameters.put("username", userName);
394            parameters.put("password", password);
395            parameters.put("referer", "/");
396            parameters.put("commit", "Login");
397            parameters.put("authenticity_token", sessionId.token);
398
399            String request = buildPostRequest(parameters);
400
401            connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
402            connection.setRequestProperty("Content-Length", Integer.toString(request.length()));
403            connection.setRequestProperty("Cookie", "_osm_session=" + sessionId.id);
404            // make sure we can catch 302 Moved Temporarily below
405            connection.setInstanceFollowRedirects(false);
406
407            connection.connect();
408
409            dout = new DataOutputStream(connection.getOutputStream());
410            dout.writeBytes(request);
411            dout.flush();
412            Utils.close(dout);
413
414            // after a successful login the OSM website sends a redirect to a follow up page. Everything
415            // else, including a 200 OK, is a failed login. A 200 OK is replied if the login form with
416            // an error page is sent to back to the user.
417            //
418            int retCode = connection.getResponseCode();
419            if (retCode != HttpURLConnection.HTTP_MOVED_TEMP)
420                throw new OsmOAuthAuthorizationException(tr("Failed to authenticate user ''{0}'' with password ''***'' as OAuth user", userName));
421        } catch(OsmOAuthAuthorizationException e) {
422            throw new OsmLoginFailedException(e.getCause());
423        } catch(IOException e) {
424            throw new OsmLoginFailedException(e);
425        } finally {
426            Utils.close(dout);
427            synchronized(this) {
428                connection = null;
429            }
430        }
431    }
432
433    protected void logoutOsmSession(SessionId sessionId) throws OsmOAuthAuthorizationException {
434        try {
435            URL url = new URL(buildOsmLogoutUrl());
436            synchronized(this) {
437                connection = Utils.openHttpConnection(url);
438            }
439            connection.setRequestMethod("GET");
440            connection.setDoInput(true);
441            connection.setDoOutput(false);
442            connection.connect();
443        } catch(MalformedURLException e) {
444            throw new OsmOAuthAuthorizationException(e);
445        } catch(IOException e) {
446            throw new OsmOAuthAuthorizationException(e);
447        }  finally {
448            synchronized(this) {
449                connection = null;
450            }
451        }
452    }
453
454    protected void sendAuthorisationRequest(SessionId sessionId, OAuthToken requestToken, OsmPrivileges privileges) throws OsmOAuthAuthorizationException {
455        Map<String, String> parameters = new HashMap<String, String>();
456        fetchOAuthToken(sessionId, requestToken);
457        parameters.put("oauth_token", requestToken.getKey());
458        parameters.put("oauth_callback", "");
459        parameters.put("authenticity_token", sessionId.token);
460        if (privileges.isAllowWriteApi()) {
461            parameters.put("allow_write_api", "yes");
462        }
463        if (privileges.isAllowWriteGpx()) {
464            parameters.put("allow_write_gpx", "yes");
465        }
466        if (privileges.isAllowReadGpx()) {
467            parameters.put("allow_read_gpx", "yes");
468        }
469        if (privileges.isAllowWritePrefs()) {
470            parameters.put("allow_write_prefs", "yes");
471        }
472        if (privileges.isAllowReadPrefs()) {
473            parameters.put("allow_read_prefs", "yes");
474        }
475        if(privileges.isAllowModifyNotes()) {
476            parameters.put("allow_write_notes", "yes");
477        }
478
479        parameters.put("commit", "Save changes");
480
481        String request = buildPostRequest(parameters);
482        DataOutputStream dout = null;
483        try {
484            URL url = new URL(oauthProviderParameters.getAuthoriseUrl());
485            synchronized(this) {
486                connection = Utils.openHttpConnection(url);
487            }
488            connection.setRequestMethod("POST");
489            connection.setDoInput(true);
490            connection.setDoOutput(true);
491            connection.setUseCaches(false);
492            connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
493            connection.setRequestProperty("Content-Length", Integer.toString(request.length()));
494            connection.setRequestProperty("Cookie", "_osm_session=" + sessionId.id + "; _osm_username=" + sessionId.userName);
495            connection.setInstanceFollowRedirects(false);
496
497            connection.connect();
498
499            dout = new DataOutputStream(connection.getOutputStream());
500            dout.writeBytes(request);
501            dout.flush();
502
503            int retCode = connection.getResponseCode();
504            if (retCode != HttpURLConnection.HTTP_OK)
505                throw new OsmOAuthAuthorizationException(tr("Failed to authorize OAuth request  ''{0}''", requestToken.getKey()));
506        } catch(MalformedURLException e) {
507            throw new OsmOAuthAuthorizationException(e);
508        } catch(IOException e) {
509            throw new OsmOAuthAuthorizationException(e);
510        } finally {
511            Utils.close(dout);
512            synchronized(this) {
513                connection = null;
514            }
515        }
516    }
517
518    /**
519     * Automatically authorises a request token for a set of privileges.
520     *
521     * @param requestToken the request token. Must not be null.
522     * @param osmUserName the OSM user name. Must not be null.
523     * @param osmPassword the OSM password. Must not be null.
524     * @param privileges the set of privileges. Must not be null.
525     * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
526     * @throws IllegalArgumentException if requestToken is null
527     * @throws IllegalArgumentException if osmUserName is null
528     * @throws IllegalArgumentException if osmPassword is null
529     * @throws IllegalArgumentException if privileges is null
530     * @throws OsmOAuthAuthorizationException if the authorisation fails
531     * @throws OsmTransferCanceledException if the task is canceled by the user
532     */
533    public void authorise(OAuthToken requestToken, String osmUserName, String osmPassword, OsmPrivileges privileges, ProgressMonitor monitor) throws IllegalArgumentException, OsmOAuthAuthorizationException, OsmTransferCanceledException{
534        CheckParameterUtil.ensureParameterNotNull(requestToken, "requestToken");
535        CheckParameterUtil.ensureParameterNotNull(osmUserName, "osmUserName");
536        CheckParameterUtil.ensureParameterNotNull(osmPassword, "osmPassword");
537        CheckParameterUtil.ensureParameterNotNull(privileges, "privileges");
538
539        if (monitor == null) {
540            monitor = NullProgressMonitor.INSTANCE;
541        }
542        try {
543            monitor.beginTask(tr("Authorizing OAuth Request token ''{0}'' at the OSM website ...", requestToken.getKey()));
544            monitor.setTicksCount(4);
545            monitor.indeterminateSubTask(tr("Initializing a session at the OSM website..."));
546            SessionId sessionId = fetchOsmWebsiteSessionId();
547            sessionId.userName = osmUserName;
548            if (canceled)
549                throw new OsmTransferCanceledException();
550            monitor.worked(1);
551
552            monitor.indeterminateSubTask(tr("Authenticating the session for user ''{0}''...", osmUserName));
553            authenticateOsmSession(sessionId, osmUserName, osmPassword);
554            if (canceled)
555                throw new OsmTransferCanceledException();
556            monitor.worked(1);
557
558            monitor.indeterminateSubTask(tr("Authorizing request token ''{0}''...", requestToken.getKey()));
559            sendAuthorisationRequest(sessionId, requestToken, privileges);
560            if (canceled)
561                throw new OsmTransferCanceledException();
562            monitor.worked(1);
563
564            monitor.indeterminateSubTask(tr("Logging out session ''{0}''...", sessionId));
565            logoutOsmSession(sessionId);
566            if (canceled)
567                throw new OsmTransferCanceledException();
568            monitor.worked(1);
569        } catch(OsmOAuthAuthorizationException e) {
570            if (canceled)
571                throw new OsmTransferCanceledException();
572            throw e;
573        } finally {
574            monitor.finishTask();
575        }
576    }
577}