Nextcloud Login Flow: handle non-success status codes (#1878)

* Nextcloud Login Flow: handle non-success status codes

* Update error message to use class name when localized message is null

* Update dav4jvm to get HTTP reason phrases in HttpException
This commit is contained in:
Ricki Hirner
2025-12-11 15:30:32 +01:00
committed by GitHub
parent d32b86789b
commit a938b511cd
3 changed files with 50 additions and 28 deletions

View File

@@ -5,11 +5,13 @@
package at.bitfire.davdroid.network package at.bitfire.davdroid.network
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.davdroid.settings.Credentials import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.withTrailingSlash import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod import at.bitfire.vcard4android.GroupMethod
import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
@@ -18,10 +20,12 @@ import io.ktor.http.URLBuilder
import io.ktor.http.Url import io.ktor.http.Url
import io.ktor.http.appendPathSegments import io.ktor.http.appendPathSegments
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.http.path import io.ktor.http.path
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URI import java.net.URI
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
/** /**
* Implements Nextcloud Login Flow v2. * Implements Nextcloud Login Flow v2.
@@ -29,11 +33,9 @@ import javax.inject.Inject
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 * See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/ */
class NextcloudLoginFlow @Inject constructor( class NextcloudLoginFlow @Inject constructor(
httpClientBuilder: HttpClientBuilder private val httpClientBuilder: Provider<HttpClientBuilder>
) { ) {
private val httpClient = httpClientBuilder.buildKtor()
// Login flow state // Login flow state
var pollUrl: Url? = null var pollUrl: Url? = null
var token: String? = null var token: String? = null
@@ -44,6 +46,8 @@ class NextcloudLoginFlow @Inject constructor(
* @param baseUrl Nextcloud login flow or base URL * @param baseUrl Nextcloud login flow or base URL
* *
* @return URL that should be opened in the browser (login screen) * @return URL that should be opened in the browser (login screen)
*
* @throws HttpException on non-successful HTTP status
*/ */
suspend fun start(baseUrl: Url): Url { suspend fun start(baseUrl: Url): Url {
// reset fields in case something goes wrong // reset fields in case something goes wrong
@@ -51,14 +55,18 @@ class NextcloudLoginFlow @Inject constructor(
token = null token = null
// POST to login flow URL in order to receive endpoint data // POST to login flow URL in order to receive endpoint data
val result = httpClient.post(loginFlowUrl(baseUrl)) createClient().use { client ->
val endpointData: EndpointData = result.body() val result = client.post(loginFlowUrl(baseUrl))
if (!result.status.isSuccess())
throw HttpException.fromResponse(result)
// save endpoint data for polling // save endpoint data for polling
pollUrl = Url(endpointData.poll.endpoint) val endpointData: EndpointData = result.body()
token = endpointData.poll.token pollUrl = Url(endpointData.poll.endpoint)
token = endpointData.poll.token
return Url(endpointData.login) return Url(endpointData.login)
}
} }
@VisibleForTesting @VisibleForTesting
@@ -87,31 +95,45 @@ class NextcloudLoginFlow @Inject constructor(
/** /**
* Retrieves login info from the polling endpoint using [pollUrl]/[token]. * Retrieves login info from the polling endpoint using [pollUrl]/[token].
*
* @throws HttpException on non-successful HTTP status
*/ */
suspend fun fetchLoginInfo(): LoginInfo { suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl") val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token") val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password // send HTTP request to request server, login name and app password
val result = httpClient.post(pollUrl) { createClient().use { client ->
contentType(ContentType.Application.FormUrlEncoded) val result = client.post(pollUrl) {
setBody("token=$token") contentType(ContentType.Application.FormUrlEncoded)
setBody("token=$token")
}
if (!result.status.isSuccess())
throw HttpException.fromResponse(result)
// make sure server URL ends with a slash so that DAV_PATH can be appended
val loginData: LoginData = result.body()
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
)
} }
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
)
} }
/**
* Creates a Ktor HTTP client that follows redirects.
*/
private fun createClient(): HttpClient =
httpClientBuilder.get()
.followRedirects(true)
.buildKtor()
/** /**
* Represents the JSON response that is returned on the first call to `/login/v2`. * Represents the JSON response that is returned on the first call to `/login/v2`.

View File

@@ -124,7 +124,7 @@ class NextcloudLoginModel @AssistedInject constructor(
uiState = uiState.copy( uiState = uiState.copy(
inProgress = false, inProgress = false,
error = e.toString() error = e.localizedMessage ?: e.javaClass.simpleName
) )
} }
} }
@@ -149,7 +149,7 @@ class NextcloudLoginModel @AssistedInject constructor(
logger.log(Level.WARNING, "Fetching login info failed", e) logger.log(Level.WARNING, "Fetching login info failed", e)
uiState = uiState.copy( uiState = uiState.copy(
inProgress = false, inProgress = false,
error = e.toString() error = e.localizedMessage ?: e.javaClass.simpleName
) )
return@launch return@launch
} }

View File

@@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0"
androidx-test-junit = "1.3.0" androidx-test-junit = "1.3.0"
androidx-work = "2.11.0" androidx-work = "2.11.0"
bitfire-cert4android = "42d883e958" bitfire-cert4android = "42d883e958"
bitfire-dav4jvm = "de16b12343" bitfire-dav4jvm = "57321c95ad"
bitfire-synctools = "de78892b5c" bitfire-synctools = "de78892b5c"
compose-accompanist = "0.37.3" compose-accompanist = "0.37.3"
compose-bom = "2025.12.00" compose-bom = "2025.12.00"