Move login client out of the data-client (#5476)

This commit is contained in:
Paul Hawke 2024-01-26 08:11:44 -06:00 committed by GitHub
parent 8789879f10
commit 0541aacdff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 195 additions and 234 deletions

View file

@ -1,17 +1,14 @@
package fr.free.nrw.commons;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import okhttp3.OkHttpClient;
import org.wikipedia.AppAdapter;
import org.wikipedia.dataclient.SharedPreferenceCookieManager;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.json.GsonMarshaller;
import org.wikipedia.json.GsonUnmarshaller;
import org.wikipedia.login.LoginResult;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import okhttp3.OkHttpClient;
public class CommonsAppAdapter extends AppAdapter {
private final int DEFAULT_THUMB_SIZE = 640;
@ -60,11 +57,6 @@ public class CommonsAppAdapter extends AppAdapter {
return sessionManager.getPassword();
}
@Override
public void updateAccount(@NonNull LoginResult result) {
sessionManager.updateAccount(result);
}
@Override
public SharedPreferenceCookieManager getCookies() {
if (!preferences.contains(COOKIE_STORE_NAME)) {

View file

@ -25,6 +25,9 @@ import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.NavUtils;
import androidx.core.content.ContextCompat;
import fr.free.nrw.commons.auth.login.LoginClient;
import fr.free.nrw.commons.auth.login.LoginInterface;
import fr.free.nrw.commons.auth.login.LoginResult;
import fr.free.nrw.commons.databinding.ActivityLoginBinding;
import fr.free.nrw.commons.utils.ActivityUtils;
import java.util.Locale;
@ -32,9 +35,7 @@ import org.wikipedia.AppAdapter;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.login.LoginClient;
import org.wikipedia.login.LoginClient.LoginCallback;
import org.wikipedia.login.LoginResult;
import fr.free.nrw.commons.auth.login.LoginClient.LoginCallback;
import javax.inject.Inject;
import javax.inject.Named;
@ -231,7 +232,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
private void doLogin(String username, String password, String twoFactorCode) {
progressDialog.show();
loginToken = ServiceFactory.get(commonsWikiSite).getLoginToken();
loginToken = ServiceFactory.get(commonsWikiSite, LoginInterface.class).getLoginToken();
loginToken.enqueue(
new Callback<MwQueryResponse>() {
@Override
@ -313,7 +314,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
compositeDisposable.clear();
sessionManager.setUserLoggedIn(true);
AppAdapter.get().updateAccount(loginResult);
sessionManager.updateAccount(loginResult);
progressDialog.dismiss();
showSuccessAndDismissDialog();
startMainActivity();

View file

@ -1,36 +0,0 @@
package fr.free.nrw.commons.auth;
import org.wikipedia.dataclient.Service;
import org.wikipedia.dataclient.mwapi.MwPostResponse;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import io.reactivex.Observable;
/**
* Handler for logout
*/
@Singleton
public class LogoutClient {
private final Service service;
@Inject
public LogoutClient(@Named("commons-service") Service service) {
this.service = service;
}
/**
* Fetches the CSRF token and uses that to post the logout api call
* @return
*/
public Observable<MwPostResponse> postLogout() {
return service.getCsrfToken().concatMap(tokenResponse -> service.postLogout(
Objects.requireNonNull(Objects.requireNonNull(tokenResponse.query()).csrfToken())));
}
}

View file

@ -9,8 +9,7 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.login.LoginResult;
import fr.free.nrw.commons.auth.login.LoginResult;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;

View file

@ -1,15 +1,16 @@
package fr.free.nrw.commons.auth.csrf
import androidx.annotation.VisibleForTesting
import fr.free.nrw.commons.auth.SessionManager
import org.wikipedia.AppAdapter
import org.wikipedia.dataclient.ServiceFactory
import org.wikipedia.dataclient.SharedPreferenceCookieManager
import org.wikipedia.dataclient.WikiSite
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import org.wikipedia.login.LoginClient
import org.wikipedia.login.LoginClient.LoginCallback
import org.wikipedia.login.LoginClient.LoginFailedException
import org.wikipedia.login.LoginResult
import fr.free.nrw.commons.auth.login.LoginClient
import fr.free.nrw.commons.auth.login.LoginClient.LoginCallback
import fr.free.nrw.commons.auth.login.LoginClient.LoginFailedException
import fr.free.nrw.commons.auth.login.LoginResult
import retrofit2.Call
import retrofit2.Response
import timber.log.Timber
@ -17,7 +18,10 @@ import java.io.IOException
import java.util.concurrent.Callable
import java.util.concurrent.Executors.newSingleThreadExecutor
class CsrfTokenClient(private val csrfWikiSite: WikiSite) {
class CsrfTokenClient(
private val csrfWikiSite: WikiSite,
private val sessionManager: SessionManager
) {
private var retries = 0
private var csrfTokenCall: Call<MwQueryResponse?>? = null
private val loginClient = LoginClient()
@ -33,7 +37,8 @@ class CsrfTokenClient(private val csrfWikiSite: WikiSite) {
try {
if (retry > 0) {
// Log in explicitly
LoginClient().loginBlocking(csrfWikiSite, userName, password, "")
LoginClient()
.loginBlocking(csrfWikiSite, userName, password, "")
}
// Get CSRFToken response off the main thread.
@ -121,10 +126,11 @@ class CsrfTokenClient(private val csrfWikiSite: WikiSite) {
password: String,
callback: Callback,
retryCallback: () -> Unit
) = LoginClient().request(csrfWikiSite, username, password, object : LoginCallback {
) = LoginClient()
.request(csrfWikiSite, username, password, object : LoginCallback {
override fun success(loginResult: LoginResult) {
if (loginResult.pass()) {
AppAdapter.get().updateAccount(loginResult)
sessionManager.updateAccount(loginResult)
retryCallback()
} else {
callback.failure(LoginFailedException(loginResult.message))

View file

@ -0,0 +1,259 @@
package fr.free.nrw.commons.auth.login;
import android.annotation.SuppressLint;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
import org.apache.commons.lang3.StringUtils;
import org.wikipedia.dataclient.Service;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.ListUserResponse;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.dataclient.mwapi.MwServiceError;
import org.wikipedia.util.log.L;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* Responsible for making login related requests to the server.
*/
public class LoginClient {
@Nullable private Call<MwQueryResponse> tokenCall;
@Nullable private Call<LoginResponse> loginCall;
/**
* userLanguage
* It holds the value of the user's device language code.
* For example, if user's device language is English it will hold En
* The value will be fetched when the user clicks Login Button in the LoginActivity
*/
@NonNull private String userLanguage;
public interface LoginCallback {
void success(@NonNull LoginResult result);
void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token);
void passwordResetPrompt(@Nullable String token);
void error(@NonNull Throwable caught);
}
public void request(@NonNull final WikiSite wiki, @NonNull final String userName,
@NonNull final String password, @NonNull final LoginCallback cb) {
cancel();
tokenCall = ServiceFactory.get(wiki, LoginInterface.class).getLoginToken();
tokenCall.enqueue(new Callback<MwQueryResponse>() {
@Override public void onResponse(@NonNull Call<MwQueryResponse> call,
@NonNull Response<MwQueryResponse> response) {
login(wiki, userName, password, null, null, response.body().query().loginToken(),
userLanguage, cb);
}
@Override
public void onFailure(@NonNull Call<MwQueryResponse> call, @NonNull Throwable caught) {
if (call.isCanceled()) {
return;
}
cb.error(caught);
}
});
}
public void login(@NonNull final WikiSite wiki, @NonNull final String userName, @NonNull final String password,
@Nullable final String retypedPassword, @Nullable final String twoFactorCode,
@Nullable final String loginToken, @NonNull final String userLanguage, @NonNull final LoginCallback cb) {
this.userLanguage = userLanguage;
loginCall = TextUtils.isEmpty(twoFactorCode) && TextUtils.isEmpty(retypedPassword)
? ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, loginToken, userLanguage, Service.WIKIPEDIA_URL)
: ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, retypedPassword, twoFactorCode, loginToken,
userLanguage, true);
loginCall.enqueue(new Callback<LoginResponse>() {
@Override
public void onResponse(@NonNull Call<LoginResponse> call, @NonNull Response<LoginResponse> response) {
LoginResponse loginResponse = response.body();
LoginResult loginResult = loginResponse.toLoginResult(wiki, password);
if (loginResult != null) {
if (loginResult.pass() && !TextUtils.isEmpty(loginResult.getUserName())) {
// The server could do some transformations on user names, e.g. on some
// wikis is uppercases the first letter.
String actualUserName = loginResult.getUserName();
getExtendedInfo(wiki, actualUserName, loginResult, cb);
} else if ("UI".equals(loginResult.getStatus())) {
if (loginResult instanceof LoginOAuthResult) {
cb.twoFactorPrompt(new LoginFailedException(loginResult.getMessage()), loginToken);
} else if (loginResult instanceof LoginResetPasswordResult) {
cb.passwordResetPrompt(loginToken);
} else {
cb.error(new LoginFailedException(loginResult.getMessage()));
}
} else {
cb.error(new LoginFailedException(loginResult.getMessage()));
}
} else {
cb.error(new IOException("Login failed. Unexpected response."));
}
}
@Override
public void onFailure(@NonNull Call<LoginResponse> call, @NonNull Throwable t) {
if (call.isCanceled()) {
return;
}
cb.error(t);
}
});
}
public void loginBlocking(@NonNull final WikiSite wiki, @NonNull final String userName,
@NonNull final String password, @Nullable final String twoFactorCode) throws Throwable {
Response<MwQueryResponse> tokenResponse = ServiceFactory.get(wiki, LoginInterface.class).getLoginToken().execute();
if (tokenResponse.body() == null || TextUtils.isEmpty(tokenResponse.body().query().loginToken())) {
throw new IOException("Unexpected response when getting login token.");
}
String loginToken = tokenResponse.body().query().loginToken();
Call<LoginResponse> tempLoginCall = StringUtils.defaultIfEmpty(twoFactorCode, "").isEmpty()
? ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, loginToken, userLanguage, Service.WIKIPEDIA_URL)
: ServiceFactory.get(wiki, LoginInterface.class).postLogIn(userName, password, null, twoFactorCode, loginToken,
userLanguage, true);
Response<LoginResponse> response = tempLoginCall.execute();
LoginResponse loginResponse = response.body();
if (loginResponse == null) {
throw new IOException("Unexpected response when logging in.");
}
LoginResult loginResult = loginResponse.toLoginResult(wiki, password);
if (loginResult == null) {
throw new IOException("Unexpected response when logging in.");
}
if ("UI".equals(loginResult.getStatus())) {
if (loginResult instanceof LoginOAuthResult) {
// TODO: Find a better way to boil up the warning about 2FA
throw new LoginFailedException(loginResult.getMessage());
} else {
throw new LoginFailedException(loginResult.getMessage());
}
} else if (!loginResult.pass() || TextUtils.isEmpty(loginResult.getUserName())) {
throw new LoginFailedException(loginResult.getMessage());
}
}
@SuppressLint("CheckResult")
private void getExtendedInfo(@NonNull final WikiSite wiki, @NonNull String userName,
@NonNull final LoginResult loginResult, @NonNull final LoginCallback cb) {
ServiceFactory.get(wiki, LoginInterface.class).getUserInfo(userName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(response -> {
ListUserResponse user = response.query().getUserResponse(userName);
int id = response.query().userInfo().id();
loginResult.setUserId(id);
loginResult.setGroups(user.getGroups());
cb.success(loginResult);
L.v("Found user ID " + id + " for " + wiki.subdomain());
}, caught -> {
L.e("Login succeeded but getting group information failed. " + caught);
cb.error(caught);
});
}
public void cancel() {
cancelTokenRequest();
cancelLogin();
}
private void cancelTokenRequest() {
if (tokenCall == null) {
return;
}
tokenCall.cancel();
tokenCall = null;
}
private void cancelLogin() {
if (loginCall == null) {
return;
}
loginCall.cancel();
loginCall = null;
}
public static final class LoginResponse {
@SuppressWarnings("unused") @SerializedName("error") @Nullable
private MwServiceError error;
@SuppressWarnings("unused") @SerializedName("clientlogin") @Nullable
private ClientLogin clientLogin;
@Nullable public MwServiceError getError() {
return error;
}
@Nullable LoginResult toLoginResult(@NonNull WikiSite site, @NonNull String password) {
return clientLogin != null ? clientLogin.toLoginResult(site, password) : null;
}
private static class ClientLogin {
@SuppressWarnings("unused,NullableProblems") @NonNull private String status;
@SuppressWarnings("unused") @Nullable private List<Request> requests;
@SuppressWarnings("unused") @Nullable private String message;
@SuppressWarnings("unused") @SerializedName("username") @Nullable private String userName;
LoginResult toLoginResult(@NonNull WikiSite site, @NonNull String password) {
String userMessage = message;
if ("UI".equals(status)) {
if (requests != null) {
for (Request req : requests) {
if ("MediaWiki\\Extension\\OATHAuth\\Auth\\TOTPAuthenticationRequest".equals(req.id())) {
return new LoginOAuthResult(site, status, userName, password, message);
} else if ("MediaWiki\\Auth\\PasswordAuthenticationRequest".equals(req.id())) {
return new LoginResetPasswordResult(site, status, userName, password, message);
}
}
}
} else if (!"PASS".equals(status) && !"FAIL".equals(status)) {
//TODO: String resource -- Looks like needed for others in this class too
userMessage = "An unknown error occurred.";
}
return new LoginResult(site, status, userName, password, userMessage);
}
}
private static class Request {
@SuppressWarnings("unused") @Nullable private String id;
//@SuppressWarnings("unused") @Nullable private JsonObject metadata;
@SuppressWarnings("unused") @Nullable private String required;
@SuppressWarnings("unused") @Nullable private String provider;
@SuppressWarnings("unused") @Nullable private String account;
@SuppressWarnings("unused") @Nullable private Map<String, RequestField> fields;
@Nullable String id() {
return id;
}
}
private static class RequestField {
@SuppressWarnings("unused") @Nullable private String type;
@SuppressWarnings("unused") @Nullable private String label;
@SuppressWarnings("unused") @Nullable private String help;
}
}
public static class LoginFailedException extends Throwable {
public LoginFailedException(String message) {
super(message);
}
}
}

View file

@ -0,0 +1,45 @@
package fr.free.nrw.commons.auth.login
import io.reactivex.Observable
import org.wikipedia.dataclient.Service
import org.wikipedia.dataclient.mwapi.MwQueryResponse
import retrofit2.Call
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query
interface LoginInterface {
@Headers("Cache-Control: no-cache")
@GET(Service.MW_API_PREFIX + "action=query&meta=tokens&type=login")
fun getLoginToken(): Call<MwQueryResponse?>
@Headers("Cache-Control: no-cache")
@FormUrlEncoded
@POST(Service.MW_API_PREFIX + "action=clientlogin&rememberMe=")
fun postLogIn(
@Field("username") user: String?,
@Field("password") pass: String?,
@Field("logintoken") token: String?,
@Field("uselang") userLanguage: String?,
@Field("loginreturnurl") url: String?
): Call<LoginClient.LoginResponse?>
@Headers("Cache-Control: no-cache")
@FormUrlEncoded
@POST(Service.MW_API_PREFIX + "action=clientlogin&rememberMe=")
fun postLogIn(
@Field("username") user: String?,
@Field("password") pass: String?,
@Field("retype") retypedPass: String?,
@Field("OATHToken") twoFactorCode: String?,
@Field("logintoken") token: String?,
@Field("uselang") userLanguage: String?,
@Field("logincontinue") loginContinue: Boolean
): Call<LoginClient.LoginResponse?>
@GET(Service.MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate")
fun getUserInfo(@Query("ususers") userName: String): Observable<MwQueryResponse?>
}

View file

@ -0,0 +1,14 @@
package fr.free.nrw.commons.auth.login;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.dataclient.WikiSite;
public class LoginOAuthResult extends LoginResult {
public LoginOAuthResult(@NonNull WikiSite site, @NonNull String status, @Nullable String userName,
@Nullable String password, @Nullable String message) {
super(site, status, userName, password, message);
}
}

View file

@ -0,0 +1,13 @@
package fr.free.nrw.commons.auth.login;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.dataclient.WikiSite;
public class LoginResetPasswordResult extends LoginResult {
public LoginResetPasswordResult(@NonNull WikiSite site, @NonNull String status, @Nullable String userName,
@Nullable String password, @Nullable String message) {
super(site, status, userName, password, message);
}
}

View file

@ -0,0 +1,73 @@
package fr.free.nrw.commons.auth.login;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.wikipedia.dataclient.WikiSite;
import java.util.Collections;
import java.util.Set;
public class LoginResult {
@NonNull private final WikiSite site;
@NonNull private final String status;
@Nullable private final String userName;
@Nullable private final String password;
@Nullable private final String message;
private int userId;
@NonNull private Set<String> groups = Collections.emptySet();
public LoginResult(@NonNull WikiSite site, @NonNull String status, @Nullable String userName,
@Nullable String password, @Nullable String message) {
this.site = site;
this.status = status;
this.userName = userName;
this.password = password;
this.message = message;
}
@NonNull public WikiSite getSite() {
return site;
}
@NonNull public String getStatus() {
return status;
}
public boolean pass() {
return "PASS".equals(status);
}
public boolean fail() {
return "FAIL".equals(status);
}
@Nullable public String getUserName() {
return userName;
}
@Nullable public String getPassword() {
return password;
}
@Nullable public String getMessage() {
return message;
}
public void setUserId(int id) {
this.userId = id;
}
public int getUserId() {
return userId;
}
public void setGroups(@NonNull Set<String> groups) {
this.groups = groups;
}
@NonNull public Set<String> getGroups() {
return groups;
}
}

View file

@ -10,6 +10,7 @@ import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.actions.PageEditInterface;
import fr.free.nrw.commons.actions.ThanksInterface;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.category.CategoryInterface;
import fr.free.nrw.commons.explore.depictions.DepictsClient;
import fr.free.nrw.commons.kvstore.JsonKvStore;
@ -40,7 +41,7 @@ import org.wikipedia.dataclient.Service;
import org.wikipedia.dataclient.ServiceFactory;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.json.GsonUtil;
import org.wikipedia.login.LoginClient;
import fr.free.nrw.commons.auth.login.LoginClient;
import timber.log.Timber;
@Module
@ -105,8 +106,9 @@ public class NetworkingModule {
@Named(NAMED_COMMONS_CSRF)
@Provides
@Singleton
public CsrfTokenClient provideCommonsCsrfTokenClient(@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite) {
return new CsrfTokenClient(commonsWikiSite);
public CsrfTokenClient provideCommonsCsrfTokenClient(
@Named(NAMED_COMMONS_WIKI_SITE) WikiSite commonsWikiSite, SessionManager sessionManager) {
return new CsrfTokenClient(commonsWikiSite, sessionManager);
}
@Provides