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