mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
Use Ktor for Nextcloud Login Flow (#1817)
* [WIP] Use Ktor for Nextcloud login flow - Replace OkHttp with Ktor for HTTP requests - Update URL handling to use Ktor's `Url` class - Adjust `postForJson` method to use Ktor's HTTP client - Refactor URL building logic for login flow initiation * Use Ktor for Nextcloud login flow - Migrate to Ktor's ContentNegotiation plugin for JSON handling - Update dependencies and configuration for Ktor serialization - Refactor `NextcloudLoginFlow` to use Ktor's JSON serialization * Add tests * Allow unit tests that mock/use HttpClient without Conscrypt * KDoc * Minor fixes * Use toUrlOrNull from dav4jvm * Don't change strings in this PR * Update dav4jvm and synctools
This commit is contained in:
@@ -7,8 +7,8 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
@@ -190,8 +190,10 @@ dependencies {
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
|
||||
@@ -29,8 +29,7 @@ class ConscryptIntegration {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
val alreadyInstalled = conscryptInstalled()
|
||||
if (!alreadyInstalled) {
|
||||
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
|
||||
// install Conscrypt as most preferred provider
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.google.errorprone.annotations.MustBeClosed
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
@@ -391,6 +393,11 @@ class HttpClientBuilder @Inject constructor(
|
||||
val client = HttpClient(OkHttp) {
|
||||
// Ktor-level configuration here
|
||||
|
||||
// automatically convert JSON from/into data classes (if requested in respective code)
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
|
||||
engine {
|
||||
// okhttp engine configuration here
|
||||
|
||||
|
||||
@@ -4,25 +4,22 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import at.bitfire.davdroid.util.withTrailingSlash
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
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 io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.appendPathSegments
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.path
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -35,6 +32,113 @@ class NextcloudLoginFlow @Inject constructor(
|
||||
httpClientBuilder: HttpClientBuilder
|
||||
) {
|
||||
|
||||
private val httpClient = httpClientBuilder.buildKtor()
|
||||
|
||||
// Login flow state
|
||||
var pollUrl: Url? = null
|
||||
var token: String? = null
|
||||
|
||||
/**
|
||||
* Starts Nextcloud Login Flow v2.
|
||||
*
|
||||
* @param baseUrl Nextcloud login flow or base URL
|
||||
*
|
||||
* @return URL that should be opened in the browser (login screen)
|
||||
*/
|
||||
suspend fun start(baseUrl: Url): Url {
|
||||
// reset fields in case something goes wrong
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
// POST to login flow URL in order to receive endpoint data
|
||||
val result = httpClient.post(loginFlowUrl(baseUrl))
|
||||
val endpointData: EndpointData = result.body()
|
||||
|
||||
// save endpoint data for polling
|
||||
pollUrl = Url(endpointData.poll.endpoint)
|
||||
token = endpointData.poll.token
|
||||
|
||||
return Url(endpointData.login)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun loginFlowUrl(baseUrl: Url): Url {
|
||||
return when {
|
||||
// already a Login Flow v2 URL
|
||||
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
|
||||
baseUrl
|
||||
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
|
||||
// drop "[index.php/login]/flow" from the end and append "/v2"
|
||||
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
|
||||
val builder = URLBuilder(baseUrl)
|
||||
builder.path(*v2Segments.toTypedArray())
|
||||
builder.build()
|
||||
}
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
else ->
|
||||
URLBuilder(baseUrl)
|
||||
.appendPathSegments(FLOW_V2_PATH.split('/'))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
|
||||
*/
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
val result = httpClient.post(pollUrl) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody("token=$token")
|
||||
}
|
||||
val loginData: LoginData = result.body()
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val serverUrl = loginData.server.withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = loginData.loginName,
|
||||
password = loginData.appPassword.toSensitiveString()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Represents the JSON response that is returned on the first call to `/login/v2`.
|
||||
*/
|
||||
@Serializable
|
||||
private data class EndpointData(
|
||||
val poll: Poll,
|
||||
val login: String
|
||||
) {
|
||||
@Serializable
|
||||
data class Poll(
|
||||
val token: String,
|
||||
val endpoint: String
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the JSON response that is returned by the polling endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
private data class LoginData(
|
||||
val server: String,
|
||||
val loginName: String,
|
||||
val appPassword: String
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
const val FLOW_V1_PATH = "index.php/login/flow"
|
||||
const val FLOW_V2_PATH = "index.php/login/v2"
|
||||
@@ -43,92 +147,4 @@ class NextcloudLoginFlow @Inject constructor(
|
||||
const val DAV_PATH = "remote.php/dav"
|
||||
}
|
||||
|
||||
val httpClient = httpClientBuilder.build()
|
||||
|
||||
|
||||
// Login flow state
|
||||
var loginUrl: HttpUrl? = null
|
||||
var pollUrl: HttpUrl? = null
|
||||
var token: String? = null
|
||||
|
||||
|
||||
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
|
||||
loginUrl = null
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
|
||||
|
||||
loginUrl = json.getString("login").toHttpUrlOrNull()
|
||||
json.getJSONObject("poll").let { poll ->
|
||||
pollUrl = poll.getString("endpoint").toHttpUrl()
|
||||
token = poll.getString("token")
|
||||
}
|
||||
|
||||
return loginUrl
|
||||
}
|
||||
|
||||
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
|
||||
val path = baseUrl.encodedPath
|
||||
|
||||
if (path.endsWith(FLOW_V2_PATH))
|
||||
// already a Login Flow v2 URL
|
||||
return baseUrl
|
||||
|
||||
if (path.endsWith(FLOW_V1_PATH))
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
return baseUrl.newBuilder()
|
||||
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
|
||||
.build()
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
return baseUrl.newBuilder()
|
||||
.addPathSegments(FLOW_V2_PATH)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val serverUrl = json.getString("server").withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = json.getString("loginName"),
|
||||
password = json.getString("appPassword").toSensitiveString()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
|
||||
val postRq = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val response = runInterruptible {
|
||||
httpClient.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@withContext JSONObject(body.string())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,15 +10,15 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.dav4jvm.ktor.toUrlOrNull
|
||||
import at.bitfire.davdroid.network.NextcloudLoginFlow
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@@ -46,24 +46,19 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
val error: String? = null,
|
||||
|
||||
/** URL to open in the browser (set during Login Flow) */
|
||||
val loginUrl: HttpUrl? = null,
|
||||
val loginUrl: Url? = null,
|
||||
|
||||
/** login info (set after successful login) */
|
||||
val result: LoginInfo? = null
|
||||
) {
|
||||
val baseUrlWithPrefix =
|
||||
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
|
||||
baseUrl
|
||||
else
|
||||
"https://$baseUrl"
|
||||
val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull()
|
||||
|
||||
val baseHttpUrl: HttpUrl? = run {
|
||||
val baseUrlWithPrefix =
|
||||
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
|
||||
baseUrl
|
||||
else
|
||||
"https://$baseUrl"
|
||||
|
||||
baseUrlWithPrefix.toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
val canContinue = !inProgress && baseHttpUrl != null
|
||||
|
||||
val canContinue = !inProgress && baseKtorUrl != null
|
||||
}
|
||||
|
||||
var uiState by mutableStateOf(UiState())
|
||||
@@ -107,7 +102,7 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
* Starts the Login Flow.
|
||||
*/
|
||||
fun startLoginFlow() {
|
||||
val baseUrl = uiState.baseHttpUrl
|
||||
val baseUrl = uiState.baseKtorUrl
|
||||
if (uiState.inProgress || baseUrl == null)
|
||||
return
|
||||
|
||||
@@ -118,13 +113,12 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val loginUrl = loginFlow.initiate(baseUrl)
|
||||
val loginUrl = loginFlow.start(baseUrl)
|
||||
|
||||
uiState = uiState.copy(
|
||||
loginUrl = loginUrl,
|
||||
inProgress = false
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Initiating Login Flow failed", e)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import io.ktor.http.Url
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NextcloudLoginFlowTest {
|
||||
|
||||
private val flow = NextcloudLoginFlow(mockk(relaxed = true))
|
||||
|
||||
@Test
|
||||
fun `loginFlowUrl accepts v2 URL`() {
|
||||
assertEquals(
|
||||
Url("http://example.com/index.php/login/v2"),
|
||||
flow.loginFlowUrl(Url("http://example.com/index.php/login/v2"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loginFlowUrl rewrites root URL to v2 URL`() {
|
||||
assertEquals(
|
||||
Url("http://example.com/index.php/login/v2"),
|
||||
flow.loginFlowUrl(Url("http://example.com/"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loginFlowUrl rewrites v1 URL to v2 URL`() {
|
||||
assertEquals(
|
||||
Url("http://example.com/index.php/login/v2"),
|
||||
flow.loginFlowUrl(Url("http://example.com/index.php/login/flow"))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0"
|
||||
androidx-test-junit = "1.3.0"
|
||||
androidx-work = "2.11.0"
|
||||
bitfire-cert4android = "42d883e958"
|
||||
bitfire-dav4jvm = "acd9bca096"
|
||||
bitfire-dav4jvm = "de16b12343"
|
||||
bitfire-synctools = "5fb54ec88c"
|
||||
compose-accompanist = "0.37.3"
|
||||
compose-bom = "2025.11.01"
|
||||
@@ -95,8 +95,10 @@ junit = { module = "junit:junit", version = "4.13.2" }
|
||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||
@@ -119,5 +121,6 @@ android-application = { id = "com.android.application", version.ref = "android-a
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" }
|
||||
|
||||
Reference in New Issue
Block a user