001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.lang.reflect.InvocationTargetException;
007import java.net.Authenticator.RequestorType;
008import java.net.MalformedURLException;
009import java.net.URL;
010import java.nio.charset.StandardCharsets;
011import java.util.Base64;
012import java.util.Objects;
013
014import org.openstreetmap.josm.data.oauth.OAuthAccessTokenHolder;
015import org.openstreetmap.josm.data.oauth.OAuthParameters;
016import org.openstreetmap.josm.io.auth.CredentialsAgentException;
017import org.openstreetmap.josm.io.auth.CredentialsAgentResponse;
018import org.openstreetmap.josm.io.auth.CredentialsManager;
019import org.openstreetmap.josm.tools.HttpClient;
020import org.openstreetmap.josm.tools.JosmRuntimeException;
021import org.openstreetmap.josm.tools.Logging;
022
023import oauth.signpost.OAuthConsumer;
024import oauth.signpost.exception.OAuthException;
025
026/**
027 * Base class that handles common things like authentication for the reader and writer
028 * to the osm server.
029 *
030 * @author imi
031 */
032public class OsmConnection {
033
034    private static final String BASIC_AUTH = "Basic ";
035
036    protected boolean cancel;
037    protected HttpClient activeConnection;
038    protected OAuthParameters oauthParameters;
039
040    /**
041     * Retrieves OAuth access token.
042     * @since 12803
043     */
044    public interface OAuthAccessTokenFetcher {
045        /**
046         * Obtains an OAuth access token for the connection. Afterwards, the token is accessible via {@link OAuthAccessTokenHolder}.
047         * @param serverUrl the URL to OSM server
048         * @throws InterruptedException if we're interrupted while waiting for the event dispatching thread to finish OAuth authorization task
049         * @throws InvocationTargetException if an exception is thrown while running OAuth authorization task
050         */
051        void obtainAccessToken(URL serverUrl) throws InvocationTargetException, InterruptedException;
052    }
053
054    static volatile OAuthAccessTokenFetcher fetcher = u -> {
055        throw new JosmRuntimeException("OsmConnection.setOAuthAccessTokenFetcher() has not been called");
056    };
057
058    /**
059     * Sets the OAuth access token fetcher.
060     * @param tokenFetcher new OAuth access token fetcher. Cannot be null
061     * @since 12803
062     */
063    public static void setOAuthAccessTokenFetcher(OAuthAccessTokenFetcher tokenFetcher) {
064        fetcher = Objects.requireNonNull(tokenFetcher, "tokenFetcher");
065    }
066
067    /**
068     * Cancels the connection.
069     */
070    public void cancel() {
071        cancel = true;
072        synchronized (this) {
073            if (activeConnection != null) {
074                activeConnection.disconnect();
075            }
076        }
077    }
078
079    /**
080     * Retrieves login from basic authentication header, if set.
081     *
082     * @param con the connection
083     * @return login from basic authentication header, or {@code null}
084     * @throws OsmTransferException if something went wrong. Check for nested exceptions
085     * @since 12992
086     */
087    protected String retrieveBasicAuthorizationLogin(HttpClient con) throws OsmTransferException {
088        String auth = con.getRequestHeader("Authorization");
089        if (auth != null && auth.startsWith(BASIC_AUTH)) {
090            try {
091                String[] token = new String(Base64.getDecoder().decode(auth.substring(BASIC_AUTH.length())),
092                        StandardCharsets.UTF_8).split(":");
093                if (token.length == 2) {
094                    return token[0];
095                }
096            } catch (IllegalArgumentException e) {
097                Logging.error(e);
098            }
099        }
100        return null;
101    }
102
103    /**
104     * Adds an authentication header for basic authentication
105     *
106     * @param con the connection
107     * @throws OsmTransferException if something went wrong. Check for nested exceptions
108     */
109    protected void addBasicAuthorizationHeader(HttpClient con) throws OsmTransferException {
110        CredentialsAgentResponse response;
111        try {
112            synchronized (CredentialsManager.getInstance()) {
113                response = CredentialsManager.getInstance().getCredentials(RequestorType.SERVER,
114                con.getURL().getHost(), false /* don't know yet whether the credentials will succeed */);
115            }
116        } catch (CredentialsAgentException e) {
117            throw new OsmTransferException(e);
118        }
119        if (response != null) {
120            if (response.isCanceled()) {
121                cancel = true;
122            } else {
123                String username = response.getUsername() == null ? "" : response.getUsername();
124                String password = response.getPassword() == null ? "" : String.valueOf(response.getPassword());
125                String token = username + ':' + password;
126                con.setHeader("Authorization", BASIC_AUTH + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)));
127            }
128        }
129    }
130
131    /**
132     * Signs the connection with an OAuth authentication header
133     *
134     * @param connection the connection
135     *
136     * @throws MissingOAuthAccessTokenException if there is currently no OAuth Access Token configured
137     * @throws OsmTransferException if signing fails
138     */
139    protected void addOAuthAuthorizationHeader(HttpClient connection) throws OsmTransferException {
140        if (oauthParameters == null) {
141            oauthParameters = OAuthParameters.createFromApiUrl(OsmApi.getOsmApi().getServerUrl());
142        }
143        OAuthConsumer consumer = oauthParameters.buildConsumer();
144        OAuthAccessTokenHolder holder = OAuthAccessTokenHolder.getInstance();
145        if (!holder.containsAccessToken()) {
146            obtainAccessToken(connection);
147        }
148        if (!holder.containsAccessToken()) { // check if wizard completed
149            throw new MissingOAuthAccessTokenException();
150        }
151        consumer.setTokenWithSecret(holder.getAccessTokenKey(), holder.getAccessTokenSecret());
152        try {
153            consumer.sign(connection);
154        } catch (OAuthException e) {
155            throw new OsmTransferException(tr("Failed to sign a HTTP connection with an OAuth Authentication header"), e);
156        }
157    }
158
159    /**
160     * Obtains an OAuth access token for the connection.
161     * Afterwards, the token is accessible via {@link OAuthAccessTokenHolder} / {@link CredentialsManager}.
162     * @param connection connection for which the access token should be obtained
163     * @throws MissingOAuthAccessTokenException if the process cannot be completed successfully
164     */
165    protected void obtainAccessToken(final HttpClient connection) throws MissingOAuthAccessTokenException {
166        try {
167            final URL apiUrl = new URL(OsmApi.getOsmApi().getServerUrl());
168            if (!Objects.equals(apiUrl.getHost(), connection.getURL().getHost())) {
169                throw new MissingOAuthAccessTokenException();
170            }
171            fetcher.obtainAccessToken(apiUrl);
172            OAuthAccessTokenHolder.getInstance().setSaveToPreferences(true);
173            OAuthAccessTokenHolder.getInstance().save(CredentialsManager.getInstance());
174        } catch (MalformedURLException | InterruptedException | InvocationTargetException e) {
175            throw new MissingOAuthAccessTokenException(e);
176        }
177    }
178
179    protected void addAuth(HttpClient connection) throws OsmTransferException {
180        final String authMethod = OsmApi.getAuthMethod();
181        if ("basic".equals(authMethod)) {
182            addBasicAuthorizationHeader(connection);
183        } else if ("oauth".equals(authMethod)) {
184            addOAuthAuthorizationHeader(connection);
185        } else {
186            String msg = tr("Unexpected value for preference ''{0}''. Got ''{1}''.", "osm-server.auth-method", authMethod);
187            Logging.warn(msg);
188            throw new OsmTransferException(msg);
189        }
190    }
191
192    /**
193     * Replies true if this connection is canceled
194     *
195     * @return true if this connection is canceled
196     */
197    public boolean isCanceled() {
198        return cancel;
199    }
200}