| 1 | // License: GPL. For details, see LICENSE file.
|
|---|
| 2 | package org.openstreetmap.josm.io;
|
|---|
| 3 |
|
|---|
| 4 | import static org.openstreetmap.josm.tools.I18n.tr;
|
|---|
| 5 |
|
|---|
| 6 | import java.lang.reflect.InvocationTargetException;
|
|---|
| 7 | import java.net.Authenticator.RequestorType;
|
|---|
| 8 | import java.net.URL;
|
|---|
| 9 | import java.nio.charset.StandardCharsets;
|
|---|
| 10 | import java.util.Base64;
|
|---|
| 11 | import java.util.Objects;
|
|---|
| 12 | import java.util.Optional;
|
|---|
| 13 | import java.util.concurrent.CountDownLatch;
|
|---|
| 14 | import java.util.concurrent.TimeUnit;
|
|---|
| 15 | import java.util.function.Consumer;
|
|---|
| 16 |
|
|---|
| 17 | import javax.swing.JOptionPane;
|
|---|
| 18 |
|
|---|
| 19 | import org.openstreetmap.josm.data.oauth.IOAuthParameters;
|
|---|
| 20 | import org.openstreetmap.josm.data.oauth.IOAuthToken;
|
|---|
| 21 | import org.openstreetmap.josm.data.oauth.OAuth20Authorization;
|
|---|
| 22 | import org.openstreetmap.josm.data.oauth.OAuthAccessTokenHolder;
|
|---|
| 23 | import org.openstreetmap.josm.data.oauth.OAuthParameters;
|
|---|
| 24 | import org.openstreetmap.josm.data.oauth.OAuthVersion;
|
|---|
| 25 | import org.openstreetmap.josm.data.oauth.osm.OsmScopes;
|
|---|
| 26 | import org.openstreetmap.josm.gui.ConditionalOptionPaneUtil;
|
|---|
| 27 | import org.openstreetmap.josm.gui.MainApplication;
|
|---|
| 28 | import org.openstreetmap.josm.gui.util.GuiHelper;
|
|---|
| 29 | import org.openstreetmap.josm.io.auth.CredentialsAgentException;
|
|---|
| 30 | import org.openstreetmap.josm.io.auth.CredentialsAgentResponse;
|
|---|
| 31 | import org.openstreetmap.josm.io.auth.CredentialsManager;
|
|---|
| 32 | import org.openstreetmap.josm.io.remotecontrol.RemoteControl;
|
|---|
| 33 | import org.openstreetmap.josm.tools.HttpClient;
|
|---|
| 34 | import org.openstreetmap.josm.tools.JosmRuntimeException;
|
|---|
| 35 | import org.openstreetmap.josm.tools.Logging;
|
|---|
| 36 |
|
|---|
| 37 | /**
|
|---|
| 38 | * Base class that handles common things like authentication for the reader and writer
|
|---|
| 39 | * to the osm server.
|
|---|
| 40 | *
|
|---|
| 41 | * @author imi
|
|---|
| 42 | */
|
|---|
| 43 | public class OsmConnection {
|
|---|
| 44 |
|
|---|
| 45 | private static final String BASIC_AUTH = "Basic ";
|
|---|
| 46 |
|
|---|
| 47 | protected boolean cancel;
|
|---|
| 48 | protected HttpClient activeConnection;
|
|---|
| 49 | protected IOAuthParameters oAuth20Parameters;
|
|---|
| 50 |
|
|---|
| 51 | /**
|
|---|
| 52 | * Retrieves OAuth access token.
|
|---|
| 53 | * @since 12803
|
|---|
| 54 | */
|
|---|
| 55 | public interface OAuthAccessTokenFetcher {
|
|---|
| 56 | /**
|
|---|
| 57 | * Obtains an OAuth access token for the connection. Afterwards, the token is accessible via {@link OAuthAccessTokenHolder}.
|
|---|
| 58 | * @param serverUrl the URL to OSM server
|
|---|
| 59 | * @throws InterruptedException if we're interrupted while waiting for the event dispatching thread to finish OAuth authorization task
|
|---|
| 60 | * @throws InvocationTargetException if an exception is thrown while running OAuth authorization task
|
|---|
| 61 | */
|
|---|
| 62 | void obtainAccessToken(URL serverUrl) throws InvocationTargetException, InterruptedException;
|
|---|
| 63 | }
|
|---|
| 64 |
|
|---|
| 65 | static volatile OAuthAccessTokenFetcher fetcher = u -> {
|
|---|
| 66 | throw new JosmRuntimeException("OsmConnection.setOAuthAccessTokenFetcher() has not been called");
|
|---|
| 67 | };
|
|---|
| 68 |
|
|---|
| 69 | /**
|
|---|
| 70 | * Sets the OAuth access token fetcher.
|
|---|
| 71 | * @param tokenFetcher new OAuth access token fetcher. Cannot be null
|
|---|
| 72 | * @since 12803
|
|---|
| 73 | */
|
|---|
| 74 | public static void setOAuthAccessTokenFetcher(OAuthAccessTokenFetcher tokenFetcher) {
|
|---|
| 75 | fetcher = Objects.requireNonNull(tokenFetcher, "tokenFetcher");
|
|---|
| 76 | }
|
|---|
| 77 |
|
|---|
| 78 | /**
|
|---|
| 79 | * Cancels the connection.
|
|---|
| 80 | */
|
|---|
| 81 | public void cancel() {
|
|---|
| 82 | cancel = true;
|
|---|
| 83 | synchronized (this) {
|
|---|
| 84 | if (activeConnection != null) {
|
|---|
| 85 | activeConnection.disconnect();
|
|---|
| 86 | }
|
|---|
| 87 | }
|
|---|
| 88 | }
|
|---|
| 89 |
|
|---|
| 90 | /**
|
|---|
| 91 | * Retrieves login from basic authentication header, if set.
|
|---|
| 92 | *
|
|---|
| 93 | * @param con the connection
|
|---|
| 94 | * @return login from basic authentication header, or {@code null}
|
|---|
| 95 | * @throws OsmTransferException if something went wrong. Check for nested exceptions
|
|---|
| 96 | * @since 12992
|
|---|
| 97 | */
|
|---|
| 98 | protected String retrieveBasicAuthorizationLogin(HttpClient con) throws OsmTransferException {
|
|---|
| 99 | String auth = con.getRequestHeader("Authorization");
|
|---|
| 100 | if (auth != null && auth.startsWith(BASIC_AUTH)) {
|
|---|
| 101 | try {
|
|---|
| 102 | String[] token = new String(Base64.getDecoder().decode(auth.substring(BASIC_AUTH.length())),
|
|---|
| 103 | StandardCharsets.UTF_8).split(":", -1);
|
|---|
| 104 | if (token.length == 2) {
|
|---|
| 105 | return token[0];
|
|---|
| 106 | }
|
|---|
| 107 | } catch (IllegalArgumentException e) {
|
|---|
| 108 | Logging.error(e);
|
|---|
| 109 | }
|
|---|
| 110 | }
|
|---|
| 111 | return null;
|
|---|
| 112 | }
|
|---|
| 113 |
|
|---|
| 114 | /**
|
|---|
| 115 | * Adds an authentication header for basic authentication
|
|---|
| 116 | *
|
|---|
| 117 | * @param con the connection
|
|---|
| 118 | * @throws OsmTransferException if something went wrong. Check for nested exceptions
|
|---|
| 119 | */
|
|---|
| 120 | protected void addBasicAuthorizationHeader(HttpClient con) throws OsmTransferException {
|
|---|
| 121 | CredentialsAgentResponse response;
|
|---|
| 122 | try {
|
|---|
| 123 | synchronized (CredentialsManager.getInstance()) {
|
|---|
| 124 | response = CredentialsManager.getInstance().getCredentials(RequestorType.SERVER,
|
|---|
| 125 | con.getURL().getHost(), false /* don't know yet whether the credentials will succeed */);
|
|---|
| 126 | }
|
|---|
| 127 | } catch (CredentialsAgentException e) {
|
|---|
| 128 | throw new OsmTransferException(e);
|
|---|
| 129 | }
|
|---|
| 130 | if (response != null) {
|
|---|
| 131 | if (response.isCanceled()) {
|
|---|
| 132 | cancel = true;
|
|---|
| 133 | } else {
|
|---|
| 134 | String username = response.getUsername() == null ? "" : response.getUsername();
|
|---|
| 135 | String password = response.getPassword() == null ? "" : String.valueOf(response.getPassword());
|
|---|
| 136 | String token = username + ':' + password;
|
|---|
| 137 | con.setHeader("Authorization", BASIC_AUTH + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)));
|
|---|
| 138 | }
|
|---|
| 139 | }
|
|---|
| 140 | }
|
|---|
| 141 |
|
|---|
| 142 | /**
|
|---|
| 143 | * Obtains an OAuth access token for the connection.
|
|---|
| 144 | * Afterwards, the token is accessible via {@link OAuthAccessTokenHolder} / {@link CredentialsManager}.
|
|---|
| 145 | * @throws MissingOAuthAccessTokenException if the process cannot be completed successfully
|
|---|
| 146 | */
|
|---|
| 147 | private void obtainOAuth20Token() throws MissingOAuthAccessTokenException {
|
|---|
| 148 | if (!Boolean.TRUE.equals(GuiHelper.runInEDTAndWaitAndReturn(() ->
|
|---|
| 149 | ConditionalOptionPaneUtil.showConfirmationDialog("oauth.oauth20.obtain.automatically",
|
|---|
| 150 | MainApplication.getMainFrame(),
|
|---|
| 151 | tr("Obtain OAuth 2.0 token for authentication?"),
|
|---|
| 152 | tr("Obtain authentication to OSM servers"),
|
|---|
| 153 | JOptionPane.YES_NO_CANCEL_OPTION,
|
|---|
| 154 | JOptionPane.QUESTION_MESSAGE, JOptionPane.YES_OPTION)))) {
|
|---|
| 155 | return; // User doesn't want to perform auth
|
|---|
| 156 | }
|
|---|
| 157 | final boolean remoteControlIsRunning = Boolean.TRUE.equals(RemoteControl.PROP_REMOTECONTROL_ENABLED.get());
|
|---|
| 158 | if (!remoteControlIsRunning) {
|
|---|
| 159 | RemoteControl.start();
|
|---|
| 160 | }
|
|---|
| 161 | CountDownLatch done = new CountDownLatch(1);
|
|---|
| 162 | Consumer<Optional<IOAuthToken>> consumer = authToken -> {
|
|---|
| 163 | if (!remoteControlIsRunning) {
|
|---|
| 164 | RemoteControl.stop();
|
|---|
| 165 | }
|
|---|
| 166 | // Clean up old token/password
|
|---|
| 167 | OAuthAccessTokenHolder.getInstance().setAccessToken(OsmApi.getOsmApi().getServerUrl(), authToken.orElse(null));
|
|---|
| 168 | OAuthAccessTokenHolder.getInstance().save(CredentialsManager.getInstance());
|
|---|
| 169 | done.countDown();
|
|---|
| 170 | };
|
|---|
| 171 | new OAuth20Authorization().authorize(oAuth20Parameters,
|
|---|
| 172 | consumer, OsmScopes.read_gpx, OsmScopes.write_gpx,
|
|---|
| 173 | OsmScopes.read_prefs, OsmScopes.write_prefs,
|
|---|
| 174 | OsmScopes.write_api, OsmScopes.write_notes);
|
|---|
| 175 | // Only wait at most 5 minutes
|
|---|
| 176 | int counter = 0;
|
|---|
| 177 | while (done.getCount() >= 0 && counter < 5) {
|
|---|
| 178 | try {
|
|---|
| 179 | if (done.await(1, TimeUnit.MINUTES)) {
|
|---|
| 180 | break;
|
|---|
| 181 | }
|
|---|
| 182 | } catch (InterruptedException e) {
|
|---|
| 183 | Thread.currentThread().interrupt();
|
|---|
| 184 | Logging.trace(e);
|
|---|
| 185 | consumer.accept(null);
|
|---|
| 186 | throw new MissingOAuthAccessTokenException(e);
|
|---|
| 187 | }
|
|---|
| 188 | counter++;
|
|---|
| 189 | }
|
|---|
| 190 | }
|
|---|
| 191 |
|
|---|
| 192 | /**
|
|---|
| 193 | * Signs the connection with an OAuth authentication header
|
|---|
| 194 | *
|
|---|
| 195 | * @param connection the connection
|
|---|
| 196 | *
|
|---|
| 197 | * @throws MissingOAuthAccessTokenException if there is currently no OAuth Access Token configured
|
|---|
| 198 | * @throws OsmTransferException if signing fails
|
|---|
| 199 | */
|
|---|
| 200 | protected void addOAuth20AuthorizationHeader(HttpClient connection) throws OsmTransferException {
|
|---|
| 201 | if (this.oAuth20Parameters == null) {
|
|---|
| 202 | this.oAuth20Parameters = OAuthParameters.createFromApiUrl(connection.getURL().getHost(), OAuthVersion.OAuth20);
|
|---|
| 203 | }
|
|---|
| 204 | OAuthAccessTokenHolder holder = OAuthAccessTokenHolder.getInstance();
|
|---|
| 205 | IOAuthToken token = holder.getAccessToken(connection.getURL().toExternalForm(), OAuthVersion.OAuth20);
|
|---|
| 206 | if (token == null) {
|
|---|
| 207 | obtainOAuth20Token();
|
|---|
| 208 | token = holder.getAccessToken(connection.getURL().toExternalForm(), OAuthVersion.OAuth20);
|
|---|
| 209 | }
|
|---|
| 210 | if (token == null) { // check if wizard completed
|
|---|
| 211 | throw new MissingOAuthAccessTokenException();
|
|---|
| 212 | }
|
|---|
| 213 | try {
|
|---|
| 214 | token.sign(connection);
|
|---|
| 215 | } catch (org.openstreetmap.josm.data.oauth.OAuthException e) {
|
|---|
| 216 | throw new OsmTransferException(tr("Failed to sign a HTTP connection with an OAuth Authentication header"), e);
|
|---|
| 217 | }
|
|---|
| 218 | }
|
|---|
| 219 |
|
|---|
| 220 | protected void addAuth(HttpClient connection) throws OsmTransferException {
|
|---|
| 221 | final String authMethod = OsmApi.getAuthMethod();
|
|---|
| 222 | switch (authMethod) {
|
|---|
| 223 | case "basic":
|
|---|
| 224 | addBasicAuthorizationHeader(connection);
|
|---|
| 225 | return;
|
|---|
| 226 | case "oauth20":
|
|---|
| 227 | addOAuth20AuthorizationHeader(connection);
|
|---|
| 228 | return;
|
|---|
| 229 | default:
|
|---|
| 230 | String msg = tr("Unexpected value for preference ''{0}''. Got ''{1}''.", "osm-server.auth-method", authMethod);
|
|---|
| 231 | Logging.warn(msg);
|
|---|
| 232 | throw new OsmTransferException(msg);
|
|---|
| 233 | }
|
|---|
| 234 | }
|
|---|
| 235 |
|
|---|
| 236 | /**
|
|---|
| 237 | * Replies true if this connection is canceled
|
|---|
| 238 | *
|
|---|
| 239 | * @return true if this connection is canceled
|
|---|
| 240 | */
|
|---|
| 241 | public boolean isCanceled() {
|
|---|
| 242 | return cancel;
|
|---|
| 243 | }
|
|---|
| 244 | }
|
|---|