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