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}