Migrate Nextcloud login to login flow v2

Close #959
This commit is contained in:
MM20 2025-04-20 13:32:56 +02:00
parent 4485b59ade
commit 53f305acd9
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
4 changed files with 108 additions and 57 deletions

View File

@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.kotlin.plugin.compose) alias(libs.plugins.kotlin.plugin.compose)
} }
@ -53,5 +54,6 @@ dependencies {
api(project(":libs:webdav")) api(project(":libs:webdav"))
implementation(project(":core:i18n")) implementation(project(":core:i18n"))
implementation(project(":core:base"))
} }

View File

@ -1,14 +1,12 @@
package de.mm20.launcher2.nextcloud package de.mm20.launcher2.nextcloud
import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.webkit.WebResourceRequest import android.util.Log
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -40,6 +38,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -118,8 +117,9 @@ class LoginActivity : AppCompatActivity() {
if (!(url.startsWith("http://") || url.startsWith("https://"))) { if (!(url.startsWith("http://") || url.startsWith("https://"))) {
url = "https://$url" url = "https://$url"
} }
if (nextcloudClient.checkNextcloudInstallation(url)) { val flow = nextcloudClient.startLoginFlow(url)
openLoginPage(url) if (flow != null) {
openLoginPage(flow)
} else { } else {
error = getString(R.string.nextcloud_server_invalid_url) error = getString(R.string.nextcloud_server_invalid_url)
} }
@ -135,49 +135,24 @@ class LoginActivity : AppCompatActivity() {
} }
} }
private fun openLoginPage(url: String) { private var currentLoginFlow: LoginFlowResponse? = null
val webView = WebView(this) private fun openLoginPage(flow: LoginFlowResponse) {
webView.settings.userAgentString = getString(R.string.app_name) currentLoginFlow = flow
webView.webViewClient = object : WebViewClient() { val customTabIntent = CustomTabsIntent.Builder().build()
override fun shouldOverrideUrlLoading( customTabIntent.launchUrl(this, flow.login.toUri())
view: WebView?, }
request: WebResourceRequest?
): Boolean { override fun onResume() {
if (request?.url?.scheme == "nc") { super.onResume()
val path = request.url?.path?.trim('/') ?: run { val flow = currentLoginFlow ?: return
setResult(0) lifecycleScope.launch {
finish() val result = nextcloudClient.pollLoginFlow(flow)
return false if (result != null) {
} nextcloudClient.setServer(result.server, result.loginName, result.appPassword)
val segments = path.split('&') currentLoginFlow = null
var username: String? = null finish()
var token: String? = null
var server: String? = null
for (segment in segments) {
when {
segment.startsWith("server") -> server = segment.substringAfter(":")
segment.startsWith("user") -> username = segment.substringAfter(":")
segment.startsWith("password") -> token = segment.substringAfter(":")
}
}
if (username != null && server != null && token != null) {
nextcloudClient.setServer(server, username, token)
}
setResult(Activity.RESULT_OK)
finish()
return true
}
webView.loadUrl(request?.url?.toString() ?: "")
return false
} }
} }
webView.settings.javaScriptEnabled = true
setContentView(webView)
val headers = mapOf(
"OCS-APIREQUEST" to "true"
)
webView.loadUrl("$url/index.php/login/flow", headers)
} }
private val nextcloudLight: ColorScheme private val nextcloudLight: ColorScheme

View File

@ -0,0 +1,22 @@
package de.mm20.launcher2.nextcloud
import kotlinx.serialization.Serializable
@Serializable
internal data class LoginFlowResponse(
val poll: LoginFlowResponsePoll,
val login: String,
)
@Serializable
internal data class LoginFlowResponsePoll(
val token: String,
val endpoint: String,
)
@Serializable
internal data class LoginPollResponse(
val server: String,
val loginName: String,
val appPassword: String,
)

View File

@ -4,14 +4,25 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import de.mm20.launcher2.serialization.Json
import de.mm20.launcher2.webdav.WebDavApi import de.mm20.launcher2.webdav.WebDavApi
import de.mm20.launcher2.webdav.WebDavFile import de.mm20.launcher2.webdav.WebDavFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.* import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import okhttp3.internal.EMPTY_REQUEST
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -65,20 +76,61 @@ class NextcloudApiHelper(val context: Context) {
} }
suspend fun checkNextcloudInstallation(url: String): Boolean { /**
var url = url * Perform a POST request to the /index.php/login/v2 endpoint of the given Nextcloud server
if (!url.startsWith("http://") && !url.startsWith("https://")) { * and return the response. Returns null if an error occurs.
url = "https://$url" */
} internal suspend fun startLoginFlow(serverUrl: String): LoginFlowResponse? {
val request = Request.Builder() val request = Request.Builder()
.url("$url/remote.php/dav") .url("$serverUrl/index.php/login/v2")
.method("POST", EMPTY_REQUEST)
.header("user-agent", context.getString(R.string.app_name))
.build() .build()
val response = runCatching { val response = try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
httpClient.newCall(request).execute() httpClient.newCall(request).execute()
} }
}.getOrNull() ?: return false } catch (e: IOException) {
return response.code == 200 || response.code == 401 Log.e("NextcloudApiHelper", "HTTP error", e)
null
}
if (response?.code != 200 || response.body == null) {
Log.e("NextcloudApiHelper", "Invalid response: ${response?.code} ${response?.message}")
return null
}
return try {
Json.Lenient.decodeFromStream<LoginFlowResponse>(response.body!!.byteStream())
} catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e)
null
}
}
internal suspend fun pollLoginFlow(loginFlow: LoginFlowResponse): LoginPollResponse? {
val request = Request.Builder()
.url(loginFlow.poll.endpoint)
.method("POST", FormBody.Builder().add("token", loginFlow.poll.token).build())
.build()
val response = try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
}
} catch (e: IOException) {
Log.e("NextcloudApiHelper", "HTTP error", e)
null
}
if (response?.code != 200 || response.body == null) {
Log.e("NextcloudApiHelper", "Invalid response: ${response?.code} ${response?.message}")
return null
}
return try {
Json.Lenient.decodeFromStream<LoginPollResponse>(response.body!!.byteStream())
} catch (e: SerializationException) {
Log.e("NextcloudApiHelper", "Invalid response body", e)
null
}
} }
suspend fun getLoggedInUser(): NcUser? { suspend fun getLoggedInUser(): NcUser? {