Skip to content
Merged
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -35,100 +32,119 @@ class NextcloudLoginFlow @Inject constructor(
httpClientBuilder: HttpClientBuilder
) {

companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"

/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
const val DAV_PATH = "remote.php/dav"
}

val httpClient = httpClientBuilder.build()

private val httpClient = httpClientBuilder.buildKtor()

// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var pollUrl: Url? = null
var token: String? = null


suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = 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

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

loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
// save endpoint data for polling
pollUrl = Url(endpointData.poll.endpoint)
token = endpointData.poll.token

return loginUrl
return Url(endpointData.login)
}

fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath

if (path.endsWith(FLOW_V2_PATH))
@VisibleForTesting
internal fun loginFlowUrl(baseUrl: Url): Url {
return when {
// already a Login Flow v2 URL
return baseUrl
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
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()
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 json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
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 = json.getString("server").withTrailingSlash()
val serverUrl = loginData.server.withTrailingSlash()

return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword").toSensitiveString()
username = loginData.loginName,
password = loginData.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()
}
/**
* 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
)
}

if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
/**
* 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
)

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())
}
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"

/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
const val DAV_PATH = "remote.php/dav"
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 baseHttpUrl: HttpUrl? = run {
val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"

baseUrlWithPrefix.toHttpUrlOrNull()
}

val canContinue = !inProgress && baseHttpUrl != null

val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"
val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull()

val canContinue = !inProgress && baseKtorUrl != null
}

var uiState by mutableStateOf(UiState())
Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
)
}

}
Loading