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