diff --git a/example/src/main/java/org/wordpress/android/fluxc/example/MainFragment.kt b/example/src/main/java/org/wordpress/android/fluxc/example/MainFragment.kt index 43ab749e88..488461d54d 100644 --- a/example/src/main/java/org/wordpress/android/fluxc/example/MainFragment.kt +++ b/example/src/main/java/org/wordpress/android/fluxc/example/MainFragment.kt @@ -30,6 +30,7 @@ import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator.C import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator.CookieNonceAuthenticationResult.Success import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.AccountStore.AuthenticatePayload +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateTwoFactorPayload import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged import org.wordpress.android.fluxc.store.AccountStore.OnAuthenticationChanged @@ -55,9 +56,11 @@ class MainFragment : Fragment() { // Would be great to not have to keep this state, but it makes HTTPAuth and self signed SSL management easier private var selfHostedPayload: RefreshSitesXMLRPCPayload? = null - // Used for 2fa private var authenticatePayload: AuthenticatePayload? = null + // Used for 2fa + private var authenticateTwoFactorPayload: AuthenticateTwoFactorPayload? = null + override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) @@ -174,8 +177,9 @@ class MainFragment : Fragment() { } private fun signIn2fa(twoStepCode: String) { - authenticatePayload?.twoStepCode = twoStepCode - dispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateAction(authenticatePayload)) + authenticateTwoFactorPayload?.twoStepCode = twoStepCode + dispatcher.dispatch(AuthenticationActionBuilder + .newAuthenticateTwoFactorAction(authenticateTwoFactorPayload)) } private fun showHTTPAuthDialog(url: String) { diff --git a/example/src/test/java/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapperTest.kt b/example/src/test/java/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapperTest.kt index 2acf35d483..b39933f85c 100644 --- a/example/src/test/java/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapperTest.kt +++ b/example/src/test/java/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapperTest.kt @@ -2,7 +2,6 @@ package org.wordpress.android.fluxc.persistence.mappers import org.assertj.core.api.Assertions.assertThat import org.junit.Test -import org.wordpress.android.fluxc.model.LocalOrRemoteId import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId import org.wordpress.android.fluxc.model.payments.woo.WooPaymentsDepositsOverviewComposedEntities import org.wordpress.android.fluxc.network.rest.wpcom.wc.payments.woo.WooPaymentsAccountDepositSummary @@ -118,8 +117,10 @@ class WooPaymentsDepositsOverviewMapperTest { depositsEnabled = true, depositsBlocked = true, depositsSchedule = WooPaymentsDepositsSchedule( - delayDays = 1, - interval = "interval" + interval = "interval", + weeklyAnchor = "monday", + monthlyAnchor = 10, + delayDays = 1 ), defaultCurrency = "defaultCurrency" ) @@ -181,6 +182,8 @@ class WooPaymentsDepositsOverviewMapperTest { assertThat(result.account?.depositsBlocked).isEqualTo(true) assertThat(result.account?.depositsEnabled).isEqualTo(true) assertThat(result.account?.depositsSchedule?.delayDays).isEqualTo(1) + assertThat(result.account?.depositsSchedule?.monthlyAnchor).isEqualTo(10) + assertThat(result.account?.depositsSchedule?.weeklyAnchor).isEqualTo("monday") assertThat(result.account?.depositsSchedule?.interval).isEqualTo("interval") } @@ -190,13 +193,15 @@ class WooPaymentsDepositsOverviewMapperTest { // GIVEN val entity = WooPaymentsDepositsOverviewComposedEntities( overview = WooPaymentsDepositsOverviewEntity( - localSiteId = LocalOrRemoteId.LocalId(1), + localSiteId = LocalId(1), account = WooPaymentsAccountDepositSummaryEntity( depositsEnabled = true, depositsBlocked = true, depositsSchedule = WooPaymentsDepositsScheduleEntity( delayDays = 1, - interval = "interval" + interval = "interval", + monthlyAnchor = null, + weeklyAnchor = null, ), defaultCurrency = "defaultCurrency" ) @@ -348,6 +353,8 @@ class WooPaymentsDepositsOverviewMapperTest { assertThat(result.account?.depositsBlocked).isEqualTo(true) assertThat(result.account?.depositsEnabled).isEqualTo(true) assertThat(result.account?.depositsSchedule?.delayDays).isEqualTo(1) + assertThat(result.account?.depositsSchedule?.monthlyAnchor).isNull() + assertThat(result.account?.depositsSchedule?.weeklyAnchor).isNull() assertThat(result.account?.depositsSchedule?.interval).isEqualTo("interval") } } diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java index 6a8130c679..ffdc24d134 100644 --- a/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java @@ -6,14 +6,19 @@ import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryResultPayload; import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.AuthEmailResponsePayload; import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateTwoFactorPayload; +import org.wordpress.android.fluxc.store.AccountStore.StartWebauthnChallengePayload; import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; import org.wordpress.android.fluxc.store.AccountStore.AuthenticatePayload; +import org.wordpress.android.fluxc.store.AccountStore.FinishWebauthnChallengePayload; @ActionEnum public enum AuthenticationAction implements IAction { // Remote actions @Action(payloadType = AuthenticatePayload.class) AUTHENTICATE, + @Action(payloadType = AuthenticateTwoFactorPayload.class) + AUTHENTICATE_TWO_FACTOR, @Action(payloadType = String.class) DISCOVER_ENDPOINT, @Action(payloadType = AuthEmailPayload.class) @@ -25,5 +30,10 @@ public enum AuthenticationAction implements IAction { @Action(payloadType = DiscoveryResultPayload.class) DISCOVERY_RESULT, @Action(payloadType = AuthEmailResponsePayload.class) - SENT_AUTH_EMAIL + SENT_AUTH_EMAIL, + @Action(payloadType = StartWebauthnChallengePayload.class) + START_SECURITY_KEY_CHALLENGE, + + @Action(payloadType = FinishWebauthnChallengePayload.class) + FINISH_SECURITY_KEY_CHALLENGE } diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java index 035d230c34..59b4cb6a4a 100644 --- a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java @@ -5,6 +5,7 @@ import androidx.annotation.NonNull; +import com.android.volley.Cache; import com.android.volley.NetworkResponse; import com.android.volley.ParseError; import com.android.volley.Request; @@ -13,6 +14,7 @@ import com.android.volley.VolleyError; import com.android.volley.toolbox.HttpHeaderParser; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.wordpress.android.fluxc.Dispatcher; @@ -22,6 +24,10 @@ import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener; import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnChallengeInfo; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnChallengeRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnToken; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnTokenRequest; import org.wordpress.android.fluxc.store.AccountStore.AuthEmailError; import org.wordpress.android.fluxc.store.AccountStore.AuthEmailErrorType; import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayload; @@ -32,7 +38,9 @@ import org.wordpress.android.util.LanguageUtils; import java.io.UnsupportedEncodingException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import javax.inject.Inject; @@ -40,16 +48,19 @@ public class Authenticator { private static final String WPCOM_OAUTH_PREFIX = "https://public-api.wordpress.com/oauth2"; + private static final String WPCOM_PREFIX = "https://wordpress.com"; private static final String AUTHORIZE_ENDPOINT = WPCOM_OAUTH_PREFIX + "/authorize"; private static final String TOKEN_ENDPOINT = WPCOM_OAUTH_PREFIX + "/token"; private static final String AUTHORIZE_ENDPOINT_FORMAT = "%s?client_id=%s&response_type=code"; - + private static final String LOGIN_BASE_ENDPOINT = WPCOM_PREFIX + "/wp-login.php?action=login-endpoint"; public static final String CLIENT_ID_PARAM_NAME = "client_id"; public static final String CLIENT_SECRET_PARAM_NAME = "client_secret"; public static final String CODE_PARAM_NAME = "code"; public static final String GRANT_TYPE_PARAM_NAME = "grant_type"; public static final String USERNAME_PARAM_NAME = "username"; public static final String PASSWORD_PARAM_NAME = "password"; + public static final String WITH_AUTH_TYPES = "with_auth_types"; + public static final String GET_BEARER_TOKEN = "get_bearer_token"; public static final String PASSWORD_GRANT_TYPE = "password"; public static final String BEARER_GRANT_TYPE = "bearer"; @@ -66,7 +77,7 @@ public class Authenticator { private final RequestQueue mRequestQueue; private AppSecrets mAppSecrets; - public interface Listener extends Response.Listener { + public interface Listener extends Response.Listener { } public interface ErrorListener extends Response.ErrorListener { @@ -90,33 +101,71 @@ public AuthEmailResponsePayload(boolean isSignup) { mAppSecrets = secrets; } + public void authenticate(String username, String password, Listener listener, ErrorListener errorListener) { + OauthRequest request = makeRequest(username, password, listener, errorListener); + mRequestQueue.add(request); + } + public void authenticate(String username, String password, String twoStepCode, boolean shouldSendTwoStepSMS, Listener listener, ErrorListener errorListener) { - TokenRequest tokenRequest = makeRequest(username, password, twoStepCode, shouldSendTwoStepSMS, listener, + OauthRequest request = makeRequest(username, password, twoStepCode, shouldSendTwoStepSMS, listener, errorListener); - mRequestQueue.add(tokenRequest); + mRequestQueue.add(request); } public String getAuthorizationURL() { return String.format(AUTHORIZE_ENDPOINT_FORMAT, AUTHORIZE_ENDPOINT, mAppSecrets.getAppId()); } - public TokenRequest makeRequest(String username, String password, String twoStepCode, boolean shouldSendTwoStepSMS, + public OauthRequest makeRequest(String username, String password, Listener listener, ErrorListener errorListener) { + return new PasswordRequest(mAppSecrets.getAppId(), mAppSecrets.getAppSecret(), + username, password, listener, errorListener); + } + + public OauthRequest makeRequest(String username, String password, String twoStepCode, boolean shouldSendTwoStepSMS, Listener listener, ErrorListener errorListener) { - return new PasswordRequest(mAppSecrets.getAppId(), mAppSecrets.getAppSecret(), username, password, twoStepCode, - shouldSendTwoStepSMS, listener, errorListener); + return new TwoFactorRequest(mAppSecrets.getAppId(), mAppSecrets.getAppSecret(), + username, password, twoStepCode, shouldSendTwoStepSMS, listener, errorListener); } - public TokenRequest makeRequest(String code, Listener listener, ErrorListener errorListener) { - return new BearerRequest(mAppSecrets.getAppId(), mAppSecrets.getAppSecret(), code, listener, errorListener); + public void makeRequest(String userId, String webauthnNonce, + Response.Listener listener, + ErrorListener errorListener) { + WebauthnChallengeRequest request = new WebauthnChallengeRequest( + userId, + webauthnNonce, + mAppSecrets.getAppId(), + mAppSecrets.getAppSecret(), + listener, + errorListener + ); + mRequestQueue.add(request); } - private static class TokenRequest extends Request { + public void makeRequest(String userId, String twoStepNonce, + String clientData, Response.Listener listener, + ErrorListener errorListener) { + WebauthnTokenRequest request = new WebauthnTokenRequest( + userId, + twoStepNonce, + mAppSecrets.getAppId(), + mAppSecrets.getAppSecret(), + clientData, + listener, + errorListener + ); + mRequestQueue.add(request); + } + + private static class OauthRequest extends Request { + private static final String DATA = "data"; + private static final String BEARER_TOKEN = "bearer_token"; + private static final String ACCESS_TOKEN = "access_token"; private final Listener mListener; protected Map mParams = new HashMap<>(); - TokenRequest(String appId, String appSecret, Listener listener, ErrorListener errorListener) { - super(Method.POST, TOKEN_ENDPOINT, errorListener); + OauthRequest(String url, String appId, String appSecret, Listener listener, ErrorListener errorListener) { + super(Method.POST, url, errorListener); mListener = listener; mParams.put(CLIENT_ID_PARAM_NAME, appId); mParams.put(CLIENT_SECRET_PARAM_NAME, appSecret); @@ -128,71 +177,83 @@ public Map getParams() { } @Override - public void deliverResponse(Token token) { - mListener.onResponse(token); + public void deliverResponse(OauthResponse response) { + mListener.onResponse(response); } @Override - protected Response parseNetworkResponse(NetworkResponse response) { + protected Response parseNetworkResponse(NetworkResponse response) { try { String jsonString = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); - JSONObject tokenData = new JSONObject(jsonString); - return Response.success(Token.fromJSONObject(tokenData), HttpHeaderParser.parseCacheHeaders(response)); - } catch (UnsupportedEncodingException e) { + JSONObject responseJson = new JSONObject(jsonString); + JSONObject responseData = responseJson.optJSONObject(DATA); + Cache.Entry headers = HttpHeaderParser.parseCacheHeaders(response); + if (responseData != null) { + return handleDataObjectResponse(headers, responseData); + } else { + String accessToken = responseJson.getString(ACCESS_TOKEN); + return Response.success(new Token(accessToken), headers); + } + } catch (UnsupportedEncodingException | JSONException e) { return Response.error(new ParseError(e)); - } catch (JSONException je) { - return Response.error(new ParseError(je)); } } + + @NonNull + private static Response handleDataObjectResponse(Cache.Entry headers, JSONObject responseData) + throws JSONException { + String bearerToken = responseData.optString(BEARER_TOKEN); + if (bearerToken.isEmpty()) { + return Response.success(new TwoFactorResponse(responseData), headers); + } + + return Response.success(new Token(bearerToken), headers); + } } - public static class PasswordRequest extends TokenRequest { - public PasswordRequest(String appId, String appSecret, String username, String password, String twoStepCode, - boolean shouldSendTwoStepSMS, Listener listener, ErrorListener errorListener) { - super(appId, appSecret, listener, errorListener); + public static class PasswordRequest extends OauthRequest { + public PasswordRequest(String appId, String appSecret, String username, String password, + Listener listener, ErrorListener errorListener) { + super(LOGIN_BASE_ENDPOINT, appId, appSecret, listener, errorListener); mParams.put(USERNAME_PARAM_NAME, username); mParams.put(PASSWORD_PARAM_NAME, password); mParams.put(GRANT_TYPE_PARAM_NAME, PASSWORD_GRANT_TYPE); + mParams.put(GET_BEARER_TOKEN, "true"); + mParams.put("wpcom_supports_2fa", "true"); + } + } - if (!TextUtils.isEmpty(twoStepCode)) { - mParams.put("wpcom_otp", twoStepCode); - } else { - mParams.put("wpcom_supports_2fa", "true"); - if (shouldSendTwoStepSMS) { - mParams.put("wpcom_resend_otp", "true"); - } + public static class TwoFactorRequest extends OauthRequest { + public TwoFactorRequest(String appId, String appSecret, String username, String password, String twoStepCode, + boolean shouldSendTwoStepSMS, Listener listener, ErrorListener errorListener) { + super(TOKEN_ENDPOINT, appId, appSecret, listener, errorListener); + mParams.put(USERNAME_PARAM_NAME, username); + mParams.put(PASSWORD_PARAM_NAME, password); + mParams.put(GRANT_TYPE_PARAM_NAME, PASSWORD_GRANT_TYPE); + mParams.put(GET_BEARER_TOKEN, "true"); + mParams.put("wpcom_otp", twoStepCode); + if (shouldSendTwoStepSMS && TextUtils.isEmpty(twoStepCode)) { + mParams.put("wpcom_resend_otp", "true"); } } } - public static class BearerRequest extends TokenRequest { + public static class BearerRequest extends OauthRequest { public BearerRequest(String appId, String appSecret, String code, Listener listener, ErrorListener errorListener) { - super(appId, appSecret, listener, errorListener); + super(TOKEN_ENDPOINT, appId, appSecret, listener, errorListener); mParams.put(CODE_PARAM_NAME, code); mParams.put(GRANT_TYPE_PARAM_NAME, BEARER_GRANT_TYPE); } } - public static class Token { - private static final String TOKEN_TYPE_FIELD_NAME = "token_type"; - private static final String ACCESS_TOKEN_FIELD_NAME = "access_token"; - private static final String SITE_URL_FIELD_NAME = "blog_url"; - private static final String SCOPE_FIELD_NAME = "scope"; - private static final String SITE_ID_FIELD_NAME = "blog_id"; + public interface OauthResponse {} - private String mTokenType; - private String mScope; + public static class Token implements OauthResponse { private String mAccessToken; - private String mSiteUrl; - private String mSiteId; - public Token(String accessToken, String siteUrl, String siteId, String scope, String tokenType) { + public Token(String accessToken) { mAccessToken = accessToken; - mSiteUrl = siteUrl; - mSiteId = siteId; - mScope = scope; - mTokenType = tokenType; } public String getAccessToken() { @@ -202,11 +263,38 @@ public String getAccessToken() { public String toString() { return getAccessToken(); } + } - public static Token fromJSONObject(JSONObject tokenJSON) throws JSONException { - return new Token(tokenJSON.getString(ACCESS_TOKEN_FIELD_NAME), tokenJSON.getString(SITE_URL_FIELD_NAME), - tokenJSON.getString(SITE_ID_FIELD_NAME), tokenJSON.getString(SCOPE_FIELD_NAME), tokenJSON.getString( - TOKEN_TYPE_FIELD_NAME)); + public static class TwoFactorResponse implements OauthResponse { + private static final String USER_ID = "user_id"; + private static final String TWO_STEP_WEBAUTHN_NONCE = "two_step_nonce_webauthn"; + private static final String TWO_STEP_BACKUP_NONCE = "two_step_nonce_backup"; + private static final String TWO_STEP_AUTHENTICATOR_NONCE = "two_step_nonce_authenticator"; + private static final String TWO_STEP_PUSH_NONCE = "two_step_nonce_push"; + private static final String TWO_STEP_SUPPORTED_AUTH_TYPES = "two_step_supported_auth_types"; + public final String mUserId; + public final String mWebauthnNonce; + public final String mBackupNonce; + public final String mAuthenticatorNonce; + public final String mPushNonce; + public final List mSupportedAuthTypes; + + public TwoFactorResponse(JSONObject data) throws JSONException { + mUserId = data.getString(USER_ID); + mWebauthnNonce = data.optString(TWO_STEP_WEBAUTHN_NONCE); + mBackupNonce = data.optString(TWO_STEP_BACKUP_NONCE); + mAuthenticatorNonce = data.optString(TWO_STEP_AUTHENTICATOR_NONCE); + mPushNonce = data.optString(TWO_STEP_PUSH_NONCE); + JSONArray supportedTypes = data.getJSONArray(TWO_STEP_SUPPORTED_AUTH_TYPES); + if (supportedTypes.length() == 0) { + throw new JSONException("No supported auth types found"); + } + + ArrayList supportedAuthTypes = new ArrayList<>(); + for (int i = 0; i < supportedTypes.length(); i++) { + supportedAuthTypes.add(supportedTypes.getString(i)); + } + mSupportedAuthTypes = supportedAuthTypes; } } diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/BaseWebauthnRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/BaseWebauthnRequest.kt new file mode 100644 index 0000000000..34e25b4349 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/BaseWebauthnRequest.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn + +import com.android.volley.NetworkResponse +import com.android.volley.ParseError +import com.android.volley.Request +import com.android.volley.Response +import com.android.volley.Response.ErrorListener +import com.android.volley.toolbox.HttpHeaderParser +import com.google.gson.Gson +import org.json.JSONException +import org.json.JSONObject +import java.io.UnsupportedEncodingException + +abstract class BaseWebauthnRequest( + url: String, + errorListener: ErrorListener, + private val listener: Response.Listener +) : Request(Method.POST, url, errorListener) { + abstract val parameters: Map + abstract fun serializeResponse(response: String): T + + internal val gson by lazy { Gson() } + + private fun NetworkResponse?.extractResult(): Response { + if (this == null) { + val error = WebauthnChallengeRequestException("Webauthn challenge response is null") + return Response.error(ParseError(error)) + } + + return try { + val headers = HttpHeaderParser.parseCacheHeaders(this) + val charsetName = HttpHeaderParser.parseCharset(this.headers) + String(this.data, charset(charsetName)) + .let { JSONObject(it).getJSONObject(WEBAUTHN_DATA) } + .let { serializeResponse(it.toString()) } + .let { Response.success(it, headers) } + } + catch (exception: UnsupportedEncodingException) { Response.error(ParseError(exception)) } + catch (exception: JSONException) { Response.error(ParseError(exception)) } + } + + override fun getParams() = parameters + override fun deliverResponse(response: T) = listener.onResponse(response) + override fun parseNetworkResponse(response: NetworkResponse?) = response.extractResult() + + internal enum class WebauthnRequestParameters(val value: String) { + USER_ID("user_id"), + AUTH_TYPE("auth_type"), + TWO_STEP_NONCE("two_step_nonce"), + CLIENT_ID("client_id"), + CLIENT_SECRET("client_secret"), + CLIENT_DATA("client_data"), + GET_BEARER_TOKEN("get_bearer_token"), + CREATE_2FA_COOKIES_ONLY("create_2fa_cookies_only") + } + + class WebauthnChallengeRequestException(message: String): Exception(message) + + companion object { + private const val baseWPLoginUrl = "https://wordpress.com/wp-login.php?action" + private const val challengeEndpoint = "webauthn-challenge-endpoint" + private const val authEndpoint = "webauthn-authentication-endpoint" + private const val WEBAUTHN_DATA = "data" + + internal const val webauthnChallengeEndpointUrl = "$baseWPLoginUrl=$challengeEndpoint" + internal const val webauthnAuthEndpointUrl = "$baseWPLoginUrl=$authEndpoint" + internal const val WEBAUTHN_AUTH_TYPE = "webauthn" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/VolleyWebauthnRequests.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/VolleyWebauthnRequests.kt new file mode 100644 index 0000000000..e84f4bbbd9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/VolleyWebauthnRequests.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn + +import com.android.volley.Response +import com.android.volley.Response.ErrorListener +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.AUTH_TYPE +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CLIENT_DATA +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CLIENT_ID +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CLIENT_SECRET +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CREATE_2FA_COOKIES_ONLY +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.GET_BEARER_TOKEN +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.TWO_STEP_NONCE +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.USER_ID + +class WebauthnChallengeRequest( + userId: String, + twoStepNonce: String, + clientId: String, + clientSecret: String, + listener: Response.Listener, + errorListener: ErrorListener +): BaseWebauthnRequest(webauthnChallengeEndpointUrl, errorListener, listener) { + override val parameters: Map = mapOf( + CLIENT_ID.value to clientId, + CLIENT_SECRET.value to clientSecret, + USER_ID.value to userId, + AUTH_TYPE.value to WEBAUTHN_AUTH_TYPE, + TWO_STEP_NONCE.value to twoStepNonce + ) + + override fun serializeResponse(response: String): WebauthnChallengeInfo = + gson.fromJson(response, WebauthnChallengeInfo::class.java) +} + +@SuppressWarnings("LongParameterList") +class WebauthnTokenRequest( + userId: String, + twoStepNonce: String, + clientId: String, + clientSecret: String, + clientData: String, + listener: Response.Listener, + errorListener: ErrorListener +) : BaseWebauthnRequest(webauthnAuthEndpointUrl, errorListener, listener) { + override val parameters = mapOf( + CLIENT_ID.value to clientId, + CLIENT_SECRET.value to clientSecret, + USER_ID.value to userId, + AUTH_TYPE.value to WEBAUTHN_AUTH_TYPE, + TWO_STEP_NONCE.value to twoStepNonce, + CLIENT_DATA.value to clientData, + GET_BEARER_TOKEN.value to "true", + CREATE_2FA_COOKIES_ONLY.value to "true" + ) + + override fun serializeResponse(response: String): WebauthnToken = + gson.fromJson(response, WebauthnToken::class.java) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/WebauthnModels.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/WebauthnModels.kt new file mode 100644 index 0000000000..b0d4876d84 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/WebauthnModels.kt @@ -0,0 +1,23 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn + +import com.google.gson.annotations.SerializedName + +class WebauthnChallengeInfo( + val challenge: String, + val rpId: String, + val allowCredentials: List, + val timeout: Int, + @SerializedName("two_step_nonce") + val twoStepNonce: String +) + +class WebauthnCredentialResponse( + val type: String, + val id: String, + val transports: List +) + +class WebauthnToken( + @SerializedName("bearer_token") + val bearerToken: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java index 284329cbee..fb9c099fe7 100644 --- a/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java @@ -5,6 +5,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.android.volley.Response; import com.android.volley.VolleyError; import com.yarolegovich.wellsql.WellSql; @@ -38,7 +39,11 @@ import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator; import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.AuthEmailResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.OauthResponse; import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.Token; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.TwoFactorResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnChallengeInfo; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnToken; import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType; import org.wordpress.android.fluxc.persistence.AccountSqlUtils; import org.wordpress.android.util.AppLog; @@ -58,15 +63,30 @@ @Singleton public class AccountStore extends Store { // Payloads - public static class AuthenticatePayload extends Payload { + public static class AuthenticationRequestPayload extends Payload { + public Action nextAction; + } + + public static class AuthenticatePayload extends AuthenticationRequestPayload { + public String username; + public String password; + public AuthenticatePayload(@NonNull String username, @NonNull String password) { + this.username = username; + this.password = password; + } + } + + public static class AuthenticateTwoFactorPayload extends AuthenticationRequestPayload { public String username; public String password; public String twoStepCode; public boolean shouldSendTwoStepSms; - public Action nextAction; - public AuthenticatePayload(@NonNull String username, @NonNull String password) { + public AuthenticateTwoFactorPayload(@NonNull String username, @NonNull String password, + @NonNull String twoStepCode, boolean shouldSendTwoStepSms) { this.username = username; this.password = password; + this.twoStepCode = twoStepCode; + this.shouldSendTwoStepSms = shouldSendTwoStepSms; } } @@ -326,6 +346,31 @@ public enum SubscriptionType { NOTIFICATION_POST } + public static class StartWebauthnChallengePayload extends Payload { + public String mUserId; + public String mWebauthnNonce; + + public StartWebauthnChallengePayload(String mUserId, String mWebauthnNonce) { + this.mUserId = mUserId; + this.mWebauthnNonce = mWebauthnNonce; + } + } + + public static class WebauthnChallengeReceived extends OnChanged { + public WebauthnChallengeInfo mChallengeInfo; + public String mUserId; + } + + public static class FinishWebauthnChallengePayload { + public String mUserId; + public String mTwoStepNonce; + public String mClientData; + } + + public static class WebauthnPasskeyAuthenticated extends OnChanged { + public WebauthnToken mWebauthnToken; + } + /** * Error for any of these methods: * {@link AccountRestClient#updateSubscriptionEmailComment(String, @@ -424,6 +469,24 @@ public static class OnUsernameChanged extends OnChanged { public String username; } + public static class OnTwoFactorAuthStarted extends OnChanged { + public final String userId; + public final String webauthnNonce; + public final String mBackupNonce; + public final String authenticatorNonce; + public final String pushNonce; + public final List mSupportedAuthTypes; + + public OnTwoFactorAuthStarted(TwoFactorResponse response) { + userId = response.mUserId; + webauthnNonce = response.mWebauthnNonce; + mBackupNonce = response.mBackupNonce; + authenticatorNonce = response.mAuthenticatorNonce; + pushNonce = response.mPushNonce; + mSupportedAuthTypes = response.mSupportedAuthTypes; + } + } + public static class OnUsernameSuggestionsFetched extends OnChanged { public List suggestions; } @@ -511,10 +574,12 @@ public enum AuthenticationErrorType { INVALID_REQUEST, INVALID_TOKEN, NEEDS_2FA, + NEEDS_SECURITY_KEY, UNSUPPORTED_GRANT_TYPE, UNSUPPORTED_RESPONSE_TYPE, UNKNOWN_TOKEN, EMAIL_LOGIN_NOT_ALLOWED, + WEBAUTHN_FAILED, // From response's "message" field - sadly... (be careful with i18n) INCORRECT_USERNAME_OR_PASSWORD, @@ -984,6 +1049,9 @@ private void onAuthenticationAction(AuthenticationAction actionType, Object payl case AUTHENTICATE: authenticate((AuthenticatePayload) payload); break; + case AUTHENTICATE_TWO_FACTOR: + authenticateTwoFactor((AuthenticateTwoFactorPayload) payload); + break; case AUTHENTICATE_ERROR: handleAuthenticateError((AuthenticateErrorPayload) payload); break; @@ -999,6 +1067,12 @@ private void onAuthenticationAction(AuthenticationAction actionType, Object payl case SENT_AUTH_EMAIL: handleSentAuthEmail((AuthEmailResponsePayload) payload); break; + case START_SECURITY_KEY_CHALLENGE: + requestWebauthnChallenge((StartWebauthnChallengePayload) payload); + break; + case FINISH_SECURITY_KEY_CHALLENGE: + submitWebauthnChallengeResult((FinishWebauthnChallengePayload) payload); + break; } } @@ -1279,27 +1353,48 @@ private AccountModel loadAccount() { } private void authenticate(final AuthenticatePayload payload) { + mAuthenticator.authenticate(payload.username, payload.password, + response -> handleAuthResponse(response, payload), + this::handleAuthError); + } + + private void authenticateTwoFactor(final AuthenticateTwoFactorPayload payload) { mAuthenticator.authenticate(payload.username, payload.password, payload.twoStepCode, - payload.shouldSendTwoStepSms, new Authenticator.Listener() { - @Override - public void onResponse(Token token) { - mAccessToken.set(token.getAccessToken()); - if (payload.nextAction != null) { - mDispatcher.dispatch(payload.nextAction); - } - emitChange(new OnAuthenticationChanged()); - } - }, new Authenticator.ErrorListener() { - @Override - public void onErrorResponse(VolleyError volleyError) { - AppLog.e(T.API, "Authentication error"); - OnAuthenticationChanged event = new OnAuthenticationChanged(); - event.error = new AuthenticationError( - Authenticator.volleyErrorToAuthenticationError(volleyError), - Authenticator.volleyErrorToErrorMessage(volleyError)); - emitChange(event); - } - }); + payload.shouldSendTwoStepSms, + response -> handleAuthResponse(response, payload), + this::handleAuthError); + } + + private void handleAuthError(VolleyError volleyError) { + AppLog.e(T.API, "Authentication error"); + OnAuthenticationChanged event = new OnAuthenticationChanged(); + event.error = new AuthenticationError( + Authenticator.volleyErrorToAuthenticationError(volleyError), + Authenticator.volleyErrorToErrorMessage(volleyError)); + emitChange(event); + } + + private void handleAuthResponse(OauthResponse response, AuthenticationRequestPayload payload) { + // Oauth endpoint can return a Token or a WebauthnResponse + if (response instanceof Token) { + Token token = (Token) response; + mAccessToken.set(token.getAccessToken()); + if (payload.nextAction != null) { + mDispatcher.dispatch(payload.nextAction); + } + emitChange(new OnAuthenticationChanged()); + } else if (response instanceof TwoFactorResponse) { + TwoFactorResponse twoFactorResponse = (TwoFactorResponse) response; + OnTwoFactorAuthStarted event = new OnTwoFactorAuthStarted(twoFactorResponse); + if (payload.nextAction != null) { + mDispatcher.dispatch(payload.nextAction); + } + emitChange(event); + } else { + OnAuthenticationChanged event = new OnAuthenticationChanged(); + event.error = new AuthenticationError(AuthenticationErrorType.GENERIC_ERROR, ""); + emitChange(event); + } } private void handleSentAuthEmail(final AuthEmailResponsePayload payload) { @@ -1313,6 +1408,38 @@ private void handleSentAuthEmail(final AuthEmailResponsePayload payload) { } } + private void requestWebauthnChallenge(final StartWebauthnChallengePayload payload) { + mAuthenticator.makeRequest(payload.mUserId, payload.mWebauthnNonce, + (Response.Listener) info -> { + WebauthnChallengeReceived event = new WebauthnChallengeReceived(); + event.mChallengeInfo = info; + event.mUserId = payload.mUserId; + emitChange(event); + }, + error -> { + WebauthnChallengeReceived event = new WebauthnChallengeReceived(); + event.error = new AuthenticationError(AuthenticationErrorType.WEBAUTHN_FAILED, + "Webauthn failed"); + emitChange(event); + }); + } + + private void submitWebauthnChallengeResult(final FinishWebauthnChallengePayload payload) { + mAuthenticator.makeRequest(payload.mUserId, payload.mTwoStepNonce, payload.mClientData, + token -> { + WebauthnPasskeyAuthenticated event = new WebauthnPasskeyAuthenticated(); + event.mWebauthnToken = token; + mAccessToken.set(token.getBearerToken()); + emitChange(event); + }, + error -> { + WebauthnPasskeyAuthenticated event = new WebauthnPasskeyAuthenticated(); + event.error = new AuthenticationError(AuthenticationErrorType.WEBAUTHN_FAILED, + "Webauthn failed"); + emitChange(event); + }); + } + private boolean checkError(AccountRestPayload payload, String log) { if (payload.isError()) { AppLog.w(T.API, log + "\nError: " + payload.error.volleyError); diff --git a/plugins/woocommerce/schemas/org.wordpress.android.fluxc.persistence.WCAndroidDatabase/30.json b/plugins/woocommerce/schemas/org.wordpress.android.fluxc.persistence.WCAndroidDatabase/30.json index 4ffe45713a..accaff4646 100644 --- a/plugins/woocommerce/schemas/org.wordpress.android.fluxc.persistence.WCAndroidDatabase/30.json +++ b/plugins/woocommerce/schemas/org.wordpress.android.fluxc.persistence.WCAndroidDatabase/30.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 30, - "identityHash": "46e29ce7aa2fba81c03063031e265024", + "identityHash": "1e3ad3f4fb750ae397a8fee0c724ed14", "entities": [ { "tableName": "AddonEntity", @@ -1291,7 +1291,7 @@ }, { "tableName": "WooPaymentsDepositsOverview", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `depositsEnabled` INTEGER, `depositsBlocked` INTEGER, `defaultCurrency` TEXT, `delayDays` INTEGER, `interval` TEXT, PRIMARY KEY(`localSiteId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `depositsEnabled` INTEGER, `depositsBlocked` INTEGER, `defaultCurrency` TEXT, `delayDays` INTEGER, `weeklyAnchor` TEXT, `monthlyAnchor` INTEGER, `interval` TEXT, PRIMARY KEY(`localSiteId`))", "fields": [ { "fieldPath": "localSiteId", @@ -1323,6 +1323,18 @@ "affinity": "INTEGER", "notNull": false }, + { + "fieldPath": "account.depositsSchedule.weeklyAnchor", + "columnName": "weeklyAnchor", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "account.depositsSchedule.monthlyAnchor", + "columnName": "monthlyAnchor", + "affinity": "INTEGER", + "notNull": false + }, { "fieldPath": "account.depositsSchedule.interval", "columnName": "interval", @@ -1595,7 +1607,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '46e29ce7aa2fba81c03063031e265024')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e3ad3f4fb750ae397a8fee0c724ed14')" ] } } \ No newline at end of file diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/model/payments/woo/WooPaymentsDepositsOverview.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/model/payments/woo/WooPaymentsDepositsOverview.kt index 696fb5cf32..6e706a4675 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/model/payments/woo/WooPaymentsDepositsOverview.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/model/payments/woo/WooPaymentsDepositsOverview.kt @@ -13,6 +13,8 @@ data class WooPaymentsDepositsOverview( ) { data class DepositsSchedule( val delayDays: Int?, + val weeklyAnchor: String?, + val monthlyAnchor: Int?, val interval: String? ) } diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/payments/woo/WooPaymentsDepositsOverviewApiResponse.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/payments/woo/WooPaymentsDepositsOverviewApiResponse.kt index fc88b54d66..d8443bdcbb 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/payments/woo/WooPaymentsDepositsOverviewApiResponse.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/payments/woo/WooPaymentsDepositsOverviewApiResponse.kt @@ -63,5 +63,7 @@ data class WooPaymentsAccountDepositSummary( data class WooPaymentsDepositsSchedule( @SerializedName("delay_days") val delayDays: Int?, + @SerializedName("weekly_anchor") val weeklyAnchor: String?, + @SerializedName("monthly_anchor") val monthlyAnchor: Int?, val interval: String? ) diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/entity/WooPaymentsDepositsOverviewEntity.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/entity/WooPaymentsDepositsOverviewEntity.kt index a15c4b43f4..0b16699756 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/entity/WooPaymentsDepositsOverviewEntity.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/entity/WooPaymentsDepositsOverviewEntity.kt @@ -26,6 +26,8 @@ data class WooPaymentsAccountDepositSummaryEntity( data class WooPaymentsDepositsSchedule( val delayDays: Int?, + val weeklyAnchor: String?, + val monthlyAnchor: Int?, val interval: String? ) diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapper.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapper.kt index 2a05e832bd..bbc436f9c3 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapper.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/persistence/mappers/WooPaymentsDepositsOverviewMapper.kt @@ -34,6 +34,8 @@ class WooPaymentsDepositsOverviewMapper @Inject constructor() { depositsSchedule = summary.depositsSchedule?.let { DepositsSchedule( delayDays = it.delayDays, + weeklyAnchor = it.weeklyAnchor, + monthlyAnchor = it.monthlyAnchor, interval = it.interval ) } @@ -221,6 +223,8 @@ class WooPaymentsDepositsOverviewMapper @Inject constructor() { depositsSchedule?.let { DepositsSchedule( delayDays = it.delayDays, + weeklyAnchor = it.weeklyAnchor, + monthlyAnchor = it.monthlyAnchor, interval = it.interval ) } @@ -239,6 +243,8 @@ class WooPaymentsDepositsOverviewMapper @Inject constructor() { depositsSchedule?.let { WooPaymentsDepositsSchedule( delayDays = it.delayDays, + weeklyAnchor = it.weeklyAnchor, + monthlyAnchor = it.monthlyAnchor, interval = it.interval ) }