001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.text.MessageFormat;
007
008import org.openstreetmap.josm.data.oauth.OAuthAccessTokenHolder;
009import org.openstreetmap.josm.data.osm.User;
010import org.openstreetmap.josm.data.osm.UserInfo;
011import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
012import org.openstreetmap.josm.io.NetworkManager;
013import org.openstreetmap.josm.io.OnlineResource;
014import org.openstreetmap.josm.io.OsmApi;
015import org.openstreetmap.josm.io.OsmServerUserInfoReader;
016import org.openstreetmap.josm.io.OsmTransferException;
017import org.openstreetmap.josm.io.auth.CredentialsManager;
018import org.openstreetmap.josm.spi.preferences.Config;
019import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
020import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
021import org.openstreetmap.josm.spi.preferences.StringSetting;
022import org.openstreetmap.josm.tools.CheckParameterUtil;
023import org.openstreetmap.josm.tools.JosmRuntimeException;
024import org.openstreetmap.josm.tools.ListenerList;
025import org.openstreetmap.josm.tools.Logging;
026import org.openstreetmap.josm.tools.Utils;
027
028/**
029 * UserIdentityManager is a global object which keeps track of what JOSM knows about
030 * the identity of the current user.
031 *
032 * JOSM can be operated anonymously provided the current user never invokes an operation
033 * on the OSM server which required authentication. In this case JOSM neither knows
034 * the user name of the OSM account of the current user nor its unique id. Perhaps the
035 * user doesn't have one.
036 *
037 * If the current user supplies a user name and a password in the JOSM preferences JOSM
038 * can partially identify the user.
039 *
040 * The current user is fully identified if JOSM knows both the user name and the unique
041 * id of the users OSM account. The latter is retrieved from the OSM server with a
042 * <code>GET /api/0.6/user/details</code> request, submitted with the user name and password
043 * of the current user.
044 *
045 * The global UserIdentityManager listens to {@link PreferenceChangeEvent}s and keeps track
046 * of what the current JOSM instance knows about the current user. Other subsystems can
047 * let the global UserIdentityManager know in case they fully identify the current user, see
048 * {@link #setFullyIdentified}.
049 *
050 * The information kept by the UserIdentityManager can be used to
051 * <ul>
052 *   <li>safely query changesets owned by the current user based on its user id, not on its user name</li>
053 *   <li>safely search for objects last touched by the current user based on its user id, not on its user name</li>
054 * </ul>
055 * @since 12743 (renamed from {@code org.openstreetmap.josm.gui.JosmUserIdentityManager})
056 * @since 2689 (creation)
057 */
058public final class UserIdentityManager implements PreferenceChangedListener {
059
060    private static UserIdentityManager instance;
061    private final ListenerList<UserIdentityListener> listeners = ListenerList.create();
062
063    /**
064     * Replies the unique instance of the JOSM user identity manager
065     *
066     * @return the unique instance of the JOSM user identity manager
067     */
068    public static synchronized UserIdentityManager getInstance() {
069        if (instance == null) {
070            instance = new UserIdentityManager();
071            if (OsmApi.isUsingOAuth() && OAuthAccessTokenHolder.getInstance().containsAccessToken() &&
072                    !NetworkManager.isOffline(OnlineResource.OSM_API)) {
073                try {
074                    instance.initFromOAuth();
075                } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
076                    Logging.error(e);
077                    // Fall back to preferences if OAuth identification fails for any reason
078                    instance.initFromPreferences();
079                }
080            } else {
081                instance.initFromPreferences();
082            }
083            Config.getPref().addPreferenceChangeListener(instance);
084        }
085        return instance;
086    }
087
088    private String userName;
089    private UserInfo userInfo;
090    private boolean accessTokenKeyChanged;
091    private boolean accessTokenSecretChanged;
092
093    private UserIdentityManager() {
094    }
095
096    /**
097     * Remembers the fact that the current JOSM user is anonymous.
098     */
099    public void setAnonymous() {
100        userName = null;
101        userInfo = null;
102        fireUserIdentityChanged();
103    }
104
105    /**
106     * Remembers the fact that the current JOSM user is partially identified
107     * by the user name of its OSM account.
108     *
109     * @param userName the user name. Must not be null. Must not be empty (whitespace only).
110     * @throws IllegalArgumentException if userName is null
111     * @throws IllegalArgumentException if userName is empty
112     */
113    public void setPartiallyIdentified(String userName) {
114        CheckParameterUtil.ensureParameterNotNull(userName, "userName");
115        String trimmedUserName = userName.trim();
116        if (trimmedUserName.isEmpty())
117            throw new IllegalArgumentException(
118                    MessageFormat.format("Expected non-empty value for parameter ''{0}'', got ''{1}''", "userName", userName));
119        this.userName = trimmedUserName;
120        userInfo = null;
121        fireUserIdentityChanged();
122    }
123
124    /**
125     * Remembers the fact that the current JOSM user is fully identified with a
126     * verified pair of user name and user id.
127     *
128     * @param userName the user name. Must not be null. Must not be empty.
129     * @param userInfo additional information about the user, retrieved from the OSM server and including the user id
130     * @throws IllegalArgumentException if userName is null
131     * @throws IllegalArgumentException if userName is empty
132     * @throws IllegalArgumentException if userInfo is null
133     */
134    public void setFullyIdentified(String userName, UserInfo userInfo) {
135        CheckParameterUtil.ensureParameterNotNull(userName, "userName");
136        String trimmedUserName = userName.trim();
137        if (trimmedUserName.isEmpty())
138            throw new IllegalArgumentException(tr("Expected non-empty value for parameter ''{0}'', got ''{1}''", "userName", userName));
139        CheckParameterUtil.ensureParameterNotNull(userInfo, "userInfo");
140        this.userName = trimmedUserName;
141        this.userInfo = userInfo;
142        fireUserIdentityChanged();
143    }
144
145    /**
146     * Replies true if the current JOSM user is anonymous.
147     *
148     * @return {@code true} if the current user is anonymous.
149     */
150    public boolean isAnonymous() {
151        return userName == null && userInfo == null;
152    }
153
154    /**
155     * Replies true if the current JOSM user is partially identified.
156     *
157     * @return true if the current JOSM user is partially identified.
158     */
159    public boolean isPartiallyIdentified() {
160        return userName != null && userInfo == null;
161    }
162
163    /**
164     * Replies true if the current JOSM user is fully identified.
165     *
166     * @return true if the current JOSM user is fully identified.
167     */
168    public boolean isFullyIdentified() {
169        return userName != null && userInfo != null;
170    }
171
172    /**
173     * Replies the user name of the current JOSM user. null, if {@link #isAnonymous()} is true.
174     *
175     * @return the user name of the current JOSM user
176     */
177    public String getUserName() {
178        return userName;
179    }
180
181    /**
182     * Replies the user id of the current JOSM user. 0, if {@link #isAnonymous()} or
183     * {@link #isPartiallyIdentified()} is true.
184     *
185     * @return the user id of the current JOSM user
186     */
187    public int getUserId() {
188        if (userInfo == null) return 0;
189        return userInfo.getId();
190    }
191
192    /**
193     * Replies verified additional information about the current user if the user is
194     * {@link #isFullyIdentified()}.
195     *
196     * @return verified additional information about the current user
197     */
198    public UserInfo getUserInfo() {
199        return userInfo;
200    }
201
202    /**
203     * Returns the identity as a {@link User} object
204     *
205     * @return the identity as user, or {@link User#getAnonymous()} if {@link #isAnonymous()}
206     */
207    public User asUser() {
208        return isAnonymous() ? User.getAnonymous() : User.createOsmUser(userInfo != null ? userInfo.getId() : 0, userName);
209    }
210
211    /**
212     * Initializes the user identity manager from Basic Authentication values in the {@link org.openstreetmap.josm.data.Preferences}
213     * This method should be called if {@code osm-server.auth-method} is set to {@code basic}.
214     * @see #initFromOAuth
215     */
216    public void initFromPreferences() {
217        String credentialsUserName = CredentialsManager.getInstance().getUsername();
218        if (isAnonymous()) {
219            if (!Utils.isBlank(credentialsUserName)) {
220                setPartiallyIdentified(credentialsUserName);
221            }
222        } else {
223            if (credentialsUserName != null && !credentialsUserName.equals(this.userName)) {
224                setPartiallyIdentified(credentialsUserName);
225            }
226            // else: same name in the preferences as JOSM already knows about.
227            // keep the state, be it partially or fully identified
228        }
229    }
230
231    /**
232     * Initializes the user identity manager from OAuth request of user details.
233     * This method should be called if {@code osm-server.auth-method} is set to {@code oauth}.
234     * @see #initFromPreferences
235     * @since 5434
236     */
237    public void initFromOAuth() {
238        try {
239            UserInfo info = new OsmServerUserInfoReader().fetchUserInfo(NullProgressMonitor.INSTANCE);
240            setFullyIdentified(info.getDisplayName(), info);
241        } catch (IllegalArgumentException | OsmTransferException e) {
242            Logging.error(e);
243        }
244    }
245
246    /**
247     * Replies true if the user with name <code>username</code> is the current user
248     *
249     * @param userName the user name
250     * @return true if the user with name <code>username</code> is the current user
251     */
252    public boolean isCurrentUser(String userName) {
253        return this.userName != null && this.userName.equals(userName);
254    }
255
256    /**
257     * Replies true if the current user is {@link #isFullyIdentified() fully identified} and the {@link #getUserId() user ids} match,
258     * or if the current user is not {@link #isFullyIdentified() fully identified} and the {@link #getUserName() user names} match.
259     *
260     * @param user the user to test
261     * @return true if given user is the current user
262     */
263    public boolean isCurrentUser(User user) {
264        if (user == null) {
265            return false;
266        } else if (isFullyIdentified()) {
267            return getUserId() == user.getId();
268        } else {
269            return isCurrentUser(user.getName());
270        }
271    }
272
273    /* ------------------------------------------------------------------- */
274    /* interface PreferenceChangeListener                                  */
275    /* ------------------------------------------------------------------- */
276    @Override
277    public void preferenceChanged(PreferenceChangeEvent evt) {
278        switch (evt.getKey()) {
279        case "osm-server.username":
280            String newUserName = "";
281            if (evt.getNewValue() instanceof StringSetting) {
282                newUserName = ((StringSetting) evt.getNewValue()).getValue();
283            }
284            if (Utils.isBlank(newUserName)) {
285                setAnonymous();
286            } else if (!newUserName.equals(userName)) {
287                setPartiallyIdentified(newUserName);
288            }
289            return;
290        case "osm-server.url":
291            String newUrl = null;
292            if (evt.getNewValue() instanceof StringSetting) {
293                newUrl = ((StringSetting) evt.getNewValue()).getValue();
294            }
295            if (Utils.isBlank(newUrl)) {
296                setAnonymous();
297            } else if (isFullyIdentified()) {
298                setPartiallyIdentified(getUserName());
299            }
300            break;
301        case "oauth.access-token.key":
302            accessTokenKeyChanged = true;
303            break;
304        case "oauth.access-token.secret":
305            accessTokenSecretChanged = true;
306            break;
307        default: // Do nothing
308        }
309
310        if (accessTokenKeyChanged && accessTokenSecretChanged) {
311            accessTokenKeyChanged = false;
312            accessTokenSecretChanged = false;
313            if (OsmApi.isUsingOAuth()) {
314                getInstance().initFromOAuth();
315            }
316        }
317    }
318
319    /**
320     * This listener is notified whenever the osm user is changed.
321     */
322    @FunctionalInterface
323    public interface UserIdentityListener {
324        /**
325         * The current user was changed.
326         */
327        void userIdentityChanged();
328    }
329
330    /**
331     * Add a listener that listens to changes of the current user.
332     * @param listener The listener
333     */
334    public void addListener(UserIdentityListener listener) {
335        listeners.addListener(listener);
336    }
337
338    private void fireUserIdentityChanged() {
339        listeners.fireEvent(UserIdentityListener::userIdentityChanged);
340    }
341}