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