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