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