Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 32040004 authored by Ricki Hirner's avatar Ricki Hirner
Browse files

Nextcloud: use Login Flow v2 for better browsing experience and to support things like Webauthn

parent 63572ad6
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -106,9 +106,10 @@ dependencies {
    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10'

    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.browser:browser:1.2.0'
    implementation 'androidx.cardview:cardview:1.0.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
    implementation 'androidx.core:core-ktx:1.3.1'
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.fragment:fragment-ktx:1.2.5'
    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
+5 −0
Original line number Diff line number Diff line
@@ -242,6 +242,11 @@
            <action android:name="*" />
            <data android:scheme="content" android:host="com.android.calendar" />
        </intent>

        <!-- Custom Tabs support (e.g. Nextcloud Login Flow) -->
        <intent>
            <action android:name="android.support.customtabs.action.CustomTabsService" />
        </intent>
    </queries>

</manifest>
+4 −1
Original line number Diff line number Diff line
@@ -43,13 +43,16 @@ object UiUtils {
     * installed), this method does nothing.
     *
     * @param toastInstallBrowser whether to show "Please install a browser" toast when
     * the Intent could not be resolved
     * the Intent could not be resolved; will also add [Intent.CATEGORY_BROWSABLE] to the Intent
     *
     * @return true on success, false if the Intent could not be resolved (for instance, because
     * there is no user agent installed)
     */
    fun launchUri(context: Context, uri: Uri, action: String = Intent.ACTION_VIEW, toastInstallBrowser: Boolean = true): Boolean {
        val intent = Intent(action, uri)
        if (toastInstallBrowser)
            intent.addCategory(Intent.CATEGORY_BROWSABLE)

        if (intent.resolveActivity(context.packageManager) != null) {
            context.startActivity(intent)
            return true
+194 −89
Original line number Diff line number Diff line
package at.bitfire.davdroid.ui.setup

import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Application
import android.content.Intent
import android.net.http.SslError
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.*
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import at.bitfire.davdroid.BuildConfig
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.ui.DebugInfoActivity
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_webview.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URLDecoder
import java.util.logging.Level


class NextcloudLoginFlowFragment: Fragment() {

    companion object {

        /**
         * Format of the Nextcloud Login URL, see:
         * https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html
         *
         * The parameter values must be URL-decoded. For instance, Nextcloud passes the username
         * "test@example.com" as "user:test%40example.com".
         */
        val LOGIN_URL_FORMAT = Regex("^nc://login/server:(.+)&user:(.+)&password:(.+)$")
        const val LOGIN_FLOW_V1_PATH = "/index.php/login/flow"
        const val LOGIN_FLOW_V2_PATH = "/index.php/login/v2"

        /** Set this to 1 to indicate that Login Flow shall be used. */
        const val EXTRA_LOGIN_FLOW = "loginFlow"
@@ -41,109 +53,202 @@ class NextcloudLoginFlowFragment: Fragment() {
        /** Path to DAV endpoint (e.g. `/remote.php/dav`). Will be appended to the
         *  server URL returned by Login Flow without further processing. */
        const val EXTRA_DAV_PATH = "davPath"

        const val REQUEST_BROWSER = 0
    }

    val loginModel by activityViewModels<LoginModel>()
    val loginFlowModel by viewModels<LoginFlowModel>()


    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.activity_webview, container, false)
        val progressBar = view.progress
        val view = View(requireActivity())

        val loginUrl = requireActivity().intent.data ?: throw IllegalArgumentException("Intent data must be set to Login Flow URL")
        Logger.log.info("Using Login Flow URL: $loginUrl")
        val entryUrl = requireActivity().intent.data ?: throw IllegalArgumentException("Intent data must be set to Login Flow URL")
        Logger.log.info("Using Login Flow entry point: $entryUrl")

        val webView = view.browser
        webView.settings.apply {
            javaScriptEnabled = true
            userAgentString = BuildConfig.userAgent
        }
        webView.webViewClient = CustomWebViewClient()
        webView.webChromeClient = object: WebChromeClient() {
            override fun onProgressChanged(view: WebView, newProgress: Int) {
                progressBar.progress = newProgress
                progressBar.visibility = if (newProgress == 100) View.GONE else View.VISIBLE
            }
        }
        webView.loadUrl(
                loginUrl.toString(),   // https://nextcloud.example.com/index.php/login/flow
                mapOf(Pair("OCS-APIREQUEST", "true"))
        )
        return view
    }
        loginFlowModel.loginUrl.observe(viewLifecycleOwner) { loginUrl ->
            if (loginUrl == null)
                return@observe
            val loginUri = loginUrl.toUri()

    private fun onReceivedNcUrl(url: String) {
        val match = LOGIN_URL_FORMAT.find(url)
        if (match == null) {
            Logger.log.severe("Unknown format of nc URL: $url")
            return
        }
            // reset URL so that the browser isn't shown another time
            loginFlowModel.loginUrl.value = null

        // determine DAV URL from root URL
        try {
            val serverUrl = URLDecoder.decode(match.groupValues[1], Charsets.UTF_8.name())
            val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH)
            loginModel.baseURI = if (davPath != null)
                (serverUrl + davPath).toHttpUrl().toUri()
            if (haveCustomTabs(loginUri)) {
                // Custom Tabs are available
                val browser = CustomTabsIntent.Builder()
                        .setToolbarColor(resources.getColor(R.color.primaryColor))
                        .build()
                browser.intent.data = loginUri
                startActivityForResult(browser.intent, REQUEST_BROWSER, browser.startAnimationBundle)

            } else {
                // fallback: launch normal browser
                val browser = Intent(Intent.ACTION_VIEW, loginUri)
                browser.addCategory(Intent.CATEGORY_BROWSABLE)
                if (browser.resolveActivity(requireActivity().packageManager) != null)
                    startActivityForResult(browser, REQUEST_BROWSER)
                else
                URI.create(serverUrl)
                    Snackbar.make(view, getString(R.string.install_browser), Snackbar.LENGTH_INDEFINITE).show()
            }
        }

            loginModel.credentials = Credentials(
                    userName = URLDecoder.decode(match.groupValues[2], Charsets.UTF_8.name()),
                    password = URLDecoder.decode(match.groupValues[3], Charsets.UTF_8.name())
            )
        loginFlowModel.error.observe(viewLifecycleOwner) { exception ->
            Snackbar.make(requireView(), exception.toString(), Snackbar.LENGTH_INDEFINITE)
                    .setAction(R.string.exception_show_details) {
                        val intent = Intent(requireActivity(), DebugInfoActivity::class.java)
                        intent.putExtra(DebugInfoActivity.EXTRA_CAUSE, exception)
                        startActivity(intent)
                    }
                    .show()
        }

        loginFlowModel.loginData.observe(viewLifecycleOwner) { (baseUri, credentials) ->
            // continue to next fragment
            loginModel.baseURI = baseUri
            loginModel.credentials = credentials
            parentFragmentManager.beginTransaction()
                    .replace(android.R.id.content, DetectConfigurationFragment(), null)
                    .addToBackStack(null)
                    .commit()
        } catch (e: IllegalArgumentException) {
            Logger.log.log(Level.SEVERE, "Couldn't parse server argument of nc URL: $url", e)
        }

        // start Login Flow
        loginFlowModel.setUrl(entryUrl)

        return view
    }

    private fun haveCustomTabs(uri: Uri): Boolean {
        val browserIntent = Intent()
                .setAction(Intent.ACTION_VIEW)
                .addCategory(Intent.CATEGORY_BROWSABLE)
                .setData(uri)
        val pm = requireActivity().packageManager
        val appsSupportingCustomTabs = pm.queryIntentActivities(browserIntent, 0)
        for (pkg in appsSupportingCustomTabs) {
            // check whether app resolves Custom Tabs service, too
            val serviceIntent = Intent(ACTION_CUSTOM_TABS_CONNECTION).apply {
                setPackage(pkg.activityInfo.packageName)
            }
            if (pm.resolveService(serviceIntent, 0) != null)
                return true
        }
        return false
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode != REQUEST_BROWSER)
            return

    inner class CustomWebViewClient: WebViewClient() {
        override fun shouldOverrideUrlLoading(view: WebView, url: String) =
                if (url.startsWith("nc://login")) {
                    Logger.log.fine("Received nc URL: $url")
                    onReceivedNcUrl(url)
                    true
                } else {
                    Logger.log.fine("Didn't handle $url")
                    false
        val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH)
        loginFlowModel.checkResult(davPath)
    }

        override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
            Logger.log.warning("Received error (deprecated API) $errorCode $description on $failingUrl")
            showError(view, "$description ($errorCode)")

    /**
     * Implements Login Flow v2.
     *
     * @see https://docs.nextcloud.com/server/20/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
     */
    class LoginFlowModel(app: Application): AndroidViewModel(app) {

        val error = MutableLiveData<Exception>()
        val loginUrl = MutableLiveData<String>()

        val httpClient by lazy {
            HttpClient.Builder(getApplication())
                    .setForeground(true)
                    .build()
        }

        var pollUrl: HttpUrl? = null
        var token: String? = null

        val loginData = MutableLiveData<Pair<URI, Credentials>>()

        override fun onCleared() {
            httpClient.close()
        }


        @UiThread
        fun setUrl(entryUri: Uri) {
            val entryUrl = entryUri.toString()
            val v2Url =
                    if (entryUrl.endsWith(LOGIN_FLOW_V1_PATH))
                        // got Login Flow v1 URL, rewrite to v2
                        entryUrl.removeSuffix(LOGIN_FLOW_V1_PATH) + LOGIN_FLOW_V2_PATH
                    else
                        entryUrl

            // send POST request and process JSON reply
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    val json = postForJson(v2Url.toHttpUrl(), "".toRequestBody())

                    // login URL
                    loginUrl.postValue(json.getString("login"))

                    // poll URL and token
                    json.getJSONObject("poll").let { poll ->
                        pollUrl = poll.getString("endpoint").toHttpUrl()
                        token = poll.getString("token")
                    }
        @TargetApi(23)
        override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
            Logger.log.warning("Received error ${error.errorCode} on ${request.url}")
            if (request.isForMainFrame)
                showError(view, "${error.description} (${error.errorCode})")
                } catch (e: Exception) {
                    error.postValue(e)
                }
        override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
            val message = "${errorResponse.statusCode} ${errorResponse.reasonPhrase}"
            Logger.log.warning("Received HTTP error $message on ${request.url}")
            if (request.isForMainFrame)
                showError(view, message)
            }
        override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
            Logger.log.warning("Received TLS error ${error.primaryError} on ${error.url}")
            if (error.url == view.url)
                showError(view, getString(R.string.login_webview_tlserror, error.primaryError))
            handler.cancel()
        }

        fun showError(view: WebView, message: CharSequence) {
            Snackbar.make(requireView(), message, Snackbar.LENGTH_INDEFINITE)
                    .setAction(R.string.login_webview_retry) { view.reload() }
                    .show()
        @UiThread
        fun checkResult(davPath: String?) {
            val pollUrl = pollUrl ?: return
            val token = token ?: return

            CoroutineScope(Dispatchers.IO).launch {
                val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
                val serverUrl = json.getString("server")
                val loginName = json.getString("loginName")
                val appPassword = json.getString("appPassword")

                val baseUri = if (davPath != null)
                    URI.create(serverUrl + davPath)
                else
                    URI.create(serverUrl)

                loginData.postValue(Pair(
                        baseUri,
                        Credentials(loginName, appPassword)
                ))
            }
        }

        @WorkerThread
        private fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject {
            val postRq = Request.Builder()
                    .url(url)
                    .post(requestBody)
                    .build()
            val response = httpClient.okHttpClient.newCall(postRq).execute()

            if (response.code != HttpURLConnection.HTTP_OK)
                throw HttpException(response)

            response.body?.use { body ->
                val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
                if (mimeType.type != "application" || mimeType.subtype != "json")
                    throw DavException("Invalid Login Flow response (not JSON)")

                // decode JSON
                return JSONObject(body.string())
            }

            throw DavException("Invalid Login Flow response (no body)")
        }

    }


+0 −22
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">

    <WebView
        android:id="@+id/browser"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toBottomOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>

</androidx.constraintlayout.widget.ConstraintLayout>
 No newline at end of file
Loading