/*
* Copyright 2016 Realm Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.realm;
import android.os.Handler;
import android.os.Looper;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;
import io.realm.internal.RealmNotifier;
import io.realm.internal.Util;
import io.realm.internal.android.AndroidCapabilities;
import io.realm.internal.android.AndroidRealmNotifier;
import io.realm.internal.async.RealmAsyncTaskImpl;
import io.realm.internal.network.AuthenticateResponse;
import io.realm.internal.network.AuthenticationServer;
import io.realm.internal.network.ChangePasswordResponse;
import io.realm.internal.network.ExponentialBackoffTask;
import io.realm.internal.network.LogoutResponse;
import io.realm.internal.objectserver.ObjectServerUser;
import io.realm.internal.objectserver.Token;
import io.realm.log.RealmLog;
import io.realm.permissions.PermissionModule;
/**
* This class represents a user on the Realm Object Server. The credentials are provided by various 3rd party
* providers (Facebook, Google, etc.).
* <p>
* A user can log in to the Realm Object Server, and if access is granted, it is possible to synchronize the local
* and the remote Realm. Moreover, synchronization is halted when the user is logged out.
* <p>
* It is possible to persist a user. By retrieving a user, there is no need to log in to the 3rd party provider again.
* Persisting a user between sessions, the user's credentials are stored locally on the device, and should be treated
* as sensitive data.
*/
public class SyncUser {
private static class ManagementConfig {
private SyncConfiguration managementRealmConfig;
synchronized SyncConfiguration initAndGetManagementRealmConfig(
ObjectServerUser syncUser, final SyncUser user) {
if (managementRealmConfig == null) {
managementRealmConfig = new SyncConfiguration.Builder(
user, getManagementRealmUrl(syncUser.getAuthenticationUrl()))
.errorHandler(new SyncSession.ErrorHandler() {
@Override
public void onError(SyncSession session, ObjectServerError error) {
if (error.getErrorCode() == ErrorCode.CLIENT_RESET) {
RealmLog.error("Client Reset required for user's management Realm: " + user.toString());
} else {
RealmLog.error(String.format("Unexpected error with %s's management Realm: %s",
user.getIdentity(),
error.toString()));
}
}
})
.modules(new PermissionModule())
.build();
}
return managementRealmConfig;
}
}
private final ManagementConfig managementConfig = new ManagementConfig();
private final ObjectServerUser syncUser;
private SyncUser(ObjectServerUser user) {
this.syncUser = user;
}
/**
* Returns the current user that is logged in and still valid.
* A user is invalidated when he/she logs out or the user's access token expires.
*
* @return current {@link SyncUser} that has logged in and is still valid. {@code null} if no user is logged in or the user has
* expired.
* @throws IllegalStateException if multiple users are logged in.
*/
public static SyncUser currentUser() {
SyncUser user = SyncManager.getUserStore().getCurrent();
if (user != null && user.isValid()) {
return user;
}
return null;
}
/**
* Returns all valid users known by this device.
* A user is invalidated when he/she logs out or the user's access token expires.
*
* @return a map from user identifier to user. It includes all known valid users.
*/
public static Map<String, SyncUser> all() {
UserStore userStore = SyncManager.getUserStore();
Collection<SyncUser> storedUsers = userStore.allUsers();
Map<String, SyncUser> map = new HashMap<String, SyncUser>();
for (SyncUser user : storedUsers) {
if (user.isValid()) {
map.put(user.getIdentity(), user);
}
}
return Collections.unmodifiableMap(map);
}
/**
* Loads a user that has previously been serialized using {@link #toJson()}.
*
* @param user JSON string representing the user.
* @return the user object.
* @throws IllegalArgumentException if the JSON couldn't be converted to a valid {@link SyncUser} object.
*/
public static SyncUser fromJson(String user) {
try {
JSONObject obj = new JSONObject(user);
URL authUrl = new URL(obj.getString("authUrl"));
Token userToken = Token.from(obj.getJSONObject("userToken"));//TODO rename to refresh_token
ObjectServerUser syncUser = new ObjectServerUser(userToken, authUrl);
JSONArray realmTokens = obj.getJSONArray("realms");
for (int i = 0; i < realmTokens.length(); i++) {
JSONObject token = realmTokens.getJSONObject(i);
URI uri = new URI(token.getString("uri"));
ObjectServerUser.AccessDescription realmDesc = ObjectServerUser.AccessDescription.fromJson(token.getJSONObject("description"));
syncUser.addRealm(uri, realmDesc);
}
return new SyncUser(syncUser);
} catch (JSONException e) {
throw new IllegalArgumentException("Could not parse user json: " + user, e);
} catch (MalformedURLException e) {
throw new IllegalArgumentException("URL in JSON not valid: " + user, e);
} catch (URISyntaxException e) {
throw new IllegalArgumentException("URI is not valid: " + user, e);
}
}
/**
* Logs in the user to the Realm Object Server. This is done synchronously, so calling this method on the Android
* UI thread will always crash. A logged in user is required to be able to create a {@link SyncConfiguration}.
*
* @param credentials credentials to use.
* @param authenticationUrl server that can authenticate against.
* @throws ObjectServerError if the login failed.
* @throws IllegalArgumentException if the URL is malformed.
*/
public static SyncUser login(final SyncCredentials credentials, final String authenticationUrl) throws ObjectServerError {
URL authUrl;
try {
authUrl = new URL(authenticationUrl);
// If no path segment is provided append `/auth` which is the standard location.
if (authUrl.getPath().equals("")) {
authUrl = new URL(authUrl.toString() + "/auth");
}
} catch (MalformedURLException e) {
throw new IllegalArgumentException("Invalid URL " + authenticationUrl + ".", e);
}
ObjectServerError error;
try {
AuthenticateResponse result;
if (credentials.getIdentityProvider().equals(SyncCredentials.IdentityProvider.ACCESS_TOKEN)) {
// Credentials using ACCESS_TOKEN as IdentityProvider are optimistically assumed to be valid already.
// So log them in directly without contacting the authentication server. This is done by mirroring
// the JSON response expected from the server.
String userIdentifier = credentials.getUserIdentifier();
String token = (String) credentials.getUserInfo().get("_token");
boolean isAdmin = (Boolean) credentials.getUserInfo().get("_isAdmin");
result = AuthenticateResponse.createValidResponseWithUser(userIdentifier, token, isAdmin);
} else {
final AuthenticationServer server = SyncManager.getAuthServer();
result = server.loginUser(credentials, authUrl);
}
if (result.isValid()) {
ObjectServerUser syncUser = new ObjectServerUser(result.getRefreshToken(), authUrl);
SyncUser user = new SyncUser(syncUser);
RealmLog.info("Succeeded authenticating user.\n%s", user);
SyncManager.getUserStore().put(user);
SyncManager.notifyUserLoggedIn(user);
return user;
} else {
RealmLog.info("Failed authenticating user.\n%s", result.getError());
error = result.getError();
}
} catch (Throwable e) {
throw new ObjectServerError(ErrorCode.UNKNOWN, e);
}
throw error;
}
/**
* Logs in the user to the Realm Object Server. A logged in user is required to be able to create a
* {@link SyncConfiguration}.
*
* @param credentials credentials to use.
* @param authenticationUrl server that the user is authenticated against.
* @param callback callback when login has completed or failed. The callback will always happen on the same thread
* as this this method is called on.
* @return representation of the async task that can be used to cancel it if needed.
* @throws IllegalArgumentException if not on a Looper thread.
*/
public static RealmAsyncTask loginAsync(final SyncCredentials credentials, final String authenticationUrl, final Callback callback) {
checkLooperThread("Asynchronous login is only possible from looper threads.");
return new Request(SyncManager.NETWORK_POOL_EXECUTOR, callback) {
@Override
public SyncUser run() throws ObjectServerError {
return login(credentials, authenticationUrl);
}
}.start();
}
/**
* Logs out the user from the Realm Object Server. Once the Object Server has confirmed the logout any registered
* {@link AuthenticationListener} will be notified and user credentials will be deleted from this device.
*
* @throws IllegalStateException if any Realms owned by this user is still open. They should be closed before
* logging out.
*/
/* FIXME: Add this back to the javadoc when enable SyncConfiguration.Builder#deleteRealmOnLogout()
<p>
Any Realms owned by the user will be deleted if {@link SyncConfiguration.Builder#deleteRealmOnLogout()} is
also set.
*/
public void logout() {
// Acquire lock to prevent users creating new instances
synchronized (Realm.class) {
if (!syncUser.isLoggedIn()) {
return; // Already local/global logout status
}
// Ensure that we can log out. If any Realm file is still open we should abort before doing anything
// else.
Collection<SyncSession> sessions = syncUser.getSessions();
for (SyncSession session : sessions) {
SyncConfiguration config = session.getConfiguration();
if (Realm.getGlobalInstanceCount(config) > 0) {
throw new IllegalStateException("A Realm controlled by this user is still open. Close all Realms " +
"before logging out: " + config.getPath());
}
}
SyncManager.getUserStore().remove(syncUser.getIdentity());
// Delete all Realms if needed.
for (ObjectServerUser.AccessDescription desc : syncUser.getRealms()) {
// FIXME: This will always be false since SyncConfiguration.Builder.deleteRealmOnLogout() is
// disabled. Make sure this works for Realm opened in the client thread/other processes.
if (desc.deleteOnLogout) {
File realmFile = new File(desc.localPath);
if (realmFile.exists() && !Util.deleteRealm(desc.localPath, realmFile.getParentFile(), realmFile.getName())) {
RealmLog.error("Could not delete Realm when user logged out: " + desc.localPath);
}
}
}
// Remove all local tokens, preventing further connections.
final Token userToken = syncUser.getUserToken();
syncUser.clearTokens();
syncUser.localLogout();
// Finally revoke server token. The local user is logged out in any case.
final AuthenticationServer server = SyncManager.getAuthServer();
ThreadPoolExecutor networkPoolExecutor = SyncManager.NETWORK_POOL_EXECUTOR;
//noinspection unused
final Future<?> future = networkPoolExecutor.submit(new ExponentialBackoffTask<LogoutResponse>() {
@Override
protected LogoutResponse execute() {
return server.logout(userToken, getAuthenticationUrl());
}
@Override
protected void onSuccess(LogoutResponse response) {
SyncManager.notifyUserLoggedOut(SyncUser.this);
}
@Override
protected void onError(LogoutResponse response) {
RealmLog.error("Failed to log user out.\n" + response.getError().toString());
}
});
}
}
/**
* Changes this user's password. This is done synchronously and involves the network, so calling this method on the
* Android UI thread will always crash.
* <p>
* <b>WARNING:</b> Changing a users password using an authentication server that doesn't use HTTPS is a major
* security flaw, and should only be done while testing.
*
* @param newPassword the user's new password.
* @throws ObjectServerError if the password could not be changed.
*/
public void changePassword(String newPassword) throws ObjectServerError {
if (newPassword == null) {
throw new IllegalArgumentException("Not-null 'newPassword' required.");
}
AuthenticationServer authServer = SyncManager.getAuthServer();
ChangePasswordResponse response = authServer.changePassword(getSyncUser().getUserToken(), newPassword, getAuthenticationUrl());
if (!response.isValid()) {
throw response.getError();
}
}
/**
* Changes this user's password asynchronously.
* <p>
* <b>WARNING:</b> Changing a users password using an authentication server that doesn't use HTTPS is a major
* security flaw, and should only be done while testing.
*
* @param newPassword the user's new password.
* @param callback callback when login has completed or failed. The callback will always happen on the same thread
* as this method is called on.
* @return representation of the async task that can be used to cancel it if needed.
* @throws IllegalArgumentException if not on a Looper thread.
*/
public RealmAsyncTask changePasswordAsync(final String newPassword, final Callback callback) {
checkLooperThread("Asynchronous changing password is only possible from looper threads.");
if (callback == null) {
throw new IllegalArgumentException("Non-null 'callback' required.");
}
return new Request(SyncManager.NETWORK_POOL_EXECUTOR, callback) {
@Override
public SyncUser run() {
changePassword(newPassword);
return SyncUser.this;
}
}.start();
}
private static void checkLooperThread(String errorMessage) {
AndroidCapabilities capabilities = new AndroidCapabilities();
capabilities.checkCanDeliverNotification(errorMessage);
}
/**
* Returns a JSON token representing this user.
* <p>
* Possession of this JSON token can potentially grant access to data stored on the Realm Object Server, so it
* should be treated as sensitive data.
*
* @return JSON string representing this user. It can be converted back into a real user object using
* {@link #fromJson(String)}.
* @see #fromJson(String)
*/
public String toJson() {
return syncUser.toJson();
}
/**
* Returns {@code true} if the user is logged into the Realm Object Server. If this method returns {@code true} it
* implies that the user has valid credentials that have not expired.
* <p>
* The user might still have been logged out by the Realm Object Server which will not be detected before the
* user tries to actively synchronize a Realm. If a logged out user tries to synchronize a Realm, an error will be
* reported to the {@link SyncSession.ErrorHandler} defined by
* {@link SyncConfiguration.Builder#errorHandler(SyncSession.ErrorHandler)}.
*
* @return {@code true} if the User is logged into the Realm Object Server, {@code false} otherwise.
*/
public boolean isValid() {
Token userToken = getSyncUser().getUserToken();
return syncUser.isLoggedIn() && userToken != null && userToken.expiresMs() > System.currentTimeMillis();
}
/**
* Returns {@code true} if this user is an administrator on the Realm Object Server, {@code false} otherwise.
* <p>
* Administrators can access all Realms on the server as well as change the permissions of the Realms.
*
* @return {@code true} if the user is an administrator on the Realm Object Server, {@code false} otherwise.
*/
public boolean isAdmin() {
return syncUser.isAdmin();
}
/**
* Returns the identity of this user on the Realm Object Server. The identity is a guaranteed to be unique
* among all users on the Realm Object Server.
*
* @return identity of the user on the Realm Object Server. If the user has logged out or the login has expired
* {@code null} is returned.
*/
public String getIdentity() {
return syncUser.getIdentity();
}
/**
* Returns this user's access token. This is the users credential for accessing the Realm Object Server and should
* be treated as sensitive data.
*
* @return the user's access token. If this user has logged out or the login has expired {@code null} is returned.
*/
public Token getAccessToken() {
Token userToken = syncUser.getUserToken();
return (userToken != null) ? userToken : null;
}
/**
* Returns an instance of the Management Realm owned by the user.
* <p>
* This Realm can be used to control access and permissions for Realms owned by the user. This includes
* giving other users access to Realms.
*
* @see <a href="https://realm.io/docs/realm-object-server/#permissions">How to control permissions</a>
*/
public Realm getManagementRealm() {
return Realm.getInstance(managementConfig.initAndGetManagementRealmConfig(syncUser, this));
}
/**
* Returns the {@link URL} where this user was authenticated.
*
* @return {@link URL} where the user was authenticated.
*/
public URL getAuthenticationUrl() {
return syncUser.getAuthenticationUrl();
}
// Creates the URL to the permission Realm based on the authentication URL.
private static String getManagementRealmUrl(URL authUrl) {
String scheme = "realm";
if (authUrl.getProtocol().equalsIgnoreCase("https")) {
scheme = "realms";
}
try {
return new URI(scheme, authUrl.getUserInfo(), authUrl.getHost(), authUrl.getPort(),
"/~/__management", null, null).toString();
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Could not create URL to the management Realm", e);
}
}
@Override
public boolean equals(Object o) {
if (this == o) { return true; }
if (o == null || getClass() != o.getClass()) { return false; }
SyncUser user = (SyncUser) o;
return syncUser.equals(user.syncUser);
}
@Override
public int hashCode() {
return syncUser.hashCode();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("{");
sb.append("UserId: ").append(syncUser.getIdentity());
sb.append(", AuthUrl: ").append(getAuthenticationUrl());
sb.append(", IsValid: ").append(isValid());
sb.append(", Sessions: ").append(syncUser.getSessions().size());
sb.append("}");
return sb.toString();
}
// Expose internal representation for other package protected classes
ObjectServerUser getSyncUser() {
return syncUser;
}
// Class wrapping requests made against the auth server. Is also responsible for calling with success/error on the
// correct thread.
private static abstract class Request {
private final Callback callback;
private final RealmNotifier handler;
private final ThreadPoolExecutor networkPoolExecutor;
public Request(ThreadPoolExecutor networkPoolExecutor, Callback callback) {
this.callback = callback;
this.handler = new AndroidRealmNotifier(null, new AndroidCapabilities());
this.networkPoolExecutor = networkPoolExecutor;
}
// Implements the request. Return the current sync user if the request succeeded. Otherwise throw an error.
public abstract SyncUser run() throws ObjectServerError;
// Start the request
public RealmAsyncTask start() {
Future<?> authenticateRequest = networkPoolExecutor.submit(new Runnable() {
@Override
public void run() {
try {
postSuccess(Request.this.run());
} catch (ObjectServerError e) {
postError(e);
} catch (Throwable e) {
postError(new ObjectServerError(ErrorCode.UNKNOWN, "Unexpected error", e));
}
}
});
return new RealmAsyncTaskImpl(authenticateRequest, networkPoolExecutor);
}
private void postError(final ObjectServerError error) {
boolean errorHandled = false;
if (callback != null) {
Runnable action = new Runnable() {
@Override
public void run() {
callback.onError(error);
}
};
errorHandled = handler.post(action);
}
if (!errorHandled) {
RealmLog.error(error, "An error was thrown, but could not be handled.");
}
}
private void postSuccess(final SyncUser user) {
if (callback != null) {
handler.post(new Runnable() {
@Override
public void run() {
callback.onSuccess(user);
}
});
}
}
}
public interface Callback {
void onSuccess(SyncUser user);
void onError(ObjectServerError error);
}
}