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