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

View File

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

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.Intent
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import de.mm20.launcher2.serialization.Json
import de.mm20.launcher2.webdav.WebDavApi
import de.mm20.launcher2.webdav.WebDavFile
import kotlinx.coroutines.Dispatchers
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 java.io.File
import java.io.IOException
@ -65,20 +76,61 @@ class NextcloudApiHelper(val context: Context) {
}
suspend fun checkNextcloudInstallation(url: String): Boolean {
var url = url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
/**
* Perform a POST request to the /index.php/login/v2 endpoint of the given Nextcloud server
* and return the response. Returns null if an error occurs.
*/
internal suspend fun startLoginFlow(serverUrl: String): LoginFlowResponse? {
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()
val response = runCatching {
val response = try {
withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
}
}.getOrNull() ?: return false
return response.code == 200 || response.code == 401
} 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<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? {