Skip to content

Commit 0d4f154

Browse files
authored
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
1 parent 9eb70a5 commit 0d4f154

File tree

7 files changed

+156
-95
lines changed

7 files changed

+156
-95
lines changed

app/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ plugins {
77
alias(libs.plugins.compose.compiler)
88
alias(libs.plugins.hilt)
99
alias(libs.plugins.kotlin.android)
10+
alias(libs.plugins.kotlin.serialization)
1011
alias(libs.plugins.ksp)
11-
1212
alias(libs.plugins.mikepenz.aboutLibraries.android)
1313
}
1414

@@ -190,8 +190,10 @@ dependencies {
190190
implementation(libs.conscrypt)
191191
implementation(libs.dnsjava)
192192
implementation(libs.guava)
193+
implementation(libs.ktor.client.content.negotiation)
193194
implementation(libs.ktor.client.core)
194195
implementation(libs.ktor.client.okhttp)
196+
implementation(libs.ktor.serialization.kotlinx.json)
195197
implementation(libs.mikepenz.aboutLibraries.m3)
196198
implementation(libs.okhttp.base)
197199
implementation(libs.okhttp.brotli)

app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ class ConscryptIntegration {
2929
if (initialized)
3030
return
3131

32-
val alreadyInstalled = conscryptInstalled()
33-
if (!alreadyInstalled) {
32+
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
3433
// install Conscrypt as most preferred provider
3534
Security.insertProviderAt(Conscrypt.newProvider(), 1)
3635

app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import com.google.errorprone.annotations.MustBeClosed
2323
import dagger.hilt.android.qualifiers.ApplicationContext
2424
import io.ktor.client.HttpClient
2525
import io.ktor.client.engine.okhttp.OkHttp
26+
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
27+
import io.ktor.serialization.kotlinx.json.json
2628
import kotlinx.coroutines.CoroutineDispatcher
2729
import kotlinx.coroutines.withContext
2830
import net.openid.appauth.AuthState
@@ -391,6 +393,11 @@ class HttpClientBuilder @Inject constructor(
391393
val client = HttpClient(OkHttp) {
392394
// Ktor-level configuration here
393395

396+
// automatically convert JSON from/into data classes (if requested in respective code)
397+
install(ContentNegotiation) {
398+
json()
399+
}
400+
394401
engine {
395402
// okhttp engine configuration here
396403

app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt

Lines changed: 88 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,22 @@
44

55
package at.bitfire.davdroid.network
66

7-
import at.bitfire.dav4jvm.okhttp.exception.DavException
8-
import at.bitfire.dav4jvm.okhttp.exception.HttpException
7+
import androidx.annotation.VisibleForTesting
98
import at.bitfire.davdroid.settings.Credentials
109
import at.bitfire.davdroid.ui.setup.LoginInfo
1110
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
1211
import at.bitfire.davdroid.util.withTrailingSlash
1312
import at.bitfire.vcard4android.GroupMethod
14-
import kotlinx.coroutines.Dispatchers
15-
import kotlinx.coroutines.runInterruptible
16-
import kotlinx.coroutines.withContext
17-
import okhttp3.HttpUrl
18-
import okhttp3.HttpUrl.Companion.toHttpUrl
19-
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
20-
import okhttp3.MediaType.Companion.toMediaType
21-
import okhttp3.Request
22-
import okhttp3.RequestBody
23-
import okhttp3.RequestBody.Companion.toRequestBody
24-
import org.json.JSONObject
25-
import java.net.HttpURLConnection
13+
import io.ktor.client.call.body
14+
import io.ktor.client.request.post
15+
import io.ktor.client.request.setBody
16+
import io.ktor.http.ContentType
17+
import io.ktor.http.URLBuilder
18+
import io.ktor.http.Url
19+
import io.ktor.http.appendPathSegments
20+
import io.ktor.http.contentType
21+
import io.ktor.http.path
22+
import kotlinx.serialization.Serializable
2623
import java.net.URI
2724
import javax.inject.Inject
2825

@@ -35,100 +32,119 @@ class NextcloudLoginFlow @Inject constructor(
3532
httpClientBuilder: HttpClientBuilder
3633
) {
3734

38-
companion object {
39-
const val FLOW_V1_PATH = "index.php/login/flow"
40-
const val FLOW_V2_PATH = "index.php/login/v2"
41-
42-
/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
43-
const val DAV_PATH = "remote.php/dav"
44-
}
45-
46-
val httpClient = httpClientBuilder.build()
47-
35+
private val httpClient = httpClientBuilder.buildKtor()
4836

4937
// Login flow state
50-
var loginUrl: HttpUrl? = null
51-
var pollUrl: HttpUrl? = null
38+
var pollUrl: Url? = null
5239
var token: String? = null
5340

54-
55-
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
56-
loginUrl = null
41+
/**
42+
* Starts Nextcloud Login Flow v2.
43+
*
44+
* @param baseUrl Nextcloud login flow or base URL
45+
*
46+
* @return URL that should be opened in the browser (login screen)
47+
*/
48+
suspend fun start(baseUrl: Url): Url {
49+
// reset fields in case something goes wrong
5750
pollUrl = null
5851
token = null
5952

60-
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
53+
// POST to login flow URL in order to receive endpoint data
54+
val result = httpClient.post(loginFlowUrl(baseUrl))
55+
val endpointData: EndpointData = result.body()
6156

62-
loginUrl = json.getString("login").toHttpUrlOrNull()
63-
json.getJSONObject("poll").let { poll ->
64-
pollUrl = poll.getString("endpoint").toHttpUrl()
65-
token = poll.getString("token")
66-
}
57+
// save endpoint data for polling
58+
pollUrl = Url(endpointData.poll.endpoint)
59+
token = endpointData.poll.token
6760

68-
return loginUrl
61+
return Url(endpointData.login)
6962
}
7063

71-
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
72-
val path = baseUrl.encodedPath
73-
74-
if (path.endsWith(FLOW_V2_PATH))
64+
@VisibleForTesting
65+
internal fun loginFlowUrl(baseUrl: Url): Url {
66+
return when {
7567
// already a Login Flow v2 URL
76-
return baseUrl
68+
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
69+
baseUrl
7770

78-
if (path.endsWith(FLOW_V1_PATH))
7971
// Login Flow v1 URL, rewrite to v2
80-
return baseUrl.newBuilder()
81-
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
82-
.build()
83-
84-
// other URL, make it a Login Flow v2 URL
85-
return baseUrl.newBuilder()
86-
.addPathSegments(FLOW_V2_PATH)
87-
.build()
72+
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
73+
// drop "[index.php/login]/flow" from the end and append "/v2"
74+
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
75+
val builder = URLBuilder(baseUrl)
76+
builder.path(*v2Segments.toTypedArray())
77+
builder.build()
78+
}
79+
80+
// other URL, make it a Login Flow v2 URL
81+
else ->
82+
URLBuilder(baseUrl)
83+
.appendPathSegments(FLOW_V2_PATH.split('/'))
84+
.build()
85+
}
8886
}
8987

90-
88+
/**
89+
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
90+
*/
9191
suspend fun fetchLoginInfo(): LoginInfo {
9292
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
9393
val token = token ?: throw IllegalArgumentException("Missing token")
9494

9595
// send HTTP request to request server, login name and app password
96-
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
96+
val result = httpClient.post(pollUrl) {
97+
contentType(ContentType.Application.FormUrlEncoded)
98+
setBody("token=$token")
99+
}
100+
val loginData: LoginData = result.body()
97101

98102
// make sure server URL ends with a slash so that DAV_PATH can be appended
99-
val serverUrl = json.getString("server").withTrailingSlash()
103+
val serverUrl = loginData.server.withTrailingSlash()
100104

101105
return LoginInfo(
102106
baseUri = URI(serverUrl).resolve(DAV_PATH),
103107
credentials = Credentials(
104-
username = json.getString("loginName"),
105-
password = json.getString("appPassword").toSensitiveString()
108+
username = loginData.loginName,
109+
password = loginData.appPassword.toSensitiveString()
106110
),
107111
suggestedGroupMethod = GroupMethod.CATEGORIES
108112
)
109113
}
110114

111115

112-
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
113-
val postRq = Request.Builder()
114-
.url(url)
115-
.post(requestBody)
116-
.build()
117-
val response = runInterruptible {
118-
httpClient.newCall(postRq).execute()
119-
}
116+
/**
117+
* Represents the JSON response that is returned on the first call to `/login/v2`.
118+
*/
119+
@Serializable
120+
private data class EndpointData(
121+
val poll: Poll,
122+
val login: String
123+
) {
124+
@Serializable
125+
data class Poll(
126+
val token: String,
127+
val endpoint: String
128+
)
129+
}
120130

121-
if (response.code != HttpURLConnection.HTTP_OK)
122-
throw HttpException(response)
131+
/**
132+
* Represents the JSON response that is returned by the polling endpoint.
133+
*/
134+
@Serializable
135+
private data class LoginData(
136+
val server: String,
137+
val loginName: String,
138+
val appPassword: String
139+
)
123140

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

129-
// decode JSON
130-
return@withContext JSONObject(body.string())
131-
}
142+
companion object {
143+
const val FLOW_V1_PATH = "index.php/login/flow"
144+
const val FLOW_V2_PATH = "index.php/login/v2"
145+
146+
/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
147+
const val DAV_PATH = "remote.php/dav"
132148
}
133149

134150
}

app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import androidx.compose.runtime.mutableStateOf
1010
import androidx.compose.runtime.setValue
1111
import androidx.lifecycle.ViewModel
1212
import androidx.lifecycle.viewModelScope
13+
import at.bitfire.dav4jvm.ktor.toUrlOrNull
1314
import at.bitfire.davdroid.network.NextcloudLoginFlow
1415
import dagger.assisted.Assisted
1516
import dagger.assisted.AssistedFactory
1617
import dagger.assisted.AssistedInject
1718
import dagger.hilt.android.lifecycle.HiltViewModel
1819
import dagger.hilt.android.qualifiers.ApplicationContext
20+
import io.ktor.http.Url
1921
import kotlinx.coroutines.launch
20-
import okhttp3.HttpUrl
21-
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
2222
import java.util.logging.Level
2323
import java.util.logging.Logger
2424

@@ -46,24 +46,19 @@ class NextcloudLoginModel @AssistedInject constructor(
4646
val error: String? = null,
4747

4848
/** URL to open in the browser (set during Login Flow) */
49-
val loginUrl: HttpUrl? = null,
49+
val loginUrl: Url? = null,
5050

5151
/** login info (set after successful login) */
5252
val result: LoginInfo? = null
5353
) {
54-
55-
val baseHttpUrl: HttpUrl? = run {
56-
val baseUrlWithPrefix =
57-
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
58-
baseUrl
59-
else
60-
"https://$baseUrl"
61-
62-
baseUrlWithPrefix.toHttpUrlOrNull()
63-
}
64-
65-
val canContinue = !inProgress && baseHttpUrl != null
66-
54+
val baseUrlWithPrefix =
55+
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
56+
baseUrl
57+
else
58+
"https://$baseUrl"
59+
val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull()
60+
61+
val canContinue = !inProgress && baseKtorUrl != null
6762
}
6863

6964
var uiState by mutableStateOf(UiState())
@@ -107,7 +102,7 @@ class NextcloudLoginModel @AssistedInject constructor(
107102
* Starts the Login Flow.
108103
*/
109104
fun startLoginFlow() {
110-
val baseUrl = uiState.baseHttpUrl
105+
val baseUrl = uiState.baseKtorUrl
111106
if (uiState.inProgress || baseUrl == null)
112107
return
113108

@@ -118,13 +113,12 @@ class NextcloudLoginModel @AssistedInject constructor(
118113

119114
viewModelScope.launch {
120115
try {
121-
val loginUrl = loginFlow.initiate(baseUrl)
116+
val loginUrl = loginFlow.start(baseUrl)
122117

123118
uiState = uiState.copy(
124119
loginUrl = loginUrl,
125120
inProgress = false
126121
)
127-
128122
} catch (e: Exception) {
129123
logger.log(Level.WARNING, "Initiating Login Flow failed", e)
130124

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
3+
*/
4+
5+
package at.bitfire.davdroid.network
6+
7+
import io.ktor.http.Url
8+
import io.mockk.mockk
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Test
11+
12+
class NextcloudLoginFlowTest {
13+
14+
private val flow = NextcloudLoginFlow(mockk(relaxed = true))
15+
16+
@Test
17+
fun `loginFlowUrl accepts v2 URL`() {
18+
assertEquals(
19+
Url("http://example.com/index.php/login/v2"),
20+
flow.loginFlowUrl(Url("http://example.com/index.php/login/v2"))
21+
)
22+
}
23+
24+
@Test
25+
fun `loginFlowUrl rewrites root URL to v2 URL`() {
26+
assertEquals(
27+
Url("http://example.com/index.php/login/v2"),
28+
flow.loginFlowUrl(Url("http://example.com/"))
29+
)
30+
}
31+
32+
@Test
33+
fun `loginFlowUrl rewrites v1 URL to v2 URL`() {
34+
assertEquals(
35+
Url("http://example.com/index.php/login/v2"),
36+
flow.loginFlowUrl(Url("http://example.com/index.php/login/flow"))
37+
)
38+
}
39+
40+
}

0 commit comments

Comments
 (0)