jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/322366 )
Change subject: Add basic 2FA support ...................................................................... Add basic 2FA support Can be modified to be a "one-step" rather than "two-step" process for the user if desired. Very basic but this gets us from zero to something for those users who need it. Bug: T150900 Change-Id: I89f339ec8d58813815ea6791bf8930b83ff7f400 --- M app/src/androidTest/java/org/wikipedia/login/LoginClientTest.java M app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.java M app/src/main/java/org/wikipedia/edit/EditSectionActivity.java M app/src/main/java/org/wikipedia/login/LoginActivity.java M app/src/main/java/org/wikipedia/login/LoginClient.java A app/src/main/java/org/wikipedia/login/LoginOAuthResult.java M app/src/main/java/org/wikipedia/login/LoginResult.java M app/src/main/res/layout/activity_wiki_login.xml M app/src/main/res/values-qq/strings.xml M app/src/main/res/values/strings.xml 10 files changed, 137 insertions(+), 11 deletions(-) Approvals: Niedzielski: Looks good to me, approved jenkins-bot: Verified diff --git a/app/src/androidTest/java/org/wikipedia/login/LoginClientTest.java b/app/src/androidTest/java/org/wikipedia/login/LoginClientTest.java index 13cf624..74cc099 100644 --- a/app/src/androidTest/java/org/wikipedia/login/LoginClientTest.java +++ b/app/src/androidTest/java/org/wikipedia/login/LoginClientTest.java @@ -1,6 +1,7 @@ package org.wikipedia.login; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.test.filters.SmallTest; @@ -15,6 +16,7 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.fail; import static org.wikipedia.test.TestUtil.runOnMainSync; @SmallTest public class LoginClientTest { @@ -43,6 +45,11 @@ } @Override + public void twoFactorPrompt(@NonNull Throwable throwble, @Nullable String token) { + fail("Two-factor prompt not expected here"); + } + + @Override public void error(@NonNull Throwable caught) { assertThat("login failed!", false); } diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.java b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.java index cc3586a..d67c2d3 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.java +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.java @@ -213,6 +213,12 @@ } @Override + public void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token) { + editFailed(new LoginFailedException(getResources() + .getString(R.string.login_2fa_other_workflow_error_msg))); + } + + @Override public void error(@NonNull Throwable caught) { editFailed(caught); } diff --git a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.java b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.java index cf644a5..c18d37a 100644 --- a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.java +++ b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.java @@ -471,6 +471,14 @@ } @Override + public void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token) { + FeedbackUtil.showError(EditSectionActivity.this, + new LoginClient.LoginFailedException(getResources() + .getString(R.string.login_2fa_other_workflow_error_msg))); + onLoginError(); + } + + @Override public void error(@NonNull Throwable caught) { onLoginError(); } diff --git a/app/src/main/java/org/wikipedia/login/LoginActivity.java b/app/src/main/java/org/wikipedia/login/LoginActivity.java index 48e689f..4a4c26f 100644 --- a/app/src/main/java/org/wikipedia/login/LoginActivity.java +++ b/app/src/main/java/org/wikipedia/login/LoginActivity.java @@ -47,8 +47,10 @@ @Pattern(regex = "[^#<>\\[\\]|{}\\/@]*", messageResId = R.string.create_account_username_error) private EditText usernameText; private EditText passwordText; + private EditText twoFactorText; private View loginButton; private ProgressDialog progressDialog; + @Nullable private String firstStepToken; private LoginFunnel funnel; private String loginSource; @@ -74,6 +76,7 @@ usernameText = (EditText) findViewById(R.id.login_username_text); passwordText = ((PasswordTextInput) findViewById(R.id.login_password_input)).getEditText(); + twoFactorText = (EditText) findViewById(R.id.login_2fa_text); View createAccountLink = findViewById(R.id.login_create_account_link); // We enable the login button as soon as the username and password fields are filled @@ -206,13 +209,25 @@ private void doLogin() { final String username = usernameText.getText().toString(); final String password = passwordText.getText().toString(); + final String twoFactorCode = twoFactorText.getText().toString(); if (loginClient == null) { loginClient = new LoginClient(); } progressDialog.show(); - loginClient.request(WikipediaApp.getInstance().getWikiSite(), username, password, - new LoginClient.LoginCallback() { + + if (!twoFactorCode.isEmpty()) { + loginClient.login(WikipediaApp.getInstance().getWikiSite(), username, password, + twoFactorCode, firstStepToken, getCallback(username, password)); + } else { + loginClient.request(WikipediaApp.getInstance().getWikiSite(), username, password, + getCallback(username, password)); + } + } + + private LoginClient.LoginCallback getCallback(@NonNull final String username, + @NonNull final String password) { + return new LoginClient.LoginCallback() { @Override public void success(@NonNull LoginResult result) { if (!progressDialog.isShowing()) { @@ -240,6 +255,18 @@ } @Override + public void twoFactorPrompt(@NonNull Throwable caught, @NonNull String token) { + if (!progressDialog.isShowing()) { + // no longer attached to activity! + return; + } + progressDialog.dismiss(); + firstStepToken = token; + twoFactorText.setVisibility(View.VISIBLE); + FeedbackUtil.showError(LoginActivity.this, caught); + } + + @Override public void error(@NonNull Throwable caught) { if (!progressDialog.isShowing()) { // no longer attached to activity! @@ -248,7 +275,7 @@ progressDialog.dismiss(); FeedbackUtil.showError(LoginActivity.this, caught); } - }); + }; } @Override diff --git a/app/src/main/java/org/wikipedia/login/LoginClient.java b/app/src/main/java/org/wikipedia/login/LoginClient.java index 07c1d4f..c9d7e26 100644 --- a/app/src/main/java/org/wikipedia/login/LoginClient.java +++ b/app/src/main/java/org/wikipedia/login/LoginClient.java @@ -5,6 +5,7 @@ import com.google.gson.annotations.SerializedName; +import org.apache.commons.lang3.StringUtils; import org.wikipedia.Constants; import org.wikipedia.WikipediaApp; import org.wikipedia.dataclient.WikiSite; @@ -14,6 +15,8 @@ import org.wikipedia.util.log.L; import java.io.IOException; +import java.util.List; +import java.util.Map; import java.util.Set; import retrofit2.Call; @@ -35,6 +38,7 @@ public interface LoginCallback { void success(@NonNull LoginResult result); + void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token); void error(@NonNull Throwable caught); } @@ -54,7 +58,7 @@ MwQueryResponse<LoginToken> body = response.body(); LoginToken query = body.query(); if (query != null && query.getLoginToken() != null) { - login(wiki, userName, password, query.getLoginToken(), cb); + 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())); @@ -74,10 +78,12 @@ }); } - private void login(@NonNull final WikiSite wiki, @NonNull final String userName, - @NonNull final String password, @NonNull String loginToken, - @NonNull final LoginCallback cb) { - loginCall = cachedService.service(wiki).logIn(userName, password, loginToken, Constants.WIKIPEDIA_URL); + 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) { @@ -90,6 +96,9 @@ // wikis is uppercases the first letter. String actualUserName = loginResult.getUser().getUsername(); getGroupMemberships(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())); } @@ -163,8 +172,15 @@ @FormUrlEncoded @POST("w/api.php?action=clientlogin&format=json&rememberMe=true") Call<LoginResponse> logIn(@Field("username") String user, @Field("password") String pass, - @Field("logintoken") String token, - @Field("loginreturnurl") String url); + @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&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 { @@ -198,6 +214,7 @@ 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; @@ -208,10 +225,41 @@ user = new User(userName, password, 0); } 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 { diff --git a/app/src/main/java/org/wikipedia/login/LoginOAuthResult.java b/app/src/main/java/org/wikipedia/login/LoginOAuthResult.java new file mode 100644 index 0000000..5895748 --- /dev/null +++ b/app/src/main/java/org/wikipedia/login/LoginOAuthResult.java @@ -0,0 +1,11 @@ +package org.wikipedia.login; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +class LoginOAuthResult extends LoginResult { + + LoginOAuthResult(@NonNull String status, @Nullable String message) { + super(status, null, message); + } +} diff --git a/app/src/main/java/org/wikipedia/login/LoginResult.java b/app/src/main/java/org/wikipedia/login/LoginResult.java index 990b79e..3814556 100644 --- a/app/src/main/java/org/wikipedia/login/LoginResult.java +++ b/app/src/main/java/org/wikipedia/login/LoginResult.java @@ -8,7 +8,7 @@ @Nullable private final User user; @Nullable private final String message; - public LoginResult(@NonNull String status, @Nullable User user, @Nullable String message) { + LoginResult(@NonNull String status, @Nullable User user, @Nullable String message) { this.status = status; this.user = user; this.message = message; diff --git a/app/src/main/res/layout/activity_wiki_login.xml b/app/src/main/res/layout/activity_wiki_login.xml index 4c1ba82..4c26cbe 100644 --- a/app/src/main/res/layout/activity_wiki_login.xml +++ b/app/src/main/res/layout/activity_wiki_login.xml @@ -35,6 +35,21 @@ app:passwordToggleEnabled="true" android:hint="@string/login_password_hint" /> + <android.support.design.widget.TextInputLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/login_2fa_hint" > + <org.wikipedia.views.PlainPasteEditText + android:id="@+id/login_2fa_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + style="?android:textAppearanceMedium" + android:inputType="textVisiblePassword|textNoSuggestions" + android:imeOptions="flagNoExtractUi" + android:maxLines="1" + android:visibility="gone" /> + </android.support.design.widget.TextInputLayout> + <TextView android:id="@+id/login_button" style="@style/ButtonProgressive" diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 301aee2..39c6280 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -105,6 +105,8 @@ <string name="nav_item_login">{{Identical|Log in}}</string> <string name="login_username_hint">{{Identical|Username}}</string> <string name="login_password_hint">{{Identical|Password}}</string> + <string name="login_2fa_hint">Hint for a field where the user can enter a two-factor authentication code, if required.</string> + <string name="login_2fa_other_workflow_error_msg">Message for when login fails during an editing workflow because two-factor authentication is required. Directs the user to return to the main activity, log in, and retry.</string> <string name="menu_login">{{Identical|Log in}}</string> <string name="login_activity_title">Used as window title.</string> <string name="login_in_progress_dialog_message">A bubble that describes the in-progress action.</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1d5131..c79f351 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,8 @@ <string name="nav_item_login">Log in to Wikipedia</string> <string name="login_username_hint">Username</string> <string name="login_password_hint">Password</string> + <string name="login_2fa_hint">Two-Factor Authentication Code</string> + <string name="login_2fa_other_workflow_error_msg">Two-factor authentication required! Please return to the main activity and log in with your 2FA token, then retry.</string> <string name="menu_login">Log in</string> <string name="login_activity_title">Log in to Wikipedia</string> <string name="login_in_progress_dialog_message">Logging you in…</string> -- To view, visit https://gerrit.wikimedia.org/r/322366 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I89f339ec8d58813815ea6791bf8930b83ff7f400 Gerrit-PatchSet: 12 Gerrit-Project: apps/android/wikipedia Gerrit-Branch: master Gerrit-Owner: Mholloway <mhollo...@wikimedia.org> Gerrit-Reviewer: BearND <bsitzm...@wikimedia.org> Gerrit-Reviewer: Brion VIBBER <br...@wikimedia.org> Gerrit-Reviewer: Dbrant <dbr...@wikimedia.org> Gerrit-Reviewer: Gergő Tisza <gti...@wikimedia.org> Gerrit-Reviewer: Matanya <mata...@foss.co.il> Gerrit-Reviewer: Mholloway <mhollo...@wikimedia.org> Gerrit-Reviewer: Niedzielski <sniedziel...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits