package org.wikipedia.login;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
import org.apache.commons.lang3.StringUtils;
import org.wikipedia.Constants;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.dataclient.mwapi.MwServiceError;
import org.wikipedia.dataclient.retrofit.MwCachedService;
import org.wikipedia.dataclient.retrofit.WikiCachedService;
import org.wikipedia.util.log.L;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.http.Field;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.POST;
/**
* Responsible for making login related requests to the server.
*/
public class LoginClient {
@NonNull private final WikiCachedService<Service> cachedService
= new MwCachedService<>(Service.class);
@Nullable private Call<MwQueryResponse<LoginToken>> tokenCall;
@Nullable private Call<LoginResponse> loginCall;
public interface LoginCallback {
void success(@NonNull LoginResult result);
void twoFactorPrompt(@NonNull Throwable caught, @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 = cachedService.service(wiki).requestLoginToken();
tokenCall.enqueue(new Callback<MwQueryResponse<LoginToken>>() {
@Override
public void onResponse(Call<MwQueryResponse<LoginToken>> call,
Response<MwQueryResponse<LoginToken>> response) {
MwQueryResponse<LoginToken> body = response.body();
LoginToken query = body.query();
if (query != null && query.getLoginToken() != null) {
login(wiki, userName, password, null, query.getLoginToken(), cb);
} else if (body.getError() != null) {
cb.error(new IOException("Failed to retrieve login token. "
+ body.getError().toString()));
} else {
cb.error(new IOException("Unexpected error trying to retrieve login token. "
+ body.toString()));
}
}
@Override
public void onFailure(Call<MwQueryResponse<LoginToken>> call, Throwable caught) {
cb.error(caught);
}
});
}
void login(@NonNull final WikiSite wiki, @NonNull final String userName,
@NonNull final String password, @Nullable final String twoFactorCode,
@Nullable final String loginToken, @NonNull final LoginCallback cb) {
loginCall = StringUtils.defaultIfEmpty(twoFactorCode, "").isEmpty()
? cachedService.service(wiki).logIn(userName, password, loginToken, Constants.WIKIPEDIA_URL)
: cachedService.service(wiki).logIn(userName, password, twoFactorCode, loginToken, true);
loginCall.enqueue(new Callback<LoginResponse>() {
@Override
public void onResponse(Call<LoginResponse> call, Response<LoginResponse> response) {
LoginResponse loginResponse = response.body();
LoginResult loginResult = loginResponse.toLoginResult(password);
if (loginResult != null) {
if (loginResult.pass() && loginResult.getUser() != null) {
// The server could do some transformations on user names, e.g. on some
// wikis is uppercases the first letter.
String actualUserName = loginResult.getUser().getUsername();
getExtendedInfo(wiki, actualUserName, loginResult, cb);
} else if ("UI".equals(loginResult.getStatus())) {
//TODO: Don't just assume this is a 2FA UI result
cb.twoFactorPrompt(new LoginFailedException(loginResult.getMessage()), loginToken);
} else {
cb.error(new LoginFailedException(loginResult.getMessage()));
}
} else {
cb.error(new IOException("Login failed. Unexpected response."));
}
}
@Override
public void onFailure(Call<LoginResponse> call, Throwable t) {
cb.error(t);
}
});
}
private void getExtendedInfo(@NonNull final WikiSite wiki, @NonNull String userName,
@NonNull final LoginResult loginResult, @NonNull final LoginCallback cb) {
UserExtendedInfoClient infoClient = new UserExtendedInfoClient();
infoClient.request(wiki, userName, new UserExtendedInfoClient.Callback() {
@Override
public void success(@NonNull Call<MwQueryResponse<UserExtendedInfoClient.QueryResult>> call,
int id, @NonNull Set<String> groups) {
final User user = loginResult.getUser();
Map<String, Integer> idMap = new HashMap<>();
idMap.put(wiki.languageCode(), id);
User.setUser(new User(user, idMap, groups));
cb.success(loginResult);
L.v("Found user ID " + id + " for " + wiki.languageCode());
}
@Override
public void failure(@NonNull Call<MwQueryResponse<UserExtendedInfoClient.QueryResult>> call,
@NonNull Throwable 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;
}
private interface Service {
/** Request a login token to be used later to log in. */
@NonNull
@POST("w/api.php?format=json&formatversion=2&action=query&meta=tokens&type=login")
Call<MwQueryResponse<LoginToken>> requestLoginToken();
/** Actually log in. Has to be x-www-form-urlencoded */
@NonNull
@FormUrlEncoded
@POST("w/api.php?action=clientlogin&format=json&formatversion=2&rememberMe=true")
Call<LoginResponse> logIn(@Field("username") String user, @Field("password") String pass,
@Field("logintoken") String token, @Field("loginreturnurl") String url);
/** Actually log in. Has to be x-www-form-urlencoded */
@NonNull
@FormUrlEncoded
@POST("w/api.php?action=clientlogin&format=json&formatversion=2&rememberMe=true")
Call<LoginResponse> logIn(@Field("username") String user, @Field("password") String pass,
@Field("OATHToken") String twoFactorCode, @Field("logintoken") String token,
@Field("logincontinue") boolean loginContinue);
}
private static final class LoginToken {
@SerializedName("tokens") private Tokens tokens;
@Nullable String getLoginToken() {
return tokens == null ? null : tokens.loginToken;
}
private class Tokens {
@SerializedName("logintoken") @Nullable
private String loginToken;
}
}
private static final class LoginResponse {
@SerializedName("error") @Nullable
private MwServiceError error;
@SerializedName("clientlogin") @Nullable
private ClientLogin clientLogin;
@Nullable
public MwServiceError getError() {
return error;
}
LoginResult toLoginResult(String password) {
return clientLogin != null ? clientLogin.toLoginResult(password) : null;
}
private static class ClientLogin {
@SerializedName("status") private String status;
@Nullable private List<Request> requests;
@SerializedName("message") @Nullable private String message;
@SerializedName("username") @Nullable private String userName;
LoginResult toLoginResult(String password) {
User user = null;
String userMessage = null;
if ("PASS".equals(status)) {
user = new User(userName, password);
} else if ("FAIL".equals(status)) {
userMessage = message;
} else if ("UI".equals(status)) {
if (requests != null) {
for (Request req : requests) {
if ("TOTPAuthenticationRequest".equals(req.id())) {
return new LoginOAuthResult(status, message);
}
}
}
userMessage = message;
} else {
//TODO: String resource -- Looks like needed for others in this class too
userMessage = "An unknown error occurred.";
}
return new LoginResult(status, user, 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);
}
}
}