001    // License: GPL. For details, see LICENSE file.
002    package org.openstreetmap.josm.gui.oauth;
003    
004    import static org.openstreetmap.josm.tools.I18n.tr;
005    
006    import java.io.BufferedReader;
007    import java.io.DataOutputStream;
008    import java.io.IOException;
009    import java.io.InputStreamReader;
010    import java.io.UnsupportedEncodingException;
011    import java.lang.reflect.Field;
012    import java.net.HttpURLConnection;
013    import java.net.MalformedURLException;
014    import java.net.URL;
015    import java.net.URLEncoder;
016    import java.util.HashMap;
017    import java.util.Iterator;
018    import java.util.List;
019    import java.util.Map;
020    import java.util.Map.Entry;
021    import java.util.regex.Matcher;
022    import java.util.regex.Pattern;
023    
024    import oauth.signpost.OAuth;
025    import oauth.signpost.OAuthConsumer;
026    import oauth.signpost.OAuthProvider;
027    import oauth.signpost.basic.DefaultOAuthProvider;
028    import oauth.signpost.exception.OAuthCommunicationException;
029    import oauth.signpost.exception.OAuthException;
030    
031    import org.openstreetmap.josm.Main;
032    import org.openstreetmap.josm.data.Version;
033    import org.openstreetmap.josm.data.oauth.OAuthParameters;
034    import org.openstreetmap.josm.data.oauth.OAuthToken;
035    import org.openstreetmap.josm.data.oauth.OsmPrivileges;
036    import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
037    import org.openstreetmap.josm.gui.progress.ProgressMonitor;
038    import org.openstreetmap.josm.io.OsmTransferCanceledException;
039    import org.openstreetmap.josm.tools.CheckParameterUtil;
040    
041    /**
042     * An OAuth 1.0 authorization client. 
043     * @since 2746
044     */
045    public 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                    System.err.println(tr("Warning: failed to cancel running OAuth operation"));
115                } catch(SecurityException e) {
116                    e.printStackTrace();
117                    System.err.println(tr("Warning: failed to cancel running OAuth operation"));
118                } catch(IllegalAccessException e) {
119                    e.printStackTrace();
120                    System.err.println(tr("Warning: 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            try {
215                BufferedReader r = new BufferedReader(new InputStreamReader(connection.getInputStream()));
216                String c;
217                Pattern p = Pattern.compile(".*authenticity_token.*value=\"([^\"]+)\".*");
218                while((c = r.readLine()) != null) {
219                    Matcher m = p.matcher(c);
220                    if(m.find())
221                        return m.group(1);
222                }
223            } catch (IOException e) {
224                return null;
225            }
226            return null;
227        }
228    
229        protected SessionId extractOsmSession(HttpURLConnection connection) {
230            List<String> setCookies = connection.getHeaderFields().get("Set-Cookie");
231            if (setCookies == null)
232                // no cookies set
233                return null;
234    
235            for (String setCookie: setCookies) {
236                String[] kvPairs = setCookie.split(";");
237                if (kvPairs == null || kvPairs.length == 0) {
238                    continue;
239                }
240                for (String kvPair : kvPairs) {
241                    kvPair = kvPair.trim();
242                    String [] kv = kvPair.split("=");
243                    if (kv == null || kv.length != 2) {
244                        continue;
245                    }
246                    if (kv[0].equals("_osm_session")) {
247                        // osm session cookie found
248                        String token = extractToken(connection);
249                        if(token == null)
250                            return null;
251                        SessionId si = new SessionId();
252                        si.id = kv[1];
253                        si.token = token;
254                        return si;
255                    }
256                }
257            }
258            return null;
259        }
260    
261        protected String buildPostRequest(Map<String,String> parameters) throws OsmOAuthAuthorizationException {
262            try {
263                StringBuilder sb = new StringBuilder();
264    
265                for(Iterator<Entry<String,String>> it = parameters.entrySet().iterator(); it.hasNext();) {
266                    Entry<String,String> entry = it.next();
267                    String value = entry.getValue();
268                    value = (value == null) ? "" : value;
269                    sb.append(entry.getKey()).append("=").append(URLEncoder.encode(value, "UTF-8"));
270                    if (it.hasNext()) {
271                        sb.append("&");
272                    }
273                }
274                return sb.toString();
275            } catch(UnsupportedEncodingException e) {
276                throw new OsmOAuthAuthorizationException(e);
277            }
278        }
279    
280        /**
281         * Derives the OSM login URL from the OAuth Authorization Website URL
282         *
283         * @return the OSM login URL
284         * @throws OsmOAuthAuthorizationException if something went wrong, in particular if the
285         * URLs are malformed
286         */
287        public String buildOsmLoginUrl() throws OsmOAuthAuthorizationException{
288            try {
289                URL autUrl = new URL(oauthProviderParameters.getAuthoriseUrl());
290                URL url = new URL(Main.pref.get("oauth.protocol", "https"), autUrl.getHost(), autUrl.getPort(), "/login");
291                return url.toString();
292            } catch(MalformedURLException e) {
293                throw new OsmOAuthAuthorizationException(e);
294            }
295        }
296    
297        /**
298         * Derives the OSM logout URL from the OAuth Authorization Website URL
299         *
300         * @return the OSM logout URL
301         * @throws OsmOAuthAuthorizationException if something went wrong, in particular if the
302         * URLs are malformed
303         */
304        protected String buildOsmLogoutUrl() throws OsmOAuthAuthorizationException{
305            try {
306                URL autUrl = new URL(oauthProviderParameters.getAuthoriseUrl());
307                URL url = new URL("http", autUrl.getHost(), autUrl.getPort(), "/logout");
308                return url.toString();
309            } catch(MalformedURLException e) {
310                throw new OsmOAuthAuthorizationException(e);
311            }
312        }
313    
314        /**
315         * Submits a request to the OSM website for a login form. The OSM website replies a session ID in
316         * a cookie.
317         *
318         * @return the session ID structure
319         * @throws OsmOAuthAuthorizationException if something went wrong
320         */
321        protected SessionId fetchOsmWebsiteSessionId() throws OsmOAuthAuthorizationException {
322            try {
323                StringBuilder sb = new StringBuilder();
324                sb.append(buildOsmLoginUrl()).append("?cookie_test=true");
325                URL url = new URL(sb.toString());
326                synchronized(this) {
327                    connection = (HttpURLConnection)url.openConnection();
328                }
329                connection.setRequestMethod("GET");
330                connection.setDoInput(true);
331                connection.setDoOutput(false);
332                setHttpRequestParameters(connection);
333                connection.connect();
334                SessionId sessionId = extractOsmSession(connection);
335                if (sessionId == null)
336                    throw new OsmOAuthAuthorizationException(tr("OSM website did not return a session cookie in response to ''{0}'',", url.toString()));
337                return sessionId;
338            } catch(IOException e) {
339                throw new OsmOAuthAuthorizationException(e);
340            } finally {
341                synchronized(this) {
342                    connection = null;
343                }
344            }
345        }
346    
347        /**
348         * Submits a request to the OSM website for a OAuth form. The OSM website replies a session token in
349         * a hidden parameter.
350         *
351         * @throws OsmOAuthAuthorizationException if something went wrong
352         */
353        protected void fetchOAuthToken(SessionId sessionId, OAuthToken requestToken) throws OsmOAuthAuthorizationException {
354            try {
355                URL url = new URL(getAuthoriseUrl(requestToken));
356                synchronized(this) {
357                    connection = (HttpURLConnection)url.openConnection();
358                }
359                connection.setRequestMethod("GET");
360                connection.setDoInput(true);
361                connection.setDoOutput(false);
362                connection.setRequestProperty("Cookie", "_osm_session=" + sessionId.id + "; _osm_username=" + sessionId.userName);
363                setHttpRequestParameters(connection);
364                connection.connect();
365                sessionId.token = extractToken(connection);
366                if (sessionId.token == null)
367                    throw new OsmOAuthAuthorizationException(tr("OSM website did not return a session cookie in response to ''{0}'',", url.toString()));
368            } catch(IOException e) {
369                throw new OsmOAuthAuthorizationException(e);
370            } finally {
371                synchronized(this) {
372                    connection = null;
373                }
374            }
375        }
376    
377        protected void authenticateOsmSession(SessionId sessionId, String userName, String password) throws OsmLoginFailedException {
378            DataOutputStream dout = null;
379            try {
380                URL url = new URL(buildOsmLoginUrl());
381                synchronized(this) {
382                    connection = (HttpURLConnection)url.openConnection();
383                }
384                connection.setRequestMethod("POST");
385                connection.setDoInput(true);
386                connection.setDoOutput(true);
387                connection.setUseCaches(false);
388    
389                Map<String,String> parameters = new HashMap<String, String>();
390                parameters.put("username", userName);
391                parameters.put("password", password);
392                parameters.put("referer", "/");
393                parameters.put("commit", "Login");
394                parameters.put("authenticity_token", sessionId.token);
395    
396                String request = buildPostRequest(parameters);
397    
398                connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
399                connection.setRequestProperty("Content-Length", Integer.toString(request.length()));
400                connection.setRequestProperty("Cookie", "_osm_session=" + sessionId.id);
401                // make sure we can catch 302 Moved Temporarily below
402                connection.setInstanceFollowRedirects(false);
403                setHttpRequestParameters(connection);
404    
405                connection.connect();
406    
407                dout = new DataOutputStream(connection.getOutputStream());
408                dout.writeBytes(request);
409                dout.flush();
410                dout.close();
411    
412                // after a successful login the OSM website sends a redirect to a follow up page. Everything
413                // else, including a 200 OK, is a failed login. A 200 OK is replied if the login form with
414                // an error page is sent to back to the user.
415                //
416                int retCode = connection.getResponseCode();
417                if (retCode != HttpURLConnection.HTTP_MOVED_TEMP)
418                    throw new OsmOAuthAuthorizationException(tr("Failed to authenticate user ''{0}'' with password ''***'' as OAuth user", userName));
419            } catch(OsmOAuthAuthorizationException e) {
420                throw new OsmLoginFailedException(e.getCause());
421            } catch(IOException e) {
422                throw new OsmLoginFailedException(e);
423            } finally {
424                if (dout != null) {
425                    try {
426                        dout.close();
427                    } catch(IOException e) { /* ignore */ }
428                }
429                synchronized(this) {
430                    connection = null;
431                }
432            }
433        }
434    
435        protected void logoutOsmSession(SessionId sessionId) throws OsmOAuthAuthorizationException {
436            try {
437                URL url = new URL(buildOsmLogoutUrl());
438                synchronized(this) {
439                    connection = (HttpURLConnection)url.openConnection();
440                }
441                connection.setRequestMethod("GET");
442                connection.setDoInput(true);
443                connection.setDoOutput(false);
444                setHttpRequestParameters(connection);
445                connection.connect();
446            }catch(MalformedURLException e) {
447                throw new OsmOAuthAuthorizationException(e);
448            } catch(IOException e) {
449                throw new OsmOAuthAuthorizationException(e);
450            }  finally {
451                synchronized(this) {
452                    connection = null;
453                }
454            }
455        }
456    
457        protected void sendAuthorisationRequest(SessionId sessionId, OAuthToken requestToken, OsmPrivileges privileges) throws OsmOAuthAuthorizationException {
458            Map<String, String> parameters = new HashMap<String, String>();
459            fetchOAuthToken(sessionId, requestToken);
460            parameters.put("oauth_token", requestToken.getKey());
461            parameters.put("oauth_callback", "");
462            parameters.put("authenticity_token", sessionId.token);
463            if (privileges.isAllowWriteApi()) {
464                parameters.put("allow_write_api", "yes");
465            }
466            if (privileges.isAllowWriteGpx()) {
467                parameters.put("allow_write_gpx", "yes");
468            }
469            if (privileges.isAllowReadGpx()) {
470                parameters.put("allow_read_gpx", "yes");
471            }
472            if (privileges.isAllowWritePrefs()) {
473                parameters.put("allow_write_prefs", "yes");
474            }
475            if (privileges.isAllowReadPrefs()) {
476                parameters.put("allow_read_prefs", "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 = (HttpURLConnection)url.openConnection();
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                setHttpRequestParameters(connection);
497    
498                connection.connect();
499    
500                dout = new DataOutputStream(connection.getOutputStream());
501                dout.writeBytes(request);
502                dout.flush();
503                dout.close();
504    
505                int retCode = connection.getResponseCode();
506                if (retCode != HttpURLConnection.HTTP_OK)
507                    throw new OsmOAuthAuthorizationException(tr("Failed to authorize OAuth request  ''{0}''", requestToken.getKey()));
508            } catch(MalformedURLException e) {
509                throw new OsmOAuthAuthorizationException(e);
510            } catch(IOException e) {
511                throw new OsmOAuthAuthorizationException(e);
512            } finally {
513                if (dout != null) {
514                    try {
515                        dout.close();
516                    } catch(IOException e) { /* ignore */ }
517                }
518                synchronized(this) {
519                    connection = null;
520                }
521            }
522        }
523    
524        protected void setHttpRequestParameters(HttpURLConnection connection) {
525            connection.setRequestProperty("User-Agent", Version.getInstance().getAgentString());
526            connection.setRequestProperty("Host", connection.getURL().getHost());
527        }
528    
529        /**
530         * Automatically authorises a request token for a set of privileges.
531         *
532         * @param requestToken the request token. Must not be null.
533         * @param osmUserName the OSM user name. Must not be null.
534         * @param osmPassword the OSM password. Must not be null.
535         * @param privileges the set of privileges. Must not be null.
536         * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
537         * @throws IllegalArgumentException if requestToken is null
538         * @throws IllegalArgumentException if osmUserName is null
539         * @throws IllegalArgumentException if osmPassword is null
540         * @throws IllegalArgumentException if privileges is null
541         * @throws OsmOAuthAuthorizationException if the authorisation fails
542         * @throws OsmTransferCanceledException if the task is canceled by the user
543         */
544        public void authorise(OAuthToken requestToken, String osmUserName, String osmPassword, OsmPrivileges privileges, ProgressMonitor monitor) throws IllegalArgumentException, OsmOAuthAuthorizationException, OsmTransferCanceledException{
545            CheckParameterUtil.ensureParameterNotNull(requestToken, "requestToken");
546            CheckParameterUtil.ensureParameterNotNull(osmUserName, "osmUserName");
547            CheckParameterUtil.ensureParameterNotNull(osmPassword, "osmPassword");
548            CheckParameterUtil.ensureParameterNotNull(privileges, "privileges");
549    
550            if (monitor == null) {
551                monitor = NullProgressMonitor.INSTANCE;
552            }
553            try {
554                monitor.beginTask(tr("Authorizing OAuth Request token ''{0}'' at the OSM website ...", requestToken.getKey()));
555                monitor.setTicksCount(4);
556                monitor.indeterminateSubTask(tr("Initializing a session at the OSM website..."));
557                SessionId sessionId = fetchOsmWebsiteSessionId();
558                sessionId.userName = osmUserName;
559                if (canceled)
560                    throw new OsmTransferCanceledException();
561                monitor.worked(1);
562    
563                monitor.indeterminateSubTask(tr("Authenticating the session for user ''{0}''...", osmUserName));
564                authenticateOsmSession(sessionId, osmUserName, osmPassword);
565                if (canceled)
566                    throw new OsmTransferCanceledException();
567                monitor.worked(1);
568    
569                monitor.indeterminateSubTask(tr("Authorizing request token ''{0}''...", requestToken.getKey()));
570                sendAuthorisationRequest(sessionId, requestToken, privileges);
571                if (canceled)
572                    throw new OsmTransferCanceledException();
573                monitor.worked(1);
574    
575                monitor.indeterminateSubTask(tr("Logging out session ''{0}''...", sessionId));
576                logoutOsmSession(sessionId);
577                if (canceled)
578                    throw new OsmTransferCanceledException();
579                monitor.worked(1);
580            } catch(OsmOAuthAuthorizationException e) {
581                if (canceled)
582                    throw new OsmTransferCanceledException();
583                throw e;
584            } finally {
585                monitor.finishTask();
586            }
587        }
588    }