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 // TODO 094 Field f = provider.getClass().getDeclaredField("connection"); 095 f.setAccessible(true); 096 HttpURLConnection con = (HttpURLConnection) f.get(provider); 097 if (con != null) { 098 con.disconnect(); 099 } 100 } catch (NoSuchFieldException | SecurityException | IllegalAccessException e) { 101 Main.error(e); 102 Main.warn(tr("Failed to cancel running OAuth operation")); 103 } 104 } 105 synchronized (this) { 106 if (connection != null) { 107 connection.disconnect(); 108 } 109 } 110 } 111 112 /** 113 * Submits a request for a Request Token to the Request Token Endpoint Url of the OAuth Service 114 * Provider and replies the request token. 115 * 116 * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null 117 * @return the OAuth Request Token 118 * @throws OsmOAuthAuthorizationException if something goes wrong when retrieving the request token 119 * @throws OsmTransferCanceledException if the user canceled the request 120 */ 121 public OAuthToken getRequestToken(ProgressMonitor monitor) throws OsmOAuthAuthorizationException, OsmTransferCanceledException { 122 if (monitor == null) { 123 monitor = NullProgressMonitor.INSTANCE; 124 } 125 try { 126 monitor.beginTask(""); 127 monitor.indeterminateSubTask(tr("Retrieving OAuth Request Token from ''{0}''", oauthProviderParameters.getRequestTokenUrl())); 128 provider.retrieveRequestToken(consumer, ""); 129 return OAuthToken.createToken(consumer); 130 } catch (OAuthException e) { 131 if (canceled) 132 throw new OsmTransferCanceledException(e); 133 throw new OsmOAuthAuthorizationException(e); 134 } finally { 135 monitor.finishTask(); 136 } 137 } 138 139 /** 140 * Submits a request for an Access Token to the Access Token Endpoint Url of the OAuth Service 141 * Provider and replies the request token. 142 * 143 * You must have requested a Request Token using {@link #getRequestToken(ProgressMonitor)} first. 144 * 145 * @param monitor a progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null 146 * @return the OAuth Access Token 147 * @throws OsmOAuthAuthorizationException if something goes wrong when retrieving the request token 148 * @throws OsmTransferCanceledException if the user canceled the request 149 * @see #getRequestToken(ProgressMonitor) 150 */ 151 public OAuthToken getAccessToken(ProgressMonitor monitor) throws OsmOAuthAuthorizationException, OsmTransferCanceledException { 152 if (monitor == null) { 153 monitor = NullProgressMonitor.INSTANCE; 154 } 155 try { 156 monitor.beginTask(""); 157 monitor.indeterminateSubTask(tr("Retrieving OAuth Access Token from ''{0}''", oauthProviderParameters.getAccessTokenUrl())); 158 provider.retrieveAccessToken(consumer, null); 159 return OAuthToken.createToken(consumer); 160 } catch (OAuthException e) { 161 if (canceled) 162 throw new OsmTransferCanceledException(e); 163 throw new OsmOAuthAuthorizationException(e); 164 } finally { 165 monitor.finishTask(); 166 } 167 } 168 169 /** 170 * Builds the authorise URL for a given Request Token. Users can be redirected to this URL. 171 * There they can login to OSM and authorise the request. 172 * 173 * @param requestToken the request token 174 * @return the authorise URL for this request 175 */ 176 public String getAuthoriseUrl(OAuthToken requestToken) { 177 StringBuilder sb = new StringBuilder(32); 178 179 // OSM is an OAuth 1.0 provider and JOSM isn't a web app. We just add the oauth request token to 180 // the authorisation request, no callback parameter. 181 // 182 sb.append(oauthProviderParameters.getAuthoriseUrl()).append('?'+OAuth.OAUTH_TOKEN+'=').append(requestToken.getKey()); 183 return sb.toString(); 184 } 185 186 protected String extractToken() { 187 try (BufferedReader r = connection.getResponse().getContentReader()) { 188 String c; 189 Pattern p = Pattern.compile(".*authenticity_token.*value=\"([^\"]+)\".*"); 190 while ((c = r.readLine()) != null) { 191 Matcher m = p.matcher(c); 192 if (m.find()) { 193 return m.group(1); 194 } 195 } 196 } catch (IOException e) { 197 Main.error(e); 198 return null; 199 } 200 Main.warn("No authenticity_token found in response!"); 201 return null; 202 } 203 204 protected SessionId extractOsmSession() throws IOException, URISyntaxException { 205 // response headers might not contain the cookie, see #12584 206 final List<String> setCookies = CookieHandler.getDefault() 207 .get(connection.getURL().toURI(), Collections.<String, List<String>>emptyMap()) 208 .get("Cookie"); 209 if (setCookies == null) { 210 Main.warn("No 'Set-Cookie' in response header!"); 211 return null; 212 } 213 214 for (String setCookie: setCookies) { 215 String[] kvPairs = setCookie.split(";"); 216 if (kvPairs == null || kvPairs.length == 0) { 217 continue; 218 } 219 for (String kvPair : kvPairs) { 220 kvPair = kvPair.trim(); 221 String[] kv = kvPair.split("="); 222 if (kv == null || kv.length != 2) { 223 continue; 224 } 225 if ("_osm_session".equals(kv[0])) { 226 // osm session cookie found 227 String token = extractToken(); 228 if (token == null) 229 return null; 230 SessionId si = new SessionId(); 231 si.id = kv[1]; 232 si.token = token; 233 return si; 234 } 235 } 236 } 237 Main.warn("No suitable 'Set-Cookie' in response header found! {0}", setCookies); 238 return null; 239 } 240 241 protected static String buildPostRequest(Map<String, String> parameters) { 242 StringBuilder sb = new StringBuilder(32); 243 244 for (Iterator<Entry<String, String>> it = parameters.entrySet().iterator(); it.hasNext();) { 245 Entry<String, String> entry = it.next(); 246 String value = entry.getValue(); 247 value = (value == null) ? "" : value; 248 sb.append(entry.getKey()).append('=').append(Utils.encodeUrl(value)); 249 if (it.hasNext()) { 250 sb.append('&'); 251 } 252 } 253 return sb.toString(); 254 } 255 256 /** 257 * Submits a request to the OSM website for a login form. The OSM website replies a session ID in 258 * a cookie. 259 * 260 * @return the session ID structure 261 * @throws OsmOAuthAuthorizationException if something went wrong 262 */ 263 protected SessionId fetchOsmWebsiteSessionId() throws OsmOAuthAuthorizationException { 264 try { 265 final URL url = new URL(oauthProviderParameters.getOsmLoginUrl() + "?cookie_test=true"); 266 synchronized (this) { 267 connection = HttpClient.create(url).useCache(false); 268 connection.connect(); 269 } 270 SessionId sessionId = extractOsmSession(); 271 if (sessionId == null) 272 throw new OsmOAuthAuthorizationException( 273 tr("OSM website did not return a session cookie in response to ''{0}'',", url.toString())); 274 return sessionId; 275 } catch (IOException | URISyntaxException e) { 276 throw new OsmOAuthAuthorizationException(e); 277 } finally { 278 synchronized (this) { 279 connection = null; 280 } 281 } 282 } 283 284 /** 285 * Submits a request to the OSM website for a OAuth form. The OSM website replies a session token in 286 * a hidden parameter. 287 * @param sessionId session id 288 * @param requestToken request token 289 * 290 * @throws OsmOAuthAuthorizationException if something went wrong 291 */ 292 protected void fetchOAuthToken(SessionId sessionId, OAuthToken requestToken) throws OsmOAuthAuthorizationException { 293 try { 294 URL url = new URL(getAuthoriseUrl(requestToken)); 295 synchronized (this) { 296 connection = HttpClient.create(url) 297 .useCache(false) 298 .setHeader("Cookie", "_osm_session=" + sessionId.id + "; _osm_username=" + sessionId.userName); 299 connection.connect(); 300 } 301 sessionId.token = extractToken(); 302 if (sessionId.token == null) 303 throw new OsmOAuthAuthorizationException(tr("OSM website did not return a session cookie in response to ''{0}'',", 304 url.toString())); 305 } catch (IOException e) { 306 throw new OsmOAuthAuthorizationException(e); 307 } finally { 308 synchronized (this) { 309 connection = null; 310 } 311 } 312 } 313 314 protected void authenticateOsmSession(SessionId sessionId, String userName, String password) throws OsmLoginFailedException { 315 try { 316 final URL url = new URL(oauthProviderParameters.getOsmLoginUrl()); 317 final HttpClient client = HttpClient.create(url, "POST").useCache(false); 318 319 Map<String, String> parameters = new HashMap<>(); 320 parameters.put("username", userName); 321 parameters.put("password", password); 322 parameters.put("referer", "/"); 323 parameters.put("commit", "Login"); 324 parameters.put("authenticity_token", sessionId.token); 325 client.setRequestBody(buildPostRequest(parameters).getBytes(StandardCharsets.UTF_8)); 326 327 client.setHeader("Content-Type", "application/x-www-form-urlencoded"); 328 client.setHeader("Cookie", "_osm_session=" + sessionId.id); 329 // make sure we can catch 302 Moved Temporarily below 330 client.setMaxRedirects(-1); 331 332 synchronized (this) { 333 connection = client; 334 connection.connect(); 335 } 336 337 // after a successful login the OSM website sends a redirect to a follow up page. Everything 338 // else, including a 200 OK, is a failed login. A 200 OK is replied if the login form with 339 // an error page is sent to back to the user. 340 // 341 int retCode = connection.getResponse().getResponseCode(); 342 if (retCode != HttpURLConnection.HTTP_MOVED_TEMP) 343 throw new OsmOAuthAuthorizationException(tr("Failed to authenticate user ''{0}'' with password ''***'' as OAuth user", 344 userName)); 345 } catch (OsmOAuthAuthorizationException 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}