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                Field f = provider.getClass().getDeclaredField("connection");
094                Utils.setObjectsAccessible(f);
095                HttpURLConnection con = (HttpURLConnection) f.get(provider);
096                if (con != null) {
097                    con.disconnect();
098                }
099            } catch (NoSuchFieldException | SecurityException | IllegalAccessException e) {
100                Main.error(e);
101                Main.warn(tr("Failed to cancel running OAuth operation"));
102            }
103        }
104        synchronized (this) {
105            if (connection != null) {
106                connection.disconnect();
107            }
108        }
109    }
110
111    /**
112     * Submits a request for a Request Token to the Request Token Endpoint Url of the OAuth Service
113     * Provider and replies the request token.
114     *
115     * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
116     * @return the OAuth Request Token
117     * @throws OsmOAuthAuthorizationException if something goes wrong when retrieving the request token
118     * @throws OsmTransferCanceledException if the user canceled the request
119     */
120    public OAuthToken getRequestToken(ProgressMonitor monitor) throws OsmOAuthAuthorizationException, OsmTransferCanceledException {
121        if (monitor == null) {
122            monitor = NullProgressMonitor.INSTANCE;
123        }
124        try {
125            monitor.beginTask("");
126            monitor.indeterminateSubTask(tr("Retrieving OAuth Request Token from ''{0}''", oauthProviderParameters.getRequestTokenUrl()));
127            provider.retrieveRequestToken(consumer, "");
128            return OAuthToken.createToken(consumer);
129        } catch (OAuthException e) {
130            if (canceled)
131                throw new OsmTransferCanceledException(e);
132            throw new OsmOAuthAuthorizationException(e);
133        } finally {
134            monitor.finishTask();
135        }
136    }
137
138    /**
139     * Submits a request for an Access Token to the Access Token Endpoint Url of the OAuth Service
140     * Provider and replies the request token.
141     *
142     * You must have requested a Request Token using {@link #getRequestToken(ProgressMonitor)} first.
143     *
144     * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
145     * @return the OAuth Access Token
146     * @throws OsmOAuthAuthorizationException if something goes wrong when retrieving the request token
147     * @throws OsmTransferCanceledException if the user canceled the request
148     * @see #getRequestToken(ProgressMonitor)
149     */
150    public OAuthToken getAccessToken(ProgressMonitor monitor) throws OsmOAuthAuthorizationException, OsmTransferCanceledException {
151        if (monitor == null) {
152            monitor = NullProgressMonitor.INSTANCE;
153        }
154        try {
155            monitor.beginTask("");
156            monitor.indeterminateSubTask(tr("Retrieving OAuth Access Token from ''{0}''", oauthProviderParameters.getAccessTokenUrl()));
157            provider.retrieveAccessToken(consumer, null);
158            return OAuthToken.createToken(consumer);
159        } catch (OAuthException e) {
160            if (canceled)
161                throw new OsmTransferCanceledException(e);
162            throw new OsmOAuthAuthorizationException(e);
163        } finally {
164            monitor.finishTask();
165        }
166    }
167
168    /**
169     * Builds the authorise URL for a given Request Token. Users can be redirected to this URL.
170     * There they can login to OSM and authorise the request.
171     *
172     * @param requestToken  the request token
173     * @return  the authorise URL for this request
174     */
175    public String getAuthoriseUrl(OAuthToken requestToken) {
176        StringBuilder sb = new StringBuilder(32);
177
178        // OSM is an OAuth 1.0 provider and JOSM isn't a web app. We just add the oauth request token to
179        // the authorisation request, no callback parameter.
180        //
181        sb.append(oauthProviderParameters.getAuthoriseUrl()).append('?'+OAuth.OAUTH_TOKEN+'=').append(requestToken.getKey());
182        return sb.toString();
183    }
184
185    protected String extractToken() {
186        try (BufferedReader r = connection.getResponse().getContentReader()) {
187            String c;
188            Pattern p = Pattern.compile(".*authenticity_token.*value=\"([^\"]+)\".*");
189            while ((c = r.readLine()) != null) {
190                Matcher m = p.matcher(c);
191                if (m.find()) {
192                    return m.group(1);
193                }
194            }
195        } catch (IOException e) {
196            Main.error(e);
197            return null;
198        }
199        Main.warn("No authenticity_token found in response!");
200        return null;
201    }
202
203    protected SessionId extractOsmSession() throws IOException, URISyntaxException {
204        // response headers might not contain the cookie, see #12584
205        final List<String> setCookies = CookieHandler.getDefault()
206                .get(connection.getURL().toURI(), Collections.<String, List<String>>emptyMap())
207                .get("Cookie");
208        if (setCookies == null) {
209            Main.warn("No 'Set-Cookie' in response header!");
210            return null;
211        }
212
213        for (String setCookie: setCookies) {
214            String[] kvPairs = setCookie.split(";");
215            if (kvPairs.length == 0) {
216                continue;
217            }
218            for (String kvPair : kvPairs) {
219                kvPair = kvPair.trim();
220                String[] kv = kvPair.split("=");
221                if (kv.length != 2) {
222                    continue;
223                }
224                if ("_osm_session".equals(kv[0])) {
225                    // osm session cookie found
226                    String token = extractToken();
227                    if (token == null)
228                        return null;
229                    SessionId si = new SessionId();
230                    si.id = kv[1];
231                    si.token = token;
232                    return si;
233                }
234            }
235        }
236        Main.warn("No suitable 'Set-Cookie' in response header found! {0}", setCookies);
237        return null;
238    }
239
240    protected static String buildPostRequest(Map<String, String> parameters) {
241        StringBuilder sb = new StringBuilder(32);
242
243        for (Iterator<Entry<String, String>> it = parameters.entrySet().iterator(); it.hasNext();) {
244            Entry<String, String> entry = it.next();
245            String value = entry.getValue();
246            value = (value == null) ? "" : value;
247            sb.append(entry.getKey()).append('=').append(Utils.encodeUrl(value));
248            if (it.hasNext()) {
249                sb.append('&');
250            }
251        }
252        return sb.toString();
253    }
254
255    /**
256     * Submits a request to the OSM website for a login form. The OSM website replies a session ID in
257     * a cookie.
258     *
259     * @return the session ID structure
260     * @throws OsmOAuthAuthorizationException if something went wrong
261     */
262    protected SessionId fetchOsmWebsiteSessionId() throws OsmOAuthAuthorizationException {
263        try {
264            final URL url = new URL(oauthProviderParameters.getOsmLoginUrl() + "?cookie_test=true");
265            synchronized (this) {
266                connection = HttpClient.create(url).useCache(false);
267                connection.connect();
268            }
269            SessionId sessionId = extractOsmSession();
270            if (sessionId == null)
271                throw new OsmOAuthAuthorizationException(
272                        tr("OSM website did not return a session cookie in response to ''{0}'',", url.toString()));
273            return sessionId;
274        } catch (IOException | URISyntaxException e) {
275            throw new OsmOAuthAuthorizationException(e);
276        } finally {
277            synchronized (this) {
278                connection = null;
279            }
280        }
281    }
282
283    /**
284     * Submits a request to the OSM website for a OAuth form. The OSM website replies a session token in
285     * a hidden parameter.
286     * @param sessionId session id
287     * @param requestToken request token
288     *
289     * @throws OsmOAuthAuthorizationException if something went wrong
290     */
291    protected void fetchOAuthToken(SessionId sessionId, OAuthToken requestToken) throws OsmOAuthAuthorizationException {
292        try {
293            URL url = new URL(getAuthoriseUrl(requestToken));
294            synchronized (this) {
295                connection = HttpClient.create(url)
296                        .useCache(false)
297                        .setHeader("Cookie", "_osm_session=" + sessionId.id + "; _osm_username=" + sessionId.userName);
298                connection.connect();
299            }
300            sessionId.token = extractToken();
301            if (sessionId.token == null)
302                throw new OsmOAuthAuthorizationException(tr("OSM website did not return a session cookie in response to ''{0}'',",
303                        url.toString()));
304        } catch (IOException e) {
305            throw new OsmOAuthAuthorizationException(e);
306        } finally {
307            synchronized (this) {
308                connection = null;
309            }
310        }
311    }
312
313    protected void authenticateOsmSession(SessionId sessionId, String userName, String password) throws OsmLoginFailedException {
314        try {
315            final URL url = new URL(oauthProviderParameters.getOsmLoginUrl());
316            final HttpClient client = HttpClient.create(url, "POST").useCache(false);
317
318            Map<String, String> parameters = new HashMap<>();
319            parameters.put("username", userName);
320            parameters.put("password", password);
321            parameters.put("referer", "/");
322            parameters.put("commit", "Login");
323            parameters.put("authenticity_token", sessionId.token);
324            client.setRequestBody(buildPostRequest(parameters).getBytes(StandardCharsets.UTF_8));
325
326            client.setHeader("Content-Type", "application/x-www-form-urlencoded");
327            client.setHeader("Cookie", "_osm_session=" + sessionId.id);
328            // make sure we can catch 302 Moved Temporarily below
329            client.setMaxRedirects(-1);
330
331            synchronized (this) {
332                connection = client;
333                connection.connect();
334            }
335
336            // after a successful login the OSM website sends a redirect to a follow up page. Everything
337            // else, including a 200 OK, is a failed login. A 200 OK is replied if the login form with
338            // an error page is sent to back to the user.
339            //
340            int retCode = connection.getResponse().getResponseCode();
341            if (retCode != HttpURLConnection.HTTP_MOVED_TEMP)
342                throw new OsmOAuthAuthorizationException(tr("Failed to authenticate user ''{0}'' with password ''***'' as OAuth user",
343                        userName));
344        } catch (OsmOAuthAuthorizationException e) {
345            Main.debug(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}