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 == null || kvPairs.length == 0) {
216                continue;
217            }
218            for (String kvPair : kvPairs) {
219                kvPair = kvPair.trim();
220                String[] kv = kvPair.split("=");
221                if (kv == null || 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            throw new OsmLoginFailedException(e.getCause());
346        } catch (IOException e) {
347            throw new OsmLoginFailedException(e);
348        } finally {
349            synchronized (this) {
350                connection = null;
351            }
352        }
353    }
354
355    protected void logoutOsmSession(SessionId sessionId) throws OsmOAuthAuthorizationException {
356        try {
357            URL url = new URL(oauthProviderParameters.getOsmLogoutUrl());
358            synchronized (this) {
359                connection = HttpClient.create(url).setMaxRedirects(-1);
360                connection.connect();
361            }
362        } catch (IOException e) {
363            throw new OsmOAuthAuthorizationException(e);
364        }  finally {
365            synchronized (this) {
366                connection = null;
367            }
368        }
369    }
370
371    protected void sendAuthorisationRequest(SessionId sessionId, OAuthToken requestToken, OsmPrivileges privileges)
372            throws OsmOAuthAuthorizationException {
373        Map<String, String> parameters = new HashMap<>();
374        fetchOAuthToken(sessionId, requestToken);
375        parameters.put("oauth_token", requestToken.getKey());
376        parameters.put("oauth_callback", "");
377        parameters.put("authenticity_token", sessionId.token);
378        if (privileges.isAllowWriteApi()) {
379            parameters.put("allow_write_api", "yes");
380        }
381        if (privileges.isAllowWriteGpx()) {
382            parameters.put("allow_write_gpx", "yes");
383        }
384        if (privileges.isAllowReadGpx()) {
385            parameters.put("allow_read_gpx", "yes");
386        }
387        if (privileges.isAllowWritePrefs()) {
388            parameters.put("allow_write_prefs", "yes");
389        }
390        if (privileges.isAllowReadPrefs()) {
391            parameters.put("allow_read_prefs", "yes");
392        }
393        if (privileges.isAllowModifyNotes()) {
394            parameters.put("allow_write_notes", "yes");
395        }
396
397        parameters.put("commit", "Save changes");
398
399        String request = buildPostRequest(parameters);
400        try {
401            URL url = new URL(oauthProviderParameters.getAuthoriseUrl());
402            final HttpClient client = HttpClient.create(url, "POST").useCache(false);
403            client.setHeader("Content-Type", "application/x-www-form-urlencoded");
404            client.setHeader("Cookie", "_osm_session=" + sessionId.id + "; _osm_username=" + sessionId.userName);
405            client.setMaxRedirects(-1);
406            client.setRequestBody(request.getBytes(StandardCharsets.UTF_8));
407
408            synchronized (this) {
409                connection = client;
410                connection.connect();
411            }
412
413            int retCode = connection.getResponse().getResponseCode();
414            if (retCode != HttpURLConnection.HTTP_OK)
415                throw new OsmOAuthAuthorizationException(tr("Failed to authorize OAuth request  ''{0}''", requestToken.getKey()));
416        } catch (IOException e) {
417            throw new OsmOAuthAuthorizationException(e);
418        } finally {
419            synchronized (this) {
420                connection = null;
421            }
422        }
423    }
424
425    /**
426     * Automatically authorises a request token for a set of privileges.
427     *
428     * @param requestToken the request token. Must not be null.
429     * @param userName the OSM user name. Must not be null.
430     * @param password the OSM password. Must not be null.
431     * @param privileges the set of privileges. Must not be null.
432     * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
433     * @throws IllegalArgumentException if requestToken is null
434     * @throws IllegalArgumentException if osmUserName is null
435     * @throws IllegalArgumentException if osmPassword is null
436     * @throws IllegalArgumentException if privileges is null
437     * @throws OsmOAuthAuthorizationException if the authorisation fails
438     * @throws OsmTransferCanceledException if the task is canceled by the user
439     */
440    public void authorise(OAuthToken requestToken, String userName, String password, OsmPrivileges privileges, ProgressMonitor monitor)
441            throws OsmOAuthAuthorizationException, OsmTransferCanceledException {
442        CheckParameterUtil.ensureParameterNotNull(requestToken, "requestToken");
443        CheckParameterUtil.ensureParameterNotNull(userName, "userName");
444        CheckParameterUtil.ensureParameterNotNull(password, "password");
445        CheckParameterUtil.ensureParameterNotNull(privileges, "privileges");
446
447        if (monitor == null) {
448            monitor = NullProgressMonitor.INSTANCE;
449        }
450        try {
451            monitor.beginTask(tr("Authorizing OAuth Request token ''{0}'' at the OSM website ...", requestToken.getKey()));
452            monitor.setTicksCount(4);
453            monitor.indeterminateSubTask(tr("Initializing a session at the OSM website..."));
454            SessionId sessionId = fetchOsmWebsiteSessionId();
455            sessionId.userName = userName;
456            if (canceled)
457                throw new OsmTransferCanceledException("Authorization canceled");
458            monitor.worked(1);
459
460            monitor.indeterminateSubTask(tr("Authenticating the session for user ''{0}''...", userName));
461            authenticateOsmSession(sessionId, userName, password);
462            if (canceled)
463                throw new OsmTransferCanceledException("Authorization canceled");
464            monitor.worked(1);
465
466            monitor.indeterminateSubTask(tr("Authorizing request token ''{0}''...", requestToken.getKey()));
467            sendAuthorisationRequest(sessionId, requestToken, privileges);
468            if (canceled)
469                throw new OsmTransferCanceledException("Authorization canceled");
470            monitor.worked(1);
471
472            monitor.indeterminateSubTask(tr("Logging out session ''{0}''...", sessionId));
473            logoutOsmSession(sessionId);
474            if (canceled)
475                throw new OsmTransferCanceledException("Authorization canceled");
476            monitor.worked(1);
477        } catch (OsmOAuthAuthorizationException e) {
478            if (canceled)
479                throw new OsmTransferCanceledException(e);
480            throw e;
481        } finally {
482            monitor.finishTask();
483        }
484    }
485}