Skip to content

Commit

Permalink
Merge pull request #13487 from woocommerce/issue/13467-improve-authen…
Browse files Browse the repository at this point in the history
…ticated-webview-2

Authenticated WebView improvement [Part 2]
  • Loading branch information
hichamboushaba authored Feb 12, 2025
2 parents 08a9a97 + cb773aa commit 0530338
Show file tree
Hide file tree
Showing 23 changed files with 663 additions and 101 deletions.
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [*] Updated the notification icon for better readability [https://github.com/woocommerce/woocommerce-android/pull/13516]
- [*] Automatically select the first available order when filtering is active in the two-pane layout.[https://github.com/woocommerce/woocommerce-android/pull/13491]
- [*] [Internal] Removal animation of the items from the cart [https://github.com/woocommerce/woocommerce-android/pull/13442]
- [*] Improved the Authenticated WebView implementation, and it will now handle opening the Analytics reports directly from the app without requiring additional authentication [https://github.com/woocommerce/woocommerce-android/pull/13487]

21.7
-----
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.woocommerce.android.di

import android.content.Context
import android.webkit.CookieManager
import com.woocommerce.android.AppPrefs
import com.woocommerce.android.BuildConfig
import com.woocommerce.android.FeedbackPrefs
Expand Down Expand Up @@ -45,4 +46,7 @@ class AppConfigModule {
@Provides
@Singleton
fun provideStringUtils() = StringUtils

@Provides
fun provideWebViewCookieManager() = CookieManager.getInstance()
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ val SiteModel.isSimpleWPComSite
val SiteModel.adminUrlOrDefault
get() = adminUrl ?: url.slashJoin("wp-admin")

val SiteModel.loginUrlOrDefault
get() = loginUrl ?: url.slashJoin("wp-login.php")

val SiteModel.clock: Clock
@Suppress("TooGenericExceptionCaught")
get() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.woocommerce.android.extensions

import org.apache.commons.text.StringEscapeUtils
import java.net.URLEncoder
import java.nio.charset.Charset
import java.text.DecimalFormat
import java.util.Locale
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.math.log10
import kotlin.math.pow

Expand Down Expand Up @@ -94,7 +98,14 @@ fun String.isVersionAtLeast(minVersion: String): Boolean {
*/
fun String.orNullIfEmpty(): String? = this.ifEmpty { null }

fun String?.isNotNullOrEmpty() = this.isNullOrEmpty().not()
@OptIn(ExperimentalContracts::class)
fun String?.isNotNullOrEmpty(): Boolean {
contract {
returns(true) implies (this@isNotNullOrEmpty != null)
}

return this.isNullOrEmpty().not()
}

fun String.toCamelCase(delimiter: String = " "): String {
return split(delimiter).joinToString(delimiter) { word ->
Expand Down Expand Up @@ -129,3 +140,7 @@ fun String.readableFileSize(): String {
return DecimalFormat("#,##0.#")
.format(size / BYTES_IN_KILOBYTE.pow(digitGroups.toDouble())) + " " + units[digitGroups]
}

fun String.urlEncode(charset: Charset = Charsets.UTF_8): String {
return URLEncoder.encode(this, charset.name())
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import com.woocommerce.android.ui.analytics.hub.sync.UpdateAnalyticsHubStats
import com.woocommerce.android.ui.analytics.hub.sync.toAnalyticData
import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection
import com.woocommerce.android.ui.analytics.ranges.StatsTimeRangeSelection.SelectionType
import com.woocommerce.android.ui.common.webview.CanAutoAuthenticateInWebView
import com.woocommerce.android.ui.dashboard.DashboardStatsUsageTracksEventEmitter
import com.woocommerce.android.ui.dashboard.domain.ObserveLastUpdate
import com.woocommerce.android.ui.feedback.FeedbackRepository
Expand Down Expand Up @@ -101,6 +102,7 @@ class AnalyticsHubViewModel @Inject constructor(
private val selectedSite: SelectedSite,
private val getReportUrl: GetReportUrl,
private val observeAnalyticsCardsConfiguration: ObserveAnalyticsCardsConfiguration,
private val canAutoAuthenticateInWebView: CanAutoAuthenticateInWebView,
savedState: SavedStateHandle
) : ScopedViewModel(savedState) {

Expand Down Expand Up @@ -200,7 +202,7 @@ class AnalyticsHubViewModel @Inject constructor(
fun onSeeReport(url: String, card: ReportCard) {
trackSeeReportInteraction(card)
selectedSite.getOrNull()?.let { site ->
val event = if (site.isWpComStore) {
val event = if (canAutoAuthenticateInWebView(url)) {
AnalyticsViewEvent.OpenAuthenticatedWebView(url)
} else {
AnalyticsViewEvent.OpenUrl(url)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.woocommerce.android.ui.common.webview

import javax.inject.Inject

/**
* A utility use-case to allow consumers to know beforehand if a URL can be auto-authenticated in a WebView.
*/
class CanAutoAuthenticateInWebView @Inject constructor(
private val authenticationFlowResolver: WebViewAuthenticationFlowResolver
) {
operator fun invoke(url: String): Boolean {
val authenticationFlow = authenticationFlowResolver.resolve(url)
return authenticationFlow != WebViewAuthenticationFlowResolver.WebViewAuthenticationFlow.None
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.woocommerce.android.ui.common.webview

import androidx.annotation.VisibleForTesting
import com.woocommerce.android.extensions.isNotNullOrEmpty
import com.woocommerce.android.tools.SelectedSite
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.store.AccountStore
import javax.inject.Inject

class WebViewAuthenticationFlowResolver @Inject constructor(
private val selectedSite: SelectedSite,
private val accountStore: AccountStore
) {
// A list of domains that we know that wordpress.com supports redirecting to
private val wpComAuthAcceptedDomains
get() = listOf("wordpress.com", "wp.com", "jetpack.com", "woocommerce.com")

fun resolve(url: String): WebViewAuthenticationFlow {
val currentSite = selectedSite.getOrNull()
val urlDomain = url.findDomain()
val isWPComAuthenticated = accountStore.accessToken.isNotNullOrEmpty() &&
accountStore.account.userName.isNotNullOrEmpty()

return if (isWPComAuthenticated) {
when {
wpComAuthAcceptedDomains.any { it == urlDomain } ||
(currentSite?.isWPComAtomic == true && url.isPartOf(currentSite)) -> {
WebViewAuthenticationFlow.WPCom
}

currentSite?.supportsJetpackSSO() == true && url.isPartOf(currentSite) -> {
WebViewAuthenticationFlow.JetpackSSO
}

else -> {
WebViewAuthenticationFlow.None
}
}
} else if (currentSite?.username.isNotNullOrEmpty() &&
currentSite.password.isNotNullOrEmpty() &&
url.isPartOf(currentSite)
) {
WebViewAuthenticationFlow.SiteCredentials
} else {
WebViewAuthenticationFlow.None
}
}

@VisibleForTesting
fun String.isPartOf(site: SiteModel): Boolean {
// This is a simple check, so it could miss some edge cases, but it should be good enough for our use-case
// We are using contains instead of equals to account for potential subdomains
return findDomain().contains(site.url.findDomain())
}

private fun String.findDomain(): String = toHttpUrl().host.substringAfter("www.")

private fun SiteModel.supportsJetpackSSO(): Boolean {
return jetpackModules?.contains("sso") == true
}

enum class WebViewAuthenticationFlow {
WPCom, JetpackSSO, SiteCredentials, None
}
}
Original file line number Diff line number Diff line change
@@ -1,51 +1,140 @@
package com.woocommerce.android.ui.common.webview

import android.webkit.CookieManager
import android.webkit.WebView
import com.woocommerce.android.extensions.isNotNullOrEmpty
import androidx.annotation.VisibleForTesting
import com.woocommerce.android.extensions.loginUrlOrDefault
import com.woocommerce.android.extensions.urlEncode
import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.ui.compose.component.web.WCWebViewEvent
import com.woocommerce.android.util.WooLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.wordpress.android.fluxc.store.AccountStore
import java.io.UnsupportedEncodingException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Locale
import javax.inject.Inject

private const val WPCOM_LOGIN_URL = "https://wordpress.com/wp-login.php"

class WebViewAuthenticator @Inject constructor(
private val accountStore: AccountStore
private val authenticationFlowResolver: WebViewAuthenticationFlowResolver,
private val selectedSite: SelectedSite,
private val accountStore: AccountStore,
private val webViewCookieManager: CookieManager
) {
fun authenticateAndLoadUrl(webView: WebView, url: String) {
getAuthPostData(url).let { postData ->
if (postData.isNotEmpty()) {
webView.postUrl(WPCOM_LOGIN_URL, postData.toByteArray())
} else {
suspend fun authenticateAndLoadUrl(webView: WebView, url: String, webViewEvents: Flow<WCWebViewEvent>) {
val authenticationFlow = authenticationFlowResolver.resolve(url)
when (authenticationFlow) {
WebViewAuthenticationFlowResolver.WebViewAuthenticationFlow.WPCom -> {
authenticateWPComAndLoad(webView, url)
}

WebViewAuthenticationFlowResolver.WebViewAuthenticationFlow.JetpackSSO -> {
authenticateSSOAndLoad(webView, url, webViewEvents)
}

WebViewAuthenticationFlowResolver.WebViewAuthenticationFlow.SiteCredentials -> {
authenticateUsingSiteCredentialsAndLoad(webView, url, webViewEvents)
}

WebViewAuthenticationFlowResolver.WebViewAuthenticationFlow.None -> {
webView.loadUrl(url)
}
}
}

private fun authenticateWPComAndLoad(webView: WebView, url: String): Boolean {
val postData = prepareLoginPostData(
redirectUrl = url,
username = accountStore.account.userName,
authorizationParam = "authorization" to "Bearer ${accountStore.accessToken}"
)

if (postData != null) {
webView.postUrl(WPCOM_LOGIN_URL, postData.toByteArray())
return true
} else {
webView.loadUrl(url)
return false
}
}

private suspend fun authenticateSSOAndLoad(webView: WebView, url: String, webViewEvents: Flow<WCWebViewEvent>) {
authenticateWPComAndLoad(webView, JETPACK_SSO_TEMP_REDIRECT_URL).also {
if (!it) {
// The authentication failed, so load the original URL
webView.loadUrl(url)
return
}
}

// Wait for the WPCom login to complete
webViewEvents.first { it is WCWebViewEvent.PageFinished && it.url == JETPACK_SSO_TEMP_REDIRECT_URL }

// Handle SSO login and redirect back to the original URL
val site = selectedSite.get()
webViewCookieManager.setCookie(site.url, "jetpack_sso_redirect_to=$url")
val ssoLoginUrl = site.loginUrlOrDefault.toHttpUrl().newBuilder()
.addQueryParameter("action", "jetpack-sso")
.build()
.toString()

webView.loadUrl(ssoLoginUrl)
}

@Suppress("ReturnCount")
private fun getAuthPostData(redirectUrl: String): String {
val username = accountStore.account.userName.takeIf { it.isNotNullOrEmpty() } ?: return ""
val token = accountStore.accessToken.takeIf { it.isNotNullOrEmpty() } ?: return ""
private suspend fun authenticateUsingSiteCredentialsAndLoad(
webView: WebView,
url: String,
webViewEvents: Flow<WCWebViewEvent>
) {
val site = selectedSite.get()

val postData = prepareLoginPostData(
redirectUrl = url,
username = site.username,
authorizationParam = "pwd" to site.password
)

if (postData != null) {
webView.postUrl(site.loginUrlOrDefault, postData.toByteArray())
} else {
webView.loadUrl(url)
}

val event = webViewEvents.firstOrNull { it is WCWebViewEvent.PageFinished || it is WCWebViewEvent.UrlFailed }
if (event is WCWebViewEvent.UrlFailed && event.url == site.loginUrlOrDefault) {
// In case we failed to authenticate, load the original URL
// The failure could happen if some other security measures were added that would prevent
// native handling of login (like using a custom login page or a captcha)
WooLog.w(WooLog.T.UTILS, "Failed to authenticate the WebView using site credentials, load the original URL")
webView.loadUrl(url)
}
}

val utf8 = StandardCharsets.UTF_8.name()
try {
var postData = String.format(
Locale.ROOT,
"log=%s&redirect_to=%s",
URLEncoder.encode(username, utf8),
URLEncoder.encode(redirectUrl, utf8),
)
private fun prepareLoginPostData(
redirectUrl: String,
username: String,
authorizationParam: Pair<String, String>,
): String? {
val (authorizationKey, authorizationValue) = authorizationParam
return try {
buildString {
append("redirect_to=").append(redirectUrl.urlEncode())

// Add token authorization
postData += "&authorization=Bearer " + URLEncoder.encode(token, utf8)
append("&log=").append(username.urlEncode())

return postData
append("&${authorizationKey.urlEncode()}=")
.append(authorizationValue.urlEncode())
}
} catch (e: UnsupportedEncodingException) {
WooLog.e(WooLog.T.UTILS, e)
null
}
return ""
}

companion object {
@VisibleForTesting
const val WPCOM_LOGIN_URL = "https://wordpress.com/wp-login.php"
const val JETPACK_SSO_TEMP_REDIRECT_URL = "https://wordpress.com/mobile-redirect"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,15 @@ fun WCWebView(

webView?.let { webView ->
LaunchedEffect(url) {
authenticator?.authenticateAndLoadUrl(webView, url) ?: webView.loadUrl(url)
if (authenticator != null) {
authenticator.authenticateAndLoadUrl(
webView = webView,
url = url,
webViewEvents = webViewClient.eventsObservable
)
} else {
webView.loadUrl(url)
}
canGoBack = webView.canGoBack()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.CallSuper
import com.woocommerce.android.ui.common.webview.WebViewAuthenticator
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
Expand Down Expand Up @@ -44,4 +46,14 @@ open class WCWebViewClient : WebViewClient() {
_eventsObservable.tryEmit(WCWebViewEvent.UrlFailed(url.toString(), errorResponse?.statusCode))
}
}

@CallSuper
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return if (request?.url?.toString() == WebViewAuthenticator.JETPACK_SSO_TEMP_REDIRECT_URL) {
// Cancel loading for the temporary redirect URL
true
} else {
false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class WPApiSiteRepository @Inject constructor(
}
}

suspend fun fetchSite(url: String, username: String?, password: String?): Result<SiteModel> {
suspend fun fetchSite(url: String, username: String? = null, password: String? = null): Result<SiteModel> {
WooLog.d(WooLog.T.LOGIN, "Fetching site using WP REST API")

return siteStore.fetchWPAPISite(
Expand Down
Loading

0 comments on commit 0530338

Please sign in to comment.