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