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