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