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 | }
|
---|